TIL: deadlocks — the fix is lock order, not speed
· til · databases · sql · concurrency · deadlocks
Ana grabs the frying pan off the stove and starts on her sauce. She'll need the wooden spoon in a second.
Across the kitchen, Ben grabs the wooden spoon for his own dish. He'll need the pan next.
"Hey, pass the spoon when you're done?" Ana says. "Sure — soon as you're done with the pan," Ben says.
Neither moves. Ana's holding the pan, waiting on the spoon. Ben's holding the spoon, waiting on the pan. Neither one is willing to put theirs down first, because putting it down means losing their spot mid-recipe.
A minute passes. Their roommate walks in, clocks the standoff immediately, and says: "Ana, put the pan down. Ben, go ahead." Ana sighs, sets the pan aside, and restarts her sauce a minute later once Ben's finished with both.
That standoff is a deadlock, and the roommate is exactly what Postgres runs internally to break one.
Mechanism, in the real vocabulary — two rows in an accounts table:
-- T1 -- T2
BEGIN;
UPDATE accounts SET balance = 400
WHERE id = 1; -- locks row 1
BEGIN;
UPDATE accounts SET balance = 100
WHERE id = 2; -- locks row 2
UPDATE accounts SET balance = balance - 100
WHERE id = 2; -- blocks, waits on T2
UPDATE accounts SET balance = balance + 100
WHERE id = 1; -- blocks, waits on T1Both statements are now frozen mid-execution — neither transaction can even
reach COMMIT, because the blocked UPDATE never returns. Ana holding the
pan while blocked on the spoon is T1 holding row 1's lock while blocked on
row 2.
Postgres doesn't let this sit forever. Once a backend has waited past
deadlock_timeout (1 second by default), it checks the waits-for
graph — an edge from T1 to T2 if T1 is blocked on a lock T2 holds. Here:
T1 → T2 → T1. That's a cycle, which means it's a real deadlock, not just a
slow lock.
Postgres picks a victim — the transaction that ran the check aborts
itself with ERROR: deadlock detected, releasing its locks. The survivor
proceeds and commits. That's the roommate telling Ana, not Ben, to stand
down.
The aborted transaction wasn't wrong — it just lost a coin flip on timing. So the fix on the app side isn't to surface the error to the user; it's to catch it and retry the transaction, same as any other transient conflict.
The structural fix is upstream of ever hitting this: have every
transaction touch shared rows in the same order. Take a
transferMoney(fromAccountId, toAccountId, amount) function — the naive
version locks whichever account is from, then whichever is to:
BEGIN;
SELECT * FROM accounts WHERE id = fromAccountId FOR UPDATE;
SELECT * FROM accounts WHERE id = toAccountId FOR UPDATE;
-- ...balance updates...
COMMIT;That deadlocks the moment User A pays User B while User B pays User A at the same instant — one call locks account 5 then wants 2, the other locks 2 then wants 5. Classic crossed order.
The fix isn't to lock fromAccountId and toAccountId as a pair and hope
for the best — WHERE id IN (fromAccountId, toAccountId) alone doesn't
guarantee which one gets locked first, it just happens to work until it
doesn't. The actual guarantee comes from an explicit order:
SELECT * FROM accounts
WHERE id IN (fromAccountId, toAccountId)
ORDER BY id
FOR UPDATE;Postgres locks FOR UPDATE rows in the order they're returned, so
ORDER BY id means the lower id is always locked first — regardless of
which account is from and which is to, regardless of which of the two
concurrent calls got there first. No cycle can form if nobody ever
acquires locks in reverse order of someone else.
One more shape worth checking: what if two transactions share only one
row instead of two? Say T1 touches ids {1, 2, 3} and T2 touches ids
{3, 4, 5} — the only overlap is id 3. Whichever transaction reaches id 3
first locks it; the other blocks and waits its turn. That's ordinary
contention, not a deadlock — a cycle needs T1 waiting on something T2
holds and T2 waiting on something T1 holds, at the same time. With only
one shared row, there's nothing left for the winner to wait on, so it just
finishes and releases the lock. A deadlock needs at least two rows in
common, acquired in opposite order — one shared row just means someone
waits in line.
A few other practices help too, beyond just lock order:
- Retry on deadlock. The aborted transaction wasn't wrong, it just lost
the timing coin flip — catch
deadlock detectedand retry. - Keep transactions short. If
transferMoneycalls out to a slow fraud API before committing, every transfer holds its locks for however long that call takes. At 100 transfers/second, a 5ms transaction has ~0.5 overlapping in flight at any instant; a 3-second one has ~300 overlapping — a 600x wider window for two of them to collide on the same rows, even with perfect ordering. Do slow or external work beforeBEGINor afterCOMMIT, never while holding a lock. - Lock only what you need. A narrow
WHEREthat touches exactly the rows being updated, not a broad range "just in case," shrinks both the blocking radius and the deadlock surface. - Set a
lock_timeout. The deadlock detector only fires for genuine cycles — an ordinary wait (like the single-shared-row case above) can still hang indefinitely if the lock holder never commits.lock_timeoutcaps how long a statement waits before erroring out, so a stuck transaction fails fast instead of queuing up waiters behind it.
Related: pessimistic vs optimistic
locking covers the
single-row locking FOR UPDATE uses here; MVCC — why reads don't block
writes covers why
the plain reads in that same table never contend in the first place.