Consensus & Raft
How a handful of machines agree on one ordered log even when some crash and messages get lost: majorities, terms, Raft's leader election and log replication (with a live simulator), reads under partitions, a little Paxos, and running etcd-style systems in production.
The 30-second version
Think of five friends in a group chat deciding where to eat. Phones die, messages arrive late, and someone is in a tunnel. The group still needs one answer that everybody ends up agreeing on, not two halves of the group showing up at two different restaurants.
Computers have exactly this problem. A Distributed system runs on several machines (each one is called a Node, or here a server) that talk over a network, and any of them can fail at any moment. Consensus is the name for getting them to agree anyway.
The rule that makes it work: a majority
The trick is simple to state: a decision counts once a majority has accepted it. With 5 servers, that's 3. Two different majorities of the same 5 servers always share at least one member, so the group can never make two conflicting decisions. Try crashing servers:
Notice what happens when too many are down: the survivors stop instead of guessing. That's the deal consensus offers: it would rather be unavailable for a while than give two different answers.
Where you meet it
You'll rarely write a consensus algorithm yourself. You'll run systems that use one inside, and depend on them:
- Kubernetes stores all of its cluster state in etcd, a small database that uses the Raft algorithm.
- Kafka, CockroachDB, Consul and many others use Raft (or the older Paxos) to choose leaders and keep copies identical.
- Tools like Patroni, from the replication topic, ask etcd “who is the leader?” instead of trusting their own timeouts.
What this topic covers
- Why agreeing is hard at all, and what's provably impossible (§02).
- The two core ideas: apply the same commands in the same order (§03), and use majorities and numbered terms (§04).
- Raft, the most popular algorithm, with a live five-server simulator you can crash and split (§05), then its elections (§06) and log copying (§07) up close.
- What goes wrong with network splits and reads (§08), a short tour of Paxos (§09), real code (§10), and running it in production (§11).
Why agreeing is hard
The group chat, again
Five friends (Ana, Ben, Cleo, Dev, Eli) are deciding where to eat tonight. Here's how it goes wrong in real life, with the computer-science name for each problem:
| In the group chat | In a distributed system |
|---|---|
| Ben's message “pizza?” reaches Cleo 20 minutes late | Network delay: messages take an unpredictable time |
| Dev's phone dies mid-conversation | A crash: a server stops |
| Eli hasn't replied. Asleep, or just slow? | Crashed or slow? From outside, you can't tell |
| Ana and Ben are on the subway and can only text each other | A Network partition: the network splits into groups that can't reach each other |
| Ana and Ben each decide “I'll book!” | Two servers both act as leader: Split brain |
The last row is the one that hurts. Booking two restaurants is annoying; two database servers both accepting writes means the data forks into two histories (the split-brain story in the replication topic shows exactly this happening with Postgres).
Three simple ideas that don't work
- “One fixed boss decides.” Fine until the boss's phone dies. Then nobody can decide anything. Picking a new boss means first agreeing the old one is gone, which is the same problem again.
- “Wait until everyone agrees.” One dead phone and you wait forever.
- “Whoever answers first wins.” Late messages mean Cleo and Dev saw different “first” answers. Now they disagree about who won.
What we need is a method with two properties, often called safety and liveness:
- Safety: never decide two different things. Not even for a moment, not even if messages are delayed for an hour. This one is non-negotiable.
- Liveness: eventually decide something. Ideally quickly, but at least eventually.
The bad news: FLP
In 1985, Fischer, Lynch and Paterson proved a famous result, now called FLP impossibility. In plain words:
If messages can be delayed for any amount of time, and even one server might crash, then no algorithm can guarantee that the servers always finish agreeing.— FLP, paraphrased
Why? In an asynchronous network, where there's no limit on message delay, the algorithm can never tell “Eli crashed” from “Eli's reply is still on its way”. If it waits for Eli, Eli may be dead and it waits forever. If it moves on without Eli, it might move on at exactly the wrong moment, and a cleverly timed sequence of delays can keep it undecided indefinitely.
How real systems cope anyway
FLP doesn't say consensus is useless. It says you can't guarantee finishing in every possible timing. Real systems keep safety always and get liveness in practice:
- Timeouts. “No word from the leader for 300 ms means it's probably dead; start an election.” The guess can be wrong (the leader was just slow), but a wrong guess only costs time: an unnecessary election. It never causes two decisions, because safety comes from majorities (§04), not from the timeout.
- Randomness. If two servers keep starting elections at the same moment, they can block each other forever. Random waits make that astronomically unlikely (§06).
- Assume the network is mostly OK. The model is called partial synchrony: the network may misbehave for a while, but eventually it's well-behaved long enough to finish. During a bad stretch the system stalls; it never lies.
What it's used for
Consensus is slow-ish (every decision needs network round trips to a majority) and handles small amounts of data. So it's used for the small, critical facts that everything else depends on:
| Use | Example |
|---|---|
| Leader election: exactly one primary at a time | Patroni picks the Postgres leader through etcd; Kafka's controller uses its own built-in Raft (KRaft mode) |
| Configuration and Service discovery | Kubernetes stores every object in etcd; Consul holds service addresses |
| Locks and leases | “Only one worker runs the billing job” (§10) |
| Metadata | Which Shard lives on which machine; Kafka's topic list |
| A whole replicated database | CockroachDB / TiKV run one Raft group per small range of data |
The systems people use for the first four are called coordination services: etcd, ZooKeeper and Consul. You'll use them through a small API, and §10 shows how.
Same commands, same order, same state
Here's the drift happening. Three copies of a bank balance get the same three commands, but the network delivered them in a different order to each copy:
- 1. balance = 100
- 2. balance += 50
- 3. balance *= 2
- 1. balance = 100
- 2. balance *= 2
- 3. balance += 50
- 1. balance += 50
- 2. balance = 100
- 3. balance *= 2
Three different balances from the same three commands. Nothing crashed; the messages simply arrived in different orders.
Switch to “agreed log order” and they all match. That's the replicated state machine idea. “State machine” just means a program whose state changes only by applying commands: a key-value store, a bank ledger, a counter.
The log
Every server keeps a log: a numbered list of commands.
| index | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| command | x = 1 | y = 7 | x = x + 1 | delete y |
The whole job of consensus is to make sure that every server ends up with exactly the same command at each index. Once all servers agree that entry #3 is x = x + 1, and agree on #1 and #2 before it, each can apply it to its own copy and get the same result.
Two words you'll see constantly from now on:
- An entry is committed when the group has agreed on it for good: it can never be removed or changed. (When exactly that happens is the clever part; §04 and §07.)
- A server applies an entry to its state machine only once it's committed, and always in index order.
Why a leader?
Who decides the order? Letting every server propose commands at once makes agreement slow and complicated. So most practical systems, Raft included, do what replication does: elect one leader, and let it put commands in order. Followers copy the leader's log.
That sounds exactly like leader/follower replication. The difference is in two places where plain replication guesses and consensus doesn't:
- Choosing the leader. Consensus guarantees at most one leader per numbered era (a term, §04), even if the old leader is still running somewhere.
- Deciding when a write is safe. A write is committed only once a majority stores it, so a committed write survives any failover. With asynchronous replication, a failover can lose confirmed writes; with consensus it can't.
Majorities and terms
Idea 1: majorities overlap
A Quorum here simply means a majority: more than half of the servers. With 5 servers, any 3.
Worked example with 5 servers, S1 to S5:
- A write is stored on S1, S2, S3: a majority, so it's committed.
- Later the leader crashes, and S3, S4, S5 vote for a new leader: also a majority.
- Both groups contain S3. There are only 5 servers, and 3 + 3 = 6, so the two groups must share at least one.
S3 has the committed write, and (as §06 shows) it refuses to vote for any candidate whose log is missing it. So whoever wins the election already has the write. Information flows from one majority to the next through the overlap. Try to make two majorities that don't touch:
How many servers?
| Servers | Majority | Crashes it survives |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 (worse than 1: two machines that can fail) |
| 3 | 2 | 1 |
| 4 | 3 | 1 (same as 3) |
| 5 | 3 | 2 |
| 7 | 4 | 3 |
That's why consensus clusters almost always have 3 or 5 servers. An even number adds a machine without surviving any more crashes (§11 has the latency side of this).
The same arithmetic handles network splits. If 5 servers split into a group of 3 and a group of 2, only the group of 3 can form a majority. The group of 2 can't commit or elect anyone; it waits. At most one side of any split can make progress, and that's what makes split brain impossible.
Idea 2: terms
Raft divides time into numbered terms: 1, 2, 3… Each term starts with an election, and each term has at most one leader. Why at most one? Each server votes at most once per term, and winning needs a majority of votes. Two winners would need two majorities of voters, which would share a server, and that server only had one vote.
Every message carries the sender's term, and three rules follow:
- See a higher term? Adopt it. A server that receives a message with a bigger term number immediately moves to that term. A leader or candidate that sees one steps back down to follower.
- See a lower term? Reject the message. It comes from someone who missed an election, like an old leader that was cut off.
- Terms never go backwards. Each server saves its current term to disk before acting on it.
Majorities and terms are the whole foundation. Raft, in the next section, is mostly the details of using them: how to run elections, and how to copy the log.
Raft, live: the whole protocol in one simulator
Raft was published in 2014 by Diego Ongaro and John Ousterhout with one stated goal: be understandable. It splits consensus into three smaller problems: electing a leader, copying the log, and keeping it safe. This section shows all of it running; §06 and §07 then zoom in.
Three roles
Every server is in exactly one role at a time:
- Follower. The normal state. It answers the leader's messages and votes in elections. It never starts anything unless it stops hearing from a leader.
- Candidate. A follower whose election timeout ran out without any word from a leader. It starts a new term and asks for votes.
- Leader. Won a majority of votes. It takes all client writes and sends regular heartbeats so followers know it's alive.
Two messages
Raft needs only two kinds of request (each with a reply):
- RequestVote: a candidate asking “vote for me in term 5?”.
- AppendEntries: the leader sending new log entries. With no entries, the same message is the heartbeat.
The life of one write
- A client sends
x=1to the leader. (A follower that receives it refuses and points the client to the leader.) - The leader appends it to its own log at the next index, say #4. It is not committed yet.
- The leader sends it to every follower in AppendEntries. Each follower writes it to its log and replies “OK”.
- As soon as the leader has the entry on a majority (itself plus 2 followers, out of 5), the entry is committed. The leader advances its commit index to 4.
- The leader applies
x=1to its state machine and answers the client “OK”. - Its next AppendEntries (or heartbeat) carries the new commit index, and the followers apply
x=1too.
Try it
Every server's timeouts and message delays come from a seeded random generator, so the same seed and the same clicks always tell the same story. The narration below the logs is generated from the simulation itself.
| log index → | 1 | state machine |
|---|---|---|
| no leader | ||
| S1 c=0 | empty | |
| S2 c=0 | empty | |
| S3 c=0 | empty | |
| S4 c=0 | empty | |
| S5 c=0 | empty |
recent events (newest first)
- 0.000sAll five servers start as followers in term 0 with empty logs. Each has an election timer (the ring around it) set to a random length between 300 and 750 ms.
Things to try, in order:
- Press ▶ Play. Watch the rings (the election timers) fill up. The first server whose timer runs out becomes a candidate, collects votes, and becomes leader. The new leader immediately adds an empty no-op entry (§07 explains why).
- Send a few client writes. Watch the violet AppendEntries go out, the cyan replies come back, and each cell turn from dashed (stored) to solid (committed). The state machine column fills in. The row above the logs shows the leader counting copies of each entry (3/5 is a majority), and the box below the logs shows what it remembers about each follower: next, the next entry to send it, and match, the last entry it knows that follower has.
- Crash the leader. The followers' rings keep filling because no heartbeats arrive; one times out, wins the next term and carries on. No committed write is lost: it was on a majority, and the new leader got votes from a majority.
- Restart the crashed server with its ↻ restart button (or click the greyed-out server, then ↻). It comes back as a follower, hears a newer term, and gets the entries it missed.
- Cut the leader off with ✂, then send a write to the old leader (click it to select it first). It stays dashed forever: the old leader can only reach 2 of 5 servers. The majority side elects a new leader. Heal the partition and watch the old leader step down and its uncommitted entry get deleted. §08 covers this case.
- Crash three servers. Nothing can be committed until one comes back.
What must survive a crash
A restarted server in the simulator remembers some things and forgets others. That split is part of the protocol:
| Saved to disk before replying | Rebuilt after restart |
|---|---|
currentTerm: the latest term it has seen | commit index (the leader tells it again) |
votedFor: who it voted for in that term | the state machine (re-applied from the log, or loaded from a snapshot) |
| the log entries | a leader's per-follower bookkeeping |
If a server forgot its votedFor in a crash, it could vote twice in the same term, and two candidates could each get a majority: two leaders in one term. That's why every one of these writes is followed by an fsync before the server replies, and why disk speed matters so much in production (§11).
Elections up close
How an election runs
When a follower's election timer runs out, it:
- increases its term by one (say from 2 to 3),
- becomes a candidate and votes for itself,
- sends RequestVote to every other server, and
- restarts its election timer with a new random value.
Then one of three things happens:
- It wins: votes from a majority (itself included). It becomes leader and sends heartbeats right away so nobody else starts an election.
- Someone else wins: it receives AppendEntries from a leader whose term is at least its own, and goes back to follower.
- Nobody wins: a split vote. Its timer runs out again and it tries again in the next term.
Split votes, and why the timeout is random
The stepper below shows a split vote happening, why it could repeat forever with a fixed timeout, and how a random one ends it. The code panel is the voter's side of RequestVote:
1func (n *Node) onRequestVote(r VoteRequest) bool {2 if r.Term > n.term { // newer term: adopt it, forget my old vote3 n.term, n.votedFor, n.role = r.Term, "", Follower4 }5 if r.Term < n.term {6 return false // a candidate from the past7 }8 if n.votedFor != "" && n.votedFor != r.Candidate {9 return false // one vote per term10 }11 if !n.logUpToDate(r.LastLogTerm, r.LastLogIndex) {12 return false // candidate is missing entries I have (§06)13 }14 n.votedFor = r.Candidate // saved to disk before replying15 n.resetElectionTimer()16 return true17}
A split vote that repeats forever, with servers constantly busy but no leader ever winning, is called a Livelock. It's exactly the kind of endless indecision FLP (§02) warns about. Random timeouts don't make it impossible, just very unlikely. The Raft paper's rule of thumb:
message round trip ≪ election timeout ≪ time between server failures
With round trips of about 1 ms in one data center, the paper suggests timeouts of 150–300 ms: wide enough that one server usually times out first by more than a round trip and wins before the others wake up.
The voting rules
Look at the code panel again. A server grants its vote only if all of these hold:
- The candidate's term isn't old (lines 5–7). If it's newer, the voter first adopts it (lines 2–4), which also clears the voter's previous vote, since that was for an older term.
- One vote per term (lines 8–10). The voter saves
votedForto disk before replying, so even a crash and restart can't make it vote twice. - The candidate's log is at least as up to date as the voter's (lines 11–13).
The third rule is what makes Raft safe. “Up to date” compares only the last entry of each log:
- If the last entries have different terms, the one with the later term is more up to date.
- If they have the same term, the longer log is more up to date.
Why this guarantees no committed entry is lost
Put §04's overlap argument together with rule 3:
- A committed entry is stored on a majority, say S1, S2, S3.
- To win, a candidate needs votes from a majority. Any majority includes at least one of S1, S2, S3.
- That server will only vote for a candidate whose log is at least as up to date as its own, so a candidate missing the entry is refused.
- So every future leader already has every committed entry. Raft calls this the Leader Completeness property, and it means a leader never needs to fetch missing committed entries from anyone: it only ever sends entries, never receives them.
There's one subtle hole in “stored on a majority means committed”, which the next section closes.
Log replication up close
The consistency check
When the leader sends entries to a follower in AppendEntries, it also sends the index and term of the entry just before them: prevLogIndex and prevLogTerm. The follower accepts only if its own log has an entry at prevLogIndex with term prevLogTerm.
That one check gives Raft its Log Matching property: if two logs have an entry with the same index and term, they're identical up to that point. Why? Think of it as a chain:
- A leader creates at most one entry per index in its term, and never changes it. So same index + same term means same command.
- A follower only accepted that entry after checking that the entry before it matched the leader's.
- That earlier entry was accepted the same way, after its own check, and so on back to entry #1.
How logs end up different
If the leader is healthy, followers' logs are just shorter copies of its log. Differences come from crashes. A leader in term 3 appends some entries, copies them to nobody (or to a minority), and crashes. Those entries were never committed. A new leader in a later term never had them and writes different entries at the same positions.
The leader handles both “missing” and “different” the same way. It keeps a nextIndex for each follower, starts optimistic, and steps back one entry each time the follower says no:
1func (n *Node) onAppendEntries(a AppendRequest) bool {2 if a.Term < n.term {3 return false // a leader from an old term4 }5 n.becomeFollower(a.Term, a.Leader) // also restarts my election timer6 // Consistency check: do I have the entry just before the new ones?7 if a.PrevLogIndex > len(n.log) || n.termAt(a.PrevLogIndex) != a.PrevLogTerm {8 return false // no: leader will retry one entry earlier9 }10 for i, e := range a.Entries {11 idx := a.PrevLogIndex + 1 + i12 if idx <= len(n.log) && n.termAt(idx) != e.Term {13 n.log = n.log[:idx-1] // conflict: drop this entry and all after it14 }15 if idx > len(n.log) {16 n.log = append(n.log, e)17 }18 }19 if a.LeaderCommit > n.commitIndex {20 n.commitIndex = min(a.LeaderCommit, a.PrevLogIndex+len(a.Entries))21 }22 return true23}
x=1
y=2
x=3
z=4
x=5
y=6
x=1
y=2
x=3
y=9
z=9
x=9
When is an entry committed?
Recall that a committed entry is one that can never be lost or changed. The obvious rule is “once it's stored on a majority”. That's almost right. The full rule is:
A leader marks an entry committed when it's stored on a majority and it's from the leader's current term. Committing it also commits every entry before it.
Entries from earlier terms get committed only indirectly, when a current-term entry after them commits. The stepper shows why “on a majority” alone isn't enough. It's the scenario from Figure 8 of the Raft paper, simplified:
1// Leader: runs after a follower confirms entries up to some index.2for idx := n.lastIndex(); idx > n.commitIndex; idx-- {3 if n.termAt(idx) != n.term {4 continue // never commit an older term's entry by counting copies5 }6 if n.countServersWith(idx) >= majority {7 n.commitIndex = idx // this commits idx AND every entry before it8 break9 }10}
a=1
x=2
a=1
x=2
a=1
a=1
a=1
x=2; S1 appends it as #2 and manages to copy it to S2, and then…The danger was a server whose log ends with a later term but lacks the entry: it can still win an election and overwrite it. Once the leader has an entry from its own term on a majority, no server lacking it can win anymore, because the voters with the newer last term refuse it.
That's also why a newly elected leader immediately appends a no-op entry (you saw it in the simulator). Without it, entries left over from earlier terms would stay uncommitted until the next client write happened to arrive, and a quiet cluster could keep them in limbo for a long time.
Followers learn about commits from the leader
Followers don't count anything. Every AppendEntries carries leaderCommit, the leader's commit index, and the follower sets its own commit index to min(leaderCommit, index of the last new entry) (line 20 of the handler above), then applies everything up to it. In the simulator, that's the moment a follower's dashed cells turn solid, one heartbeat after the leader's.
Network splits, old leaders, and reads
The old leader in the minority
A Network partition splits the servers into groups that can't reach each other. Run the simulator in §05: cut the leader off and send it a write. Here's what happens, and why it's safe:
- The old leader (term 1) and one follower are on one side; three servers on the other.
- The old leader keeps accepting client writes and appending them, but it can reach only 2 of 5 servers. Nothing it appends can commit, so none of those clients ever gets “OK”. They time out.
- The three-server side stops getting heartbeats, elects a new leader in term 2, and commits new writes normally.
- When the split heals, the old leader receives a message with term 2. It steps down at once, and the new leader's AppendEntries delete its uncommitted entries (§07).
Compare that with the split brain in the replication topic, where both Postgres servers accepted and confirmed writes. In Raft, two servers may both believe they're leader for a while, but only one of them, the one with a majority, can commit. The term numbers tell everyone which is which.
Reads are the hard part
A read doesn't change anything, so it's tempting to answer it straight from the leader's memory. But the server answering might be an old leader that hasn't noticed it was replaced:
1func (l *Leader) Read(key string) (string, error) {2 l.waitForOwnTermCommit() // 0. a new leader first commits its no-op (§07)3 readIndex := l.commitIndex // 1. remember how far commits have got4 if !l.confirmLeadership() { // 2. one heartbeat round: does a majority5 return "", ErrNotLeader // still accept me as leader?6 }7 l.waitUntilApplied(readIndex) // 3. let the state machine catch up8 return l.kv[key], nil // 4. now the answer is up to date9}
x = 1 is committed everywhere. S1 is the leader of term 1.A read that returns an older value after a newer one was already confirmed breaks linearizability (“behaves like one copy”). There are three standard fixes:
| Approach | How | Cost | Catch |
|---|---|---|---|
| Put reads in the log | Treat every read like a write: append, commit, then answer | A disk write and a majority round trip per read | Slow; rarely used |
| ReadIndex | Make sure an entry from my own term (the no-op) is committed, note the commit index, confirm leadership with one heartbeat round, wait until applied, answer | One round trip to a majority, no disk write | The usual default (etcd's linearizable reads work like this) |
| Lease read | After a successful heartbeat round, the leader assumes nobody else can become leader for a little less than the election timeout, and answers locally until then | No network at all | Safe only if clocks run at the same rate and the process doesn't pause |
ReadIndex has one easy-to-miss first step (line 2 in the stepper's code). A freshly elected leader's commit index can be behind: it may hold entries from earlier terms that are already committed but that it can't yet count as committed, the Figure 8 case from §07. Its commit index only becomes trustworthy once an entry from its own term commits. That's another job for the no-op every new leader appends: until it commits, the leader holds reads back.
What clocks have to do with it
Raft's safety never depends on clocks. Terms and log indexes are counters, not timestamps. A server whose clock is an hour wrong, or that is paused for ten seconds, can cause extra elections but never two committed values. Clock skew only affects how quickly things happen.
Lease reads are different. A lease is a promise like “no one can be elected for the next 900 ms”, and it's only true if 900 ms on the leader is about 900 ms on the followers. If the leader's process freezes for two seconds (a long garbage-collection pause, or the cloud provider briefly pausing its virtual machine), it can wake up believing its lease is still valid after a new leader was already elected. Implementations measure leases with a Monotonic clock and subtract a safety margin, but the assumption remains. The Clocks topic covers why machines disagree about time.
Leases outside Raft: still use fencing tokens
The same pause problem hits your code when it uses a consensus store for locks. Your process holds a lease on the lock “billing-job”, freezes for 15 seconds, the lease expires, another worker takes the lock, and then your process wakes up and keeps writing. Raft can't stop that: the stale write goes to your database, not through Raft.
The fix is the fencing token from the replication topic: each lock holder gets a number that grows with every new holder, sends it with every write, and the storage rejects numbers lower than the highest it has seen. etcd hands you that number for free (the key's creation revision); §10 shows it in code.
Paxos in one section
Paxos, described by Leslie Lamport (written in 1989, published in 1998), was the first practical, proven-correct consensus algorithm. It powers Google's Chubby lock service and Spanner, and variants run inside many databases. It's also famously hard to follow, which is why Raft was invented. You don't need Paxos to use etcd, but you'll meet the name everywhere, and seeing it makes Raft's choices clearer.
Roles and the two phases
Basic Paxos decides one value. There are two main roles (a server can play both): proposers suggest values, and acceptors vote. Every proposal has a unique number, and numbers only go up.
- Phase 1, prepare/promise. A proposer picks a number
nand asks the acceptorsPrepare(n). An acceptor that hasn't promised anything higher promises to ignore all proposals belown, and replies with the highest-numbered proposal it has already accepted, if any. - Phase 2, accept. With promises from a majority, the proposer sends
Accept(n, v). The valuevmust be the value of the highest-numbered proposal reported in the promises; only if none was reported may it use its own. Acceptors accept unless they've promised a higher number since.
A value is chosen once a majority of acceptors have accepted it under the same number.
1type Acceptor struct {2 promised int // highest proposal number I promised to respect3 accN int // number of the proposal I last accepted (0 = none)4 accV string // and its value5}67// Phase 1: "will you listen to proposal n?"8func (a *Acceptor) Prepare(n int) (ok bool, accN int, accV string) {9 if n <= a.promised {10 return false, 0, ""11 }12 a.promised = n // promise: ignore anything numbered below n13 return true, a.accN, a.accV // and report what I accepted before14}1516// Phase 2: "please accept value v under number n"17func (a *Acceptor) Accept(n int, v string) bool {18 if n < a.promised {19 return false // I promised someone with a higher number20 }21 a.promised, a.accN, a.accV = n, n, v22 return true23}
Why the “adopt the earlier value” rule
Without it, P2 would have proposed sushi under number 2, and A1 and A3 would have accepted. Meanwhile A1 and A2 had already accepted pizza under number 1. Two values, each accepted by a majority: exactly what consensus forbids.
With the rule, any proposer that gets past phase 1 has heard from a majority, and that majority overlaps the majority that accepted any chosen value (§04 again). So it learns the chosen value and proposes it again. Once a value is chosen, every later proposal carries the same value.
Dueling proposers, and Multi-Paxos
Paxos has its own liveness problem. P1 prepares 1; P2 prepares 2 (so P1's accepts get rejected); P1 retries with 3 (so P2's get rejected); and so on forever. The standard fix sounds familiar: pick one leader (a “distinguished proposer”) and use random back-off when two compete.
A replicated log needs a decision for every slot: entry 1, entry 2, and so on. Running full two-phase Paxos per slot costs two round trips each. Multi-Paxos lets a stable leader run phase 1 once for all future slots, and then only phase 2, one round trip, per entry. At that point it looks a lot like Raft.
Raft vs Paxos
| Raft | (Multi-)Paxos | |
|---|---|---|
| Leader | At most one per term (a split-vote term has none); required for progress | An optimization for liveness and speed |
| Who can lead | Only a server whose log is up to date (§06) | Anyone; a new leader first learns earlier values in phase 1 |
| Log | Continuous, no gaps; entries flow only from leader to followers | Slots can be decided out of order, leaving gaps to fill |
| Spec | One paper with the whole system (elections, membership, snapshots) | The core is tiny; the full system is left to the implementer |
| Used by | etcd, Consul, CockroachDB, TiKV, Kafka KRaft | Chubby, Spanner; ZooKeeper's Zab and Cassandra's lightweight transactions are close relatives |
The two are closer than they look. Raft's term is Paxos's proposal number; Raft's election plays the role of phase 1; AppendEntries is phase 2. Raft's main simplification is the up-to-date voting rule: because only a server that already has every committed entry can win, a new Raft leader never has to collect old values from anyone.
Code: a tiny election in Go, and etcd in practice
raft library, HashiCorp's raft) or just use etcd itself as your coordination service.Part 1: a toy election in Go
This is Raft's election and nothing else: no log, no commits. Five nodes run as five goroutines inside one program, and a fake network of channels carries their messages. It's about 170 lines in total, shown in three pieces. First, the messages and the network:
// Package miniraft is a tiny Raft-style leader election: no log, just terms and votes.
package miniraft
import (
"math/rand/v2"
"sync"
"time"
)
type Role string
const (
Follower Role = "follower"
Candidate Role = "candidate"
Leader Role = "leader"
)
type kind int
const (
voteReq kind = iota
voteResp
heartbeat
)
type message struct {
kind kind
from int
term int
granted bool // only used in voteResp
}
// Network delivers messages between nodes and can cut a node off.
type Network struct {
mu sync.Mutex
inbox []chan message
cut []bool
}
func (n *Network) send(to int, m message) {
n.mu.Lock()
dropped := n.cut[to] || n.cut[m.from]
n.mu.Unlock()
if dropped {
return // a cut-off node neither sends nor receives
}
select {
case n.inbox[to] <- m:
default: // inbox full: drop it, like a lost packet
}
}
func (n *Network) Disconnect(id int) {
n.mu.Lock()
n.cut[id] = true
n.mu.Unlock()
}Next, one node. The important design choice: each node's state is owned by one goroutine. Only run reads or writes term, votedFor, role and votes, so they need no lock. (run keeps them in its own local variables. The term and role fields on the struct are just a copy for the test: after handling each event, run copies its two locals into them under the mutex, so the test can peek at them safely.) Messages and timers all arrive through one select:
const heartbeatEvery = 20 * time.Millisecond
// electionTimeout is random, so two followers rarely time out together.
func electionTimeout() time.Duration {
return 150*time.Millisecond + rand.N(150*time.Millisecond)
}
type Node struct {
id int
size int
net *Network
stop chan struct{}
mu sync.Mutex // guards the two fields below, which tests read
term int
role Role
}
func (n *Node) State() (int, Role) {
n.mu.Lock()
defer n.mu.Unlock()
return n.term, n.role
}
func (n *Node) broadcast(m message) {
for to := 0; to < n.size; to++ {
if to != n.id {
n.net.send(to, m)
}
}
}
// run is the node's only goroutine; it alone owns term, votedFor, role and votes.
func (n *Node) run(inbox <-chan message) {
term, votedFor, role, votes := 0, -1, Follower, 0
timer := time.NewTimer(electionTimeout())
beat := time.NewTicker(heartbeatEvery)
defer timer.Stop()
defer beat.Stop()
for {
select {
case <-n.stop:
return
case <-timer.C: // heard nothing for a whole timeout: start an election
if role != Leader {
term++
role, votedFor, votes = Candidate, n.id, 1 // vote for myself
n.broadcast(message{kind: voteReq, from: n.id, term: term})
}
timer.Reset(electionTimeout())
case <-beat.C:
if role == Leader {
n.broadcast(message{kind: heartbeat, from: n.id, term: term})
}
case m := <-inbox:
if m.term > term { // someone is in a newer term: catch up and follow
term, votedFor, role = m.term, -1, Follower
}
switch m.kind {
case voteReq:
grant := m.term == term && (votedFor == -1 || votedFor == m.from)
if grant {
votedFor = m.from
timer.Reset(electionTimeout())
}
n.net.send(m.from, message{kind: voteResp, from: n.id, term: term, granted: grant})
case voteResp:
if role == Candidate && m.term == term && m.granted {
votes++
if votes > n.size/2 { // a majority: 3 of 5
role = Leader
n.broadcast(message{kind: heartbeat, from: n.id, term: term})
}
}
case heartbeat:
if m.term == term {
role = Follower // a candidate that hears a leader of its term gives up
timer.Reset(electionTimeout())
}
}
}
n.mu.Lock()
n.term, n.role = term, role
n.mu.Unlock()
}
}Follow one election through the code: the <-timer.C case is “my election timeout ran out”; the voteReq case is the voter's side of §06 (newer term adopted, one vote per term; there's no log, so no up-to-date check); the voteResp case counts votes until votes > n.size/2, a majority. A heartbeat from a leader of the current term turns a candidate back into a follower and restarts the timer. The first if m.term > term line is the “higher term wins” rule from §04, applied to every message.
Finally, a cluster to hold the nodes:
type Cluster struct {
Net *Network
Nodes []*Node
wg sync.WaitGroup // counts running node goroutines
}
func NewCluster(size int) *Cluster {
c := &Cluster{Net: &Network{inbox: make([]chan message, size), cut: make([]bool, size)}}
for id := range size {
c.Net.inbox[id] = make(chan message, 64)
}
for id := range size {
n := &Node{id: id, size: size, net: c.Net, stop: make(chan struct{}), role: Follower}
c.Nodes = append(c.Nodes, n)
c.wg.Add(1)
go func() {
defer c.wg.Done()
n.run(c.Net.inbox[id])
}()
}
return c
}
// Stop tells every node to quit and waits until all of them have.
func (c *Cluster) Stop() {
for _, n := range c.Nodes {
close(n.stop)
}
c.wg.Wait()
}for id := range size(Go 1.22+) loops withid= 0, 1, …,size−1. Since Go 1.22 each loop iteration gets its ownidandn, so each goroutine sees its own node.- A
sync.WaitGroupis a counter of running goroutines:Add(1)before starting one,Done()when it finishes (here viadefer), andWait()blocks until the count is back to zero.go func() { … }()starts an anonymous function as a goroutine. WithoutWait,Stopwould return while nodes were still running, and they could outlive the test.
Testing it: cut off the leader
The test waits for a leader, cuts it off from the network (from the others' point of view, it crashed), and checks that a new leader appears in a higher term:
package miniraft
import (
"testing"
"time"
)
// leader waits until exactly one connected node is leader, and returns it.
func leader(t *testing.T, c *Cluster, skip map[int]bool) (id, term int) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
id, term = -1, 0
leaders := 0
for i, n := range c.Nodes {
tm, role := n.State()
if !skip[i] && role == Leader {
leaders++
id, term = i, tm
}
}
if leaders == 1 {
return id, term
}
}
t.Fatal("no single leader within 3 s")
return
}
func TestNewLeaderAfterCrash(t *testing.T) {
c := NewCluster(5)
defer c.Stop()
old, oldTerm := leader(t, c, nil)
t.Logf("leader is node %d in term %d", old, oldTerm)
c.Net.Disconnect(old) // "crash" it: its messages go nowhere
cut := map[int]bool{old: true}
next, nextTerm := leader(t, c, cut)
t.Logf("node %d cut off; new leader is node %d in term %d", old, next, nextTerm)
if nextTerm <= oldTerm {
t.Fatalf("new leader's term %d should be > %d", nextTerm, oldTerm)
}
if _, role := c.Nodes[old].State(); role == Leader {
t.Logf("node %d still thinks it is leader of term %d: nobody listens", old, oldTerm)
}
}t.Helper() makes failure messages point at the caller's line. skip[i] on a nil map (the first call passes nil) is fine in Go: reading a missing key returns false.
Run it with the Race detector on, which watches for two goroutines touching the same memory without a lock:
$ go test -race -v ./...
=== RUN TestNewLeaderAfterCrash
raft_test.go:36: leader is node 3 in term 1
raft_test.go:41: node 3 cut off; new leader is node 2 in term 2
raft_test.go:47: node 3 still thinks it is leader of term 1: nobody listens
--- PASS: TestNewLeaderAfterCrash (0.46s)
PASS
ok example.com/miniraft 1.467sWhich node wins changes from run to run (the timeouts are random). It also passed 30 runs in a row with -race -count=30. Notice the last log line: the cut-off node still believes it is the leader of term 1. That's §08 in miniature: being leader in your own head is harmless as long as nobody accepts your old term.
Part 2: leader election with etcd
Most of the time you don't need Raft inside your service: you need one instance of your service to be in charge, say, only one of the 6 running copies (pods) of your service should run the nightly billing job. etcd already runs Raft; your code just asks it for a leader. Both clients below put a key in etcd attached to a lease, a permission that expires unless the client keeps renewing it. If your process dies or loses contact with etcd, the lease runs out (here after 10 seconds), etcd deletes the key, and the next candidate takes over.
package main
import (
"context"
"log"
"os"
"os/signal"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/client/v3/concurrency"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"},
DialTimeout: 5 * time.Second,
})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// A session = a lease that this client keeps alive in the background.
// If this process dies or is cut off, etcd deletes its keys after ~10 s.
sess, err := concurrency.NewSession(cli, concurrency.WithTTL(10))
if err != nil {
log.Fatal(err)
}
defer sess.Close()
host, _ := os.Hostname()
election := concurrency.NewElection(sess, "/jobs/billing/leader")
// Blocks until we are the leader (or ctx is cancelled).
if err := election.Campaign(ctx, host); err != nil {
log.Fatal(err)
}
token := election.Rev() // ← grows with every new leader: a fencing token
log.Printf("I am the leader, fencing token %d", token)
select {
case <-sess.Done(): // lease lost: someone else may be leader now
log.Print("lost leadership: stop all leader-only work")
return
case <-ctx.Done(): // Ctrl+C: hand over politely
resignCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = election.Resign(resignCtx)
}
}The fencing token
Leadership through a lease has the pause problem from §08: your process can freeze, lose the lease, and wake up still acting as leader. election.Rev() in Go returns the etcd revision at which this candidate's key was created. Every later leader's key is created later, so it gets a larger number: a ready-made Fencing token. Send it with every write, and have the storage refuse older ones:
-- $1 = the fencing token from election.Rev()
UPDATE billing_state
SET last_run = now(), fence = $1
WHERE id = 1 AND fence <= $1; -- 0 rows updated → a newer leader exists: stopWithout a check like this at the storage, the lease alone can't protect you. The etcd3 package's own documentation for its lock() helper warns the same thing: in rare situations the caller may lose the lock while its operations are still running.
Running consensus in production
3 or 5 servers, and where to put them
Round-trip times are illustrative. Add the leader's own disk fsync (often 1–10 ms) to every commit as well.
- 3 servers survive 1 failure. Fine for most clusters, but during planned maintenance (one server down for an upgrade) you're one crash away from an outage.
- 5 servers survive 2, so maintenance plus an unexpected crash is still fine. Commits get only barely slower in one data center (the leader waits for the 2nd-fastest of 4 followers instead of the faster of 2), but every server does the work of every write, and elections involve more servers.
- 7 or more rarely pays off. More voters means more messages per commit for little gain. Add read capacity with non-voting members instead (below).
- Place servers so no single failure takes out a majority. 3 servers in 2 data centers means one data center has 2 of them, and losing it loses the majority. Use 3 availability zones (or 3 regions) for 3 or 5 servers.
Across regions, the arithmetic gets expensive: the leader must hear from a majority, and with servers spread over 3 regions, that majority always includes another region. Every write pays tens of milliseconds, and an election timeout tuned for one data center will fire constantly. That's why most systems run consensus within one region across zones, and only go cross-region when surviving a whole region's loss is worth it.
Keep consensus off the hot path
A consensus store is a small, slow, very reliable notebook for the facts everything else depends on. It is not your main database or a message queue:
- etcd's default storage quota is about 2 GB, and its docs advise not to go far beyond 8 GB. Requests are limited to about 1.5 MB by default.
- Every write is a disk flush on a majority, so throughput is thousands to low tens of thousands of small writes per second, not millions.
- Kubernetes itself is one of etcd's heaviest users. Storing your application's per-request data in the same etcd competes with Kubernetes' Control plane, the part that runs your whole cluster.
The usual pattern: put who is leader, which shard lives where, feature flags and locks in etcd/ZooKeeper/Consul; put the actual data in a database. Systems that need consensus for all their data (CockroachDB, TiKV, YugabyteDB) split it into thousands of small ranges, each with its own Raft group and leader, so the work is spread over many servers (Multi-Raft).
Disks: fsync is the commit
Before a Raft server answers “OK, stored”, it must fsync the entry to disk (§05). So the slowest part of a commit inside one data center is usually the disk, not the network. etcd's guidance is that the p99 of fsync time for its write-ahead log (the file where it appends entries) should stay below about 10 ms.
Slow or shared disks (network storage shared with other busy machines, a disk that other programs also hammer) cause heartbeats to be late, followers to time out, and leaders to change for no visible reason. Leader flapping in etcd is very often a disk problem. Give consensus servers dedicated SSDs.
Changing who's in the cluster
Adding or removing a server changes what “majority” means. Switch every server from the old member list to the new one at different moments, and for a short time the old majority and the new majority might not overlap: two leaders in the same term become possible. Raft offers two safe methods:
- Joint consensus: a transition phase in which every decision needs a majority of the old set and a majority of the new set.
- One server at a time: adding or removing a single server always keeps old and new majorities overlapping, so changes are made one by one, each committed before the next.
New servers usually join as a learner (non-voting member) first: they copy the log without counting toward majorities until they've caught up, so a brand-new, empty server can't stall commits. etcd has supported learners since v3.4.
Snapshots and log compaction
The log grows with every write forever, unless something trims it. Each server periodically writes a snapshot of its state machine (“the whole key-value store as of entry #1,000,000”), and then deletes the log up to that point: log compaction. A follower that's so far behind that the leader has already deleted the entries it needs receives the snapshot instead (Raft's InstallSnapshot message), then continues from there.
(etcd also has a separate “compaction” of its key history, the old versions of keys it keeps for watches. That's a different setting from Raft log compaction.)
Elections that shouldn't happen
- A server that rejoins after being cut off has been timing out and raising its term the whole time. When it reconnects, its high term makes the healthy leader step down: a pointless election. Pre-vote fixes it: a server first asks “would you vote for me?” and only raises its term if a majority says yes. etcd has it as an option (
--pre-vote, on by default in recent versions). - A leader that can't reach a majority keeps believing it's leader (§08). CheckQuorum makes it step down by itself if it hasn't heard from a majority within an election timeout.
- Planned maintenance: transfer leadership to another server before restarting the leader, instead of letting the cluster discover it's gone after a timeout.
What to monitor
| Signal | Why |
|---|---|
Is there a leader? (etcd_server_has_leader) | No leader means no writes |
Leader changes per hour (etcd_server_leader_changes_seen_total) | More than a few a day usually means slow disks, an overloaded network, or a timeout set too low |
Disk fsync latency (etcd_disk_wal_fsync_duration_seconds) | The biggest part of commit latency |
Failed proposals (etcd_server_proposals_failed_total) | Writes that didn't commit, often during elections |
| Database size vs quota | When etcd hits its quota, it stops accepting writes |
| How far each member is behind | A lagging member is one failure away from being needed for the majority |
Pitfalls
Two servers (or any even number)
A 2-server cluster needs both servers for a majority, so it survives zero failures: worse than one server. 4 survives the same 1 failure as 3. Use 3 or 5 (§04).
Spreading 3 servers over 2 data centers
One data center ends up with 2 of the 3 servers. If that data center goes down, the survivor alone can't form a majority, so the cluster stops. Use three failure zones; a small third site holding just one server is enough (§11).
Treating a lease or lock as a guarantee
“I hold the etcd lock, so I'm the only one writing.” Not if your process paused for longer than the lease: another worker took over while you were frozen, and you just woke up. Pass a Fencing token (the etcd revision) with every write and have the storage reject older ones (§08, §10).
Reading from “the leader” without checking
An old leader that was cut off still answers reads with old data. Use the store's linearizable reads (etcd's default), or ReadIndex if you build your own. Turn on serializable or follower reads only where slightly old data is acceptable (§08).
Retrying a timed-out write blindly
A timeout doesn't tell you whether the write committed. Retrying a non-idempotent command (balance += 50) can apply it twice. Use idempotency keys or compare-and-set writes (put only if revision is still 41), so a repeat is harmless (Idempotency).
Putting hot data in the consensus store
Per-request sessions, counters, job queues and caches in etcd or ZooKeeper turn a small coordination service into the bottleneck of everything, Kubernetes included if it's the same etcd. Keep consensus for small, critical metadata (§11).
Election timeouts copied from a tutorial
150 ms works on a quiet local network. Across regions, on busy virtual machines, or with garbage-collection pauses, it causes constant elections, and each one blocks writes for a moment. Too long, and a dead leader isn't replaced for that long. Tune the timeout against your measured round-trip times and pauses, and watch the leader-change rate.
Slow or shared disks
Every commit waits for an fsync on a majority. Network storage shared with other busy machines makes heartbeats late and leaders flap. If leaders change often, check disk latency first (§11).
Restoring a member with a wiped disk under its old identity
A server that has lost its saved term and vote can vote twice in the same term, and two candidates could each collect a majority. Remove the member and add it back as a new one, or restore the whole cluster from a snapshot as your tool documents (§05, §11).
Writing your own consensus for production
The core fits on a page; the edge cases fill years. The toy in §10 is for learning. Use etcd, ZooKeeper or Consul as a service, or a proven library (go.etcd.io/raft, hashicorp/raft) if consensus must live inside your system.
Check yourself
Predict the answer before you click. Each explanation points back to the section that covers it.
A 5-server Raft cluster. Two servers crash; the other three can all reach each other. Can the cluster still commit new writes?
How many crashed servers can a 4-server cluster survive and still commit?
The network splits: {S1 (leader, term 4), S2} | {S3, S4, S5}. A client sends x=9 to S1. What happens to it?
A voter's log ends with (index 5, term 3). A candidate's log ends with (index 7, term 2). Does the voter grant its vote (assuming it hasn't voted this term)?
Why does Raft pick a random election timeout (e.g. 150–300 ms) instead of one fixed value?
The leader of term 4 sees that an entry from term 2 is now stored on 3 of 5 servers. May it mark that entry committed right now?
Your service reads from whichever node says it's leader, straight from its memory, with no extra checks. What can go wrong?
Cheat sheet
| Situation | What Raft does |
|---|---|
| Leader crashes | Followers time out, one wins the next term; committed entries survive |
| Network splits 3 / 2 | The 3-side elects a leader and carries on; the 2-side can't commit anything |
| Split heals | The old leader sees a higher term, steps down; its uncommitted entries are deleted |
| Too many servers down (no majority) | No writes, no new leader, but never a wrong answer |
| A write times out | Unknown outcome: retry only with an idempotency key |
| Cluster size | Majority | Survives |
|---|---|---|
| 3 | 2 | 1 failure |
| 5 | 3 | 2 failures |
| 7 | 4 | 3 failures |
| Want… | Use |
|---|---|
| One instance of my service in charge | etcd election: Go concurrency.NewElection + Campaign, Node etcd3 election().campaign() |
| Safe writes from a lease holder | A fencing token (etcd revision) checked by the storage |
| Correct reads | Linearizable reads (etcd's default); serializable only if stale is OK |
| Raft inside my own system | go.etcd.io/raft or hashicorp/raft, not your own |