Faults
- Database software or hardware dies mid-write
- The application crashes halfway through a series of operations
- The network cuts the app off from the DB, or nodes from each other
An interactive walk through Chapter 8 of Designing Data-Intensive Applications (2nd ed.). It covers every guarantee, anomaly and algorithm in the chapter. Most sections have a demo: press Next ▶ to step through what each client and the database do, and use the toggles to compare isolation levels and strategies.
Real data systems fail in many ways at once:
A transaction groups several reads and writes into one logical unit. Either the whole thing commits or it aborts (rolls back), and after an abort the application can safely retry. The application no longer has to deal with partial failure.
A short history. Almost every relational database (MySQL, PostgreSQL, Oracle, SQL Server) implements transactions in a way that is remarkably similar to IBM’s System R (1975), the first SQL database. In the late 2000s many NoSQL databases dropped transactions, or redefined the word to mean something weaker, for the sake of replication and sharding. That led to a popular belief that transactions don’t scale. “NewSQL” systems (CockroachDB, TiDB, Spanner, FoundationDB, YugabyteDB) have since shown this is wrong: they combine sharding with consensus protocols to provide strong ACID guarantees at scale.
Some applications can do without transactions, or weaken them for performance or availability. They do prevent a lot of grief, though: the technical cause of the UK Post Office Horizon scandal was probably an accounting system without ACID transactions. To decide whether you need them, you have to know exactly what they guarantee and what they cost.
Click any node to jump there.
ACID (Atomicity, Consistency, Isolation, Durability) was coined in 1983 by Härder and Reuter to give fault-tolerance mechanisms precise names. In practice one database’s ACID is not another’s; isolation in particular is ambiguous. “ACID compliant” has become largely a marketing term.
In multithreaded programming, “atomic” means no other thread can see a half-finished operation. ACID atomicity is not about concurrency (that’s the I). It describes what happens when a client makes several writes and a fault occurs partway through: a crash, a dropped connection, a full disk, a violated constraint. The transaction is aborted and the database discards every write it made.
Without that guarantee, the application can’t tell which changes took effect. Retrying risks applying some changes twice, and not retrying leaves the data inconsistent. With atomicity, an aborted transaction changed nothing, so a retry is safe.
“Consistency” has at least five unrelated meanings:
| Context | Meaning |
|---|---|
| Replication (ch. 6) | Replica / eventual consistency: do replicas agree, and when? |
| Snapshots / backups | A consistent snapshot respects happens-before: if it contains a write, it contains everything that happened before that write. |
| Sharding (ch. 7) | Consistent hashing, a rebalancing technique. |
| CAP theorem (ch. 10) | Consistency = linearizability. |
| ACID | The database is in an application-specific “good state”: your invariants hold. |
An invariant is something that must always be true, e.g. “credits and debits balance across all accounts”. If every transaction starts from a valid state and preserves validity, the invariants always hold. They may be temporarily broken during a transaction, as long as they hold again at commit.
The database can enforce invariants you declare as constraints: foreign keys, uniqueness, check constraints, and sometimes triggers or materialized views. Anything more complex is the application’s responsibility. If you write bad data that breaks an undeclared invariant, the database can’t stop you. So C is a property of how the application uses the database, not of the database alone. (A, I and D are database properties.)
Many clients access the database at once. If they touch the same records, you can get race conditions. In the classic example below, two clients increment a counter without a database-level increment.
The textbook definition of isolation is serializability: each transaction can pretend it is the only one running, and the committed result equals running them serially, one after another. Serializability costs performance, so many databases offer only weaker levels. Oracle’s level called “serializable” is actually snapshot isolation, which is weaker.
Once a transaction has committed, its data must survive hardware faults and crashes.
fsync to force data to disk. A write-ahead log allows recovery from a crash mid-write, and checksums (MySQL, MongoDB, PostgreSQL) detect torn or corrupt log entries.The database must finish these writes before acknowledging the commit. Try it:
| Risk | Why it matters |
|---|---|
| Machine dies, disk intact | Data isn’t lost, but it’s unreachable until the machine is fixed or the disk moved. Replicas stay available. |
| Correlated faults | A power outage, or a bug that crashes every node on the same input, can take out all replicas at once. In-memory-only data is lost, so disk still matters. |
| Async replication | Recent writes can be lost when the leader fails (ch. 6 failover). |
| SSDs on power loss | Some violate their guarantees, so even fsync may not work. Firmware has bugs, e.g. drives failing after exactly 32,768 hours. |
fsync is hard | PostgreSQL used it incorrectly for over 20 years. |
| Storage engine × filesystem | Subtle interactions corrupt files after crashes, and errors on one replica can spread to others. |
| Silent corruption | Can go undetected long enough that replicas and recent backups are also bad, so you need historical backups. |
| Bad blocks | 30–80% of SSDs develop at least one in four years. HDDs have fewer bad sectors but more total failures. |
| Worn, unpowered SSDs | Can start losing data within weeks to months, depending on temperature. |
There are no absolute guarantees, only risk-reduction techniques: disk writes, replication and backups. Use them together.
Atomicity and isolation matter most when a transaction modifies several objects that must stay in sync:
Example: an email app stores the unread count in a separate field (denormalized) because SELECT COUNT(*) … WHERE unread_flag is too slow. Now inserting an email and incrementing the counter must happen together.
How does the database know which operations belong together? In relational databases, by the client’s TCP connection: everything between BEGIN TRANSACTION and COMMIT on that connection is one transaction, and if the connection drops, the transaction is aborted. Many non-relational databases have no such grouping. A multi-put that updates several keys at once may succeed for some keys and fail for others, so it isn’t necessarily a transaction.
Even writing one 20 kB JSON document raises questions. If the network drops after 10 kB, is a broken fragment stored? If power fails mid-overwrite, do old and new values get spliced? Can a concurrent reader see half a document? Storage engines almost universally make single-object writes on one node atomic (via a log for crash recovery) and isolated (via a per-object lock).
Some databases offer richer single-object operations:
These prevent lost updates on one object, but they are not transactions in the usual sense. Aerospike’s “strong consistency” mode and the “lightweight transactions” of Cassandra and ScyllaDB give linearizable reads and conditional writes on a single object, with no multi-object guarantees.
Foreign keys between tables, or edges between graph vertices, must stay valid when inserting related records together.
A single document is one object, so no transaction is needed to update it. But document databases without joins encourage denormalized copies, and those must be updated in sync.
An index is a separate object. Without isolation, a record can appear in one index but not yet in another.
You can build all of these without transactions, but error handling gets much harder without atomicity, and missing isolation causes the concurrency problems covered below.
ACID databases would rather abandon a transaction entirely than leave it half-finished. Leaderless-replication datastores work on a best-effort basis instead: they do as much as they can, and on error they don’t undo what’s done, so recovery is the application’s job. Meanwhile popular ORMs (Rails ActiveRecord, Django) don’t retry aborted transactions; the exception bubbles up and user input is lost. That wastes the main benefit of aborts.
Retrying isn’t a silver bullet, though:
| Situation | Retry? | What to do |
|---|---|---|
| Commit succeeded, but the ack was lost (client timed out) | careful | A retry performs it twice. You need app-level deduplication (idempotence). |
| Error due to overload or high contention | careful | Retries make it worse. Limit retries, use exponential backoff, and treat overload errors differently. |
| Transient error: deadlock, isolation violation, network blip, failover | yes | This is exactly what retries are for. |
| Permanent error, e.g. constraint violation | no | A retry will fail the same way. |
| Side effects outside the DB, e.g. sending an email | careful | They happen even if the transaction aborts. Use 2PC or make them idempotent. |
| Client crashes while retrying | — | Whatever it was trying to write is lost. |
Race conditions only arise when one transaction reads data another is modifying, or both modify the same data. They are timing-dependent, so they are rarely triggered in testing and hard to reason about in large codebases. Serializable isolation hides all of them but costs performance, so databases commonly default to weaker levels that prevent some anomalies and not others.
The ladder, from weakest to strongest:
The most basic real isolation level makes two guarantees:
A dirty read means seeing another transaction’s writes before it commits or aborts. Under read committed, a transaction’s writes become visible only when it commits, and then all at once. Toggle the level and the ending below:
Why it matters: (1) a reader may see some but not all of a multi-row update, which is confusing and can lead to wrong decisions; (2) if the writer aborts, the reader has seen data that never existed, and undoing that leads to cascading aborts.
A dirty write means overwriting a value another transaction has written but not yet committed. Read committed prevents it, usually by making the second writer wait until the first commits or aborts.
Read committed is the default in Oracle, PostgreSQL, SQL Server and many others.
read_committed_snapshot=off do. But one long write transaction then stalls every reader, hurting response time and letting a slowdown in one part of the app spread to others. Most databases instead keep both the old committed value and the new uncommitted value, and give readers the old one until commit. (This is MVCC in miniature; see below.)Read uncommitted is weaker still: it prevents dirty writes but allows dirty reads, returning the latest value even if uncommitted. It can be faster, since only one version is kept, and it makes lost updates less likely but doesn’t prevent them.
Read committed allows aborts, hides incomplete results and keeps concurrent writes from mixing. Plenty of bugs remain. Here is one where nothing uncommitted is ever read:
This is read skew, a kind of nonrepeatable read. (This “skew” is a timing anomaly, unrelated to the hot-spot skew of sharding.) For Aaliyah it’s temporary, since a reload a few seconds later looks fine. It is not tolerable for:
Snapshot isolation is the usual fix. Each transaction reads from a consistent snapshot: all data committed when it started, frozen, even if other transactions change it afterwards. This is a big help for long read-only queries. PostgreSQL, MySQL/InnoDB, Oracle and SQL Server all support variants of it. Oracle, TiDB and Aurora DSQL even make it their highest level, and cloud warehouses like BigQuery use it for point-in-time analytics.
Like read committed, snapshot isolation uses write locks to prevent dirty writes, so writers can block writers. Reads take no locks. The core principle:
Generalizing the two-version trick from read committed: the database keeps several committed versions of each row, because different in-progress transactions may need to see the database at different points in time. Hence multiversion. In PostgreSQL-style MVCC:
inserted_by (the txid that created it) and deleted_by (empty until deleted).deleted_by. An update = delete + insert, so updating account 2 from $500 to $400 leaves two versions.When a transaction reads, txids decide which versions it sees:
These apply to insertions and deletions. Put differently, a row version is visible if:
Never updating in place means a long-running transaction can keep reading values that everyone else has long since overwritten, at only a small overhead. In the lab above, “Load Figure 8-7” shows exactly this: txn 12 still sees $500 + $500 after txn 13 committed its transfer.
Commonly, each index entry points at one version of a matching row (oldest or newest), and each version links to the next. A query walks the chain to find a version that is both visible and matching. When GC removes a dead version, its index entries go too. Implementation details matter a great deal: PostgreSQL skips index updates when versions of a row fit on the same page, and some databases store only the deltas between versions.
A different approach is used by CouchDB, Datomic and LMDB: immutable, copy-on-write B-trees. Pages are never overwritten. A write copies the modified leaf and every parent up to the root, and unaffected pages are shared. Each root is a consistent snapshot, so there is no txid filtering. It still needs background compaction and GC.
The SQL standard is based on System R’s 1975 isolation levels and predates snapshot isolation, so it has no such level. It defines repeatable read, which looks superficially similar. The standard’s definitions are also ambiguous and imprecise. The result:
| Database | Level name | What you actually get |
|---|---|---|
| PostgreSQL | repeatable read | Snapshot isolation (meets the standard’s wording, so it can claim compliance) |
| Oracle | serializable | Snapshot isolation |
| MySQL / InnoDB | repeatable read | MVCC weaker than snapshot isolation (e.g. no lost-update detection) |
| IBM Db2 | repeatable read | Serializability |
Research has defined repeatable read formally, but most implementations don’t satisfy that definition. Nobody really knows what “repeatable read” means. Test your actual database (the Hermitage project does this).
So far we have looked at what a reader sees. With two concurrent writers, the best-known problem is the lost update: two transactions do a read-modify-write cycle on the same object, and the second write clobbers the first because it doesn’t include the first one’s change. It shows up in:
Compare the five main solutions:
UPDATE counters SET value = value + 1 WHERE key = 'foo';
This is concurrency-safe in most relational databases. MongoDB has atomic operations on parts of a JSON document, and Redis on data structures like priority queues. They are usually the best choice when the change can be expressed this way; arbitrary text edits can’t (those need CRDTs or operational transformation, ch. 6). Atomic operations are implemented with an exclusive lock on the object from read to write, or by running them all on one thread. A common pitfall: ORMs make it easy to write an unsafe read-modify-write by accident.
When the logic is too complex for an atomic op, such as checking that a game move is legal, the application can lock the rows it’s about to modify:
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE; -- lock every row this returns
-- check whether the move is valid, then:
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;
It works, but it’s easy to forget a lock somewhere. Locking several objects also risks deadlock; the database detects it and aborts one transaction, which the app must retry.
Instead of forcing read-modify-write cycles to run one after another, let them run in parallel. If the transaction manager detects a lost update, it aborts the offender, which retries. This fits naturally with snapshot isolation: PostgreSQL repeatable read, Oracle serializable and SQL Server snapshot isolation do it automatically. MySQL/InnoDB repeatable read does not, so by some definitions it doesn’t provide snapshot isolation at all. The big advantage is that it needs no special code, so there’s no lock to forget. You still need to retry.
Databases without transactions often offer a conditional write that applies only if the value hasn’t changed since you read it, the database version of a CPU’s CAS instruction:
-- may or may not be safe, depending on the implementation
UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content';
If it updates 0 rows, retry. Comparing a version number column that you increment on every write is common, and is called optimistic locking. Caveat: under MVCC, the other transaction’s new content might be invisible in your snapshot. Many MVCC implementations make an exception and let UPDATE/DELETE WHERE clauses see the latest committed writes. Check that yours does.
Locks and CAS assume a single up-to-date copy of the data. Multi-leader and leaderless replication allow concurrent writes on different nodes, replicated asynchronously, so that assumption fails (more under linearizability, ch. 10). Instead they keep conflicting versions (siblings) and merge them afterwards with application code or special data structures.
Dirty writes and lost updates involve two transactions writing the same object. Subtler races remain. A hospital must always have at least one doctor on call per shift. A doctor may go off call only if a colleague remains. Aaliyah and Bryce, the only two on call, both feel sick and click at the same moment:
Write skew is neither a dirty write nor a lost update: the two transactions update different objects. It is still clearly a race, since serially the second doctor would have been refused.
| Both transactions read… | …then write | Result | |
|---|---|---|---|
| Dirty write | — | the same object, before the other commits | writes interleave |
| Lost update | the same object | the same object | one write clobbers the other |
| Write skew | the same objects | different objects | the decision’s premise silently breaks |
Write skew is a generalization of the lost update. The options for fixing it are more limited:
SELECT … FOR UPDATE). Try the second option in the demo above.Check for overlapping bookings; if none, insert. Snapshot isolation doesn’t stop a concurrent overlapping insert. Needs serializable.
A lock stops two players moving the same figure, but not two figures moving onto the same square or other rule violations. A uniqueness constraint may help, depending on the rule.
Check whether the name is taken, then create the account. Unsafe under SI, but a uniqueness constraint fixes it simply: the second insert aborts.
Insert a tentative spend, then check the sum is still positive. Two concurrent spends can together push the balance negative, and neither notices.
All these examples follow one pattern:
SELECT checks a requirement by searching for rows matching a condition.INSERT/UPDATE/DELETE) and commits, and that write changes the result of step 1’s search. Re-running the query now would give a different answer.(The steps can come in a different order, e.g. write first, then query, then decide whether to commit.)
In the doctors case, the modified row was among those returned in step 1, so locking them works. The other examples check for the absence of rows and then add one. A SELECT FOR UPDATE that returns nothing locks nothing.
A write in one transaction that changes the result of another transaction’s search query is called a phantom. Snapshot isolation prevents phantoms in read-only queries, but in read-write transactions they cause especially tricky write skew. ORM-generated SQL is prone to it too.
If the problem is that there’s no object to lock, create one. Pre-create a table of (room, 15-minute time slot) rows for the next six months. A booking transaction first locks the slot rows for its room and time (SELECT FOR UPDATE), then checks and inserts as before. The table stores no booking data; it is purely a collection of locks. This turns a phantom into a lock conflict on concrete rows (see the third option in the demo above).
It is hard to get right and error-prone, and it leaks concurrency control into the data model. Treat it as a last resort; serializable isolation is usually better.
So far: isolation levels are hard to understand and inconsistently implemented. It’s hard to tell from application code whether it’s safe at a given level, especially in a large app where you don’t know what else runs concurrently. And there are no good tools: static analysis exists in research but isn’t practical yet, and concurrency bugs are nondeterministic. Researchers have given the same answer since the 1970s: use serializable isolation.
Why isn’t everyone using it? The answer lies in the cost of the three implementation techniques:
One transaction at a time, on one thread.
For about 30 years the only widely used option.
Run freely, check at commit, abort on conflict.
Remove the concurrency entirely: one transaction at a time, on a single thread. Conflicts can’t happen, so the result is serializable by definition. Database designers only decided this was feasible in the 2000s, for two reasons:
VoltDB/H-Store, Redis and Datomic work this way. With no locking overhead, a single-threaded system can sometimes beat a concurrent one, but its throughput is capped at one CPU core, and transactions must be structured differently.
The early hope was that one transaction could span an entire user flow (search flights, choose, book seats, enter details, pay). Humans are slow, though, and a database can’t efficiently support huge numbers of mostly idle transactions. So OLTP apps keep transactions within one HTTP request.
Even so, transactions usually run interactively: one statement, wait for the result, the next statement, with network round trips in between. A single-threaded database would sit idle during those waits. So serial-execution systems don’t allow interactive multi-statement transactions. You must either use single statements or submit the whole transaction up front as a stored procedure.
| Bad reputation of stored procedures | Modern answer |
|---|---|
| Vendor languages (PL/SQL, T-SQL, PL/pgSQL) look archaic and lack library ecosystems | General-purpose languages: VoltDB uses Java/Groovy, Datomic Java/Clojure, Redis Lua, MongoDB JavaScript |
| Code in the database is hard to debug, version, deploy, test and monitor | Still a real cost, so use it deliberately |
| The DB is shared by many app servers, so a bad procedure (memory, CPU, crash) hurts everyone | Same: keep procedures small and fast |
| Running untrusted tenant code inside the database kernel is a security risk | Sandboxing / avoid in multitenant systems |
Procedures also help where logic can’t live elsewhere, for example validation behind a GraphQL proxy that exposes the database directly (otherwise you’d need a separate validation service). VoltDB also replicates with stored procedures: it executes the same procedure on every replica instead of copying writes. That requires procedures to be deterministic, so the current time etc. comes from special deterministic APIs. This is state machine replication, covered further in ch. 10.
To go beyond one core, shard the data so each transaction touches one shard. Each shard then gets its own thread, and throughput scales linearly with cores. A cross-shard transaction must run in lockstep across all its shards, which is vastly slower: VoltDB reports about 1,000 cross-shard writes per second, which doesn’t improve with more machines. (Research such as Lotus explores scaling multi-shard transactions.) Key-value data shards easily; data with many secondary indexes needs a lot of cross-shard coordination.
For about 30 years, 2PL (strictly, strong strict two-phase locking, SS2PL) was the only widely used serializability algorithm. It strengthens the write locks used against dirty writes: many transactions may read an object concurrently, but writing needs exclusive access.
So writers block readers and readers block writers, the opposite of snapshot isolation’s mantra. In exchange you get protection from all races, including lost updates and write skew. It is used by serializable in MySQL/InnoDB and SQL Server, and by repeatable read in Db2.
Every object has a lock that is either shared (S) or exclusive (X), i.e. a multi-reader single-writer lock:
| held ↓ / requested → | S (read) | X (write) |
|---|---|---|
| none | grant | grant |
| S by others | grant | wait |
| X by other | wait | wait |
With so many locks, deadlocks are common: A waits for B and B waits for A. The database detects the cycle and aborts one transaction, and the application retries it.
2PL’s throughput and response times are significantly worse than weak isolation’s, which is why it hasn’t been the default since the 1970s. Part of the cost is acquiring and releasing locks, but mostly it is reduced concurrency: anything that might race has to wait.
A serializable database must also prevent phantoms. If a transaction searched for bookings of room 123 from noon to 1 p.m., no other transaction may insert or update a booking matching that search. Bookings for other rooms or times are fine. Conceptually this needs a predicate lock, which belongs to all objects matching a condition:
SELECT * FROM bookings
WHERE room_id = 123 AND
end_time > '2026-01-01 12:00' AND
start_time < '2026-01-01 13:00';
The key point is that a predicate lock covers objects that don’t exist yet. 2PL with predicate locks prevents all write skew and race conditions.
Checking every write against every active predicate is slow. Most 2PL databases use index-range locking (also called next-key locking), a simplified approximation. It is safe to lock a superset of the predicate: lock room 123 at any time, or all rooms from noon to 1 p.m. Any write matching the original predicate matches the approximation too.
room_id, put a shared lock on index entry 123.Any conflicting insert, update or delete has to touch the same part of the index, finds the lock, and waits. This is less precise than predicate locking, since it may block harmless writes, but much cheaper. With no suitable index the database falls back to a shared lock on the whole table: safe, but bad for performance.
So far serializability has meant poor performance (2PL) or poor scalability (serial execution), and weak isolation has meant races. SSI, first described in 2008, gives full serializability for a small cost over snapshot isolation. It’s used by PostgreSQL’s serializable level, SQL Server’s In-Memory OLTP (Hekaton), HyPer, CockroachDB, FoundationDB and BadgerDB.
2PL: if anything might go wrong (a lock is held), wait until it’s safe, like mutual exclusion in multithreaded code. Serial execution is pessimism taken to the extreme, effectively an exclusive lock on the whole database (or shard), made tolerable by keeping each transaction very short.
SSI: don’t block. Carry on and hope for the best, then check at commit whether isolation was violated. If so, abort and retry. Only serializable executions commit.
Optimistic control is an old idea. It does badly under high contention, because many aborts happen, and near maximum throughput the retries add load and make things worse. With spare capacity and moderate contention it usually beats pessimistic control. Commutative atomic operations reduce contention: concurrent increments of a counter that isn’t read in the same transaction don’t conflict.
Write skew always looks like this: read → examine → act on what was seen. By commit time the read may be stale. The transaction acted on a premise (“two doctors are on call”) that is no longer true. The database doesn’t know how the app uses a query result, so to be safe it assumes that any change to the result invalidates the writes. It must detect when a transaction may have acted on an outdated premise and abort it. There are two cases:
shift_id = 1234, or at table level without an index). A write looks up recent readers and notifies them, which works like a tripwire. The first to commit wins, and a later committer whose premise was overwritten by a committed write aborts. The read records can be dropped once the reader and all transactions concurrent with it have finished.| Serial execution | Two-phase locking | SSI | |
|---|---|---|---|
| Style | pessimistic (extreme) | pessimistic | optimistic |
| Readers vs writers | one at a time | block each other | never block |
| Conflict outcome | impossible | wait (or deadlock → abort) | abort at commit |
| Scales across cores | only by sharding; cross-shard is slow | yes, but contention hurts | yes, even distributed |
| Slow transaction | stalls everyone | stalls whoever needs its locks | mostly harmless if read-only |
| Requirements | short txns, in-memory data, stored procedures | deadlock retry handling | retries; short read-write txns; moderate contention |
| Used by | VoltDB, Redis, Datomic | MySQL/InnoDB & SQL Server serializable, Db2 RR | PostgreSQL serializable, CockroachDB, FoundationDB |
A transaction that touches multiple nodes, such as several shards or a global secondary index whose entries live on other nodes, is a distributed transaction. Concurrency control carries over (serial execution with sharding, 2PL across nodes, distributed SSI checkers). The hard new problem is atomicity.
On one node, atomic commit is the storage engine’s job. It makes the writes durable (in the WAL), then appends a commit record. After a crash, recovery keeps the transaction if the commit record made it to disk and rolls it back otherwise. The deciding moment is when the disk finishes writing the commit record, so a single device on a single node makes the commit atomic.
With several nodes, simply sending COMMIT to each one independently doesn’t work. One may see a constraint violation, a request may be lost and time out, or a node may crash before its commit record is durable:
Once committed on a node, a transaction can’t be retracted. Under read committed or stronger, other transactions may already have read the data and acted on it. Making all nodes commit or all abort is the atomic commitment problem.
2PC is the classic algorithm for atomic commit across nodes. It is used inside some databases and exposed to applications as XA transactions (e.g. via the Java Transaction API) or WS-AtomicTransaction for SOAP. It adds a coordinator (transaction manager), often a library inside the application process (e.g. a Java EE container), sometimes a separate service: Narayana, JOTM, BTM, MSDTC. The database nodes are participants.
It resembles a Western wedding ceremony: the officiant asks each partner individually, both say “I do”, and only then is the couple pronounced married and the news broadcast. If either says no, the ceremony is aborted.
Prepare and commit messages can be lost just as easily as a one-phase commit, so why does 2PC work? Look at the details:
In the wedding analogy: before “I do” you may still say “No way!”. After it you can’t take it back. If you faint and miss the pronouncement, you are still married. When you come round, you can ask the officiant about your global transaction ID, or wait for their next retry.
Participant or network failures are handled: failed prepares lead to an abort, and failed commits/aborts are retried. If the coordinator crashes:
Participants could in principle ask each other, but that isn’t part of 2PC. The only way to finish is for the coordinator to recover, which is why it logs its decision before sending it. On recovery it reads the log, and any transaction without a commit record is aborted. So the commit point of 2PC comes down to a regular single-node atomic commit on the coordinator. (Choose “Coordinator crash after deciding” in the 2PC demo above to see this.)
2PC is a blocking atomic commit protocol: it can get stuck waiting for the coordinator. Three-phase commit (3PC) aims to be nonblocking, but it assumes bounded network delay and bounded response times. Real systems have unbounded delays and process pauses (ch. 9), so 3PC can’t guarantee atomicity in practice. The practical fix is to replace the single coordinator with a fault-tolerant consensus protocol (ch. 10).
Distributed transactions have a mixed reputation: they provide a safety guarantee that’s hard to get otherwise, but they are blamed for operational problems, killing performance and overpromising. Many cloud services don’t offer them. Much of 2PC’s cost comes from the extra fsyncs for crash recovery and the extra network round trips. Two very different kinds often get lumped together:
All participants run the same software: YugabyteDB, TiDB, FoundationDB, Spanner, VoltDB, Cassandra, MySQL Cluster (NDB). Any protocol or optimization can be used, so these often work well.
Participants are different technologies: two vendors’ databases, or a database plus a message broker. Atomic commit is needed across entirely different internals, which is much harder.
Heterogeneous transactions allow a message from a queue to be acknowledged if and only if the database transaction that processed it committed. The ack and the DB writes are committed atomically. If either fails, both abort and the broker safely redelivers later. The message is effectively processed exactly once, even after retries. This is exactly-once semantics.
It only works if every affected system speaks the same atomic commit protocol. If processing also sends an email and the email server doesn’t support 2PC, retries can send the email twice. Only if all side effects roll back on abort can processing be retried as if nothing happened.
X/Open XA (eXtended Architecture, 1991) is the standard for 2PC across heterogeneous systems. It is supported by PostgreSQL, MySQL, Db2, SQL Server, Oracle, and by brokers such as ActiveMQ, HornetQ, MSMQ and IBM MQ. XA is not a network protocol; it is a C API for talking to a transaction coordinator, with bindings like JTA (backed by JDBC drivers for databases and JMS drivers for brokers).
If the application process crashes or its machine dies, the coordinator dies with it, and every prepared participant is stuck in doubt. The server must restart and the coordinator library must read its log before it can tell the participants the outcome through the drivers’ XA callbacks.
Why care so much about in-doubt transactions? Because of locks. Modified rows carry exclusive locks against dirty writes, and with 2PL, read rows carry shared locks. None can be released until commit or abort. If the coordinator takes 20 minutes to restart, the locks are held for 20 minutes. If its log is lost, they are held forever, or until an administrator steps in. Other transactions touching those rows block, and large parts of the application can become unavailable. (In the 2PC demo, the shaded lifelines show the locks being held.)
Orphaned in-doubt transactions, whose outcome the coordinator can’t determine (lost or corrupted log, software bugs), do happen. They hold their locks forever, and rebooting doesn’t help: correct 2PC must preserve in-doubt locks across restarts. An administrator has to look at every participant, see whether any has committed or aborted, and apply the same outcome to the rest, usually under pressure during an outage.
The escape hatch is heuristic decisions: a participant unilaterally commits or aborts an in-doubt transaction. Here “heuristic” is a euphemism for probably breaking atomicity, so they are for emergencies only.
These problems are inherent to heterogeneous transactions. Keeping heterogeneous systems consistent is still important, so other approaches are needed (below, and in ch. 12).
Internal distributed transactions are a defining feature of NewSQL databases (CockroachDB, TiDB, Spanner, FoundationDB, YugabyteDB); Kafka has them too. Many use 2PC for multi-shard writes without XA’s problems, because they don’t interoperate with anything else and can use better, faster protocols. They fix XA’s biggest problems by:
Consensus algorithms (ch. 10) replicate the coordinator and shards and fail over automatically while keeping strong guarantees. Snapshot isolation and SSI are both possible across shards.
You don’t need a distributed transaction for exactly-once semantics. Transactions inside the database are enough:
Recording the ID makes processing idempotent, so retries are safe. Kafka Streams uses a similar approach (ch. 12). Internal distributed transactions still help this pattern scale, e.g. storing message IDs on one shard and the data they update on others, with atomic commit across those shards.
Transactions are an abstraction layer that lets an application pretend certain concurrency problems and certain hardware and software faults don’t exist. A large class of errors becomes a simple abort, and the application just retries. Apps with trivial access patterns (single-record reads and writes) may not need them. For anything more complex they greatly reduce the number of error cases you have to think about. Without them, crashes, network interruptions, power outages, full disks and unexpected concurrency leave data inconsistent in many ways; denormalized data drifts out of sync, for example. Transactions are valuable regardless of data model.
The book’s Table 8-1. Dirty writes are prevented by almost every implementation, so they aren’t listed.
| Anomaly | In one line | Prevented by |
|---|---|---|
| Dirty read | Reading another client’s uncommitted writes | read committed and up |
| Dirty write | Overwriting another client’s uncommitted write | almost every implementation |
| Read skew | Seeing different parts of the DB at different points in time (nonrepeatable read) | snapshot isolation (MVCC) |
| Phantom read | Another client’s write changes the result of your search | SI for plain reads; index-range locks / serializable for write skew |
| Lost update | Concurrent read-modify-write; one clobbers the other | some SI implementations; otherwise atomic ops, FOR UPDATE, CAS |
| Write skew | Decision based on a premise that’s no longer true by write time | only serializable |
Serializable implementations: serial execution (fast, short stored procedures, fits one core or shards cleanly); two-phase locking (the classic, poor performance); SSI (optimistic, non-blocking, aborts at commit if not serializable). Distributed atomicity: 2PC works well inside one database system, and is fragile across heterogeneous systems (XA): sensitive to coordinator and application faults, and poor with concurrency control. Idempotence provides exactly-once semantics without cross-system atomic commit.