Race condition on balance updates: Pessimistic Lock or Redlock?
Question
We have a queue job that deducts money from a user's balance. If the user triggers two operations in quick succession, two jobs for the same user end up in the queue; we run 10 parallel workers. Worker 1 and 2 read the old balance at the same time (100), both deduct 40 and write 60 (it should be 20). To prevent this race condition, should I use Pessimistic Locking or a Redis distributed lock (Redlock)?
Answer
Short answer: what you’re hitting is the classic lost-update problem. When money is involved, keep the invariant in the DB; the cleanest fix is an atomic conditional UPDATE, with SELECT ... FOR UPDATE as the second choice. Don’t reach for Redlock.
Short answer
The core of your scenario: the read-modify-write cycle is split across parallel workers. Both read the old value, both write over it, and one update is lost. The fix is to make that cycle atomic. Once you move to row locking with FOR UPDATE, the lock ordering has to stay consistent; I covered how deadlocks arise and how to prevent them in the deadlock answer.
Why
- The database, not the app, should protect the invariant. When read and write collapse into one statement, the DB manages the row lock itself — which both resolves the race and refuses to overdraw via a condition like
balance >= 40. - Redlock is too fragile for an in-DB invariant. A Redis distributed lock (Redlock) makes sense when the thing you’re protecting isn’t a single DB row (cross-service/cross-resource coordination) — and it has edge cases around clock drift/timeouts. When a balance can already be protected by the DB’s transaction, don’t entrust it to a distributed lock.
- Optimistic locking backfires under contention. The
version-column approach is cheap when conflicts are rare, but it creates a retry storm under heavy contention — and in your scenario the same user fires jobs back to back.
What to do
- Best: an atomic conditional
UPDATE. WriteUPDATE accounts SET balance = balance - 40 WHERE id = ? AND balance >= 40. Read and write are one statement; no app-side lock needed. If zero rows are affected, the operation failed on insufficient funds. - Alternative:
SELECT ... FOR UPDATE. If your logic between read and write is complex, lock the balance row inside a transaction withFOR UPDATE. The second worker that reaches that row waits for the first to commit; they serialize, no clash. That’s pessimistic locking. - Consider optimistic locking under low contention. Add a
versioncolumn to the row; on update includeWHERE version = ?and retry if it doesn’t match. - Use Redlock only for a non-DB resource. If what you’re protecting isn’t a DB row — a cross-service resource, say — that’s when a distributed lock earns its place.
Bottom line: use an atomic conditional UPDATE (or SELECT FOR UPDATE); save Redlock for non-DB resources. For money, keep the rule where the transaction guarantees it — in the DB; a distributed lock is too fragile a tool to protect an invariant the database itself could enforce.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.