07

Transactions

A transaction groups several reads and writes into one unit that either commits or aborts, so the application can stop worrying about crashes halfway through and about other clients racing it. That promise is weaker than it sounds. Isolation levels like read committed and snapshot isolation let specific race conditions through, and which ones they allow is often poorly understood. This chapter runs each anomaly as a real two-transaction schedule against a small MVCC engine, then looks at the three ways to get true serializability and what each one costs.
The four letters are a mix of guarantees. Atomicity is about faults rather than concurrency, and consistency belongs to your application. Crash a money transfer at different points and see which mechanism saves you.
AAtomicity

really means abortability: on any fault mid-transaction, all its writes are undone, so retrying is safe.

CConsistency

is a property of the application's invariants (money is conserved). The database can only help via A and I.

IIsolation

concurrent transactions don't step on each other; ideally as if they ran one at a time (see the playground below).

DDurability

once committed, data survives crashes: the commit record is fsynced (or replicated) before the client hears “ok”.

Transfer $100 from A to B

  1. ✓UPDATE accounts SET bal = bal - 100 WHERE id = 'A'⚡ crash
  2. ·UPDATE accounts SET bal = bal + 100 WHERE id = 'B'
  3. ·COMMIT → client is told “ok”
after restart and recovery
The debit hit disk but the credit never happened: $100 vanished. Retrying would debit A again.

Try: crash after the debit with atomicity off, then on. Then crash just after “ok” with durability off: a lie to the client that no amount of atomicity can fix.

account A
$400
account B
$500
invariant A + B = $1000
$900
consistency (the app's rule)
client was told
error
Pick an anomaly and an isolation level, then step through a fixed interleaving of two transactions. Each level is implemented the way databases do it: row locks, last-committed reads, MVCC snapshots, shared/exclusive and predicate locks, or SSI's dependency tracking. Blocked operations wait and the schedule continues with the other transaction.

Write skew · snapshot isolation

Alice and Bob are both on call and both feel sick. Each checks that at least two doctors are on call, then goes off call.

T1Aliceactive
T2Bobactive
  1. Press Step to run the schedule one operation at a time.
next: T1 SELECT count(*) WHERE on_call

Database

doc:alice1
doc:bob1
locks held
none
outcome
running
step 0

Which isolation level prevents which anomaly? (each cell = this engine actually running the schedule)

no isolationread uncommittedread committedsnapshot isolationserializable (2PL)serializable (SSI)
Dirty read
Dirty write
Read skew
Lost update
Write skew
Phantom

Try: write skew under snapshot isolation (both doctors go off call), then the same schedule under 2PL (deadlock, one aborts) and SSI (the second committer is aborted). Click any cell to load it.

Snapshot isolation looks almost perfect in the matrix: only write skew and phantoms get through. Those are exactly the bugs that slip through code review. Each transaction is correct on its own, and the invariant breaks only when they run together.

MVCC: one table, many truths

mvccsnapshot-isolation
Snapshot isolation keeps old row versions instead of overwriting. Each version records the txid that created it and the txid that deleted it, and simple visibility rules decide which version each transaction sees. Step through the book's transfer example and look at the same rows as different transactions.
view as
step 7/14

Row versions on disk

txn 13 commits. New transactions will see $600/$400; 12 and 14 still won't.

keybalancecreated_bydeleted_bytxn 12 sees it?
acct 1$500313✓deletion ignored (rule 3: 13 started after 12)
acct 2$500313✓deletion ignored (rule 3: 13 started after 12)
acct 1$60013—✗creation hidden (rule 3: 13 started after 12)
acct 2$40013—✗creation hidden (rule 3: 13 started after 12)

Try: step to 8 and switch between txn 12, txn 14 and a new transaction. Three readers, three answers, one set of rows and no read locks. Then step to the end to watch garbage collection.

txn 12 reads
$500 + $500
total $1000

Transactions

3initial loadcommitted
12Alice readsactive
13transfer $100committed · saw [12] running
14auditactive · saw [12,13] running
Reader ignores writes by: (1) txns in progress when it began, (2) aborted txns, (3) txns with a later txid. Everything else is visible.

Preventing lost updates

lost-updatetxn-compare-and-set
Several clients increment the same counter. Each bar is one read-modify-write cycle. The naive version loses increments whenever cycles overlap. The three fixes all get the right answer, but they pay for it differently.

4 clients × 6 increments · read-modify-write

SELECT v → v+1 in app → UPDATE SET v = :new
client 1client 2client 3client 4××××××
increment appliedwrote a stale value (clobbered others)waiting for row lockCAS failed, retry

Try: read-modify-write with 6+ clients and short think time — count the red bars. Switch to SELECT FOR UPDATE: no losses, but the lanes fill with amber waiting. Compare-and-set trades waiting for retries.

final counter
19
expected 24
lost updates
5
retries
0
time waiting
0.0
in RTTs

Time to finish all increments

read-modify-write
33.2
5 lost
atomic update
23.9
SELECT FOR UPDATE
38.2
compare-and-set
35.3
A fourth option: PostgreSQL's repeatable read, Oracle's serializable and SQL Server's snapshot isolation detect the lost update themselves and abort the second writer (in the playground above, lost update under SI shows this). MySQL InnoDB's repeatable read does not.

Serializability #1: actually run them one at a time

actual-serial-execution
If every transaction is short and its data is in memory, one thread per partition can execute them back to back with no locks at all. Two conditions make this work: no network round trips inside a transaction (send the whole thing as a stored procedure) and few transactions that span partitions.

One thread per partition, one transaction at a time

core 1core 2core 3core 401.5 ms
single-partition transaction cross-partition transaction (stalls every core)

Try: switch to interactive: throughput falls ~50× because the single thread idles during every network round trip. Then go back, add cores, and drag cross-partition up to 30%: the red stalls eat most of the gain.

throughput
31.6k tx/s
avg txn 40 µs
without cross-partition
92.5k tx/s
34% of this retained
core utilization
82%
share of core-time doing useful work
no concurrency control
no locks
serial = serializable by construction

Serializability #2: two-phase locking

two-phase-lockingtxn-deadlockpredicate-lock
You are the scheduler. Each click runs one operation of a transaction against a real lock manager: shared locks for reads, exclusive locks for writes, FIFO wait queues, and a deadlock detector watching the wait-for graph.

Transactions

T1growing phase
w(A)w(B)commit
T2growing phase
w(B)w(A)commit
T3growing phase
r(C)commit

Lock table

objectholderswait queue
A—
B—
C—
S is compatible with S. X is compatible with nothing. Locks are held until commit or abort (strict 2PL).

Wait-for graph

T1T2T3
no one is waiting

Log

  1. Click Step on a transaction to run its next operation.

Try: Step T1, T2, T1, T2: each holds what the other wants. The detector finds the cycle and aborts T2; restart it after T1 commits.

committed
0
deadlocks
0

Serializability #3: SSI, and the cost of contention

ssitwo-phase-lockingactual-serial-execution
SSI is optimistic. Transactions run on snapshots without blocking, and at commit the database checks whether something they read has since been overwritten by a committed transaction. Here many clients run random transactions against a shrinking set of hot keys, under 2PL (block and wait) and SSI (abort and retry).

Committed transactions per 100 ticks vs contention

no conflicts at alltwo-phase lockingserializable snapshot isolationserial execution (one thread)
050100150416642561024size of the hot set of keys (← more contention)commits / 100 ticks64 keys

Try: drag writes to 0%: readers never conflict, so both lines reach the ceiling. Then raise writes and shrink the hot set: 2PL burns time waiting and breaking deadlocks, SSI burns it on aborted work. Serial execution ignores contention entirely, but only scales with the speed of a single core.

2PL commits
111.4
1% deadlock aborts
2PL time spent waiting
29%
of all client-ticks blocked on a lock
SSI commits
82.8
SSI abort rate
34%
attempts that read a value made stale by a commit
  • takeawayAtomicity is abortability. It turns a crash halfway through into a clean error you can retry. Durability means the commit is on disk or replicated before the client hears “ok”.
  • takeawayWeak isolation levels each let something through. Read committed allows read skew and lost updates. Snapshot isolation still allows write skew and phantoms. Learn which level your database actually defaults to.
  • takeawayTrue serializability comes three ways. Serial execution is fast only for tiny in-memory transactions. 2PL blocks and deadlocks under contention. SSI never blocks but aborts more as conflicts rise.

Key concepts · 20

book chapter 7
  1. ACID

    Atomicity, Consistency, Isolation, Durability: the safety guarantees a transaction is supposed to provide.

    The term is more marketing than definition: implementations differ widely, and 'isolation' in particular rarely means serializable by default.

    ↑ see it in the visualization
  2. Atomicity (abortability)

    If a transaction can't finish, every write it made is discarded; it's all or nothing.

    It has nothing to do with concurrency. Its value is that after a fault the application can safely retry instead of guessing which writes landed.

    ↑ see it in the visualization
  3. Consistency (in ACID)

    Application-defined invariants (e.g. credits equal debits) hold before and after every transaction.

    It's a property of the application, not the database: the database can't know your invariants, it can only give you atomicity and isolation to preserve them.

    ↑ see it in the visualization
  4. Durability

    Committed data is not lost, even after a crash: written to non-volatile storage (with a write-ahead log) or replicated before acknowledging.

    Perfect durability doesn't exist (disks fail, fsync lies, correlated outages); it's a matter of reducing risk, usually by combining disks and replication.

    ↑ see it in the visualization
  5. Dirty read

    A transaction sees another transaction's writes before they've committed.

    The other transaction may still abort, so you'd act on data that never officially existed. Prevented by read committed and above.

    ↑ see it in the visualization
  6. Dirty write

    A transaction overwrites a value that another uncommitted transaction has written.

    Interleaved writes across rows (the car sold to Bob but invoiced to Alice). Almost every database prevents it with row-level write locks held until commit.

    ↑ see it in the visualization
  7. Read committed

    Isolation level that prevents dirty reads (show the last committed value) and dirty writes (row write locks).

    The default in PostgreSQL, Oracle and SQL Server. It still allows read skew, lost updates and write skew.

    PostgreSQLOracleSQL Server
    ↑ see it in the visualization
  8. Read skew (non-repeatable read)

    A transaction sees different parts of the database at different points in time, e.g. one account before a transfer and the other after.

    Tolerable for a single page view, fatal for backups and long analytic queries, which would observe a state that never existed.

    ↑ see it in the visualization
  9. Snapshot isolation

    Each transaction reads from a consistent snapshot of the database as of when it started.

    Readers never block writers and writers never block readers. Called 'repeatable read' in PostgreSQL and MySQL and 'serializable' in Oracle, though it isn't serializable.

    PostgreSQLMySQL InnoDBOracleSQL Server
    ↑ see it in the visualization
  10. Multi-version concurrency control (MVCC)

    Keep several committed versions of each row, tagged with created_by and deleted_by transaction IDs, so different transactions can see different points in time.

    Visibility rules decide which version each reader sees, which is how snapshot isolation is implemented without read locks; garbage collection removes versions nobody can see.

    ↑ see it in the visualization
  11. Lost update

    Two transactions read-modify-write the same value concurrently, and one overwrites the other's change.

    The classic counter-increment bug. Fix it with atomic updates, explicit locks (SELECT FOR UPDATE), compare-and-set, or a database that detects it and aborts.

    ↑ see it in the visualization
  12. Compare-and-set

    Write a new value only if the current value still equals what you read; otherwise retry.

    Avoids locks, but under contention most of the work is spent on retries. Unsafe if the 'compare' reads from an old snapshot.

    ↑ see it in the visualization
  13. Write skew

    Two transactions read the same objects, each decides based on what it read, then they update different objects, together breaking an invariant.

    A generalization of lost update that snapshot isolation does not prevent (doctors on call, double-spending, unique usernames). Needs serializable isolation or explicit locks on the rows read.

    ↑ see it in the visualization
  14. Phantom

    A write in one transaction changes the result of a search query in another, typically by inserting a row that didn't exist when the search ran.

    There's no row to lock when the check is 'no booking exists'; you need predicate or index-range locks, materialized conflicts, or SSI.

    ↑ see it in the visualization
  15. Serializability

    The strongest isolation level: the result is the same as if transactions had run one at a time, in some order.

    Prevents every race condition above. Three ways to get it: actual serial execution, two-phase locking, and serializable snapshot isolation.

    ↑ see it in the visualization
  16. Actual serial execution

    Run every transaction one at a time on a single thread, with data in memory and transactions submitted as stored procedures.

    No concurrency control at all, but throughput is capped at one CPU core per partition, and cross-partition transactions are much slower.

    VoltDBH-StoreRedisDatomic
    ↑ see it in the visualization
  17. Two-phase locking (2PL)

    Readers take shared locks and writers exclusive locks, all held until the transaction ends (grow, then shrink).

    Pessimistic: readers block writers and writers block readers. Serializable, but with unstable latency, low throughput under contention, and frequent deadlocks.

    MySQL serializableSQL Server serializableDB2
    ↑ see it in the visualization
  18. Predicate and index-range locks

    Locks on all objects matching a search condition, including rows that don't exist yet; usually approximated by locking a range of an index.

    What lets 2PL prevent phantoms such as double-booked meeting rooms.

    ↑ see it in the visualization
  19. Deadlock

    Transactions each waiting for a lock the other holds, forming a cycle in the wait-for graph.

    The database detects the cycle and aborts one transaction, which the application must retry. Under 2PL this happens often.

    ↑ see it in the visualization
  20. Serializable snapshot isolation (SSI)

    Snapshot isolation plus tracking of which transactions read data that a concurrent transaction wrote; on a dangerous pattern, abort at commit.

    Optimistic: no blocking, readers and writers proceed freely. Performs well under low contention, but abort rates rise as contention grows.

    PostgreSQL serializableFoundationDBCockroachDB
    ↑ see it in the visualization