Percolator: Distributed Transactions Without a Transaction Server
Classic two-phase commit needs a coordinator — a process that remembers, across crashes, which transactions were told to commit. Coordinators are painful: they're state you must replicate, a liveness dependency for every participant, and the reason "2PC blocks" became folklore. Percolator, built at Google to incrementally update the search index, asked a sharper question: what if the data store itself kept all the transaction state, and any client could finish (or abort) any other client's crashed transaction just by reading it? The design — snapshot isolation over Bigtable with locks stored as columns — is the ancestor of TiDB and TiKV's transaction layer, and it's the cleanest case study in decentralizing commit.
Ingredients
A Bigtable-like store providing single-row atomic read-modify-write (that's all — no multi-row operations), and a timestamp oracle (TSO): a service handing out strictly increasing timestamps (a counter batching allocations — millions/sec from one node — with its own failover story). Every transaction gets start_ts at begin and commit_ts at commit; versions of data are written at these timestamps (MVCC).
Each logical column gets three physical columns: data (values by timestamp), lock (in-flight transaction markers), and write (commit records mapping commit_ts → start_ts — "the version written at start_ts became visible at commit_ts").
The protocol: 2PC where the coordinator is a row
Prewrite (phase one): pick one written cell as the primary — this cell will be the commit point. For every cell in the write set, atomically check-and-write: no conflicting lock, no write committed after our start_ts → write the value at start_ts into data and a lock (pointing at the primary) into lock. Any check fails → abort, clean up.
Commit (phase two): get commit_ts from the TSO. Then, on the primary cell only, in one single-row atomic op: verify our lock still exists, replace it with a write record at commit_ts. That single-row operation is the entire commit decision. If it succeeds, the transaction is committed — everywhere, logically — even though the secondary cells still carry locks. Secondaries are then cleaned up lazily: their locks swapped for write records, asynchronously, by anyone.
prewrite: lock all cells (each points at primary), values at start_ts
commit: atomic swap on PRIMARY: lock -> write@commit_ts <- the commit point
secondaries: cleaned up lazily, by anyone, later
Crash recovery without a recovery service
A client dies mid-transaction, leaving locks behind. A later reader hits one of those locks and must decide: in-flight, committed-but-uncleaned, or dead? It follows the lock's pointer to the primary:
- Primary lock still there → the transaction never committed. The reader may abort it (remove the primary lock) after a timeout/TTL check — and once the primary lock is gone, the transaction can never commit.
- Primary shows a
writerecord → the transaction did commit. The reader rolls the secondary forward (installs its write record) and proceeds.
Every observer reaches the same verdict because the verdict is a fact stored in one cell, readable and CAS-able by anyone. No coordinator log, no blocking on a dead process — the "coordinator state" that makes 2PC scary became a row with single-row atomicity, and recovery became a pure function of visible data. That is the transferable idea, and it's worth stating exactly that way in an interview.
What you get and what it costs
Snapshot isolation: reads at start_ts see the state as of that timestamp — consistent, lock-free reads of committed data (readers do wait on in-flight locks below their timestamp — that wait is what makes the snapshot honest). Write-write conflicts are detected at prewrite (first committer wins; the loser retries). SI's known hole — write skew (two transactions each read what the other writes, both commit, invariant dies) — is the follow-up question to expect; the fixes are SELECT FOR UPDATE-style lock-the-read (materialize the conflict) or the SSI machinery Postgres uses.
Costs, honestly: each transaction pays multiple round trips (prewrite per shard + TSO + primary commit) — throughput-oriented, latency-mediocre, which suited an indexing pipeline and suits OLTP-at-scale (TiDB) with batching. Lazy cleanup means readers occasionally do repair work on the hot path. The TSO is a real dependency (single point for ordering — batched, replicated, but say it: Percolator centralizes time instead of commit state, a much easier thing to make highly available). Long transactions hold locks long — the design wants short transactions and gets grumpy otherwise.
| Interview probe | Answer sketch |
|---|---|
| vs Spanner? | Spanner: consensus groups + 2PC across them + TrueTime → external consistency, interactive latency. Percolator: client-driven 2PC over MVCC + TSO → SI, higher latency, no special clocks. Different hardware bets, same "make commit a single atomic fact" instinct |
| Why is the primary special? | It serializes the commit decision into one CAS-able cell — exactly one place where "did T commit" has an answer |
| Lock TTLs vs live slow clients? | Heartbeat on the primary lock (TiKV's approach); cleanup requires TTL expiry, so a slow-but-alive client isn't murdered mid-commit |
| Read-only transactions? | Free: take a start_ts, read the snapshot — the whole point of MVCC underneath |
The design lesson to bank: any distributed protocol's dangerous state is whatever must survive a crash. Move that state into the storage system as ordinary, atomically-swappable data, and recovery stops being a special mode — it's just another reader.
Keep reading
Write Skew: The Anomaly Snapshot Isolation Does Not Catch
Two transactions each read what the other writes, both commit, and an invariant dies. Snapshot isolation allows write skew — here is how to close the hole.
Designing a Shopping Cart: Price Snapshots, Merge, and No Inventory Hold
A cart is a per-user document, not a reservation. What to store on the line, how to merge an anonymous cart at login, and when the price is allowed to change.
Designing Snowflake-Style IDs: Ordering, Workers, and Clock Rollback
A 64-bit id from a timestamp, a worker number, and a per-millisecond sequence. How many you can issue, why the clock must not step backwards, and why JavaScript cannot hold one.
PostgreSQL 19 on October 29: WAIT for Read-Your-Writes, REPACK Without the Lock, and What Got Pulled
PostgreSQL 19 GA is scheduled for October 29, 2026. The WAIT command gives standbys read-your-writes, REPACK CONCURRENTLY replaces VACUUM FULL and CLUSTER, autovacuum goes parallel, and SQL/PGQ, online checksums, and FOR PORTION OF were reverted in Beta 4.
Idempotency Keys for Write APIs
How to make POST safe to retry: the key, the stored response, the race between two identical requests, and the expiry that forgets too soon.
Read-Your-Writes: The User Just Saved and the Read Replica Does Not Know
Why a write to the primary followed by a read from a replica shows stale data, and the practical ways to pin the next read without sending all traffic to the primary.
Newsletter
New posts, straight to your inbox
One email per post. No spam, no tracking pixels, unsubscribe anytime.
Comments
- No comments yet. Be the first.