Locks force concurrent transfers into a safe order, so the books always balance.
Without locking, two transfers that both read an account's balance can each write back a value computed from the same stale number — the second write silently overwrites the first. That is the lost update anomaly, and money simply vanishes from the ledger.
With row locks acquired in a fixed order, transfers that touch a shared account serialize: one runs to completion before the other reads. Each transfer is balanced (one debit, one matching credit), so the total across all accounts is a conserved invariant — it is the same before and after, no matter how the work interleaves.
Each transfer opens a transaction, then locks the rows it will touch in account-id order. Acquiring locks in the same global order on every code path is what stops two transfers from grabbing the same pair in opposite orders and deadlocking. Inside the lock it reads, checks funds, debits and credits, then commits and releases.
# Transfer money safely under row locks.
def transfer(conn, src, dst, amount):
# Lock both accounts in a FIXED order (ascending id),
# so concurrent transfers can never deadlock on the pair.
first, second = sorted((src, dst))
conn.execute("BEGIN")
# SELECT ... FOR UPDATE takes a row lock and blocks
# any other transfer touching the same account.
lock_row(conn, first)
lock_row(conn, second)
bal_src = read_balance(conn, src)
if bal_src < amount:
conn.execute("ROLLBACK") # check funds INSIDE the lock
return "insufficient funds"
write_balance(conn, src, bal_src - amount) # debit
write_balance(conn, dst, read_balance(conn, dst) + amount) # credit
conn.execute("COMMIT") # releases both locks
return "ok"
| Property | Detail |
|---|---|
| Isolation | Row locks serialize transfers that share an account; disjoint transfers still run in parallel |
| Concurrency | Reduced on hot accounts — waiters queue behind the lock holder |
| Deadlock risk | Removed when every path locks in the same account-id order |
| Conserved invariant | Each transfer nets zero, so the total across all accounts never changes |
A then B and another locks B then A, they can wait on each other forever. Always sort by account id.Start with A = $100, B = $50, C = $80, so the total is $230. T1 transfers $30 from A to B; T2 transfers $20 from C to A. Both T1 and T2 touch account A, so they contend on it. Locking in account-id order, T1 holds A first and runs to completion: A = $70, B = $80 (total still $230). Only then does T2 read the committed A = $70, debit C to $60, and credit A to $90. Final balances are A = $90, B = $80, C = $60 — total $230, unchanged. Had T2 read A before T1 committed, its write would have lost T1's $30 debit; the lock is exactly what prevents that.
1. Why does locking the two accounts in account-id order matter?
2. What anomaly happens when two transfers read a balance without locking it?
Coach note: if a pick doesn't land, give it another pass — the reasoning is what sticks.