TiKV 源碼解析系列文章(十七)raftstore 概覽

第一作者:李建俊,第二作者:楊哲軒,王聰

TiKV 作爲一個分佈式 KV 數據庫,使用 Raft 算法來提供強一致性。Raft 算法提供了單一 group 的一致性,但是單一 group 無法擴展和均衡。因此,TiKV 採用了 MultiRaft 的方式基於 Raft 算法提供能兼顧一致性、擴展均衡的 KV 儲存。下文以 3.0 版本代碼爲例,講述 raftstore 源碼中的關鍵定義和設計。

MultiRaft

MultiRaft 顧名思義就是多個 Raft group。數據組織上,TiKV 將數據按範圍劃分成多個分片,這些分片稱之爲 region。每個 region 由一個 Raft group 來管理。Raft group 和 region 是一對一的關係。由下方示意圖可以看到一個 Raft group 管理的多個副本分別落在不同的機器上,一個機器的數據包含了多個不同 region 的副本。通過這種組織方式,我們讓 Raft group 並行起來,從而實現擴展和均衡。

Batch System

Batch System 是 raftstore 處理的基石,是一套用來併發驅動狀態機的機制。狀態機的核心定義如下:

pub trait Fsm {
    type Message: Send;
 
    fn is_stopped(&self) -> bool;
}

狀態機通過 PollHandler 來驅動,其定義如下:

pub trait PollHandler<N, C> {
    fn begin(&mut self, batch_size: usize);
    fn handle_control(&mut self, control: &mut C) -> Option<usize>;
    fn handle_normal(&mut self, normal: &mut N) -> Option<usize>;
    fn end(&mut self, batch: &mut [Box<N>]);
    fn pause(&mut self) {}
}

大體來看,狀態機分成兩種,normal 和 control。對於每一個 Batch System,只有一個 control 狀態機,負責管理和處理一些需要全局視野的任務。其他 normal 狀態機負責處理其自身相關的任務。每個狀態機都有其綁定的消息和消息隊列。PollHandler 負責驅動狀態機,處理自身隊列中的消息。Batch System 的職責就是檢測哪些狀態機需要驅動,然後調用 PollHandler 去消費消息。消費消息會產生副作用,而這些副作用或要落盤,或要網絡交互。PollHandler 在一個批次中可以處理多個 normal 狀態機,這些狀態機在作爲參數傳入 end 方法時被命名爲 batch,意思就是副作用會被聚合,一批批地處理。具體實現細節,後面的源碼閱讀章節會提到,這裏就先不展開。

RaftBatchSystem 和 ApplyBatchSystem

在 raftstore 裏,一共有兩個 Batch System。分別是 RaftBatchSystem 和 ApplyBatchSystem。RaftBatchSystem 用於驅動 Raft 狀態機,包括日誌的分發、落盤、狀態躍遷等。當日志被提交以後會發往 ApplyBatchSystem 進行處理。ApplyBatchSystem 將日誌解析並應用到底層 KV 數據庫中,執行回調函數。所有的寫操作都遵循着這個流程。
當客戶端發起一個請求時,RaftBatchSystem 會將其序列化成日誌,並提交給 raft。一個簡化的流程如下:

從代碼來看,序列化發生在 propose 過程中。

fn propose_normal(&mut self, req: RaftCmdRequest) -> Result<()> {
    let ctx = match self.pre_propose(poll_ctx, &mut req)?;
    let data = req.write_to_bytes()?;
    self.raft_group.propose(ctx.to_vec(), data)?;
    Ok(())
}

而 propose 的副作用後續會通過 Raft 庫的 Ready 機制蒐集,batch 處理。

if !self.raft_group.has_ready_since(Some(self.last_applying_idx)) {
    return None;
}
 
let mut ready = self.raft_group.ready_since(self.last_applying_idx);
self.mut_store().handle_raft_ready(ctx, &ready);
if !self.is_applying_snapshot() && !ready.committed_entries.is_empty() {
    let apply = Apply::new(self.region_id, self.term(), mem::replace(&mut ready.committed_entries, vec![]));
    apply_router
        .schedule_task(self.region_id, ApplyTask::apply(apply));
}
self.raft_group.advance_append(ready);

在 PeerStorage 的 handle_raft_ready 方法中,會將收集到 Ready 中的 Raft 日誌收集到一個 WriteBatch 中,最終在 RaftPoller 的 end 方法中批量寫入磁盤。而 Ready 中收集到的確認過的 Raft 日誌,會被 apply_router 發送到 apply 線程中,由 ApplyBatchSystem 來處理。關於一個寫入在 Raftstore 模塊中從提交到確認的整條鏈路,將在後續的章節中更詳細地探討,這裏就不作展開了。

Split 和 Merge

TiKV 的每一個 Raft group 都是一個 Region 的冗餘複製集,而 Region 數據不斷增減時,它的大小也會不斷髮生變化,因此必須支持 Region 的分裂和合並,才能確保 TiKV 能夠長時間穩定運行。Region Split 會將一段包含大量數據的 range 切割成多個小段,並創建新的 Raft Group 進行管理,如將 [a, z) 切割成 [a, h), [h, x) 和 [x, z),併產生兩個新的 Raft group。Region Merge 則會將 2 個相鄰的 Raft group 合併成一個,如 [a, h) 和 [h, x) 合併成 [a, x)。這些邏輯也在 Raftstore 模塊中實現。這些特殊管理操作也作爲一個特殊的寫命令走一遍上節所述的 Raft propose/commit/apply 流程。爲了保證 split/merge 前後的寫命令不會落在錯誤的範圍,我們給 region 加了一個版本的概念。每 split 一次,版本加一。假設 region A 合併到 region B,則 B 的版本爲 max(versionB, versionA + 1) + 1。更多的細節實現包括各種 corner case 的處理在後續文章中展開。

LocalReader

對於讀操作,如果和寫操作混在一起,會帶來不必要的延遲和抖動。所以 TiKV 實現了一個單獨的組件來處理。Raft group 的 leader 會維護一個 lease 機制,對於在 lease 內收到的請求,會立刻進行讀操作;lease 外的請求,會觸發 lease 續期。續期是通過心跳完成的。也就是讀操作不會觸發寫盤行爲。Lease 定義如下:

pub struct RemoteLease {
    expired_time: Arc<AtomicU64>,
    term: u64,
}
 
pub struct Lease {
    // A suspect timestamp is in the Either::Left(_),
    // a valid timestamp is in the Either::Right(_).
    bound: Option<Either<Timespec, Timespec>>,
    max_lease: Duration,
 
    max_drift: Duration,
    last_update: Timespec,
    remote: Option<RemoteLease>,
}

RemoteLease 是讀行爲發生線程裏所持有的 lease,它的狀態由 Lease 來維護。Lease 自身由 RaftBatchSystem 來實際維護。bound 記錄的是 lease 的失效時間,max_drift 表示允許精度誤差。

Coprocessor

雖然讀寫已經包含了絕大多數 KV 操作,但是我們仍然需要一些特殊機制來自定義行爲。比如爲了保證事務正確,region 分裂不應該將同一個 key 的 MVCC 數據拆分到不同的 region 裏。這些行爲由 Coprocessor 來實現。TiKV 中一共有兩種 Coprocessor。之前這篇文章介紹的 SQL 下推邏輯屬於 Endpoint,這裏主要涉及的是 Observer。Observer 的作用是監聽 KV 處理過程中的各種事件,並在事件發生時執行自定義邏輯。Coprocessor 的定義如下:

pub trait Coprocessor {
    fn start(&self) {}
    fn stop(&self) {}
}

目前已經定義的 Coprocessor 包括 AdminObserver、QueryObserver、SplitCheckObserver、RoleObserver、RegionChangeObserver。拿 QueryObserver 舉個例子:

pub trait QueryObserver: Coprocessor {
    /// Hook to call before proposing write request.
    ///
    /// We don't propose read request, hence there is no hook for it yet.
    fn pre_propose_query(&self, _: &mut ObserverContext<'_>, _: &mut Vec<Request>) -> Result<()> {
        Ok(())
    }
 
    /// Hook to call before applying write request.
    fn pre_apply_query(&self, _: &mut ObserverContext<'_>, _: &[Request]) {}
 
    /// Hook to call after applying write request.
    fn post_apply_query(&self, _: &mut ObserverContext<'_>, _: &mut Vec<Response>) {}
}

此 Observer 監聽了 pre_propose、pre_apply 和 post_apply 三個事件。ObserverContext 裏面包含了 region 信息以及是否繼續處理的標記。具體實現細節由後續文章介紹,這裏不展開了。

小結

這篇文章主要針對 TiKV 項目中 src/raftstore 裏源碼涉及的概念和原理做了一個大概的介紹。深入解讀請留意後續的系列文章,歡迎大家關注。

原文閱讀:https://pingcap.com/blog-cn/tikv-source-code-reading-17/

發佈了250 篇原創文章 · 獲贊 280 · 訪問量 12萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章