Speculative 2PC (Mako)
Paper: Mako: Speculative Distributed Transactions with Geo-Replication (OSDI 2025)
Setting
- sharded key-value store, serializable transactions
- each shard is a Paxos group: leader + followers in other datacenters
- shard leaders are (usually) in the same datacenter
- leader to leader: LAN, fast
- leader to follower: WAN, tens of ms
- one-shot transactions: client sends the whole transaction to a shard leader
- running example: shard A holds
Alice.bal, shard B holdsBob.bal, shard C holdsCharlie.balT1: Alice transfers 100 to Bob if Alice.bal > 100: Alice.bal -= 100 (shard A) Bob.bal += 100 (shard B)
What is slow in Spanner?
- every 2PC step (prepare, commit) is replicated via Paxos before the next step
- A and B each replicate their YES before answering; each replicates COMMIT before applying
- T1 holds its locks across two WAN round trips. the next transaction on Alice or Bob waits
- can we hide it with more concurrent transactions? no
- more concurrent transactions –> more conflicts –> more aborts/waits
- throughput is limited by contention, not by the number of in-flight requests
Idea: run 2PC first, replicate later
- execute + 2PC among leaders only (LAN). no replication inside
- once 2PC passes, writes are visible to the next transaction. keep going
- replicate in the background
- reply to the client only after replication finishes
- a transaction that passed 2PC but is not replicated yet: certified (speculative)
Problem: a leader fails before replication
- T1 runs 2PC: A votes YES, B votes YES, coordinator sends COMMIT. A applies it. then A’s leader dies
- its memory is gone: data, locks, and its part of every 2PC
- B: did A commit? abort? nobody knows. 2PC is not fault-tolerant
- Spanner avoids this by replicating the vote before casting it. we just gave that up
- the client has not been answered yet, so we are allowed to roll T1 back on B. fine, if it stopped there. it does not
A: Alice.bal 100 -> 0 T1 B: Bob.bal 0 -> 100 T1 B: Bob.bal 100 -> 0 T2: Bob spends the 100 (read T1's write) - T2 read T1’s write before T1 was replicated. that was the point: don’t wait
- T1 rolls back –> Bob never received 100 –> T2 spent money that never existed –> T2 rolls back
- and whatever read T2’s writes, and so on. cascading rollback
- same problem on followers: T1’s B half may be replicated while its A half is lost. replaying half of T1 is wrong
Strawmen
- track exact dependencies (“T2 read from T1”): precise, but a record per read at millions of reads/s. too slow
- on failure, freeze the whole system, find what is lost, roll back it and its dependents: one dead server halts all shards
What do we actually need on failure?
- not “who read from whom”
- just a cut: keep a set of transactions such that
- every kept transaction is replicated on all shards it wrote
- everything a kept transaction read from is also kept
- roll back everything above the cut. nothing below can be affected
- so we need a cheap per-transaction summary that is monotone: T2 reads from T1 ==> summary(T2) >= summary(T1)
- then “keep everything with summary <= W” is a valid cut, for any W
- simplest summary: a timestamp
Version = timestamp
- each shard has a logical clock (Lamport style: bump on every use, jump forward on seeing a larger timestamp)
- a transaction’s timestamp: fresh clock from every shard it writes, max with the timestamps of everything it read
- invariant: T2 reads from T1 ==> ts(T2) >= ts(T1) (and transitively)
- on one shard, a later writer gets a larger timestamp than an earlier writer
T1 W(Alice) W(Bob) reads {} ts 1 T2 R(Bob) W(Bob) reads Bob: ts 1 ts 2 T3 W(Charlie) reads {} ts 1
Execution and certification (= OCC + 2PC among leaders)
- coordinator = the shard leader that received the transaction
- execute: read newest certified version, record (key, version) in ReadSet. buffer writes in WriteSet
- certify, 4 rounds among involved leaders:
- Lock: lock WriteSet keys. already locked –> abort
- GetClock: each written shard bumps its clock and returns it. ts = max(fresh clocks, ReadSet timestamps)
- Validate: every ReadSet key still at the version we read, and unlocked? else abort
- Install: write WriteSet with version ts. keep old versions (may need to roll back)
- step 2 is where the invariant comes from: T1’s timestamp sits in T2’s ReadSet
- Validate/Install = prepare/commit. now CERTIFIED. no WAN traffic so far
Replication and the watermark
- each shard appends (writes on this shard, ts) to a log, replicated with Paxos in the background
- actually one log per core, no coordination between logs or shards
- shard watermark w_i = timestamp of the last replicated transaction, min over shard i’s logs
- meaning: every transaction with ts <= w_i is durable on shard i
shard A, two logs replicated | pending log-0: 1 3 4 | 8 9 w_A = min(4, 6) = 4 log-1: 2 5 6 | 7 (6 might be unreplicated in log-0, so not 6)
- meaning: every transaction with ts <= w_i is durable on shard i
- system watermark W = min over all shards of w_i, gossiped between shards
- commit rule: ts <= W –> COMMITTED, reply to client
- own writes durable on every shard (ts <= w_i for all i)
- everything it read from has a smaller ts, so also <= W, so also durable
- followers replay a log entry only when its ts <= W. otherwise wait
- T1’s A half lost –> w_A stays 0 –> W stays 0 –> B’s follower never replays T1’s B half, nor T2
Failure: epochs
- why epochs: every shard must separate “before the failure” from “after”
- Otherwise, the system pauses for long
- every shard closes the old epoch
- healthy shard: finish or abort its in-flight old-epoch transactions, then move on
- new leader of the failed shard: recover from followers what was replicated, fill holes with no-ops
- new-epoch transactions do not read old-epoch versions until the old epoch is decided
- Rollback safety: roll back the whole old epoch on every shard
- key: an old-epoch transaction read only from the old epoch or earlier; it does not depend on any new-epoch transactions
- Simple solution: Should we rollback the entire epoch?
old epoch: T1 (A, B) T2 (B) T3: Charlie.bal -= 100 (C) ... thousands more A fails --> roll back T1, T2, T3, and everything else in the old epoch
- too much: transactions that were fully replicated before the failure die too. and T3 had nothing to do with A
Failure: cut the old epoch at the watermark
- we already have a cut, the watermark. finalize it for the old epoch
- new leader of the failed shard: final w_i = min over its recovered logs, as usual
- healthy shard: append an INF marker to each log and replicate it. final w_i = INF (it lost nothing)
- finalized watermark FW = min over shards of the final w_i
- rule: old-epoch transaction with ts <= FW commits. otherwise roll back on leaders, never replay on followers
A failed before T1 reached its followers. w_A = 0, w_B = INF, w_C = INF. FW = 0 T1 ts 1 roll back, even though its B half is durable on B T2 ts 2 roll back T3 ts 1 roll back. Charlie had nothing to do with A had A replicated up to ts 7 before dying: FW = 7, everything with ts <= 7 survives - why not re-apply T1 from B’s copy of its write? B’s copy may depend on other lost transactions. we don’t know, that was the deal
- bounded: the set above FW is fixed once FW is. no cascade into the new epoch
- healthy shards keep running new-epoch transactions that avoid the dead shard
Why correct
- atomicity: all writes of a transaction carry the same ts, and the decision is “ts <= FW?”, so every shard decides the same
- a transaction stuck mid-2PC has a timestamp above its log’s last entry, so above FW: rolled back everywhere
- rollback safety: T2 read from T1, T1 rolled back ==> ts(T2) >= ts(T1) > FW ==> T2 rolled back
- never undo a reply: replied ==> ts <= W <= FW
Problem with one timestamp: too many rollbacks
- the watermark saved the replicated prefix, but T3 still died for nothing. in a big system most transactions are unrelated to the failed shard
- same thing without failures: W = min over all shards. one slow shard holds W back, so every shard’s transactions wait to commit
- the timestamp is too coarse: it says “after”, not “after on which shard”
Fix: vector clock
- one entry per shard: vc = [v_A, v_B, v_C]
- same rule, per entry: fresh clock from every shard it writes, element-wise max with the vcs of everything it read
- same invariant, per entry: T2 reads from T1 ==> vc(T2) >= vc(T1) in every entry
- watermark becomes a vector W = [w_A, w_B, w_C]; commit rule: vc <= W in every entry
- unrelated transactions are incomparable and do not wait for each other ``` T1 W(Alice) W(Bob) fresh (1,1,-) vc [1,1,0] T2 R(Bob) W(Bob) fresh (-,2,-) vc [1,2,0] >= [1,1,0] T3 W(Charlie) fresh (-,-,1) vc [0,0,1] incomparable with both
A failed. FVW = [0, INF, INF] T3 [0,0,1] <= FVW keep. Charlie is fine T1 [1,1,0] roll back T2 [1,2,0] roll back ```
- straggler: a slow shard holds back only its own entry. transactions that do not touch it commit as before
- correctness argument is unchanged: replace <= on numbers with <= on every entry
- cost: n entries per version. with many shards, merge entries (max of a group of shards). still monotone, so still correct; the fully merged case is the single timestamp above