本文是《Raft 實戰系列——理論篇》的最後一文,首先介紹 Raft 協議場景採用「寫主讀從」、「寫主讀主」兩種模式均無法保障線性一致性的原因;其次介紹基於 Raft 協議實現工程系統時,如何來保證線性一致性;最後介紹 Raft 針對一致性所做的讀性能優化的具體策略。
系列前文快速連結:
在前面5篇文章中,我們分別介紹了《Raft 基本概念》、《Raft 選主機制》、《Raft 基於日誌複製實現狀態機機制》、《Raft 選主及狀態機維護的安全性》、《Raft 集群變更防腦裂 & 解決數據膨脹》,《線性一致性概念介紹》,系統學習 Raft 協議建議從頭閱讀。
--
1. 線性一致性基礎數據一致性是為提升系統可用性所採用多副本機制所帶來的新問題。在上文《Raft 實戰番外篇——線性一致性》中我們重點解析了線性一致性的模型及挑戰,對線性一致性不了解的同學,先閱讀上文再學習下面內容會更容易理解吸收~
2. Raft 線性一致性讀在了解了什麼是線性一致性之後,我們將其與 Raft 結合來探討。首先需要明確一個問題,使用了 Raft 的系統都是線性一致的嗎?不是的,Raft 只是提供了一個基礎,要實現整個系統的線性一致還需要做一些額外的工作。假設我們期望基於 Raft 實現一個線性一致的分布式 kv 系統,讓我們從最樸素的方案開始,指出每種方案存在的問題,最終使整個系統滿足線性一致性2.1 寫主讀從缺陷分析寫操作並不是我們關注的重點,如果你稍微看了一些理論部分就應該知道,所有寫操作都要作為提案從 leader 節點發起,當然所有的寫命令都應該簡單交給 leader 處理。真正關鍵的點在於讀操作的處理方式,這涉及到整個系統關於一致性方面的取捨。在該方案中我們假設讀操作直接簡單地向 follower 發起,那麼由於 Raft 的 Quorum 機制(大部分節點成功即可),針對某個提案在某一時間段內,集群可能會有以下兩種狀態:某次寫操作的日誌尚未被複製到一少部分 follower,但 leader 已經將其 commit。某次寫操作的日誌已經被同步到所有 follower,但 leader 將其 commit 後,心跳包尚未通知到一部分 follower。以上每個場景客戶端都可能讀到過時的數據,整個系統顯然是不滿足線性一致的。2.2 寫主讀主缺陷分析在該方案中我們限定,所有的讀操作也必須經由 leader 節點處理,讀寫都經過 leader 難道還不能滿足線性一致?是的!! 並且該方案存在不止一個問題!!問題一:狀態機落後於 committed log 導致髒讀回想一下前文講過的,我們在解釋什麼是 commit 時提到了寫操作什麼時候可以響應客戶端:所謂 commit 其實就是對日誌簡單進行一個標記,表明其可以被 apply 到狀態機,並針對相應的客戶端請求進行響應。也就是說一個提案只要被 leader commit 就可以響應客戶端了,Raft 並沒有限定提案結果在返回給客戶端前必須先應用到狀態機。所以從客戶端視角當我們的某個寫操作執行成功後,下一次讀操作可能還是會讀到舊值。這個問題的解決方式很簡單,在 leader 收到讀命令時我們只需記錄下當前的 commit index,當 apply index 追上該 commit index 時,即可將狀態機中的內容響應給客戶端。假設集群發生網絡分區,舊 leader 位於少數派分區中,而且此刻舊 leader 剛好還未發現自己已經失去了領導權,當多數派分區選出了新的 leader 並開始進行後續寫操作時,連接到舊 leader 的客戶端可能就會讀到舊值了。因此,僅僅是直接讀 leader 狀態機的話,系統仍然不滿足線性一致性。2.3 Raft Log Read為了確保 leader 處理讀操作時仍擁有領導權,我們可以將讀請求同樣作為一個提案走一遍 Raft 流程,當這次讀請求對應的日誌可以被應用到狀態機時,leader 就可以讀狀態機並返回給用戶了。這種讀方案稱為 Raft Log Read,也可以直觀叫做 Read as Proposal。為什麼這種方案滿足線性一致?因為該方案根據 commit index 對所有讀寫請求都一起做了線性化,這樣每個讀請求都能感知到狀態機在執行完前一寫請求後的最新狀態,將讀寫日誌一條一條的應用到狀態機,整個系統當然滿足線性一致。但該方案的缺點也非常明顯,那就是性能差,讀操作的開銷與寫操作幾乎完全一致。而且由於所有操作都線性化了,我們無法並發讀狀態機。3. Raft 讀性能優化接下來我們將介紹幾種優化方案,它們在不違背系統線性一致性的前提下,大幅提升了讀性能。3.1 Read Index與 Raft Log Read 相比,Read Index 省掉了同步 log 的開銷,能夠大幅提升讀的吞吐,一定程度上降低讀的時延。其大致流程為:Leader 在收到客戶端讀請求時,記錄下當前的 commit index,稱之為 read index。Leader 向 followers 發起一次心跳包,這一步是為了確保領導權,避免網絡分區時少數派 leader 仍處理請求。等待狀態機至少應用到 read index(即 apply index 大於等於 read index)。這裡第三步的 apply index 大於等於 read index 是一個關鍵點。因為在該讀請求發起時,我們將當時的 commit index 記錄了下來,只要使客戶端讀到的內容在該 commit index 之後,那麼結果一定都滿足線性一致(如不理解可以再次回顧下前文線性一致性的例子以及2.2中的問題一)。3.2 Lease Read與 Read Index 相比,Lease Read 進一步省去了網絡交互開銷,因此更能顯著降低讀的時延。基本思路是 leader 設置一個比選舉超時(Election Timeout)更短的時間作為租期,在租期內我們可以相信其它節點一定沒有發起選舉,集群也就一定不會存在腦裂,所以在這個時間段內我們直接讀主即可,而非該時間段內可以繼續走 Read Index 流程,Read Index 的心跳包也可以為租期帶來更新。Lease Read 可以認為是 Read Index 的時間戳版本,額外依賴時間戳會為算法帶來一些不確定性,如果時鐘發生漂移會引發一系列問題,因此需要謹慎的進行配置。3.3 Follower Read在前邊兩種優化方案中,無論我們怎麼折騰,核心思想其實只有兩點:保證在讀取時的最新 commit index 已經被 apply。其實無論是 Read Index 還是 Lease Read,最終目的都是為了解決第二個問題。換句話說,讀請求最終一定都是由 leader 來承載的。那麼讀 follower 真的就不能滿足線性一致嗎?其實不然,這裡我們給出一個可行的讀 follower 方案:Follower 在收到客戶端的讀請求時,向 leader 詢問當前最新的 commit index,反正所有日誌條目最終一定會被同步到自己身上,follower 只需等待該日誌被自己 commit 並 apply 到狀態機後,返回給客戶端本地狀態機的結果即可。這個方案叫做 Follower Read。注意:Follower Read 並不意味著我們在讀過程中完全不依賴 leader 了,在保證線性一致性的前提下完全不依賴 leader 理論上是不可能做到的。如果你一路堅持看了下來,相信已經對 Raft 算法的理論有了深刻的理解。當然,理論和工程實踐之間存在的鴻溝可能比想像的還要大,實踐中有眾多的細節問題需要去面對。在後續的源碼分析及實踐篇中,我們會結合代碼講解到許多理論部分沒有提到的這些細節點,並介紹基礎架構設計的諸多經驗,敬請期待!