作者:屈鵬

本文為 TiKV 源碼解析系列的第二篇,按照計劃首先將為大家介紹 TiKV 依賴的周邊庫 raft-rs 。raft-rs 是 Raft 演算法的 Rust語言實現。Raft 是分散式領域中應用非常廣泛的一種共識演算法,相比於此類演算法的鼻祖 Paxos,具有更簡單、更容易理解和實現的特點。

分散式系統的共識演算法會將數據的寫入複製到多個副本,從而在網路隔離或節點失敗的時候仍然提供可用性。具體到 Raft 演算法中,發起一個讀寫請求稱為一次 proposal。本文將以 raft-rs 的公共 API 作為切入點,介紹一般 proposal 過程的實現原理,讓用戶可以深刻理解並掌握 raft-rs API 的使用, 以便用戶開發自己的分散式應用,或者優化、定製 TiKV。

文中引用的代碼片段的完整實現可以參見 raft-rs 倉庫中的 source-code 分支。

Public API 簡述

倉庫中的 examples/five_mem_node/main.rs 文件是一個包含了主要 API 用法的簡單示例。它創建了一個 5 節點的 Raft 系統,並進行了 100 個 proposal 的請求和提交。經過進一步精簡之後,主要的類型封裝和運行邏輯如下:

struct Node {
// 持有一個 RawNode 實例
raft_group: Option<RawNode<MemStorage>>,
// 接收其他節點發來的 Raft 消息
my_mailbox: Receiver<Message>,
// 發送 Raft 消息給其他節點
mailboxes: HashMap<u64, Sender<Message>>,
}
let mut t = Instant::now();
// 在 Node 實例上運行一個循環,週期性地處理 Raft 消息、tick 和 Ready。
loop {
thread::sleep(Duration::from_millis(10));
while let Ok(msg) = node.my_mailbox.try_recv() {
// 處理收到的 Raft 消息
node.step(msg);
}
let raft_group = match node.raft_group.as_mut().unwrap();
if t.elapsed() >= Duration::from_millis(100) {
raft_group.tick();
t = Instant::now();
}
// 處理 Raft 產生的 Ready,並將處理進度更新回 Raft 中
let mut ready = raft_group.ready();
persist(ready.entries()); // 處理剛剛收到的 Raft Log
send_all(ready.messages); // 將 Raft 產生的消息發送給其他節點
handle_committed_entries(ready.committed_entries.take());
raft_group.advance(ready);
}

這段代碼中值得注意的地方是:

  1. RawNode 是 raft-rs 庫與應用交互的主要界面。要在自己的應用中使用 raft-rs,首先就需要持有一個 RawNode 實例,正如 Node 結構體所做的那樣。
  2. RawNode 的範型參數是一個滿足 Storage 約束的類型,可以認為是一個存儲了 Raft Log 的存儲引擎,示例中使用的是 MemStorage。
  3. 在收到 Raft 消息之後,調用 RawNode::step 方法來處理這條消息。
  4. 每隔一段時間(稱為一個 tick),調用 RawNode::tick 方法使 Raft 的邏輯時鐘前進一步。
  5. 使用 RawNode::ready 介面從 Raft 中獲取收到的最新日誌(Ready::entries),已經提交的日誌(Ready::committed_entries),以及需要發送給其他節點的消息等內容。
  6. 在確保一個 Ready 中的所有進度被正確處理完成之後,調用 RawNode::advance 介面。

接下來的幾節將展開詳細描述。

Storage trait

Raft 演算法中的日誌複製部分抽象了一個可以不斷追加寫入新日誌的持久化數組,這一數組在 raft-rs 中即對應 Storage。使用一個表格可以直觀地展示這個 trait 的各個方法分別可以從這個持久化數組中獲取哪些信息:

值得注意的是,這個 Storage 中並不包括持久化 Raft Log,也不會將 Raft Log 應用到應用程序自己的狀態機的介面。這些內容需要應用程序自行處理。

RawNode::step 介面

這個介面處理從該 Raft group 中其他節點收到的消息。比如,當 Follower 收到 Leader 發來的日誌時,需要把日誌存儲起來並回復相應的 ACK;或者當節點收到 term 更高的選舉消息時,應該進入選舉狀態並回復自己的投票。這個介面和它調用的子函數的詳細邏輯幾乎涵蓋了 Raft 協議的全部內容,代碼較多,因此這裡僅闡述在 Leader 上發生的日誌複製過程。

當應用程序希望向 Raft 系統提交一個寫入時,需要在 Leader 上調用 RawNode::propose 方法,後者就會調用 RawNode::step,而參數是一個類型為 MessageType::MsgPropose 的消息;應用程序要寫入的內容被封裝到了這個消息中。對於這一消息類型,後續會調用 Raft::step_leader 函數,將這個消息作為一個 Raft Log 暫存起來,同時廣播到 Follower 的信箱中。到這一步,propose 的過程就可以返回了,注意,此時這個 Raft Log 並沒有持久化,同時廣播給 Follower 的 MsgAppend 消息也並未真正發出去。應用程序需要設法將這個寫入掛起,等到從 Raft 中獲知這個寫入已經被集羣中的過半成員確認之後,再向這個寫入的發起者返回寫入成功的響應。那麼, 如何能夠讓 Raft 把消息真正發出去,並接收 Follower 的確認呢?

RawNode::readyRawNode::advance 介面

這個介面返回一個 Ready 結構體:

pub struct Ready {
pub committed_entries: Option<Vec<Entry>>,
pub messages: Vec<Message>,
// some other fields...
}
impl Ready {
pub fn entries(&self) -> &[Entry] {
&self.entries
}
// some other methods...
}

一些暫時無關的欄位和方法已經略去,在 propose 過程中主要用到的方法和欄位分別是:

對照 examples/five_mem_node/main.rs 中的示例,可以知道應用程序在 propose 一個消息之後,應該調用 RawNode::ready 並在返回的 Ready 上繼續進行處理:包括持久化 Raft Log,將 Raft 消息發送到網路上等。

而在 Follower 上,也不斷運行著示例代碼中與 Leader 相同的循環:接收 Raft 消息,從 Ready 中收集回復並發回給 Leader……對於 propose 過程而言,當 Leader 收到了足夠的確認這一 Raft Log 的回復,便能夠認為這一 Raft Log 已經被確認了,這一邏輯體現在 Raft::handle_append_response 之後的 Raft::maybe_commit 方法中。在下一次這個 Raft 節點調用 RawNode::ready 時,便可以取出這部分被確認的消息,並應用到狀態機中了。

在將一個 Ready 結構體中的內容處理完成之後,應用程序即可調用這個方法更新 Raft 中的一些進度,包括 last index、commit index 和 apply index 等。

RawNode::tick 介面

這是本文最後要介紹的一個介面,它的作用是驅動 Raft 內部的邏輯時鐘前進,並對超時進行處理。比如對於 Follower 而言,如果它在 tick 的時候發現 Leader 已經失聯很久了,便會發起一次選舉;而 Leader 為了避免自己被取代,也會在一個更短的超時之後給 Follower 發送心跳。值得注意的是,tick 也是會產生 Raft 消息的,為了使這部分 Raft 消息能夠及時發送出去,在應用程序的每一輪循環中一般應該先處理 tick,然後處理 Ready,正如示常式序中所做的那樣。

總結

最後用一張圖展示在 Leader 上是通過哪些 API 進行 propose 的:

本期關於 raft-rs 的源碼解析就到此結束了,我們非常鼓勵大家在自己的分散式應用中嘗試 raft-rs 這個庫,同時提出寶貴的意見和建議。後續關於 raft-rs 我們還會深入介紹 Configuration Change 和 Snapshot 的實現與優化等內容,展示更深入的設計原理、更詳細的優化細節,方便大家分析定位 raft-rs 和 TiKV 使用中的潛在問題。

更多閱讀:

博客?

pingcap.com
圖標

推薦閱讀:
相關文章