線性一致性和 Raft 原

作者:沈泰寧

在討論分佈式系統時,共識算法(Consensus algorithm)和一致性(Consistency)通常是討論熱點,兩者的聯繫很微妙,很容易搞混。一些常見的誤解:使用了 Raft [0] 或者 paxos 的系統都是線性一致的(Linearizability [1],即強一致),其實不然,共識算法只能提供基礎,要實現線性一致還需要在算法之上做出更多的努力。以 TiKV 爲例,它的共識算法是 Raft,在 Raft 的保證下,TiKV 提供了滿足線性一致性的服務。

本篇文章會討論一下線性一致性和 Raft,以及 TiKV 針對前者的一些優化。

線性一致性

什麼是一致性,簡單的來說就是評判一個併發系統正確與否的標準。線性一致性是其中一種,CAP [2] 中的 C 一般就指它。什麼是線性一致性,或者說怎樣才能達到線性一致?在回答這個問題之前先了解一些背景知識。

背景知識

爲了回答上面的問題,我們需要一種表示方法描述分佈式系統的行爲。分佈式系統可以抽象成幾個部分:

  • Client
  • Server
  • Events
    • Invocation
    • Response
  • Operations
    • Read
    • Write

一個分佈式系統通常有兩種角色,Client 和 Server。Client 通過發起請求來獲取 Server 的服務。一次完整請求由兩個事件組成,Invocation(以下簡稱 Inv)和 Response(以下簡稱 Resp)。一個請求中包含一個 Operation,有兩種類型 Read 和 Write,最終會在 Server 上執行。

說了一堆不明所以的概念,現在來看如何用這些表示分佈式系統的行爲。

上圖展示了 Client A 的一個請求從發起到結束的過程。變量 x 的初始值是 1,“x R() A” 是一個事件 Inv 意思是 A 發起了讀請求,相應的 “x OK(1) A” 就是事件 Resp,意思是 A 讀到了 x 且值爲 1,Server 執行讀操作(Operation)。

如何達到線性一致

背景知識介紹完了,怎樣才能達到線性一致?這就要求 Server 在執行 Operations 時需要滿足以下三點:

  1. 瞬間完成(或者原子性)

  2. 發生在 Inv 和 Resp 兩個事件之間

  3. 反映出“最新”的值

下面我舉一個例子,用以解釋上面三點。

例:

先下結論,上圖表示的行爲滿足線性一致。

對於同一個對象 x,其初始值爲 1,客戶端 ABCD 併發地進行了請求,按照真實時間(real-time)順序,各個事件的發生順序如上圖所示。對於任意一次請求都需要一段時間才能完成,例如 A,“x R() A” 到 “x Ok(1) A” 之間的那條線段就代表那次請求花費的時間,而請求中的讀操作在 Server 上的執行時間是很短的,相對於整個請求可以認爲瞬間,讀操作表示爲點,並且在該線段上。線性一致性中沒有規定讀操作發生的時刻,也就說該點可以在線段上的任意位置,可以在中點,也可以在最後,當然在最開始也無妨。

第一點和第二點解釋的差不多了,下面說第三點。

反映出“最新”的值?我覺得三點中最難理解就是它了。先不急於對“最新”下定義,來看看上圖中 x 所有可能的值,顯然只有 1 和 2。四個次請求中只有 B 進行了寫請求,改變了 x 的值,我們從 B 着手分析,明確 x 在各個時刻的值。由於不能確定 B 的 W(寫操作)在哪個時刻發生,能確定的只有一個區間,因此可以引入上下限的概念。對於 x=1,它的上下限爲開始到事件“x W(2) B”,在這個範圍內所有的讀操作必定讀到 1。對於 x=2,它的上下限爲 事件“x Ok() B” 到結束,在這個範圍內所有的讀操作必定讀到 2。那麼“x W(2) B”到“x Ok() B”這段範圍,x 的值是什麼?1 或者 2。由此可以將 x 分爲三個階段,各階段"最新"的值如下圖所示:

清楚了 x 的變化後理解例子中 A C D 的讀到結果就很容易了。

最後返回的 D 讀到了 1,看起來是 “stale read”,其實並不是,它仍滿足線性一致性。D 請求橫跨了三個階段,而讀可能發生在任意時刻,所以 1 或 2 都行。同理,A 讀到的值也可以是 2。C 就不太一樣了,C 只有讀到了 2 才能滿足線性一致。因爲 “x R() C” 發生在 “x Ok() B” 之後(happen before [3]),可以推出 R 發生在 W 之後,那麼 R 一定得讀到 W 完成之後的結果:2。

一句話概括:在分佈式系統上實現寄存器語義。

實現線性一致

如開頭所說,一個分佈式系統正確實現了共識算法並不意味着能線性一致。共識算法只能保證多個節點對某個對象的狀態是一致的,以 Raft 爲例,它只能保證不同節點對 Raft Log(以下簡稱 Log)能達成一致。那麼 Log 後面的狀態機(state machine)的一致性呢?並沒有做詳細規定,用戶可以自由實現。

Raft

Raft 是一個強 Leader 的共識算法,只有 Leader 能處理客戶端的請求,集羣的數據(Log)的流向是從 Leader 流向 Follower。其他的細節在這就不贅述了,網上有很多資料 [4]。

In Practice

以 TiKV 爲例,TiKV 內部可分成多個模塊,Raft 模塊,RocksDB 模塊,兩者通過 Log 進行交互,整體架構如下圖所示,consensus 就是 Raft 模塊,state machine 就是 RocksDB 模塊。

Client 將請求發送到 Leader 後,Leader 將請求作爲一個 Proposal 通過 Raft 複製到自身以及 Follower 的 Log 中,然後將其 commit。TiKV 將 commit 的 Log 應用到 RocksDB 上,由於 Input(即 Log)都一樣,可推出各個 TiKV 的狀態機(即 RocksDB)的狀態能達成一致。但實際多個 TiKV 不能保證同時將某一個 Log 應用到 RocksDB 上,也就是說各個節點不能實時一致,加之 Leader 會在不同節點之間切換,所以 Leader 的狀態機也不總有最新的狀態。Leader 處理請求時稍有不慎,沒有在最新的狀態上進行,這會導致整個系統違反線性一致性。好在有一個很簡單的解決方法:依次應用 Log,將應用後的結果返回給 Client。

這方法不僅簡單還通用,讀寫請求都可以這樣實現。這個方法依據 commit index 對所有請求都做了排序,使得每個請求都能反映出狀態機在執行完前一請求後的狀態,可以認爲 commit 決定了 R/W 事件發生的順序。Log 是嚴格全序的(total order),那麼自然所有 R/W 也是全序的,將這些 R/W 操作一個一個應用到狀態機,所得的結果必定符合線性一致性。這個方法的缺點很明顯,性能差,因爲所有請求在 Log 那邊就被序列化了,無法併發的操作狀態機。

這樣的讀簡稱 LogRead。由於讀請求不改變狀態機,這個實現就顯得有些“重“,不僅有 RPC 開銷,還有寫 Log 開銷。優化的方法大致有兩種:

  • ReadIndex

  • LeaseRead

ReadIndex

相比於 LogRead,ReadIndex 跳過了 Log,節省了磁盤開銷,它能大幅提升讀的吞吐,減小延時(但不顯著)。Leader 執行 ReadIndex 大致的流程如下:

  1. 記錄當前的 commit index,稱爲 ReadIndex

  2. 向 Follower 發起一次心跳,如果大多數節點回復了,那就能確定現在仍然是 Leader

  3. 等待狀態機至少應用到 ReadIndex 記錄的 Log

  4. 執行讀請求,將結果返回給 Client

第 3 點中的“至少”是關鍵要求,它表明狀態機應用到 ReadIndex 之後的狀態都能使這個請求滿足線性一致,不管過了多久,也不管 Leader 有沒有飄走。爲什麼在 ReadIndex 只有就滿足了線性一致性呢?之前 LogRead 的讀發生點是 commit index,這個點能使 LogRead 滿足線性一致,那顯然發生這個點之後的 ReadIndex 也能滿足。

LeaseRead

LeaseRead 與 ReadIndex 類似,但更進一步,不僅省去了 Log,還省去了網絡交互。它可以大幅提升讀的吞吐也能顯著降低延時。基本的思路是 Leader 取一個比 Election Timeout 小的租期,在租期不會發生選舉,確保 Leader 不會變,所以可以跳過 ReadIndex 的第二步,也就降低了延時。 LeaseRead 的正確性和時間掛鉤,因此時間的實現至關重要,如果漂移嚴重,這套機制就會有問題。

Wait Free

到此爲止 Lease 省去了 ReadIndex 的第二步,實際能再進一步,省去第 3 步。這樣的 LeaseRead 在收到請求後會立刻進行讀請求,不取 commit index 也不等狀態機。由於 Raft 的強 Leader 特性,在租期內的 Client 收到的 Resp 由 Leader 的狀態機產生,所以只要狀態機滿足線性一致,那麼在 Lease 內,不管何時發生讀都能滿足線性一致性。有一點需要注意,只有在 Leader 的狀態機應用了當前 term 的第一個 Log 後才能進行 LeaseRead。因爲新選舉產生的 Leader,它雖然有全部 committed Log,但它的狀態機可能落後於之前的 Leader,狀態機應用到當前 term 的 Log 就保證了新 Leader 的狀態機一定新於舊 Leader,之後肯定不會出現 stale read。

寫在最後

本文粗略地聊了聊線性一致性,以及 TiKV 內部的一些優化。最後留四個問題以便更好地理解本文:

  1. 對於線性一致中的例子,如果 A 讀到了 2,那麼 x 的各個階段是怎樣的呢?

  2. 對於下圖,它符合線性一致嗎?(溫馨提示:請使用遊標卡尺。;-P)

  3. Leader 的狀態機在什麼時候沒有最新狀態?要線性一致性,Raft 該如何解決這問題?

  4. FollowerRead 可以由 ReadIndex 實現,那麼能由 LeaseRead 實現嗎?

如有疑問或想交流,歡迎聯繫我:[email protected]

[0].Ongaro, Diego. Consensus: Bridging theory and practice. Diss. Stanford University, 2014.

[1].Herlihy, Maurice P., and Jeannette M. Wing. "Linearizability: A correctness condition for concurrent objects." ACM Transactions on Programming Languages and Systems (TOPLAS) 12.3 (1990): 463-492.

[2].Gilbert, Seth, and Nancy Lynch. "Brewer's conjecture and the feasibility of consistent, available, partition-tolerant web services." Acm Sigact News 33.2 (2002): 51-59.

[3].Lamport, Leslie. "Time, clocks, and the ordering of events in a distributed system." Communications of the ACM 21.7 (1978): 558-565.

[4].https://raft.github.io/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章