Skip to content
Software Atlas

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.

⏱ ~55 min read14 sections12 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceConsensus lets a group of machines agree on one ordered list of decisions, and keep agreeing even when some of them crash or messages get lost, as long as more than half of them are alive and can talk to each other.

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:

Does the cluster still work?
Click a server to crash it (click again to bring it back). The group can only make decisions while a majority is alive.
S1aliveS2aliveS3aliveS4aliveS5alive5/5alive · need 3
Working. 5 of 5 servers can still reach each other, and 3 is a majority, so they can keep agreeing on new writes. It can survive 2 more crashes.

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

  1. Why agreeing is hard at all, and what's provably impossible (§02).
  2. The two core ideas: apply the same commands in the same order (§03), and use majorities and numbered terms (§04).
  3. 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.
  4. What goes wrong with network splits and reads (§08), a short tour of Paxos (§09), real code (§10), and running it in production (§11).
Foundations§02

Why agreeing is hard

In one sentenceMessages can be late, lost or out of order, and a crashed server looks exactly like a slow one. Agreement must be safe no matter what the network does, and should finish whenever the network behaves.

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 chatIn a distributed system
Ben's message “pizza?” reaches Cleo 20 minutes lateNetwork delay: messages take an unpredictable time
Dev's phone dies mid-conversationA 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 otherA 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

  1. “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.
  2. “Wait until everyone agrees.” One dead phone and you wait forever.
  3. “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:

UseExample
Leader election: exactly one primary at a timePatroni picks the Postgres leader through etcd; Kafka's controller uses its own built-in Raft (KRaft mode)
Configuration and Service discoveryKubernetes stores every object in etcd; Consul holds service addresses
Locks and leases“Only one worker runs the billing job” (§10)
MetadataWhich Shard lives on which machine; Kafka's topic list
A whole replicated databaseCockroachDB / 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.

Foundations§03

Same commands, same order, same state

In one sentenceIf every copy starts the same and applies the same commands in the same order, every copy ends the same. So instead of agreeing on the data, servers only need to agree on the order of the commands.

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:

Same commands, different order
Three copies of a bank balance receive the same three commands. The network delivered them in a different order to each copy.
Copy 1
  1. 1. balance = 100
  2. 2. balance += 50
  3. 3. balance *= 2
balance = 300
Copy 2
  1. 1. balance = 100
  2. 2. balance *= 2
  3. 3. balance += 50
balance = 250
Copy 3
  1. 1. balance += 50
  2. 2. balance = 100
  3. 3. balance *= 2
balance = 200

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.

index1234
commandx = 1y = 7x = x + 1delete 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:

  1. Choosing the leader. Consensus guarantees at most one leader per numbered era (a term, §04), even if the old leader is still running somewhere.
  2. 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.
Foundations§04

Majorities and terms

In one sentenceTwo ideas carry all the safety: any two majorities share a server, so information can't be lost between decisions; and numbered terms, so everyone can tell a current leader from an old one.

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:

  1. A write is stored on S1, S2, S3: a majority, so it's committed.
  2. Later the leader crashes, and S3, S4, S5 vote for a new leader: also a majority.
  3. 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:

Any two majorities share a server
Top row: the servers that stored a committed entry. Bottom row: the servers that voted for a new leader. Click to change either group, and try to make them not overlap.
Stored entry #7
3/5 · majority ✓
Voted for new leader
3/5 · majority ✓
Overlap: S3. That server has entry #7 and will only vote for a candidate whose log is at least as complete as its (§06). So the new leader is guaranteed to have entry #7 as well.

How many servers?

ServersMajorityCrashes it survives
110
220 (worse than 1: two machines that can fail)
321
431 (same as 3)
532
743

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.

Terms: at most one leader each, sometimes none
Click a term. Orange = election, blue = a leader is in charge.
time →term 1electleader S1term 2electleader S2term 3splitno leaderterm 4electleader S4term 5electleader S3S1 wakes up: “AppendEntries, term 1”“rejected, we're in term 4”S1 adopts term 4, becomes a follower
S2 crashes. Two followers time out at almost the same moment and split the votes: nobody gets a majority. Term 3 has no leader at all. That's allowed: the rule is at most one leader per term.

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.

Practitioner§05

Raft, live: the whole protocol in one simulator

In one sentenceRaft elects one leader per term; the leader appends each client command to its log, copies it to the followers, and once a majority has stored it, the entry is committed and everyone applies it.

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.
Followerwaits, votes, copiesCandidateasks for votesLeadertakes writes, heartbeatstimer runs out:term + 1, vote for selfvotes froma majorityhears a leaderor a higher termsplit vote: timer runs out again → new termsees a message with a higher term → steps down

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

  1. A client sends x=1 to the leader. (A follower that receives it refuses and points the client to the leader.)
  2. The leader appends it to its own log at the next index, say #4. It is not committed yet.
  3. The leader sends it to every follower in AppendEntries. Each follower writes it to its log and replies “OK”.
  4. 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.
  5. The leader applies x=1 to its state machine and answers the client “OK”.
  6. Its next AppendEntries (or heartbeat) carries the new commit index, and the followers apply x=1 too.

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.

Raft, live: five servers
Press ▶ Play (or Step to jump to the next interesting event). Click a server to select it; the buttons below act on the selected server, or on the leader if none is selected.
S1term 0followerS2term 0followerS3term 0followerS4term 0followerS5term 0follower
RequestVoteAppendEntries (number = entries carried)replylost / “no”ring = election timer
log index →1state machine
no leader
S1 c=0
empty
S2 c=0
empty
S3 c=0
empty
S4 c=0
empty
S5 c=0
empty
Solid cell = committed · dashed = stored but not committed yet · · = no-op · colour = the term it was created in · c = commit index (how far that server knows entries are committed)
t = 0.000 s
All 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.
recent events (newest first)
  1. 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.
seed 1 · t = 0.00 s
No leader yet: click a server to select it

Things to try, in order:

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 replyingRebuilt after restart
currentTerm: the latest term it has seencommit index (the leader tells it again)
votedFor: who it voted for in that termthe state machine (re-applied from the log, or loaded from a snapshot)
the log entriesa 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).

Practitioner§06

Elections up close

In one sentenceA follower that hears nothing for a random timeout becomes a candidate and asks for votes. Each server gives one vote per term, and only to a candidate whose log is at least as up to date as its own.

How an election runs

When a follower's election timer runs out, it:

  1. increases its term by one (say from 2 to 3),
  2. becomes a candidate and votes for itself,
  3. sends RequestVote to every other server, and
  4. 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:

A split vote, and how random timeouts end it
Five servers, the leader crashes. Step with the buttons or ← →.
Code
1func (n *Node) onRequestVote(r VoteRequest) bool {
2 if r.Term > n.term { // newer term: adopt it, forget my old vote
3 n.term, n.votedFor, n.role = r.Term, "", Follower
4 }
5 if r.Term < n.term {
6 return false // a candidate from the past
7 }
8 if n.votedFor != "" && n.votedFor != r.Candidate {
9 return false // one vote per term
10 }
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 replying
15 n.resetElectionTimer()
16 return true
17}
S1
leader · term 1
♥ heartbeats
S2
follower · term 1
⏱ 300 ms
S3
follower · term 1
⏱ 300 ms
S4
follower · term 1
⏱ 300 ms
S5
follower · term 1
⏱ 300 ms
Term 1. S1 is the leader and sends heartbeats; every heartbeat restarts the followers' election timers. Suppose, for now, that every server uses the same fixed 300 ms timeout. (Real Raft doesn't. That's the point of this example.)
1 / 10

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:

  1. 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.
  2. One vote per term (lines 8–10). The voter saves votedFor to disk before replying, so even a crash and restart can't make it vote twice.
  3. 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.
Would you vote for this candidate?
A voter compares only the LAST entry of each log: first its term, then the log length. Pick a candidate.
Voter's log
1t1
2t1
3t2
4t2
Candidate's log
1t1
2t1
3t2
Vote refused. Same last term (2), but the candidate's log is shorter (3 < 4): it is missing entry 4.

Why this guarantees no committed entry is lost

Put §04's overlap argument together with rule 3:

  1. A committed entry is stored on a majority, say S1, S2, S3.
  2. To win, a candidate needs votes from a majority. Any majority includes at least one of S1, S2, S3.
  3. 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.
  4. 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.

Practitioner§07

Log replication up close

In one sentenceEach AppendEntries says “this goes right after entry #N from term T”. A follower that doesn't have exactly that entry says no, and the leader backs up until the logs match, then overwrites whatever comes after.

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:

  1. A leader creates at most one entry per index in its term, and never changes it. So same index + same term means same command.
  2. A follower only accepted that entry after checking that the entry before it matched the leader's.
  3. 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:

How the leader repairs a follower's log
Each AppendEntries carries the index and term of the entry just before the new ones. Step with the buttons or ← →.
Code
1func (n *Node) onAppendEntries(a AppendRequest) bool {
2 if a.Term < n.term {
3 return false // a leader from an old term
4 }
5 n.becomeFollower(a.Term, a.Leader) // also restarts my election timer
6 // 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 earlier
9 }
10 for i, e := range a.Entries {
11 idx := a.PrevLogIndex + 1 + i
12 if idx <= len(n.log) && n.termAt(idx) != e.Term {
13 n.log = n.log[:idx-1] // conflict: drop this entry and all after it
14 }
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 true
23}
Leader S1term 6
#1 · t1
x=1
#2 · t1
y=2
#3 · t2
x=3
#4 · t4
z=4
#5 · t4
x=5
#6 · t6
y=6
nextIndex[S3] = 7
AppendEntries S1 → S3
nothing sent yet
Follower S3was leader in term 3
#1 · t1
x=1
#2 · t1
y=2
#3 · t2
x=3
#4 · t3
y=9
#5 · t3
z=9
#6 · t3
x=9
S1 is the new leader of term 6. S3's log has the same first 3 entries, but entries #4–#6 came from term 3, when S3 itself was briefly leader and accepted writes it never managed to copy to anyone else. The leader doesn't know any of that yet. It optimistically assumes S3 is up to date: nextIndex[S3] = 7, “the next entry I'll send S3 is #7”.
1 / 7

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:

Why “on a majority” is not always “committed”
Raft's Figure 8, simplified. Colours are terms. Step with the buttons or ← →.
Code
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 copies
5 }
6 if n.countServersWith(idx) >= majority {
7 n.commitIndex = idx // this commits idx AND every entry before it
8 break
9 }
10}
S1
★ leader t2
#1 · t1
a=1
#2 · t2
x=2
S2
follower
#1 · t1
a=1
#2 · t2
x=2
S3
follower
#1 · t1
a=1
S4
follower
#1 · t1
a=1
S5
follower
#1 · t1
a=1
Entry #1 is committed everywhere. S1 is leader of term 2. A client asks for x=2; S1 appends it as #2 and manages to copy it to S2, and then…
1 / 7

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.

Practitioner§08

Network splits, old leaders, and reads

In one sentenceDuring a split, an old leader may still think it's leader, but it can't commit anything. Writes are safe automatically; reads are not, unless the leader checks with a majority first.

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:

  1. The old leader (term 1) and one follower are on one side; three servers on the other.
  2. 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.
  3. The three-server side stops getting heartbeats, elects a new leader in term 2, and commits new writes normally.
  4. 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:

A stale read from a leader that doesn't know it's been replaced
Writes are protected by the majority rule. Reads need their own protection. Step with the buttons or ← →.
Code
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 got
4 if !l.confirmLeadership() { // 2. one heartbeat round: does a majority
5 return "", ErrNotLeader // still accept me as leader?
6 }
7 l.waitUntilApplied(readIndex) // 3. let the state machine catch up
8 return l.kv[key], nil // 4. now the answer is up to date
9}
Minority sideS1, S2
S1 ★ leader · term 1
S2 follower · term 1
x = 1
Majority sideS3, S4, S5
S3, S4, S5 followers · term 1
x = 1
Clients
no requests yet
x = 1 is committed everywhere. S1 is the leader of term 1.
1 / 6

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:

ApproachHowCostCatch
Put reads in the logTreat every read like a write: append, commit, then answerA disk write and a majority round trip per readSlow; rarely used
ReadIndexMake 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, answerOne round trip to a majority, no disk writeThe usual default (etcd's linearizable reads work like this)
Lease readAfter a successful heartbeat round, the leader assumes nobody else can become leader for a little less than the election timeout, and answers locally until thenNo network at allSafe 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.

When does a lease read go wrong?
S1 is cut off from the majority right after a heartbeat round at 0 ms. Drag the sliders.
0 ms500 ms1000 ms1500 ms2000 ms2500 ms3000 msold leader S1lease: “900 ms” on S1's clock“lease ok?” ✓answers read: x = 1S3, S4, S5hear nothing; timers runS3 leader, term 2x = 2 committed, client told “OK”
Safe. S1's lease ends at 900 ms of real time, before anyone else can be elected (the followers wait at least 1000 ms). Lease reads work as long as those two assumptions hold: no long pauses, clocks at the same rate.

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.

Staff-level§09

Paxos in one section

In one sentencePaxos picks one value in two rounds: first reserve a proposal number with a majority (prepare/promise), then ask that majority to accept a value, which must be any value they already accepted.

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 n and asks the acceptors Prepare(n). An acceptor that hasn't promised anything higher promises to ignore all proposals below n, 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 value v must 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.

Paxos: two proposers, one decision
Phase 1 = prepare/promise, phase 2 = accept. Step with the buttons or ← →.
Code
1type Acceptor struct {
2 promised int // highest proposal number I promised to respect
3 accN int // number of the proposal I last accepted (0 = none)
4 accV string // and its value
5}
6
7// 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 n
13 return true, a.accN, a.accV // and report what I accepted before
14}
15
16// 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 number
20 }
21 a.promised, a.accN, a.accV = n, n, v
22 return true
23}
Proposer P1
wants 🍕 pizza
Proposer P2
wants 🍣 sushi
Acceptor A1
promised: –
accepted: nothing
Acceptor A2
promised: –
accepted: nothing
Acceptor A3
promised: –
accepted: nothing
Two proposers want the group to pick a restaurant. Three acceptors vote. A value is chosen once a majority (2 of 3) has accepted it under the same proposal number. Proposal numbers are unique (e.g. a counter plus the proposer's ID) and only go up.
1 / 7

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
LeaderAt most one per term (a split-vote term has none); required for progressAn optimization for liveness and speed
Who can leadOnly a server whose log is up to date (§06)Anyone; a new leader first learns earlier values in phase 1
LogContinuous, no gaps; entries flow only from leader to followersSlots can be decided out of order, leaving gaps to fill
SpecOne paper with the whole system (elections, membership, snapshots)The core is tiny; the full system is left to the implementer
Used byetcd, Consul, CockroachDB, TiKV, Kafka KRaftChubby, 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.

Practitioner§10

Code: a tiny election in Go, and etcd in practice

In one sentenceWriting a toy election teaches you how Raft works; for real systems, use a battle-tested implementation (etcd's 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:

raft.go (1/3): messages and 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:

raft.go (2/3): one node
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:

raft.go (3/3): the cluster
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 with id = 0, 1, …, size−1. Since Go 1.22 each loop iteration gets its own id and n, so each goroutine sees its own node.
  • A sync.WaitGroup is a counter of running goroutines: Add(1) before starting one, Done() when it finishes (here via defer), and Wait() blocks until the count is back to zero. go func() { … }() starts an anonymous function as a goroutine. Without Wait, Stop would 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:

raft_test.go
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.467s

Which 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.

leader.go
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: stop

Without 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.

Staff-level§11

Running consensus in production

In one sentenceEvery commit costs a disk flush plus a round trip to the nearest majority. Size the cluster for the failures you must survive, keep the data small, give it fast disks, and keep it out of your per-request path.

3 or 5 servers, and where to put them

How many servers?
The leader needs replies from the fastest majority. More servers survive more crashes; they don't make writes faster.
majority
3 of 5
crashes it survives
2
network wait per commit (approx.)
0.5 ms
follower round trips:0.4 ms0.5 ms0.6 ms0.7 ms(green = the replies the leader waits for)

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

SignalWhy
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 quotaWhen etcd hits its quota, it stops accepting writes
How far each member is behindA lagging member is one failure away from being needed for the majority
Practitioner§12

Pitfalls

01

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).

02

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).

03

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).

04

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).

05

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).

06

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).

07

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.

08

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).

09

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).

10

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.

Practitioner§13

Check yourself

Predict the answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 7
Two down, three up

A 5-server Raft cluster. Two servers crash; the other three can all reach each other. Can the cluster still commit new writes?

QUESTION 2 / 7
Four is not better than three

How many crashed servers can a 4-server cluster survive and still commit?

QUESTION 3 / 7
The leader on the wrong side

The network splits: {S1 (leader, term 4), S2} | {S3, S4, S5}. A client sends x=9 to S1. What happens to it?

QUESTION 4 / 7
Longer log, older term

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)?

QUESTION 5 / 7
Why random?

Why does Raft pick a random election timeout (e.g. 150–300 ms) instead of one fixed value?

QUESTION 6 / 7
On a majority, but…

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?

QUESTION 7 / 7
A read on the leader

Your service reads from whichever node says it's leader, straight from its memory, with no extra checks. What can go wrong?

Answered 0 / 7
Foundations§14

Cheat sheet

🗳️
Majority decidesA decision counts once more than half agree. Any two majorities share a server, so nothing committed is ever forgotten.
🔢
Terms order leadersAt most one leader per term. Higher term wins; messages from lower terms are rejected. A built-in fencing token.
📜
Same log, same stateAgree on the order of commands; every server applies them in that order and ends up identical.
⏱️
Random timeoutsA follower that hears nothing for a random 150–300 ms (etcd: about 1 s) runs for leader. Randomness ends split votes.
✋
Who gets a voteOne vote per term, saved to disk, and only for a candidate whose last entry has a later term, or the same term and at least as long a log.
🔗
Consistency checkAppendEntries says “after entry #N of term T”. Mismatch → follower says no, leader backs up, conflicting entries are overwritten.
✅
Commit ruleCommitted = on a majority and from the leader's current term (plus everything before it). New leaders append a no-op.
📖
Reads need careAn old leader can serve stale data. Use ReadIndex (a majority heartbeat) or leases (which trust clock rates); fence lease-based locks.
SituationWhat Raft does
Leader crashesFollowers time out, one wins the next term; committed entries survive
Network splits 3 / 2The 3-side elects a leader and carries on; the 2-side can't commit anything
Split healsThe 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 outUnknown outcome: retry only with an idempotency key
Cluster sizeMajoritySurvives
321 failure
532 failures
743 failures
Want…Use
One instance of my service in chargeetcd election: Go concurrency.NewElection + Campaign, Node etcd3 election().campaign()
Safe writes from a lease holderA fencing token (etcd revision) checked by the storage
Correct readsLinearizable reads (etcd's default); serializable only if stale is OK
Raft inside my own systemgo.etcd.io/raft or hashicorp/raft, not your own
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.