Skip to the chapter
←System DesignGGroundworkSDSystem Design
System Design/Advanced/A2
22 / 36 written chapters
/

The cover
  1. B1The mental model
  2. B2Client-server basics
  3. B3Vertical vs horizontal
  4. B4Databases in design
  5. B5Caching fundamentals
  6. B6APIs & communication
  7. B7Walkthrough: URL shortener
  1. I1Load balancing in depth
  2. I2CDNs
  3. I3Message queues & async
  4. I4Consistency models
  5. I5Replication
  6. I6Rate limiting & throttling
  7. I7Designing for availability
  8. I8Storage systems
  9. I9Search systems
  10. I10Microservices
  11. I11Security basics
  12. I12Walkthrough: feed/chat
  13. I13Walkthrough: notifications
  1. A1CAP theorem in depth
  2. A2Transactions and isolation
  3. A3Storage engines
  4. A4Sharding at scale
  5. A5Time, ordering and IDs
  6. A6Distributed consensus
  7. A7Batch & stream processing
  8. A8Fault tolerance
  9. A9Observability at scale
  10. A10Release safety
  11. A11Capacity estimation
  12. A12Case studies
  13. A13Walkthrough: key-value store
  14. A14Walkthrough: web crawler
  15. A15Walkthrough: payments
  16. A16Tradeoff thinking

SDA2 · Part 3 · Advanced

Transactions and isolation

A transaction is a promise about what concurrent users can and cannot see, and the isolation level is how much of that promise you are actually buying.

21 min read3 diagrams9 sections0 exercises
22 of 36

In this chapter

  • ACID, honestly
  • The anomalies, each as a story you can retell
  • Isolation levels, and why the names lie
  • Making it truly serializable: three techniques
  • What the four systems actually default to

and 4 more

about 21 min
09On this page
  1. ACID, honestly
  2. The anomalies, each as a story you can retell
  3. Isolation levels, and why the names lie
  4. Making it truly serializable: three techniques
  5. What the four systems actually default to
  6. Distributed transactions: two-phase commit and why it blocks
  7. Sagas and the outbox: when you cannot or will not hold locks
  8. Picking the right tool, in one table
  9. Recognizing it in an unseen problem

ACID, honestly

A transaction groups several reads and writes into one unit that either happens or does not. The four letters of ACID are usually recited as if they were four equal guarantees. They are not, and an interviewer who asks "what does ACID mean" is checking whether you know which letters the database gives you and which ones you supply.

  • Atomicity means all-or-nothing on failure: if a transaction aborts or the server crashes halfway, none of its writes survive. It says nothing about concurrency. A better name is abortability.
  • Consistency is the odd one out. It means your application's invariants hold (an account never goes below zero, every order has a customer). The database can enforce only the rules you declare as constraints; everything else is the application's job, and the other three letters are the tools for keeping it.
  • Isolation is what concurrent transactions are allowed to see of each other. This is the letter with levels, the one the rest of the chapter is about, and the one most often weaker than people assume.
  • Durability means a committed transaction survives a crash, which is a statement about when the log reaches disk. PostgreSQL's default synchronous_commit = on waits for the local WAL flush (and for the synchronous standbys' flush too, if any are configured). Setting it to off lets the server report success first: the manual says recent commits can be lost in a crash, for up to three times wal_writer_delay (200 ms by default, so about 600 ms), but the database stays consistent, as though those transactions had aborted cleanly. Turning fsync off is different and can corrupt the cluster. Surviving a lost machine is a replication question, covered in the replication chapter.

The word "transaction" also stretches. Redis MULTI/EXEC runs its queued commands one after another with nothing interleaved, but it has no rollback: a command that fails after EXEC does not stop the rest, and the docs say rollback was left out for simplicity and performance. It is isolation without abortability. A relational database is the opposite trade.

The anomalies, each as a story you can retell

Isolation levels are defined by which anomalies they allow, so learn the anomalies first. Everything below involves two concurrent transactions, T1 and T2.

AnomalyWhat goes wrongExample
Dirty writeT2 overwrites a value T1 wrote but has not committedTwo buyers of one item: the car goes to Alice in the listing table and to Bob in the invoice table
Dirty readT2 reads a value T1 wrote but has not committed; T1 then abortsYou see an unread-count of 1 for a message that was rolled back
Non-repeatable read (read skew)T1 reads the same row twice, or two related rows, and sees different committed states because T2 committed in betweenA backup reads account A, T2 moves 100 from B to A, the backup reads B: the total is short by 100
Lost updateTwo transactions read a value, both compute a new one from it, and the second write overwrites the firstA counter at 10, two increments, final value 11 instead of 12
PhantomT1 runs a query for rows matching a condition; T2 inserts a row that matches; T1 repeats the query and gets a different set"Is anyone booked in room 4 from 2 to 3?" answered no, then, after another transaction commits a booking, yes. (The cousin where both transactions check and both insert is write skew.)
Write skewT1 and T2 each read overlapping data, then write different rows, and the combination breaks an invariant neither checked aloneThe on-call doctors below

Write skew is the one worth dwelling on, because every simple rule people carry ("a transaction sees a consistent snapshot", "the later write wins") fails to catch it. Two doctors are on call; the rule is that at least one must stay on call. Both feel unwell and click "go off call" within the same second.

T1 (Alice) T2 (Bob) 1 read: 2 on call 2 set Alice off call 3 commit 1 read: 2 on call 2 set Bob off call 3 commit 0 on call rule broken Neither transaction wrote what the other wrote, so there is no conflicting row for snapshot isolation to catch. Each decision was correct against its own snapshot. The combination was never checked by anyone.
Write skew: the invariant spans two rows, the writes touch different rows, and so a write-write conflict check sees nothing wrong.

Isolation levels, and why the names lie

The SQL standard names four levels (read uncommitted, read committed, repeatable read, serializable) and defines them by three anomalies: dirty reads, non-repeatable reads and phantoms. Real databases implemented the names in different ways, and the standard's definitions never mentioned lost update or write skew at all. The consequence is that the label on the level tells you less than the engine behind it. Kleppmann's Hermitage project exists for exactly this reason: it runs the same test transactions against each database to "nail down precisely what different database systems actually mean with their isolation levels".

AnomalyRead committedSnapshot isolation (PostgreSQL "repeatable read")Serializable
Dirty readPreventedPreventedPrevented
Non-repeatable readPossiblePreventedPrevented
PhantomPossiblePrevented by the snapshot in PostgreSQLPrevented
Lost update (read, compute, write)PossiblePostgreSQL aborts the second writer; some engines do notPrevented
Write skewPossiblePossiblePrevented

PostgreSQL's own table says a Read Uncommitted request behaves as Read Committed, and that its Repeatable Read does not allow phantoms even though the standard would permit them. Its documentation calls Repeatable Read an implementation of snapshot isolation and says that level is not necessarily consistent with any serial execution.

Snapshot isolation, the level most databases actually run

Most systems implement the middle levels with multi-version concurrency control (MVCC). Writers never overwrite a row in place; they add a new version, and each transaction reads the versions that were committed when its snapshot was taken. Readers never block writers and writers never block readers, which is why this design won. In PostgreSQL's Repeatable Read the snapshot is taken at the first statement of the transaction and held to the end; Read Committed takes a fresh snapshot for each statement. InnoDB uses snapshots too, though Hermitage rates its repeatable read as weaker than PostgreSQL's: its consistent reads at REPEATABLE READ use the snapshot established by the first read, while at READ COMMITTED each consistent read sets and reads its own fresh snapshot.

Snapshot isolation catches write-write conflicts on the same row. In PostgreSQL, if a Repeatable Read transaction tries to update a row that a concurrent transaction changed and committed since its snapshot, it fails with could not serialize access due to concurrent update and the application must retry the whole transaction. That kills the classic lost update. InnoDB does not do this: Hermitage's results for MySQL REPEATABLE READ show lost update and write skew both still possible, so the same "repeatable read" label protects you in one database and not in the other. Neither engine can see write skew, because T1 and T2 wrote different rows.

Dry run: lost update, then write skew, under each rule

First a counter at 10 and two clients each adding 1. Then the doctors, with 2 on call and a rule that the count must stay at 1 or more.

ScenarioRuleWhat the two transactions doFinal stateVerdict
Counter at 10Read, add 1 in the app, write back (any level that lets both read 10)T1 reads 10, T2 reads 10, T1 writes 11, T2 writes 1111Lost update: 10 + 2 should be 12
Counter at 10One statement: UPDATE c SET n = n + 1Each update takes the row lock, reads the latest value and adds 1 in turn12Correct, and the cheapest fix
Counter at 10PostgreSQL Repeatable Read, read-modify-writeT1 writes 11 and commits; T2's update hits a row changed after its snapshot and aborts; T2 retries, reads 11, writes 1212Correct, at the price of a retry loop
2 on callSnapshot isolationT1 and T2 both read count = 2; T1 sets Alice off; T2 sets Bob off; no shared row, both commit0 on callWrite skew: 2 − 1 − 1 = 0
2 on callPostgreSQL Serializable (SSI)Same steps; the engine notices a dangerous pattern of read-write dependencies that could be part of a non-serializable cycle; one transaction gets SQLSTATE 40001 (on a later statement or at commit)1 on callCorrect, after a retry; the retry reads count = 1 and refuses
2 on callLocking read at Read Committed: SELECT ... FOR UPDATE on all on-call rowsT1 locks both rows; T2's lock request waits; T1 commits (1 left); T2 reads count = 1 and stops1 on callCorrect, and T2 waited instead of failing (at Repeatable Read or Serializable, PostgreSQL would instead abort T2 with a serialization failure)

Notice that the three fixes for write skew have different shapes. Declare the rule as a constraint if the database can express it; take locks on the rows you read so the second transaction waits; or run at a serializable level and retry on failure. Phantom-based variants of the same bug (a booking inserted into an empty range) cannot be fixed by locking rows that do not exist yet, which is why range locks, predicate locks or a materialised slot table exist.

Making it truly serializable: three techniques

Serializable means the outcome equals some one-at-a-time ordering of the transactions, as the consistency models chapter defined it. There are three ways to deliver it.

TechniqueHow it worksCostWhere you see it
Actual serial executionRun one transaction at a time on one thread, so isolation is free. The whole transaction must be a single stored procedure or script, since waiting for a client round trip would stall everything behind itThroughput of one core per partition, and transactions across partitions need extra coordinationRedis: commands in a transaction are serialised, and scripts are transactional
Two-phase locking (2PL)Take a shared lock to read and an exclusive lock to write, hold every lock until commit. Readers block writers and writers block readersBlocking, deadlocks (the database aborts one), and a slow transaction stalls everyone who touches its rowsInnoDB SERIALIZABLE converts plain SELECTs to SELECT ... FOR SHARE when autocommit is off. Spanner's read-write transactions use locks with wound-wait to avoid deadlock
Serializable snapshot isolation (SSI)Run snapshot isolation and track read-write dependencies between concurrent transactions. When they form a pattern that could be non-serializable, abort oneNo blocking, but aborts, and tracking memory. Every client needs a retry loopPostgreSQL SERIALIZABLE

PostgreSQL's SSI shows the design trade clearly. Its predicate locks, shown as SIReadLock in pg_locks, do not block anything and so cannot cause deadlock; they only record what was read. When a dangerous structure appears, a transaction fails with SQLSTATE 40001 and the application must start over, so under SSI a retry loop is part of the contract, not an optional extra. The authors of the PostgreSQL implementation measured, in 2012 on PostgreSQL 9.1, a performance cost of less than 7% relative to snapshot isolation on their transaction-processing and web-application benchmarks (a simple microbenchmark showed more), and found it outperformed two-phase locking significantly on some workloads. Two sharp edges: a hot standby cannot run SERIALIZABLE transactions, and data read in a serializable transaction should not be trusted until that transaction commits.

Which to choose? Serial execution when the data fits one partition and every transaction is short; SSI when contention is moderate and you want readers to stay unblocked; 2PL or explicit row locks when contention is high and aborting wastes more than waiting does. Under heavy contention SSI wastes work on aborts, though PostgreSQL's design guarantees that an immediate retry will not fail again with the same conflict; under locks the transactions queue instead.

What the four systems actually default to

SystemDefaultStronger optionWhat to remember
PostgreSQLRead CommittedRepeatable Read (snapshot isolation); Serializable (SSI)Read Committed permits lost update and write skew; Repeatable Read aborts conflicting updates but still permits write skew; only Serializable stops it, with retries
MySQL (InnoDB)REPEATABLE READSERIALIZABLE (locking reads); READ COMMITTED drops gap locksPlain SELECT reads a snapshot, but locking reads and writes use next-key or gap locks to block inserts into scanned ranges. Hermitage found lost update and write skew possible at the default
SpannerSerializable, with external consistencyRepeatable read (snapshot isolation) is available; the docs name write skew as the anomaly it permitsRead-write transactions lock and run two-phase commit across Paxos groups; read-only snapshot transactions take no locks
DynamoDBSingle-item operations; strongly consistent reads are optionalTransactWriteItems and TransactGetItemsTransactions are serializable with respect to other single-item operations, but Query, Scan and BatchGetItem are read-committed against a transaction. In multi-Region eventually consistent global tables a transaction is atomic only inside its own Region, and strongly consistent global tables do not support transactions at all

Two cautions. A default is a statement about the average application, not about yours, so a design that depends on an invariant across rows needs to name the level it relies on. And a name can hide an engine difference, as the lost-update row shows: "repeatable read" on two databases is two different products.

DynamoDB's transaction page is a good example of what distributed transactions cost. A transaction groups up to 100 items totalling at most 4 MB, performs two underlying reads or writes per item (one to prepare, one to commit), and a conflicting concurrent transaction is cancelled rather than waited for. Its own capacity example: one transaction a second writing three 500-byte items needs 3 × 2 = 6 write capacity units. A client token makes TransactWriteItems idempotent for 10 minutes after the request finishes. That prepare-then-commit doubling is the next topic in miniature.

Distributed transactions: two-phase commit and why it blocks

When one transaction spans two databases (or two shards), atomicity needs agreement: all must commit or none. Two-phase commit (2PC) achieves it with a coordinator. In phase one the coordinator asks every participant to prepare: each writes its changes and a promise to the log, keeps its locks, and votes yes or no. If every vote is yes, the coordinator durably records the decision to commit, and in phase two tells everyone. A single no, or a timeout, means abort.

coordinator shard A shard B 1 prepare? 2 yes (locks held) 3 log "commit" 4 commit in doubt: cannot decide alone same wait locks held If the coordinator dies between steps 2 and 4, both shards wait with their locks. A participant that voted yes may neither commit nor abort on its own.
2PC's atomicity is real, but the window between "I voted yes" and "I was told the outcome" is a window in which the participant has surrendered its freedom.

The blocking is the point. Once a participant has voted yes it must be able to commit if told to, so it cannot abort alone; yet it cannot commit alone because another participant may have voted no. If the coordinator crashes after collecting votes, participants are in doubt and hold their locks until the coordinator recovers and answers. PostgreSQL's PREPARE TRANSACTION documentation says exactly this: the transaction continues to hold whatever locks it held, it is unwise to leave one prepared for long (it holds up VACUUM and can even force the database to shut down to prevent transaction ID wraparound), and the feature is for external transaction managers, not for applications.

Costs to quote. A remote participant holds its locks from the moment it votes yes until the commit message reaches it, which is about one coordinator-to-participant round trip. With an 80 ms round trip that is 80 ms, so a hot row in such a transaction can commit at most 1 ÷ 0.080 = 12.5 times a second, and that is before counting the log forces and the cross-region round trips of the statements themselves, so real figures are worse. That arithmetic is why cross-region 2PC on contended rows is a bad idea. Spanner shows the repair: it runs 2PC across Paxos groups, but each coordinator and participant is itself a replicated state machine, so no single machine's crash blocks the transaction, and most transactions touch only one Paxos group and skip 2PC entirely. The price is paid in latency, with the commit wait described in the time and ordering chapter.

Sagas and the outbox: when you cannot or will not hold locks

Across services that own separate databases, holding locks across network calls is rarely acceptable, and a payment provider or email system will not join your 2PC at all. The alternative is a saga: a sequence of local transactions, each committed on its own, coordinated so that a failure midway triggers compensating transactions that undo the earlier steps. Reserve inventory, charge the card, create the shipment. If the shipment step fails, issue a refund and release the inventory.

  • Choreography: each service reacts to the previous step's event and publishes its own. No central owner, but the flow lives nowhere you can read.
  • Orchestration: an orchestrator tells each participant which local transaction to run and what to do on failure. One place to read, test and observe the flow.
  • A compensation is not a rollback. It is a new business action, and some effects cannot be undone: an email already sent, a parcel already shipped. Design the step order so the irreversible actions come last.
  • Sagas have no isolation. Another transaction can see the intermediate state (stock reserved, payment not yet taken). Treat that state as a legitimate status in the data model, such as "pending", and handle it explicitly.

A saga is only as reliable as each step's ability to commit locally and publish the message that triggers the next step. Doing those two things separately is the dual-write problem: publish before commit, and a rollback leaves a message about something that never happened; commit then publish, and a crash in between loses the message. The transactional outbox removes the gap. The service writes its business change and an outbox row in the same local transaction, so they commit or fail together. A separate relay then reads the outbox, either by polling it or by tailing the database's transaction log, and publishes to the broker. Because the relay can crash after publishing and before marking the row done, delivery is at least once, and consumers must be idempotent. The message queues chapter covers idempotent consumers and delivery guarantees in detail.

order service one local txn: order + outbox row outbox table rows wait until the relay sends them relay poll or log tail, then publish broker consumer dedups by id crash after publish, before marking sent: the row is sent again, so delivery is at least once Message sent if and only if the transaction committed.
The outbox turns "write to the database and the broker" into one atomic write plus a retryable copy job.

Picking the right tool, in one table

SituationReach forWhy
A counter, a balance, an inventory decrementOne atomic statement or conditional writeAvoids the read-compute-write gap entirely: no lost update at any level, though at Repeatable Read or Serializable PostgreSQL may abort the second writer and expect a retry
"At most one" (username, seat, doctor on call)A uniqueness or exclusion constraint, or a lock on the rows readWrite skew and phantoms are invisible to snapshot reads
Complex multi-row invariants in one databaseSerializable with a retry loopCorrect without reasoning about every interleaving
Two shards of one system that must both commit2PC inside a platform that replicates its coordinator (Spanner-style), or redesign so the entities share a shardPlain 2PC blocks on coordinator failure
Several services, each with its own databaseA saga with an outboxNo cross-service locks; failure handled by compensation
Say it like this → "Inside one database I'd use the default level and make each invariant explicit. Counters and stock decrements are single conditional updates, so there's no lost-update window. 'At most one doctor off call' is write skew, which snapshot isolation does not catch, so I'd either lock the rows I read or run that transaction at serializable and retry on a 40001. Across services I won't use two-phase commit, because a coordinator crash leaves participants holding locks; I'd use a saga with a transactional outbox so the event is published if and only if the order commits, and I'd make the consumers idempotent."

Recognizing it in an unseen problem

  • Signals: "two users at once", "transfer", "book the last seat", "reserve", "exactly one", "must not go negative", "across services". Each is a concurrency or atomicity question; name the anomaly before naming the fix.
  • The naive design reads a value, checks it in application code, then writes. That read-compute-write gap is where lost updates and write skew live. Replace it with a conditional write, a lock or a serializable transaction.
  • Ask which level, and in which database. "We'll use transactions" is incomplete; the answer that scores is "Postgres at read committed by default, so I'd add FOR UPDATE here" or "serializable, with a retry loop".
  • Distributed means expensive. Quote the 2PC lock-hold time (about a round trip per hop, plus log forces) and the blocking-on-coordinator-failure property, then offer the alternative: co-locate the data, or a saga with compensation.
  • Events plus database writes always raise the dual-write question. Volunteer the outbox before it is asked.
  • The pitfall to avoid: assuming "repeatable read" or "serializable" means the same thing in every engine, or treating compensation as a true rollback.
Before you move on
  • Say which letters of ACID the database enforces by itself, and which depend on your constraints and your replication setup.
  • Explain write skew with the on-call example, and why snapshot isolation does not detect it.
  • Compare serial execution, two-phase locking and SSI by what they do on a conflict: stall, block or abort.
  • Trace what happens when a two-phase commit coordinator crashes after collecting votes, and estimate how long a participant holds locks, and the hot-row commit rate, for an 80 ms round trip.
  • Describe the dual-write problem and how an outbox with an idempotent consumer solves it.
Sources and further reading
  • PostgreSQL: transaction isolation — the per-level anomaly table, snapshot isolation, serialization failures, SSI and predicate locks
  • PostgreSQL: write-ahead log settings — synchronous_commit levels, what off risks, and why fsync off is different
  • PostgreSQL: PREPARE TRANSACTION — prepared transactions keep their locks, and are meant for external transaction managers
  • MySQL 8.4: InnoDB transaction isolation levels — the REPEATABLE READ default, snapshots, next-key locks, SERIALIZABLE
  • Hermitage: what databases mean by their isolation levels — the same test transactions run against PostgreSQL, MySQL and Oracle
  • Ports and Grittner: Serializable Snapshot Isolation in PostgreSQL (VLDB 2012) — the design and a performance cost of under 7% against snapshot isolation
  • Spanner: isolation levels — serializable by default, repeatable read as snapshot isolation, write skew
  • Spanner paper (OSDI 2012, extended in ACM TOCS 2013) — locks and two-phase commit across Paxos groups, lock-free snapshot reads
  • DynamoDB transactions — limits, isolation against other operations, capacity cost, idempotency token
  • microservices.io: saga — choreography, orchestration, compensating transactions, lack of isolation
  • microservices.io: transactional outbox — the dual-write problem, relays, at-least-once delivery

Claims in this chapter were checked against these sources in October 2026. The 80 ms round trip is the planning figure for US to Western Europe from the capacity chapter; the lock-hold arithmetic is an illustration from the protocol's message flow, not a measurement. PostgreSQL and MySQL details are for PostgreSQL 18 and MySQL 8.4.

✓Practise

5 questions drawn from 8, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.

A2

Finished reading?

0 of 36 chapters read in this series

→Next stop

  1. A1Previous · A1The CAP theorem in depth
  2. A2you are here
  3. Next · A3Storage enginesEvery database is a bet about how data should sit on disk, and most of what surprises you in production (slow writes, slow deletes, bloat, compaction storms) follows from that bet.19 min read

01On this page

9 sections · 0%

  1. 01ACID, honestly
  2. 02The anomalies, each as a story you can retell
  3. 03Isolation levels, and why the names lie
  4. 04Making it truly serializable: three techniques
  5. 05What the four systems actually default to
  6. 06Distributed transactions: two-phase commit and why it blocks
  7. 07Sagas and the outbox: when you cannot or will not hold locks
  8. 08Picking the right tool, in one table
  9. 09Recognizing it in an unseen problem

[] chapters · t top