MongoDB Replication
Paper: Zhou and Mu, Fault-Tolerant Replication with Pull-Based Consensus in MongoDB (NSDI 2021).
Election competition
- Majority vote. Two candidates at once → often neither wins (split vote)
- Then everyone times out, increments the term, tries again — unavailability stretches
- Raft’s fix: randomized election timeout
- why random?
- why does the spread have to be relatively large?
- if two timeouts fire within one RTT of each other, both campaign
- the random window needs to be ≫ broadcast time, so typically one candidate starts well before the others
- why is the timeout itself large compared to the heartbeat?
- heartbeat ≪ election timeout, or a slow RPC looks like leader death
- the Raft paper’s example is 150–300 ms; production is often seconds (MongoDB default 10 s)
- tradeoff: slower failover vs fewer spurious elections
- planned failover (upgrade, step-down): do you still want to sit out a random 10 s? (leadership transfer)
A half-isolated node that keeps incrementing its term
- Partition: S5 cannot hear the leader. It times out,
currentTerm++, campaigns, gets no majority, times out, term++ … - Half-isolated: can talk to some nodes, never a majority — so it can never win, but it can deliver a higher term
- When a
RequestVotewith term 10,000 reaches the leader (or the partition heals):- everyone adopts the high term
- the real leader steps down
- cluster is unavailable while they elect again
- S5 still cannot win (log is stale) → it will do this again
- Safety is fine. Availability is not
- What do implementations do?
- Pre-vote (Dryrun): ask “would you vote for me?” before incrementing
currentTerm. If you cannot win, don’t bump the term - Check-quorum: leader stays leader only while it can hear a majority — a minority’s term bump cannot depose it by itself (details vary)
- Pre-vote (Dryrun): ask “would you vote for me?” before incrementing
- Priority-based “please elect me” policies make this worse if you skip pre-vote
Learners (and other extra replicas)
- Recall TiFlash (TiDB lecture) is a Raft learner: gets the log, not in the election, not in the commit quorum
- Why not just add another follower?
- majority gets larger → writes wait on more nodes (including the analytic box)
- that node can become leader — you may not want the OLAP replica taking writes
- Learner = copy of the log that cannot vote and does not count toward commit
- leader does not wait on it at commit time
- it cannot win an election (until you promote it)
- Other extra roles people invent:
- witness / arbiter: votes, stores no data (cheap tie-break)
- read-only geo copies, a node that is still catching up, …
Linearizable reads: the leader cannot just serve locally
- Tempting: reads dominate, they do not change state, the leader already has the log
- Why that is wrong:
- leader is partitioned; majority elects a new leader; new leader commits writes
- old leader does not know; a client reads from it → stale value, after another client already saw the new write
- linearizability forbids that
- Strawman that is correct: treat the read like a write. Append a no-op (or the read itself), wait until it commits, then answer
- every read pays a majority round trip and dirties the log
-
Two standard bypasses (do not log the read):
- Read leases
- leader holds a lease of length T. Followers will not elect (or the new leader will not serve reads) until T has passed since the old leader’s last heartbeat
- clocks must be close enough — the lease is a time bound
- while the lease is valid, this node is the only one allowed to serve linearizable reads → local, no RPC
- cost: clock assumption, and a new leader must wait out the remaining lease
- TiDB’s “lease read”; later MongoDB work (LeaseGuard) is this problem done carefully
- Wait until the next write succeeds
- do not append a dummy. Record “linearize me with the next commit” (or with some write already in flight)
- when that write hits a majority, you have proof you were still leader, and the read sits after every write that committed before that point
- if a write is already replicating: piggyback — no extra log entry
- if the system is idle: wait for the next client write, or fall back to a no-op
- still a round trip when you have to wait; still leader only
- Read leases
- Weak / stale reads on followers are a different product knob — not this problem
Speculative apply and rollback
- Raft textbook: append to the log, wait for commit, then apply to the state machine
- applied prefix = committed prefix
- failover only truncates an uncommitted log tail; state is always a prefix of committed history
- Production KVs often apply before commit (speculative)
- why: weak / read-your-writes without waiting for a majority; hide apply latency on the leader; leftover of primary-backup (“ack after local apply”)
- linearizable clients still wait for commit
- Cost: failover may throw away log entries you already applied
- truncating the log is not enough — those writes are already in the state machine
- you need rollback of state, not just of the log
- How do you roll state back?
- undo log: one compensating op per speculative entry
- or MVCC / timestamped versions: revert to a snapshot timestamp, then optionally replay forward to the last common log entry
- or speculate only on the leader — still a problem when that leader is deposed
- Invariant: a linearizable read must not see a speculative write; a committed write must survive rollback
- This paper: every replica speculates (weak-consistency product); rollback via timestamp revert, not an undo log
A few more you will hit
- catching up a far-behind replica: snapshot, or clone + replay?
- membership change without two leaders of two configs? (Raft §6 — we are not redoing that today)
What MongoDB is (for this lecture)
History of one product’s replication, not a green-field Raft KV.
Single node
- 10gen / MongoDB (~2007–2009): a document store on one machine
- BSON documents in collections (JSON-ish rows)
- crash the box, the database is down
- That is the product people adopted. Replication is bolted on later — unlike Spanner / TiDB, which started as replicated systems
Primary-backup (replica set, pre-consensus)
- From 1.0: replica set = one primary, some secondaries, a logical copy of the DB
- Data plane is pull, not the usual push-from-primary
- a secondary fetches the oplog from some other node (often not the primary)
- this predates Raft in the codebase; it is why they will refuse vanilla
AppendEntries
- Failover is not consensus:
- either a human appoints the new primary, or
- they assume a semi-synchronous network: if you have not heard in 30 s, the node is dead
- What that cannot do:
- a partition looks like death → two primaries (split brain)
- a slow network looks like death → spurious failover
- no majority commit, no Leader Completeness — “the primary said so” is the durability story
- Weaker consistency knobs already exist here (ack after primary apply, after one extra copy, …). Those knobs never went away — they will force speculative apply and primary catchup later
- Sharding, when it comes, is many replica sets. Today is one set
Then Raft (from 2015)
- Demand: linearizability + survive any minority, in an asynchronous network (arbitrary delay/loss; no perfect failure detector)
- No drop-in protocol: Paxos / Raft push from the leader; they needed to keep pull
- Remodel the replica set as a Raft group
- same elections (terms, majority, up-to-date log)
- same commit-on-majority
- data sync still pull, between any two replicas
- Autonomous failover; election timeout drops from “30 s means dead” to ~10 s
- This paper (NSDI 2021) is that protocol: Raft safety with the old pull data plane
Why pull, not vanilla Raft?
MongoDB: any replica can fetch from any other replica.
- Topology control. Users (esp. multi-DC / cloud) want to pick the path.
- Intra-DC traffic is cheap/fast; cross-DC is billed and bandwidth-limited
- Backward compatibility. MongoDB had pull-based replication long before Raft. Keep the programming model, put consensus underneath
Takeaway: the paper is not “a better Raft.” It is “Raft’s safety, with the data plane of an old pull-based primary-backup system.”
Split AppendEntries into two RPCs
Raft AppendEntries does three jobs at once: ship entries, learn matchIndex, heartbeat.
MongoDB splits that:
| Job | Who initiates | RPC |
|---|---|---|
| fetch new entries | secondary (any → any) | PullEntries |
| report log position so the primary can commit | secondary, forwarded hop-by-hop toward the primary | UpdatePosition |
| liveness, commit-point gossip, sync-source selection | everyone ↔ everyone | Heartbeat |
Elections still use RequestVote (same as Raft).
Principle: decouple the data path from the commit path. Data can flow on a chain. Commit still needs a majority at the primary.
PullEntries
- Syncing server = the puller. Sync source = who it pulls from (need not be the primary)
- Request carries the last local log index
- Source returns entries at/after that index, or empty if it is behind
- if logs are equal, wait ~5 s for new data (avoid busy loop)
- On reply, concatenate only if the first received entry matches the last local entry
- If they don’t line up and the received log is newer: walk the source’s log back to the last common entry, truncate the local tail, then append
- same idea as Raft log rollback, but the follower drives it
- extra work because of speculative apply (opening question)
UpdatePosition
- After a successful pull, the secondary tells its sync source its last log position
- Each hop forwards toward the primary (batch: keep the highest position per server; at most one in-flight)
- Primary keeps a volatile
lastPosition[]map (Raft’smatchIndex) - Commit rule (same shape as Raft Figure 2):
- some entry
ewithe.term == currentTerm - a majority of
lastPosition[i] ≥ e - then
lastCommitted ← e(and everything before it)
- some entry
lastCommittedrides back on heartbeats andPullEntriesreplies
The change is not a rename
PullEntries does not check that the sync source’s term ≥ the puller’s term.
In Raft, after you vote in term 3 you refuse AppendEntries from a term-2 leader. In MongoDB you can still pull that term-2 entry, because the pull does not carry “I am the current leader of term T.”
That is the whole protocol hazard.
Figure 2 (why “on a majority” is not “committed”)
Five servers. Entry at index 1 is term 1 everywhere. Then:
(a) start (b) Raft (c) MongoDB pull (d) A sees term 3 (e) later
A term 2 1 2 1 2 1 2 1 2 (steps down) 1 3
B term 2 1 1 2 ← from A 1 2 1 2 1 3
C term 3 1 1 (rejects A) 1 2 ← from A 1 2 → UpdatePos 1 3
D term 3 1 1 (rejects A) 1 2 ← from A 1 2 → UpdatePos 1 3
E term 3 1 3 1 3 1 3 1 3 1 3
- (a) A won term 2 (votes A/B/C), wrote a local entry. E won term 3 (votes C/D/E), wrote a different entry at the same index
- (b) Raft: only B will accept A’s
AppendEntries. C/D/E already have a higher term - (c) MongoDB: if A is the sync source, B/C/D will all take A’s term-2 entry — it is “newer” than what they have
- If A now used Raft’s naive rule (“I created this entry and it is on a majority → commit”), the term-2 entry would be committed
- Then E’s term-3 entry can overwrite it → committed entry lost
Same bug Raft already warns about as “counting replicas, not replies.” Here it shows up because pull does not refuse a stale primary.
The fix: put the puller’s term on UpdatePosition
UpdatePositioncarries the syncing server’s current term- Recipient adopts a higher term. A stale primary steps down before advancing
lastCommitted - In the picture: C/D send
UpdatePositionwith term 3 → A steps down. Term-2 entry is on a majority, but not committed. Later it can be rolled back (e)
Invariant they are restoring: Raft’s Leader Completeness.
To commit an entry in term T, the primary of T needs UpdatePosition with term T from a majority.
A later primary of term U > T also needs a majority of votes. The two majorities share a voter:
- that voter sent
UpdatePositionin T before voting → by log matching, the new leader already has the committed entry, or - that voter voted first, then sent
UpdatePositionwith a term > T → the old primary stepped down and did not commit
Either way, a committed entry is on every later leader.
A useful quirk, not a bug
A newly elected primary (or candidate) may keep pulling old-term entries after voting for itself, until it appends its first new-term entry.
Vanilla Raft would not do this: the new leader’s log is already “the” log. MongoDB uses the quirk for primary catchup (preserve uncommitted writes after failover).