R0KernelPath
module 188 / 262

Transactions & ACID

Transactions give databases a superpower: all-or-nothing bundles of operations that survive crashes and concurrent chaos — the bedrock of every financial, medical, and social system on earth.

~12h focused study11 concepts

Overview

Transactions and ACID

Every day, millions of bank transfers happen. Money leaves one account and arrives in another. But what if the database crashes between the debit and the credit? What if two people withdraw from the same account at the exact same millisecond? Without a principled answer to these questions, databases would be treacherous — silently corrupting the data that societies depend on.

The transaction is the database's answer. A transaction is a logical unit of work: a sequence of reads and writes that the database treats as indivisible, as if they happened all at once, or not at all. Transactions were not invented out of elegance — they were invented out of necessity, hard-won from decades of catastrophic failures in early banking and airline systems. Jim Gray, who won the Turing Award largely for formalizing this work in the 1970s, described the discipline as "making failures invisible to the programmer."

The properties that make transactions trustworthy are crystallized in the acronym ACID — Atomicity, Consistency, Isolation, Durability. Each letter is a separate guarantee, enforced by a distinct mechanism inside the database engine (the write-ahead log, the lock manager, the buffer pool). Together, they form a contract: the programmer describes what should happen as a logical unit, and the database guarantees it happens correctly — even if the machine loses power, even if a thousand concurrent transactions are running simultaneously.

This module builds ACID from the ground up: why each property exists, what goes wrong without it, how it is implemented, and how the strictness of isolation can be tuned for performance. We will also meet the specific anomalies — dirty reads, lost updates, phantom reads — that isolation levels exist to prevent. By the end, you will not just know what ACID means; you will feel why it is the right abstraction, and understand the precise engineering that makes it possible.

why this matters for the kernel

Transactions are arguably the most consequential idea in applied computer science. Every financial system, every e-commerce checkout, every medical record system, every reservation engine in the world relies on the ACID contract. When you click "Pay" on Amazon, a transaction atomically reserves your item, charges your card, and creates a shipment record — and if any step fails, the entire operation rolls back cleanly. Without transactions, partial failures would silently corrupt data in ways nearly impossible to audit or recover from.

Beyond correctness, the isolation model is central to systems performance. Modern databases run thousands of concurrent transactions per second. The difference between serializable isolation and read-committed isolation can be a 10x throughput difference — but the weaker level permits subtle bugs. Every senior engineer working on data systems must understand these tradeoffs deeply: when an anomaly is acceptable (analytics), when it is catastrophic (money), and how to choose the right isolation level. The NewSQL and distributed database era (Google Spanner, CockroachDB, Yugabyte) re-implements ACID across geographically distributed machines — proof that this 1970s abstraction remains the right model even at planetary scale.

interactive visualization
Txn A — deposit +50
local register
—
shared row
100
accounts.balance
Txn B — withdraw −30
local register
—
schedule · operation 0 / 4
AA: read balance
BB: read balance
AA: write balance + 50
BB: write balance − 30
expected
100 + 50 − 30 = 120
final balance
…

Interleaved: both read 100 before either writes — B's write clobbers A's. Isolation makes concurrent txns behave as if run one after another.

Transaction Lifecycle State Machine

rendering diagram…

A transaction moves from Active (executing operations) to Partially Committed (waiting for WAL flush) to Committed (durable). Any error along the way leads to Failed and then Aborted, where the undo log cleanly reverses all partial writes. Committed and Aborted are both terminal states visible to the rest of the system.

The concepts

01What Is a Transaction?

A transaction is a sequence of one or more database operations (reads and writes) that the database treats as a single logical unit of work. The key intuition: either all operations in the transaction happen, or none do — and while the transaction is running, its partial effects are invisible to everyone else.

Think of it like a surgeon performing an operation on a patient. You don't want the surgeon to stop halfway through and leave the patient "partially fixed". The transaction says: do all of this as if it were one atomic action.

In SQL, a transaction is delimited with BEGIN and either COMMIT (make it permanent) or ROLLBACK (undo everything):

BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 'alice';
  UPDATE accounts SET balance = balance + 100 WHERE id = 'bob';
COMMIT;

If the database crashes after the first UPDATE but before the second, the DBMS — on recovery — will detect the incomplete transaction and automatically undo the first UPDATE. Alice's money is restored. Nothing is lost.

This is the fundamental value proposition: the programmer expresses what "one thing" means, and the database enforces it. Without transactions, the programmer would have to write complex recovery code for every possible failure scenario — an impossible task in practice.

BEGIN;
  -- Debit Alice
  UPDATE accounts SET balance = balance - 100 WHERE user = 'alice';
  -- Credit Bob
  UPDATE accounts SET balance = balance + 100 WHERE user = 'bob';
COMMIT; -- or ROLLBACK on error

02Atomicity: All or Nothing

Atomicity means a transaction is indivisible. It either commits completely — all writes become permanent — or aborts completely — all writes are undone, as if the transaction never ran. There is no observable half-committed state.

The mechanism that enforces atomicity is the Write-Ahead Log (WAL), also called the redo/undo log. Before any page in the database is modified on disk, the database first writes a log record describing the change to a separate log file on disk. This log record contains:

  • The transaction ID
  • The page being modified
  • The before-image (old value, for UNDO)
  • The after-image (new value, for REDO)

If the system crashes mid-transaction, the recovery algorithm (ARIES is the canonical algorithm) replays the log on restart:

  1. REDO all changes from committed transactions that may not have reached disk
  2. UNDO all changes from transactions that were in-flight when the crash happened

Atomicity also handles logical aborts: if a transaction detects an error (insufficient balance, constraint violation), it can call ROLLBACK explicitly. The undo log is traversed backwards, reversing each operation in reverse order.

A crucial subtlety: atomicity says nothing about what other transactions see during execution — that is isolation's job. Atomicity only guarantees the outcome: all or nothing.

# Python with psycopg2 — explicit transaction control
import psycopg2

conn = psycopg2.connect(dsn)
conn.autocommit = False   # start transaction manually
try:
    cur = conn.cursor()
    cur.execute("UPDATE accounts SET balance = balance - 100 WHERE user = 'alice'")
    cur.execute("UPDATE accounts SET balance = balance + 100 WHERE user = 'bob'")
    conn.commit()           # atomic: both writes persist
except Exception:
    conn.rollback()         # atomic: neither write persists
    raise

03Consistency: Preserving Invariants

Consistency is the "C" in ACID, and it is philosophically different from the other three. Atomicity, Isolation, and Durability are database guarantees — the DBMS enforces them mechanically. Consistency is a joint responsibility: the programmer defines the invariants (rules), and the database enforces them via constraints, but the programmer must write transactions that respect those invariants.

An invariant is a rule that must always be true. Examples:

  • The sum of all account balances should not change during a transfer
  • A foreign key must point to an existing row
  • A student's GPA must be between 0 and 4.0
  • No two seats on the same flight can be sold to different passengers

The database enforces declarative constraints (foreign keys, unique constraints, CHECK constraints, NOT NULL) automatically. If a transaction attempts a write that would violate a constraint, the database aborts the transaction.

But many application-level invariants — like "the booking system should never sell more seats than exist" — require the programmer to write the transaction correctly, often by acquiring the right locks or using the right isolation level.

A subtle but important point: in the CAP theorem and distributed systems literature, "consistency" means something entirely different (linearizability / all nodes see the same data). This ACID consistency is about application invariants, not distributed coherence. Jim Gray himself noted this naming collision was unfortunate.

-- Database-enforced consistency via constraints
CREATE TABLE accounts (
    user_id   TEXT PRIMARY KEY,
    balance   NUMERIC NOT NULL CHECK (balance >= 0)  -- invariant enforced
);

-- This transaction will be ABORTED by the DB if alice has < 100:
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE user_id = 'alice';
  -- If CHECK constraint fails, entire transaction rolls back
  UPDATE accounts SET balance = balance + 100 WHERE user_id = 'bob';
COMMIT;

04Isolation: Anomalies and Concurrent Transactions

Isolation is the most nuanced and performance-sensitive ACID property. It governs what one transaction can see of another's concurrent work.

The ideal is serializable isolation: the system behaves as if transactions ran one after another in some serial order, even though they actually ran concurrently for performance. This is provably the safest model.

But full serializability is expensive. Databases therefore offer weaker isolation levels, each permitting a specific class of anomaly in exchange for better throughput. Understanding the anomalies is essential:

Dirty Read: Transaction T2 reads a value written by T1, but T1 has not committed yet. If T1 aborts, T2 has read data that "never existed".

Non-repeatable Read: T1 reads a row, T2 updates and commits that row, then T1 reads the same row again and sees a different value. The same query gives different results within one transaction.

Phantom Read: T1 executes a query with a predicate (e.g., WHERE age > 30), T2 inserts a new row matching that predicate and commits, then T1 re-executes the query and sees the new row — a "phantom" that appeared from nowhere.

Lost Update: T1 and T2 both read a value, both increment it, and both write back. One write overwrites the other. A classic example is two airline agents booking the last seat simultaneously.

Write Skew: T1 and T2 each read an overlapping set of rows and make decisions based on what they see; both then write to different rows, but their combined writes violate an invariant. For example: an on-call scheduling system requires at least one doctor on call. T1 reads (Alice=on-call, Bob=on-call) and removes Alice. T2 reads (Alice=on-call, Bob=on-call) and removes Bob. Both commits succeed; now nobody is on-call.

Each isolation level is defined by which anomalies it prevents.

-- Demonstrating a Lost Update (under Read Committed)
-- Session 1:
BEGIN;
SELECT balance FROM accounts WHERE user = 'alice';  -- reads 1000
-- (Session 2 concurrently reads 1000, adds 500, writes 1500, commits)
UPDATE accounts SET balance = 1000 + 200 WHERE user = 'alice';  -- writes 1200
COMMIT;
-- Session 2's 500 deposit is LOST. Alice should have 1700.

-- Fix: use SELECT FOR UPDATE to acquire a lock before reading
BEGIN;
SELECT balance FROM accounts WHERE user = 'alice' FOR UPDATE;
-- Now Session 2 must wait. Safe to read-modify-write.

05Isolation Levels: The SQL Standard

The SQL standard defines four isolation levels, each a tradeoff between safety and performance. They form a hierarchy from weakest to strongest:

LevelDirty ReadNon-Repeatable ReadPhantom Read
READ UNCOMMITTEDpossiblepossiblepossible
READ COMMITTEDpreventedpossiblepossible
REPEATABLE READpreventedpreventedpossible
SERIALIZABLEpreventedpreventedprevented

READ UNCOMMITTED is almost never used in practice. It permits dirty reads, which means you can see data from transactions that haven't committed yet — like reading a check before it clears.

READ COMMITTED is the default in PostgreSQL and Oracle. Every read sees only committed data. Dirty reads are impossible. However, two reads of the same row in one transaction can return different values (non-repeatable reads), because another transaction may commit between them.

REPEATABLE READ guarantees that if you read a row, it will return the same value if read again within the same transaction. MySQL's InnoDB implements this with a consistent snapshot (MVCC). However, phantom rows can still appear from inserted rows.

SERIALIZABLE is the gold standard. The database ensures the outcome is equivalent to some serial execution. PostgreSQL implements this with Serializable Snapshot Isolation (SSI), which detects dangerous read-write dependencies between transactions and aborts one if a cycle is found.

A crucial practical note: Snapshot Isolation (SI), used by PostgreSQL's REPEATABLE READ level, prevents more anomalies than the SQL standard requires for REPEATABLE READ — but it does NOT prevent write skew or certain phantom scenarios. True serializability requires SSI.

Choose your isolation level deliberately: financial ledgers deserve SERIALIZABLE; analytics dashboards can tolerate READ COMMITTED.

-- Setting isolation level in PostgreSQL
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  SELECT balance FROM accounts WHERE user = 'alice';
  -- ... other reads ...
  UPDATE accounts SET balance = balance - 100 WHERE user = 'alice';
COMMIT;

-- For a read-only analytics query, READ COMMITTED is fine:
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
  SELECT AVG(balance) FROM accounts;
COMMIT;

06Implementing Isolation: Locking vs. MVCC

Two fundamentally different mechanisms implement isolation in real databases:

Two-Phase Locking (2PL) is the classical approach. Every transaction acquires locks before accessing data and holds them until commit. The protocol has two phases:

  1. Growing phase: acquire locks, never release them
  2. Shrinking phase: release locks, never acquire new ones

If two transactions conflict (one wants a write lock on a row that another holds a read lock on), one must wait. This prevents anomalies by ensuring conflicting operations are totally ordered. However, 2PL can suffer from deadlocks: T1 holds a lock on row A and wants row B; T2 holds row B and wants row A. The database detects deadlock cycles (via a waits-for graph) and aborts one transaction.

Multi-Version Concurrency Control (MVCC) is the modern approach, used by PostgreSQL, Oracle, and MySQL/InnoDB. Instead of locking rows, the database maintains multiple versions of each row — one per transaction that modified it. Each row version is tagged with the transaction ID that created it and (optionally) the transaction that deleted it.

When T2 reads a row, it sees the most recent version that was committed before T2 started. T1's in-progress writes create new versions but don't disturb T2's snapshot. This means readers never block writers and writers never block readers — a massive throughput win.

The tradeoff: MVCC needs garbage collection (called VACUUM in PostgreSQL) to reclaim old row versions no longer needed by any active transaction. Without it, tables bloat.

MVCC elegantly implements snapshot isolation: every transaction sees a consistent point-in-time view of the database — like a photograph taken the moment the transaction started.

-- MVCC in action: two concurrent sessions in PostgreSQL
-- Time 1: Session A starts
-- Session A: BEGIN; (snapshot taken at T=100)

-- Time 2: Session B starts and commits
-- Session B: BEGIN; UPDATE accounts SET balance=999 WHERE id=1; COMMIT;

-- Time 3: Session A reads — sees balance=1000, NOT 999
-- Session A: SELECT balance FROM accounts WHERE id=1;
-- Returns: 1000  (the version visible at T=100)
-- Session B's committed write is a NEWER version; A's snapshot predates it

-- This is snapshot isolation: each transaction sees a consistent snapshot
-- Readers do NOT block writers. Writers do NOT block readers.

07Durability: Surviving Crashes

Durability means that once a transaction commits, its changes are permanent — even if the database crashes immediately after the COMMIT returns to the client. A power failure, OS panic, or hardware fault cannot undo a committed transaction.

Durability is enforced by the Write-Ahead Log (WAL). The WAL protocol has one rule: log records describing a change must reach durable storage (disk) before the data page itself is written to disk. This is enforced by calling fsync() on the log file at commit time.

Why does this work? On recovery after a crash:

  1. The database reads the WAL from the last checkpoint forward
  2. Any transaction whose COMMIT record is in the log but whose data pages haven't been written is redone (the after-images are replayed)
  3. Any transaction without a COMMIT record is undone (the before-images are replayed backwards)

This is the ARIES recovery algorithm (1992, IBM Research), which underlies essentially every major relational database.

A critical performance consideration: fsync() is slow — it flushes the entire disk write buffer. Databases use a group commit optimization: batch multiple transactions' log records and call fsync() once for the group. This amortizes the I/O cost.

The durability guarantee is only as strong as the storage. A battery-backed RAID controller or NVMe SSD with power-loss protection is required for true durability. Cloud databases (Amazon RDS, Google Cloud SQL) achieve durability through replication: a commit only returns after the log record is written to multiple replicas in multiple availability zones.

# Simplified WAL write protocol (pseudocode)
def commit_transaction(txn):
    # Step 1: Write all log records to the WAL buffer
    for op in txn.operations:
        wal_buffer.append(LogRecord(
            txn_id=txn.id,
            before_image=op.old_value,
            after_image=op.new_value
        ))
    wal_buffer.append(LogRecord(txn_id=txn.id, type=COMMIT))

    # Step 2: Flush WAL to disk (THIS is what makes it durable)
    wal_buffer.flush_to_disk()  # calls fsync()

    # Step 3: NOW safe to reply 'committed' to client
    # (Data pages may still be in buffer pool, not yet on disk -- that's OK!)
    return 'COMMITTED'

08The Transaction Lifecycle and State Machine

A transaction moves through a well-defined set of states from the moment it begins to its final outcome. Understanding this lifecycle is essential for reasoning about recovery, locking, and the behavior of the ROLLBACK command.

Active: The transaction has started and is executing operations. Reads and writes are being performed. Locks are being acquired (in 2PL). MVCC versions are being created.

Partially Committed: The transaction has issued its final operation and called COMMIT. The database is writing log records and flushing them to disk. It has not yet confirmed durability to the client.

Committed: The commit log record has been flushed to disk. The transaction's writes are now permanent. The database replies to the client. Locks are released (2PL).

Failed: Something went wrong — a constraint violation, deadlock victim selection, explicit ROLLBACK, or crash. The transaction must be rolled back.

Aborted (Rolled Back): The undo log has been applied. All partial writes by this transaction are reversed. The database is in the same state as if this transaction never ran. Locks are released.

Note that "Committed" and "Aborted" are both terminal states. There is no "partially committed" visible state — if the database crashes between "Partially Committed" and "Committed", recovery replays the log and either completes the commit (if the COMMIT log record made it to disk) or undoes the transaction (if it didn't).

This state machine is what makes ACID feel so clean from the programmer's perspective: you either get a committed transaction or a cleanly aborted one. The messy middle is the database's problem, not yours.

09Schedules and Serializability

To reason formally about isolation, we use the concept of a schedule: an ordering of the operations (reads and writes) of a set of concurrent transactions.

A schedule is serial if all operations of one transaction complete before any operation of the next begins. Serial schedules are always correct by definition — there is no concurrency to cause problems.

A schedule is serializable (or conflict-serializable) if it produces the same result as some serial schedule. This is the formal definition of the SERIALIZABLE isolation level.

Two operations conflict if they:

  1. Belong to different transactions
  2. Access the same data item
  3. At least one of them is a write

A schedule is conflict-serializable if and only if its precedence graph (also called conflict graph) is acyclic:

  • Draw one node per transaction
  • Draw an edge Ti → Tj if Ti has an operation that conflicts with and precedes a Tj operation
  • If there is a cycle, the schedule is NOT serializable

Example: T1 reads X, T2 writes X, T1 reads Y, T2 writes Y.

  • T1 reads X before T2 writes X → edge T1→T2
  • T1 reads Y before T2 writes Y → edge T1→T2
  • No cycle. Conflict-serializable (equivalent to serial order T1, T2).

Another example: T1 reads X, T2 writes X, T2 reads Y, T1 writes Y.

  • T1 reads X before T2 writes X → T1→T2
  • T2 reads Y before T1 writes Y → T2→T1
  • Cycle! NOT serializable. This is the formal definition of a problem.

This graph-theoretic framework is not just theoretical — it directly underpins how SSI in PostgreSQL detects and resolves dangerous schedules at runtime.

# Precedence-graph cycle detection (simplified)
def is_conflict_serializable(schedule):
    """schedule: list of (txn_id, op_type, data_item)"""
    edges = set()
    # Find all conflicting pairs
    for i, (ti, op_i, item_i) in enumerate(schedule):
        for j, (tj, op_j, item_j) in enumerate(schedule):
            if i < j and ti != tj and item_i == item_j:
                if op_i == 'W' or op_j == 'W':  # at least one write
                    edges.add((ti, tj))  # Ti precedes Tj in conflict
    # Check for cycles in the precedence graph
    return not has_cycle(edges)  # True means serializable

10Savepoints and Nested Transactions

Real applications often need finer-grained control than all-or-nothing. Savepoints allow a transaction to mark a point within itself, and roll back to that point without aborting the entire transaction.

This is invaluable in long-running workflows — for example, a multi-step order processing system that wants to retry a single failed step without losing earlier work.

SQL syntax:

BEGIN;
  INSERT INTO orders (id, user) VALUES (42, 'alice');
  SAVEPOINT after_order;
  INSERT INTO payments (order_id, amount) VALUES (42, 99.99);
  -- payment fails
  ROLLBACK TO SAVEPOINT after_order;
  -- try again with a different payment method
  INSERT INTO payments (order_id, amount) VALUES (42, 99.99);
COMMIT;

Savepoints do NOT provide true nested transaction semantics (where an inner transaction can commit independently of the outer). They are sub-boundaries within a single transaction. If the outer transaction aborts, all savepoints within it are also discarded.

True nested transactions (where inner transactions can commit or abort independently) exist in some academic database systems and in the Microsoft SQL Server SAVE TRANSACTION model, but are not universally supported or standardized. In PostgreSQL, autonomous transactions can be simulated via dblink to a separate connection.

The practical lesson: use savepoints when you need partial rollback within a long transaction. They are a valuable escape hatch in complex application logic.

BEGIN;
  INSERT INTO orders VALUES (1, 'alice', 99.99);
  SAVEPOINT sp1;
  
  INSERT INTO inventory_holds VALUES (1, 'item-A');
  -- Suppose this violates a constraint:
  INSERT INTO inventory_holds VALUES (1, 'item-B');  -- ERROR
  
  ROLLBACK TO SAVEPOINT sp1;  -- undo inventory inserts, keep order
  
  -- Try alternative allocation
  INSERT INTO inventory_holds VALUES (1, 'item-C');
COMMIT;  -- order + item-C hold committed; item-A and item-B never happened

11Transactions in Distributed Systems

The hardest version of the ACID problem is when a single transaction spans multiple machines. A bank transfer that moves money between branches stored on different servers must still be atomic.

Two-Phase Commit (2PC) is the classical protocol for distributed atomicity:

  1. Prepare phase: A coordinator sends PREPARE to all participant nodes. Each participant writes a prepare log record and replies YES (I am ready to commit) or NO (I need to abort).
  2. Commit phase: If all participants said YES, the coordinator writes a commit record and sends COMMIT to all. Each participant commits and acknowledges. If any said NO, the coordinator sends ABORT.

The critical invariant: once a participant says YES, it has irrevocably promised to commit if asked. It must retain its locks and log records until it hears the final decision — even across crashes.

2PC is blocking: if the coordinator crashes after participants have said YES but before sending the decision, participants are stuck holding locks indefinitely, waiting for a coordinator that may never return. This is the fundamental vulnerability of 2PC.

Three-Phase Commit (3PC) and Paxos/Raft-based commit protocols address this at the cost of more message rounds.

Modern distributed databases like Google Spanner achieve serializable distributed transactions using TrueTime — GPS and atomic clocks to assign globally consistent timestamps — giving them external consistency stronger than traditional ACID. CockroachDB implements a similar model using hybrid logical clocks.

The distributed transactions problem remains one of the deepest in systems research. The FLP impossibility theorem (1985) proves that consensus — which underlies distributed commit — cannot be solved deterministically in an asynchronous system with even one faulty process. Real systems work around this with timeouts and probabilistic guarantees.

# Two-Phase Commit pseudocode
def two_phase_commit(coordinator, participants, transaction):
    # PHASE 1: Prepare
    votes = []
    for p in participants:
        p.write_prepare_log(transaction)
        votes.append(p.vote())  # YES or NO

    # PHASE 2: Decide
    if all(v == 'YES' for v in votes):
        coordinator.write_commit_log(transaction)
        for p in participants:
            p.commit()     # each participant commits and releases locks
    else:
        coordinator.write_abort_log(transaction)
        for p in participants:
            p.rollback()   # each participant rolls back

Key insights

  • ✦ACID is not a monolithic concept: Atomicity and Durability are enforced by the Write-Ahead Log, Isolation by the lock manager or MVCC, and Consistency is a joint contract between the programmer (who defines invariants) and the database (which enforces constraints).
  • ✦The 'C' in ACID is the odd one out: it is the only property that is primarily the programmer's responsibility, not the database's — the database only enforces constraints you declare.
  • ✦MVCC enables readers and writers to never block each other by maintaining multiple timestamped versions of each row — this is the key insight behind PostgreSQL, Oracle, and MySQL/InnoDB's concurrency model.
  • ✦Serializability is provably correct by definition (it is equivalent to a serial schedule), but it requires detecting cycles in a precedence graph — Serializable Snapshot Isolation (SSI) does this at runtime with low overhead.
  • ✦Write skew is the most subtle anomaly: two transactions each read overlapping data and write to non-overlapping data, but together violate an invariant. It is not prevented by Snapshot Isolation — only full serializability stops it.
  • ✦Two-Phase Commit solves distributed atomicity but is a blocking protocol: a coordinator crash after participants vote YES but before the commit decision leaves participants holding locks indefinitely.
  • ✦The WAL guarantee is precise: a COMMIT is only acknowledged to the client after the commit log record is flushed to disk (fsync). Data pages themselves may still be in the buffer pool — REDO on recovery handles this.
  • ✦Choosing a weaker isolation level is not just a performance hack — it is a deliberate tradeoff that requires understanding exactly which anomalies become possible and whether your application can tolerate them.

Common pitfalls

  • ⚠Confusing the 'C' in ACID with the 'C' in CAP: ACID consistency means application invariants are preserved; CAP consistency means all nodes see the same data at the same time. They are completely different concepts that happen to share a word.
  • ⚠Assuming READ COMMITTED prevents lost updates: it does not. Two transactions can both read a value, compute an updated value, and write it back — the second write overwrites the first. Use SELECT FOR UPDATE or optimistic concurrency control.
  • ⚠Believing REPEATABLE READ in PostgreSQL is the same as in the SQL standard: PostgreSQL's REPEATABLE READ is actually Snapshot Isolation, which prevents more anomalies than the standard requires but still permits write skew.
  • ⚠Forgetting that Snapshot Isolation does NOT prevent write skew: the classic on-call doctor example. If your application has invariants over overlapping data sets written by concurrent transactions, use SERIALIZABLE.
  • ⚠Treating transactions as free: each transaction has overhead — log writes, lock acquisition, MVCC version creation. Very short-lived transactions (e.g., one INSERT) in a tight loop may benefit from batching multiple operations into one transaction.
  • ⚠Assuming that a ROLLBACK is instantaneous: undoing a large transaction (millions of rows updated) requires replaying the undo log in reverse — this can take as long as or longer than the original transaction took to run.
  • ⚠Using long-running transactions carelessly: in MVCC databases, old row versions must be retained as long as any transaction holds a snapshot older than those versions. A single long-running transaction can cause unbounded table bloat (PostgreSQL's 'table bloat' problem).

Practice

  1. 01
    medium

    **The lost-update lab**: Create a PostgreSQL table `counters(id INT PRIMARY KEY, val INT)` with one row `(1, 0)`. Write two Python threads that each run 1000 iterations of: `BEGIN; SELECT val; UPDATE counters SET val = val+1; COMMIT;` using READ COMMITTED isolation and separate connections. After both threads finish, what value do you observe? Explain exactly why it is less than 2000. Then fix the race using `SELECT FOR UPDATE` and verify you get exactly 2000.

  2. 02
    hard

    **Build a WAL from scratch**: Implement a tiny key-value store in Python that is crash-safe. Use a plain text file as a WAL. Every `set(key, value)` must append a log record to the WAL before updating an in-memory dict. Write a `recover()` function that replays the WAL on startup to reconstruct state. Demonstrate durability by simulating a crash (raise an exception) midway through setting multiple keys and showing recovery restores the correct state.

  3. 03
    medium

    **Write-skew demonstration**: Design a SQL schema for an on-call scheduling system where the invariant is 'at least one doctor must be on call at all times'. Write two transactions that each check the count of on-call doctors, see two, and each remove one — demonstrating write skew under REPEATABLE READ / Snapshot Isolation. Then show how using `SERIALIZABLE` isolation causes one transaction to be aborted and retried, preserving the invariant.

  4. 04
    easy

    **Precedence graph analysis**: Given the following schedule of two transactions (R=read, W=write): `T1:R(A), T2:W(A), T2:R(B), T1:W(B)` — draw the precedence graph, determine whether the schedule is conflict-serializable, and if so, find the equivalent serial order. Then construct a schedule over the same two transactions that is NOT conflict-serializable and prove it by showing a cycle.

  5. 05
    hard

    **Implement optimistic concurrency control (OCC)**: OCC is an alternative to locking where transactions execute freely, then *validate* at commit time that no conflicts occurred. Implement a simple OCC scheme in Python: each 'row' has a version number. A transaction reads rows and records the versions it saw. At commit, it checks whether any row's version has changed; if so, it aborts and retries. Benchmark your OCC implementation vs. a mutex-based approach under low and high contention workloads.

Resources

📕Readings in Database Systems (The Red Book), 5th Edition
bookfree

Peter Bailis, Joe Hellerstein, Michael Stonebraker eds. Chapter on transactions is the definitive graduate-level reading. Free online.

📕Database System Concepts, 7th Edition
bookpaidSilberschatz, Korth, Sudarshan — McGraw-Hill. The standard undergraduate textbook; Chapters 17-19 cover transactions, concurrency control, and recovery in full rigor.

The canonical undergrad DB textbook. Chapters 17 (transactions), 18 (concurrency control), 19 (recovery) are the definitive treatment at this level.

📕Transaction Processing: Concepts and Techniques
bookpaidJim Gray and Andreas Reuter — Morgan Kaufmann, 1992. The bible of transactions, written by the Turing Award winner who invented much of this material.

Encyclopedic and authoritative. Chapter 1 alone is worth reading for the history and motivation.

📜ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging
paperfree

The 1992 IBM Research paper by Mohan et al. that describes the recovery algorithm used in essentially all modern databases. Dense but essential.

📜A Critique of ANSI SQL Isolation Levels
paperfree

Berenson et al., 1995. Demonstrates that the SQL standard's English definitions of isolation levels are ambiguous and miss anomalies like write skew and lost updates. Introduced the precise anomaly-based taxonomy.

📜Serializable Snapshot Isolation in PostgreSQL
paperfree

Ports SSI theory to a real production database. Explains the predicate-locking and cycle-detection approach used in PostgreSQL 9.1+.

🎓CMU 15-445/645: Database Systems — Lectures on Transactions and Concurrency Control
coursefree

Andy Pavlo's excellent CMU course. Lecture videos and slides on ACID, 2PL, MVCC, and recovery are among the best free resources available.

📄PostgreSQL Documentation: Transaction Isolation
docsfree

Precise, authoritative documentation on PostgreSQL's isolation levels with concrete examples of which anomalies each level prevents.

Checkpoints — you can now…

  • ✓You can explain why a bank transfer must be wrapped in a transaction, and describe exactly what the database does if the machine crashes between the debit and the credit.
  • ✓You can define all four ACID properties, explain which database mechanism enforces each one (WAL for A and D, lock manager or MVCC for I, constraints + programmer for C), and explain why C is different from the other three.
  • ✓You can describe at least four distinct concurrency anomalies (dirty read, non-repeatable read, phantom read, lost update, write skew) and for each, give a concrete two-transaction example that demonstrates the anomaly.
  • ✓You can construct the precedence graph for a given schedule of two or three transactions and determine whether it is conflict-serializable.
  • ✓You can explain the difference between Two-Phase Locking and MVCC, and state the key advantage of MVCC: readers never block writers and writers never block readers.
  • ✓You can explain the WAL durability guarantee precisely: a COMMIT is durable once the commit log record is flushed to disk (fsync), even if data pages are still in memory.
  • ✓You can choose the appropriate isolation level for a given scenario (e.g., SERIALIZABLE for on-call scheduling, READ COMMITTED for analytics), and justify why a weaker level would permit a specific anomaly.
  • ✓You can sketch the Two-Phase Commit protocol for a distributed transaction and explain its fundamental vulnerability: it is a blocking protocol if the coordinator crashes after participants vote YES.