總覽與定位
如何讓一群節點對「一個單一且有序的決策序列」達成一致——這是本篇唯一的主軸。
多台機器各自接受寫入,狀態必然分叉。要讓它們表現得像「一台機器」,系統需要一份所有存活節點都同意、且一旦回覆客戶端成功就不會被回捲的 log。Raft 把這件事做成可實作的協定:同一個 term 最多一個 Leader 負責編號與複製,Follower 只附加、不改寫歷史;多數派持久化之後,那筆決策才算 committed,才能安全地套用到狀態機、回覆客戶端。
資料複寫基礎講的是主從拓撲與同步/異步取捨;進階複寫與一致性講的是讀到什麼(R + W > N、Session Guarantees、Version Vector)。那些處理「多個副本上的值如何收斂」。本篇處理的是協定如何決定唯一順序——log 裡第 i 筆是什麼、何時不能再改。產品化之後對外的 Watch、Ephemeral Node、分散式鎖,交給 協調服務與分散式鎖;每個 shard 一個 Raft group、跨 shard 交易與 TrueTime,是 Distributed SQL 的事,這裡只點出「分片邊界 = 共識組邊界」。
Quorum 讀寫保證「讀到的集合與寫入的集合相交」,所以你看得到最新值——但它不產生一份全序 log,衝突仍可能並存。Raft 的多數派是為了對「下一筆要附加什麼」投票,產出一條不會分叉的決策序列。面試把兩者混成「都是多數派所以一樣」會直接失分。
動機:為什麼多副本會分歧
沒有單一決策者時,每個節點都是合法的寫入入口——分區一來,歷史就拆成兩條。
兩台都接受 SET x 的複本,在網路正常時看起來像同步;一分區,左側寫 x=A、右側寫 x=B,修復後沒有客觀的「誰比較新」。時間戳不可靠(時鐘跳動),「最後寫入獲勝」只是再引入一個衝突規則,不是共識。要讓狀態機確定性重放,所有節點必須先同意同一份指令序列,再各自套用。
- 分區兩側都認為自己可寫
- 修復時不知該丟哪一邊
- 客戶端已拿到的成功回覆可能被回捲
- 同一時間只有一個 Leader 編號
- 多數派落盤後才回覆成功
- 少數派上未提交的尾巴可以被截斷
兩欄的差別不在「有沒有複製」,而在誰有權編號:左邊兩側都能編號,於是同一個 index 可以是兩筆不同的東西;右邊只有一個 Leader 編號,少數派寫下的尾巴因此可以被安全地切掉。
這份 log 有兩個不可妥協的性質:
- 唯一順序:index i 在所有節點上要麼還沒有、要麼是同一筆(同 term、同指令)。
- 不可回捲已確認寫入:一旦 Leader 告訴客戶端「成功」,該項目必須出現在之後每一位 Leader 的 log 裡。否則金融入帳、鎖釋放、組態變更都會幽靈消失。
注意箭頭是單向的。反過來不成立:一筆項目就算已經複製到多數派,也未必已經提交、更未必回覆過客戶端。這個縫隙看起來像吹毛求疵,卻是共識協定最容易出錯的地方——第 6 章會用論文的 Figure 8 場景說明它為什麼會吃掉已確認的寫入。
主從複寫的 failover 常靠心跳 + 提升最接近的 Follower。若舊主帶著未同步寫入回來,你要嘛丟棄它們(客戶端以為成功的寫入消失),要嘛合併衝突。Raft 把「誰是主」和「log 夠不夠新才能當主」綁在同一套投票規則裡,就是為了不走到這條歧路。
三態、Term 與選舉
Follower 待命、Candidate 拉票、Leader 編號。Term 單調增加,用來作廢過期的權力。
1 · 三個角色
| 狀態 | 做什麼 | 何時離開 |
|---|---|---|
| Follower | 接收 AppendEntries(心跳或 log)、對 RequestVote 投票 | 一段時間沒聽到 Leader → 變 Candidate |
| Candidate | 自增 term、投自己、向其他人拉票 | 得多數派 → Leader;發現更高 term 或現任 Leader → Follower;超時 → 新 term 再選 |
| Leader | 接受客戶端寫入、編號、複製、推進 commitIndex | 收到更高 term 的 RPC → 立刻降為 Follower |
同一 term 最多一個 Leader。兩個 Leader 同時存活,只可能發生在它們處於不同 term——這正是後面 fencing 要處理的腦裂。
2 · Term 是邏輯時鐘,不是牆鐘
每次選舉開始,候選人把 currentTerm 加一。任何 RPC 若攜帶更大的 term,收件者先更新自己的 term、放棄手上的 Leader/Candidate 身分,再處理請求。這樣過期的 Leader 一旦聽到新世界,權力自動作廢。牆鐘不同步不會讓 term 倒退;它只影響「誰先超時」。
實線是「往上爬」的轉移,虛線是「被作廢」的轉移。注意兩條虛線都終止於 Follower——Raft 沒有任何一條邊可以從 Leader 直接跳到 Candidate,權力只會先歸零再重選。圖上刻意不畫 Candidate 的自迴圈(split vote 後換新 term 重選),那是下一段的主題。
3 · 隨機 Election Timeout 拆開同時超時
若所有 Follower 用同一個超時、在同一毫秒發現 Leader 靜音,他們會在同一 term 同時變成 Candidate、互不投票,形成票數瓜分(split vote)。Raft 的解法極其工程:每個節點從區間(例如 150–300 ms)抽隨機超時。先響的那位先發 RequestVote,其他人還在等,就會把票投給他。即使偶發瓜分,下一個隨機超時幾乎不會再對齊。
「拆開」到什麼程度才夠?這句口訣其實是一道可以算的題。設區間寬度 W = T_max − T_min、單程網路延遲 D、參與抽籤的存活節點 m 台。最早超時者在 t 發出 RequestVote,訊息在 t + D 抵達其他人;凡是自己的籤落在這段 D 寬的前導區間裡的節點,會在收到拉票之前先自己變成 Candidate——那就是一次瓜分。把「所有人的籤都落在安全側」的機率積出來,會得到一個乾淨的閉式:
上半部每一條橫條是一台存活節點的抽籤區間(不是時間線):整條寬 W,左端 D 寬的斜紋是危險前導段,未斜紋的比例就是 (W − D) / W。下半部的連乘階梯把「為什麼是 m 次方」畫出來——每多一台一起抽籤的節點,安全比例就再乘一次,最後一根就是 P。兩處申報:E[T] = C / P 是簡化模型(失敗的那一輪也按整輪成本 C 計),且區間被拖成上界小於下界時一律夾成 W = max(1, T_max − T_min)、只剩一台在抽籤時 P 定義為 1。
Leader 用空的 AppendEntries 當心跳。若心跳週期接近超時,正常抖動也會觸發選舉,集群會抖動切主。實務把心跳設在超時下界的幾分之一,並把超時上界拉到足以吸收 GC 與短暫停滯——但又不能太長,否則故障切換的 SLO 會被超時上限綁死。
投票規則與 log 新舊
不是誰先舉手誰當選。票只投給「log 至少跟我一樣新」的候選人,這才保得住已提交的歷史。
RequestVote 攜帶候選人的 term、lastLogTerm、lastLogIndex。Follower 答應的條件有兩條,缺一不可:
- 這個 term 還沒投過(每個 term 一票;已投給別人就拒絕)。
- 候選人的 log 至少與自己一樣新:先比最後一筆的 term,term 相同再比 index。自己比較新就拒絕——否則一個空白節點靠先超時就能當選,然後用空 log 覆蓋已提交的歷史。
| 候選人最後一筆 | 投票者最後一筆 | 投票? |
|---|---|---|
| term 3, index 10 | term 2, index 50 | 投——最後 term 較新,勝過更長但更舊的 log |
| term 2, index 8 | term 2, index 10 | 拒——同 term 但比較短,投票者比較新 |
| term 2, index 10 | term 2, index 10 | 投——一樣新;若該 term 尚未投票 |
「新」的第一鍵是最後一筆的 term,不是 log 長度。較新的 term 代表那條歷史已經經歷過更新的選舉,較可能含有已提交項目。
多數派的定義是 ⌊N / 2⌋ + 1。五節點集群要三票(含自己)。候選人同時只會在一個 term 拉票;拿到多數立刻轉 Leader,開始心跳以阻止其他人再選。
這裡要避開一個常見誤讀:投票規則不保證「log 最新的那台一定當選」,它只擋下「舊到拿不出多數票」的候選人。若某台沒有跟上最後那幾筆未提交的項目,卻仍湊得出三票,它照樣合法當選——那段尾巴接著會被截斷。這不違反任何承諾,因為未提交的項目從來沒有回覆過客戶端成功。第 9 節的 log 推進互動可以讓你親手挑一條路走走看:同樣的崩潰,換一個接任者、或讓那條尾巴多複製一台,命運就完全相反。
若三個空白新節點組成多數、兩個持有已提交 log 的舊節點在分區另一側,空白多數會選出一個沒有歷史的 Leader——除非投票規則擋下「log 比較舊」的候選人。Safety 不是從「過半機器開著」來的,是從「過半機器開著 且 當選者的 log 夠新」來的。
Log Replication 與 commitIndex
Leader 附加、Follower 對齊、多數派落盤後才推進 commitIndex——這一步之前不能回覆客戶端成功。
1 · AppendEntries 與一致性檢查
Leader 為每位 Follower 維護 nextIndex(下一個要送的位置)與 matchIndex(已知已複製到的最高位置)。RPC 帶著 prevLogIndex / prevLogTerm:Follower 若在該位置對不上,回覆失敗。Leader 把該 Follower 的 nextIndex 往回退,重送更早的項目,直到對上為止。對上之後,Follower 截斷後面衝突的尾巴,再附加 Leader 送來的新項目。未提交的分歧允許被切掉;已提交的前綴因為 Log Matching,本來就不會衝突。
-
1
Client
SET x=1抵達 Leader寫入還沒進任何 log。送到 Follower 的請求會被轉介或拒絕——只有 Leader 能編號。 -
2
Leader 本地 append 到 index i順序在這一刻決定。此時回覆客戶端成功就是說謊:這筆只存在一台機器上。
-
3
AppendEntries 帶 prevLogIndex / prevLogTerm 送出錨點檢查。對不上的 Follower 回失敗,Leader 把它的 nextIndex 往回退再重送,直到對上為止。
-
4
Follower 截斷衝突尾巴、持久化、回 ACK落盤才算數——回 ACK 代表「已寫進我的磁碟」,不是「已收到」。
-
5
多數派 ACK → commitIndex 推進五節點要三份(含 Leader 自己)。而且該項目的 term 必須等於現任 term——第 6 節解釋這個限制從哪來。
-
6
套用到狀態機 → 回覆客戶端 OK到這一步,那筆寫入才真正成為歷史的一部分。
2 · 何時能回覆客戶端
commitIndex 是 Leader 知道「多數派已持久化」的最高 index。只有 commitIndex 推進過的項目,Leader 才套用到狀態機並回覆成功。只寫在 Leader 本地、或只複製到少數派,都還是 uncommitted——Leader 崩潰後可以被下一任截斷。這就是「成功」的契約:不是 Leader 記憶體裡有,是多數派磁碟裡有。
這是一條推進規則,不是絕對定義:commitIndex 只增不減。新 Leader 剛當選、本 term 還沒提交任何項目時,右邊的集合是空的,但它從前任繼承的 commitIndex 不會因此倒退。式子裡的「現任 term」也不是筆誤,第 6 節會解釋為什麼不能單憑計數把前任 term 的項目標成 committed。
空的 AppendEntries 重置 Follower 的選舉計時器,同時捎帶 leaderCommit,讓落後的 Follower 把已經在多數派的項目往前套用。Catch-up 不是另開一條協定,就是同一支 RPC 在 nextIndex 回溯之後連續送 log。
Safety:Matching、Completeness、前任 term
兩條不變量保住已確認寫入;一條反直覺規則防止「數人頭」把未封印的歷史提前定稿。
1 · Log Matching
若兩個節點的 log 在某 index 有相同 term,則從開頭到該 index 的所有項目完全相同。AppendEntries 的 prevLog 檢查 + 衝突截斷,讓這條不變量在每一步複製後都成立。它的用途是:Leader 不必重傳整份歷史來證明前綴一致,檢查一個錨點就夠。
2 · Leader Completeness
若一筆項目在某 term 被提交,之後任何 term 的 Leader 都帶有這筆項目。證明骨架:提交需要當時的多數派;下一任要當選也需要一個多數派;兩個多數派必相交,相交的那台持有已提交項目,又因為投票規則拒絕 log 比較舊的候選人,當選者至少跟相交者一樣新——於是已提交項目跟著走。
假設 index 2 是 term 1 留下的、尚未提交。新 Leader(term 2)把它複製到多數派之後,仍不可把 commitIndex 推到 2。原因是:在它複製途中,另一場選舉可能讓一個不含這筆項目的節點當選(term 3),該新 Leader 會截斷這筆未封印的歷史。若 term 2 的 Leader 已經對客戶端說成功,寫入就消失了。
Raft 的修補:Leader 只把「本 term 自己附加的項目」靠多數派計數來提交。一旦本 term 有一筆(常見做法是當選後立刻寫一筆 no-op)被提交,Log Matching 會把前面的前綴一起封印。這就是論文 Figure 8 的工程結論,面試常考。
T3——它在 term 3 當過 Leader,寫了自己的版本。(T3, idx2),比 S2/S3 的 (T2, idx2) 更新,投票規則擋不住它。S5 當選後用自己的 T3 蓋掉 S2/S3 的 index 2(S4 原本是空的,屬於新增)——剛才回覆過成功的 T2 憑空消失。S1 手上那份 T2 隨著它崩潰成了孤本,救不回任何人。T4(本 term 項目,實務上常是當選後立刻寫的 no-op);Log Matching 讓它前面的 T1、T2 一併封印。(T4, idx3),比 S5 的 (T3, idx2) 新,拒絕投票。S5 只拿得到 S4 與自己共 2 票,選不上。能當選的人必定帶著 T2。綠色 = 已提交、黃虛線 = 已複製但未提交、斜線底 = 該位置還沒有項目、紅色 = 原本的項目被後續 Leader 覆蓋掉。左欄階段 2 的 S1 已經崩潰,它的 log 停在崩潰當下、不再參與任何決策。兩欄的差別只有一件事:右邊多提交了一筆現任 term 的項目。這就是論文 Figure 8 的全部結論。
成員變更,以及為什麼是 Raft 不是 Paxos
換機器比換 log 更危險。Joint consensus 讓舊集合與新集合同時過半;強 Leader 則是拿彈性換可理解性。
1 · 一次換掉多數派會裂成兩個合法集群
五節點 {A,B,C,D,E} 若直接改成 {C,D,E,F,G},舊多數 {A,B,C} 與新多數 {E,F,G} 可以在過渡期同時選出 Leader——兩份 log、兩份真實。這不是實作 bug,是「新舊多數不相交」的組合後果。
Joint consensus 把變更本身寫進 log,而且規則很反直覺:節點一 append 到 C_old,new 就立刻採用它,不等提交。之後任何決策(含下一場選舉)都必須同時取得舊集合多數 與 新集合多數;兩組多數的交集非空,所以不會出現兩個 Leader。等 C_old,new 被提交,才可以 append C_new;C_new 提交後舊節點才能下線。若改成「提交後才生效」,append 到 commit 之間會留下一段只需舊多數的窗口,Safety 就從那裡漏掉。實務上 etcd / Consul 常把變更限制成一次加一台或減一台,讓每一步的新舊多數自然相交,同等安全、狀態機比較簡單。
把「誰有投票權」當成 committed log 的一部分,新 Leader 一當選就知道當前配置。若配置只活在某個運維腳本裡,分區兩側會對「多數派是誰」得出不同答案,Safety 從根上裂開。
2 · Paxos / Multi-Paxos 對照
| Paxos(單值) | Multi-Paxos | Raft | |
|---|---|---|---|
| 誰可以提案 | 任何 Proposer | 穩定後的 distinguished leader | 當選的強 Leader |
| log 是什麼 | 一次決定一個值 | 同一 Leader 連續對多 slot 提案 | 從第一天就是 replicated log |
| 工程負擔 | 活鎖、角色交錯難實作 | 要補齊 Leader 選舉與空洞 slot | 角色、RPC、Safety 寫在同一份演算法 |
| 彈性 | 無 Leader 也能推進單值 | Leader 失效可換人續寫 | 無 Leader 期間寫入停住,換可理解性 |
Raft 並沒有放寬 Safety,它把「先選出強 Leader,再由他編號」做成預設路徑。代價是選舉期間集群不接受寫入;換到的是可教學、可實作、可審查的狀態機。
在 Distributed SQL 裡,這份協定的應用面很窄、但關鍵:每個 shard / range 一個 Raft group。單一分片的線性寫入由該 group 的 Leader 提供;跨分片原子性是另一層交易協定的事,不是把全世界放進同一個 Raft log。
腦裂與 Fencing Token
舊 Leader 在分區另一側仍自認 Leader。Raft log 是安全的;寫到外部資源則必須另加單調 token。
分區把 N1(舊 Leader, term 1)隔開,N2–N5 選出 N2(term 2)。N1 還沒聽到更高 term,心跳繼續、客戶端若連到它仍會把寫入送進去。對 Raft log 本身這是安全的:N1 湊不齊多數派,commitIndex 不會前進,它不該回覆成功。危險在協定外面:N1 可能對磁碟陣列、物件儲存、或另一個沒有共識的資料庫發出副作用——那些系統不知道 term 已經換人。
- 持有者 GC pause、長 IO、行程被凍結,本地以為 lease 還在
- 對端已經過期並把權力給了新人
- 醒來後的寫入沒有任何欄位證明「我比較舊」
- 每次當選,token := term(或 epoch)
- 資源記住見過的最大 token,拒絕較小者
- 即使舊 Leader 還活著、lease 還沒在它的牆上到期,寫入也進不去
關鍵差別在誰做檢查:左欄把判斷留在持有者身上(我的 lease 還沒到期),右欄把判斷搬到被保護的資源上(我見過更大的 token)。只有後者不依賴任何一方的時鐘。
這是 協調服務與分散式鎖 那一篇展開的同一條正確性論證:鎖 = lease + fencing token。那篇的鎖競態時間軸互動可以把這段競態一秒一秒攤開——GC pause 多長、keepalive 多久一次、租約何時過期、危險窗有多寬,都是算出來的;本篇不重複那個模擬。token 的定義是由共識集群分配、單調遞增、客戶端無法偽造的世代號——在 Raft 裡它就是 term;換成協調服務,會是 ZooKeeper 的 zxid 或 etcd 鎖 key 的 revision。沒有這樣一個世代號,任何「我還是主」的宣告在分區下都只是意見。
給 Leader 一段 lease、保證 lease 內不選舉,可以減少誤切。它不能代替外部資源上的 token 檢查:lease 依賴時鐘與「持有者能在到期前停止寫入」的假設,GC 與時鐘跳動正好打破這兩個假設。面試被問腦裂,先答 term 作廢舊 Leader 的 RPC,再答外部副作用靠 fencing;不要停在「設一個超時」。
互動 · 五節點 log 推進與提交規則
你選誰當 term 1 的 Leader、第二筆寫入複製到幾台、崩潰後由誰接任;投票規則與提交規則負責算出每一步的 log 矩陣、票型與 commitIndex。
第 6 節的 Figure 8 對照圖是靜態的結論。這裡把同一組規則交到你手上:三個選擇一共一百種組合,每一步的狀態都由規則推出來,沒有預寫的劇本。特別注意第二筆寫入的複製台數那支滑桿——它同時決定兩件事:那筆寫入算不算已提交,以及接任者拿不拿得到票。
橫軸是 log index、縱軸是節點,所以「同一 index 在不同節點上是不是同一筆」可以直接對齊著看。三個讀法:格子裡印的是該位置的 term 號(不是佔用與否);綠色直線是 commitIndex 的封印線,線左側的前綴再也不會被任何後任 Leader 改寫;節點名右邊那道欄位在選舉步會變成票標,把每一張 YES/NO 連同「我的 last log 比你新」的理由一起印出來。(nextIndex 回溯要 Follower 落後多筆才會觸發,這裡刻意讓衝突都落在下一個 index,好把注意力留給提交規則。)
面試折衷與邊界
先畫「誰在決定順序」,再談讀取、分片與鎖。協定講完就停,不要滑進產品 API。
| 面試常問 | 先答這句 | 不要滑去 |
|---|---|---|
| Raft 在解什麼? | 讓節點對單一有序、不可回捲的 log 達成一致 | R+W>N、Read Repair、Version Vector |
| 為什麼隨機超時? | 拆開同時選舉,降低 split vote;心跳必須遠短於超時下界 | 「隨機比較公平」或跟負載均衡混談 |
| 何時能回覆客戶端? | 該項目的 index ≤ commitIndex;而 commitIndex 只靠本 term 項目的多數派推進 | Leader 本地 append 成功就算寫入完成 |
| 舊主回來怎麼辦? | 聽到更高 term 降為 Follower,未提交尾巴可被截斷;外部副作用靠 fencing token | 「等 TTL 到了就安全」或手動合併兩份 log |
| 怎麼換機器? | joint consensus,或一次只加/減一台,讓新舊多數相交 | 一次替換過半節點、配置只寫在運維腳本 |
| 和 Paxos 差在哪? | 強 Leader 換可理解性與工程可實作性;Safety 目標相同 | 「Raft 更一致」或「Paxos 比較快」 |
本篇停在協定。Watch、Ephemeral Node、分散式鎖的正確用法見 協調服務與分散式鎖;訊息骨幹從 訊息佇列基礎 進;API 契約的先備是 gRPC 與 Protocol Buffers。
把熱 key 跟冷 key 塞進同一個 group,提交延遲被最慢那台綁死,成員變更也變成全庫停寫。Distributed SQL 用「每個 shard 一個 Raft group」把共識的範圍切到分片大小:單分片線性可寫,跨分片另走交易層。共識的成本是多數派往返;範圍選錯,再快的磁碟也救不了。