Transactions And Concurrency
Deadlocks and Practical Concurrency
T1: UPDATE accounts SET ... WHERE id='A' -- X on A
JrCodex·8 min read
Jr Codex DBMS Notes
Level: Advanced Prerequisites: Chapter 4: Multi-Version Concurrency Control Time to complete: ~20 minutes
Table of Contents
- What a Deadlock Is
- Detection
- Prevention
- Optimistic vs Pessimistic Concurrency
- Four Patterns Worth Knowing
- A Concurrency Checklist
- Summary & Next Steps
1. What a Deadlock Is
The Cycle
─────────────────────────────────────────
T1: UPDATE accounts SET ... WHERE id='A' -- X on A
T2: UPDATE accounts SET ... WHERE id='B' -- X on B
T1: UPDATE accounts SET ... WHERE id='B' -- WAITS for T2
T2: UPDATE accounts SET ... WHERE id='A' -- WAITS for T1
T1 ──waits for──► T2
▲ │
└───waits for─────┘
Neither can proceed. Neither will ever release.
─────────────────────────────────────────
The Four Necessary Conditions
─────────────────────────────────────────
MUTUAL EXCLUSION a resource is held
exclusively
HOLD AND WAIT a transaction holds one lock
while requesting another
NO PREEMPTION locks are not forcibly taken
away
CIRCULAR WAIT a cycle exists in the
wait-for graph
ALL FOUR must hold. Breaking any ONE prevents
deadlock — which is what every strategy in
Section 3 does.
─────────────────────────────────────────
2. Detection
The Wait-For Graph
─────────────────────────────────────────
Nodes: transactions
Edge Tᵢ → Tⱼ: Tᵢ is waiting for a lock held by Tⱼ
A CYCLE in this graph IS a deadlock.
The database checks periodically (PostgreSQL:
deadlock_timeout, default 1 second) and, on
finding a cycle, chooses a VICTIM and aborts it.
─────────────────────────────────────────
def find_deadlock(wait_for):
"""wait_for: {txn: set of txns it waits on}. Returns a cycle, or None."""
WHITE, GREY, BLACK = 0, 1, 2
colour = {t: WHITE for t in wait_for}
stack = []
def visit(t):
colour[t] = GREY
stack.append(t)
for nxt in wait_for.get(t, ()):
if colour.get(nxt, WHITE) == GREY: # back edge ──► CYCLE
return stack[stack.index(nxt):]
if colour.get(nxt, WHITE) == WHITE:
if (cycle := visit(nxt)):
return cycle
stack.pop()
colour[t] = BLACK
return None
for t in wait_for:
if colour[t] == WHITE and (cycle := visit(t)):
return cycle
return None
print(find_deadlock({'T1': {'T2'}, 'T2': {'T1'}})) # ['T1', 'T2']Choosing a Victim
─────────────────────────────────────────
Databases prefer to abort the transaction that
costs least to redo — typically the one that has
done the least work, or holds the fewest locks.
Your application receives a deadlock error.
DEADLOCKS ARE NORMAL under contention. Code that
takes multiple locks must be prepared to retry,
exactly like the serialisation-failure retry from
Chapter 2.
─────────────────────────────────────────
3. Prevention
Four Strategies
─────────────────────────────────────────
1. CONSISTENT LOCK ORDERING
Breaks CIRCULAR WAIT.
Always acquire locks in the same order — by
primary key, alphabetically, any total order.
── the best fix, and usually easy
2. TAKE ALL LOCKS AT ONCE
Breaks HOLD AND WAIT.
One statement locking every needed row.
Hard when the set is not known upfront.
3. TIMEOUTS
Breaks NO PREEMPTION.
lock_timeout aborts a transaction waiting too
long. Crude but effective as a backstop.
4. AVOID LOCKS
Breaks MUTUAL EXCLUSION.
Single-statement updates that need no
application-side read (Chapter 4, Option A).
─────────────────────────────────────────
# The transfer deadlock, and its one-line fix.
# ✗ DEADLOCKS: transfer(A,B) and transfer(B,A) run concurrently.
def transfer_bad(conn, src, dst, amount):
conn.execute("UPDATE accounts SET balance=balance-? WHERE id=?", (amount, src))
conn.execute("UPDATE accounts SET balance=balance+? WHERE id=?", (amount, dst))
# ✓ SAFE: always lock the lower id first, whichever direction the money moves.
def transfer_good(conn, src, dst, amount):
first, second = sorted([src, dst]) # ← the entire fix
conn.execute("SELECT balance FROM accounts WHERE id=? FOR UPDATE", (first,))
conn.execute("SELECT balance FROM accounts WHERE id=? FOR UPDATE", (second,))
conn.execute("UPDATE accounts SET balance=balance-? WHERE id=?", (amount, src))
conn.execute("UPDATE accounts SET balance=balance+? WHERE id=?", (amount, dst))Why Ordering Works
─────────────────────────────────────────
With a total order on resources, a cycle is
impossible.
T1 holding A and wanting B, plus T2 holding B and
wanting A, requires T2 to have taken B before A —
which the ordering rule forbids.
No cycle is constructible. The deadlock cannot
occur, rather than being detected and recovered
from.
─────────────────────────────────────────
4. Optimistic vs Pessimistic Concurrency
The Two Philosophies
─────────────────────────────────────────
PESSIMISTIC
Assume conflict. Lock first, then work.
SELECT ... FOR UPDATE.
Cost: waiting.
Best when conflicts are FREQUENT.
OPTIMISTIC
Assume no conflict. Work, then check at commit.
Version numbers, or SERIALIZABLE.
Cost: wasted work on the retries.
Best when conflicts are RARE.
─────────────────────────────────────────
-- Optimistic concurrency with a version column — the standard application pattern.
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
content TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1
);
-- Read, including the version.
SELECT id, content, version FROM documents WHERE id = 42; -- version = 7
-- Write, asserting the version has not moved.
UPDATE documents
SET content = :new_content, version = version + 1
WHERE id = 42 AND version = 7;
-- 1 row affected ──► success.
-- 0 rows affected ──► someone else edited it. Re-read and merge.Why This Pattern Is Everywhere
─────────────────────────────────────────
It holds NO locks between the read and the write,
so a user can have a form open for ten minutes
without blocking anyone.
The conflict is detected at write time, and the
application decides what to do — merge, warn, or
overwrite.
This is how document editors, wikis and most web
forms handle concurrent edits. It is also exactly
the durable-agent pattern of "check the version
you based your work on".
─────────────────────────────────────────
5. Four Patterns Worth Knowing
1. THE WORK QUEUE
─────────────────────────────────────────
SELECT * FROM jobs WHERE status='pending'
ORDER BY created_at LIMIT 1
FOR UPDATE SKIP LOCKED;
Chapter 3's SKIP LOCKED. Many workers, no
contention, no double-processing.
2. THE COUNTER
─────────────────────────────────────────
✗ read count, add 1, write back
──► lost updates under concurrency
✓ UPDATE stats SET views = views + 1 WHERE id=?
──► atomic; the database serialises it
For very hot counters, shard the row into N
sub-counters and sum them on read.
3. THE UNIQUE CLAIM
─────────────────────────────────────────
✗ SELECT to check if the username is free, then
INSERT
──► two transactions both see it free
✓ a UNIQUE constraint, and INSERT
──► one succeeds, one gets a constraint
violation. Catch and report it.
Let the database arbitrate. It is the only
component that can.
4. THE IDEMPOTENT WRITE
─────────────────────────────────────────
INSERT ... ON CONFLICT DO NOTHING / DO UPDATE
(Module 3, Chapter 6's UPSERT)
Safe to retry, which matters because every
pattern above may need retrying.
─────────────────────────────────────────
6. A Concurrency Checklist
Before Shipping Code That Writes
─────────────────────────────────────────
□ Do I know which isolation level I am running
at? (Chapter 2 — check, do not assume)
□ Is there a read-then-write? If so, is it
protected by FOR UPDATE, a version column, or
expressed as a single statement?
□ Do I take more than one lock? If so, are they
ordered consistently?
□ Is there a retry loop for serialisation
failures and deadlocks?
□ Are my transactions SHORT — no network calls,
no user input, no file I/O inside them?
□ Does any decision read rows other than the ones
it writes? (write skew — needs SERIALIZABLE or
an explicit lock)
□ Are the columns I filter on in an UPDATE
indexed? (Chapter 3 — otherwise it over-locks)
─────────────────────────────────────────
The Two Highest-Value Items
─────────────────────────────────────────
SHORT TRANSACTIONS. They reduce lock duration,
reduce deadlock probability, and prevent the
vacuum problem from Chapter 4 — three separate
failure modes, one habit.
RETRY LOOPS. Under any correct isolation level,
aborts are normal operation. Code without a retry
loop is code that fails randomly under load.
─────────────────────────────────────────
7. Summary & Next Steps
Key Takeaways
- Deadlock requires four simultaneous conditions, and breaking any one prevents it — consistent lock ordering breaks circular wait and is usually the easiest fix.
- Databases detect deadlocks by finding cycles in the wait-for graph and abort a victim, so any code taking multiple locks needs a retry loop.
- Pessimistic concurrency waits and suits frequent conflicts; optimistic version columns hold no locks between read and write and suit rare ones.
- Let the database arbitrate uniqueness with a constraint rather than checking first, and express read-modify-write as a single statement wherever possible.
Module 7 Complete — What's Next
You now understand what happens when many transactions run at once and how to write code that stays correct. Module 8 covers the other half of the durability promise: what happens when the machine simply stops.
Concept Check
- Why does sorting the two account ids make the transfer function deadlock-free rather than merely less likely?
- When is optimistic concurrency the better choice than
SELECT ... FOR UPDATE, and why? - Why is checking whether a username is free before inserting it wrong, and what should you do instead?
Next Module
→ Module 8: Recovery and Reliability
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to DBMS Index