SOFAStack Scalable Open Financial Architecture Stack
是螞蟻金服自主研發的金融級分散式架構,包含了構建金融級雲原生架構所需的各個元件,是在金融場景裡錘煉出來的最佳實踐。
本文為《剖析 | SOFAJRaft 實現原理》第一篇,本篇作者米麒麟,來自陸金所。《剖析 | SOFAJRaft 實現原理》系列由 SOFA 團隊和原始碼愛好者們出品,專案代號:,文章尾部有參與方式,歡迎同樣對原始碼熱情的你加入。
SOFAJRaft :https://github.com/alipay/sofa-jraft
前言
SOFAJRaft 是一個基於 Raft 一致性演演算法的生產級高效能 Java 實現,支援 MULTI-RAFT-GROUP,適用於高負載低延遲的場景。SOFAJRaft 儲存模組分為:
-
Log 儲存記錄 Raft 配置變更和使用者提交任務日誌;
-
Meta 儲存即元資訊儲存記錄 Raft 實現的內部狀態;
-
Snapshot 儲存用於存放使用者的狀態機 Snapshot 及元資訊。
本文將圍繞日誌儲存,元資訊儲存以及快照儲存等方面剖析 SOFAJRaft 儲存模組原理,闡述如何解決 Raft 協議儲存問題以及儲存模組實現:
-
Raft 配置變更和使用者提交任務日誌如何儲存?如何呼叫管理日誌儲存?
-
SOFAJRaft Server 節點 Node 是如何儲存 Raft 內部配置?
-
Raft 狀態機快照 Snapshot 機制如何實現?如何儲存安裝映象?
日誌儲存
Log 儲存,記錄 Raft 配置變更和使用者提交任務的日誌,把日誌從 Leader 複製到其他節點上面。
-
LogStorage 是日誌儲存實現,預設實現基於 RocksDB 儲存,透過 LogStorage 介面擴充套件自定義日誌儲存實現;
-
LogManager 負責呼叫底層日誌儲存 LogStorage,針對日誌儲存呼叫進行快取、批次提交、必要的檢查和最佳化。
LogStorage 儲存實現
LogStorage 日誌儲存實現,定義 Raft 分組節點 Node 的 Log 儲存模組核心 API 介面包括:
-
傳回日誌裡的首/末個日誌索引;
-
按照日誌索引獲取 Log Entry 及其任期;
-
把單個/批次 Log Entry 新增到日誌儲存;
-
從 Log 儲存頭部/末尾刪除日誌;
-
刪除所有現有日誌,重置下任日誌索引。
Log Index 提交到 Raft Group 中的任務序列化為日誌儲存,每條日誌一個編號,在整個 Raft Group 內單調遞增並複製到每個 Raft 節點。LogStorage 日誌儲存實現介面定義入口:
-
com.alipay.sofa.jraft.storage.LogStorage
RocksDBLogStorage 基於 RocksDB 實現
Log Structured Merge Tree 簡稱 LSM ,把一顆大樹拆分成 N 棵小樹,資料首先寫入記憶體,記憶體裡構建一顆有序小樹,隨著小樹越來越大,記憶體的小樹 Flush 到磁碟,磁碟中的樹定期做合併操作合併成一棵大樹以最佳化讀效能,透過把磁碟的隨機寫轉化為順序寫提高寫效能,RocksDB 就是基於 LSM-Tree 資料結構使用 C++ 編寫的嵌入式 KV 儲存引擎,其鍵值均允許使用二進位制流。RocksDB 按順序組織所有資料,通用操作包括 get(key), put(key), delete(Key) 以及 newIterator()。RocksDB 有三種基本的資料結構:memtable,sstfile 以及 logfile。memtable 是一種記憶體資料結構–所有寫入請求都會進入 memtable,然後選擇性進入 logfile。logfile 是一種有序寫儲存結構,當 memtable 被填滿的時候被刷到 sstfile 檔案並儲存起來,然後相關的 logfile 在之後被安全地刪除。sstfile 內的資料都是排序好的,以便於根據 key 快速搜尋。
LogStorage 預設實現 RocksDBLogStorage 是基於 RocksDB 儲存日誌,初始化日誌儲存 StorageFactory 根據 Raft節點日誌儲存路徑和 Raft 內部實現是否呼叫 fsync 配置預設建立 RocksDBLogStorage 日誌儲存。基於 RocksDB 儲存實現 RocksDBLogStorage 核心操作包括:
-
init():
建立 RocksDB 配置選項呼叫 RocksDB#open() 方法構建 RocksDB 實體,新增 default 預設列族及其配置選項獲取列族處理器,透過 newIterator() 生成 RocksDB 迭代器遍歷 KeyValue 資料檢查 Value 型別載入 Raft 配置變更到配置管理器 ConfigurationManager。RocksDB 引入列族 ColumnFamily 概念,所謂列族是指一系列 KeyValue 組成的資料集,RocksDB 讀寫操作需要指定列族,建立 RocksDB 預設構建命名為 default 的列族。
-
shutdown():
首先關閉列族處理器以及 RocksDB 實體,其次遍歷列族配置選項執行關閉操作,接著關閉 RocksDB 配置選項,最後清除強取用以達到 Help GC 垃圾回收 RocksDB 實體及其配置選項物件。
-
getFirstLogIndex():
基於處理器 defaultHandle 和讀選項 totalOrderReadOptions 方法構建 RocksDB 迭代器 RocksIterator,檢查是否載入過日誌裡第一個日誌索引,未載入需呼叫 seekToFirst() 方法獲取快取 RocksDB 儲存日誌資料的第一個日誌索引。
-
getLastLogIndex():
基於處理器 defaultHandle 和讀選項 totalOrderReadOptions 構建 RocksDB 迭代器 RocksIterator,呼叫 seekToLast() 方法傳回 RocksDB 儲存日誌記錄的最後一個日誌索引。
-
getEntry(index):
基於處理器 defaultHandle 和指定日誌索引呼叫 RocksDB#get() 操作傳回 RocksDB 索引位置日誌 LogEntry。
-
getTerm(index):
基於處理器 defaultHandle 和指定日誌索引呼叫 RocksDB#get() 操作獲取 RocksDB 索引位置日誌並且傳回其 LogEntry 的任期。
-
appendEntry(entry):
檢查日誌 LogEntry 型別是否為配置變更,配置變更型別呼叫 RocksDB#write() 方法執行批次寫入,使用者提交任務的日誌基於處理器 defaultHandle 和 LogEntry 物件呼叫 RocksDB#put() 方法儲存。
-
appendEntries(entries):
呼叫 RocksDB#write() 方法把 Raft 配置變更或者使用者提交任務的日誌同步刷盤批次寫入 RocksDB 儲存,透過 Batch Write 手段合併 IO 寫入請求減少方法呼叫和背景關係切換。
-
truncatePrefix(firstIndexKept):
獲取第一個日誌索引,後臺啟動一個執行緒基於預設處理器 defaultHandle 和配置處理器 confHandle 執行 RocksDB#deleteRange() 操作刪除從 Log 頭部以第一個日誌索引到指定索引位置範圍的 RocksDB 日誌資料。
-
truncateSuffix(lastIndexKept):
獲取最後一個日誌索引,基於預設處理器 defaultHandle 和配置處理器 confHandle 執行 RocksDB#deleteRange() 操作清理從 Log 末尾以指定索引位置到最後一個索引範疇的 RocksDB 未提交日誌。
-
reset(nextLogIndex):
獲取 nextLogIndex 索引對應的 LogEntry,執行 RocksDB#close() 方法關閉 RocksDB實體,呼叫 RocksDB#destroyDB() 操作銷毀 RocksDB 實體清理 RocksDB 所有資料,重新初始化載入 RocksDB 實體並且重置下一個日誌索引位置。
RocksDBLogStorage 基於 RocksDB 儲存日誌實現核心入口:
-
com.alipay.sofa.jraft.storage.RocksDBLogStorage
LogManager 儲存呼叫
日誌管理器 LogManager 負責呼叫 Log 日誌儲存 LogStorage,對 LogStorage 呼叫進行快取管理、批次提交、檢查最佳化。Raft 分組節點 Node 初始化/啟動時初始化日誌儲存 StorageFactory 構建日誌管理器 LogManager,基於日誌儲存 LogStorage、配置管理器 ConfigurationManager、有限狀態機呼叫者 FSMCaller、節點效能監控 NodeMetrics 等 LogManagerOptions 配置選項實體化 LogManager。
根據 Raft 節點 Disruptor Buffer 大小配置生成穩定狀態回呼 StableClosure 事件 Disruptor 佇列,設定穩定狀態回呼 StableClosure 事件處理器 StableClosureEventHandler 處理佇列事件,其中 StableClosureEventHandler 處理器事件觸發的時候判斷任務回呼 StableClosure 的 Log Entries 是否為空,如果任務回呼的 Log Entries 為非空需積攢日誌條目批次 Flush,空則檢查 StableClosureEvent 事件型別並且呼叫底層儲存 LogStorage#appendEntries(entries) 批次提交日誌寫入 RocksDB,當事件型別為SHUTDOWN、RESET、TRUNCATEPREFIX、TRUNCATESUFFIX、LASTLOGID 時呼叫底層日誌儲存 LogStorage 進行指定事件回呼 ResetClosure、TruncatePrefixClosure、TruncateSuffixClosure、LastLogIdClosure 處理。
當 Client 向 SOFAJRaft 傳送命令之後,Raft 分組節點 Node 的日誌管理器 LogManager 首先將命令以 Log 的形式儲存到本地,呼叫 appendEntries(entries, done) 方法檢查 Node 節點當前為 Leader 並且 Entries 來源於使用者未知分配到的正確日誌索引時需要分配索引給新增的日誌 Entries ,而當前為 Follower 時並且 Entries 來源於 Leader 必須檢查以及解決本地日誌和 Entries 之間的衝突。
接著遍歷日誌條目 Log Entries 檢查型別是否為配置變更,配置管理器 ConfigurationManager 快取配置變更 Entry,將現有日誌條目 Entries 新增到 logsInMemory 進行快取,穩定狀態回呼 StableClosure 設定需要儲存的日誌,釋出 OTHER 型別事件到穩定狀態回呼 StableClosure 事件佇列,觸發穩定狀態回呼 StableClosure 事件處理器 StableClosureEventHandler 處理該事件,處理器獲取任務回呼的 Log Entries 把日誌條目積累到記憶體中以便後續統一批次 Flush,透過 appendToStorage(toAppend) 操作呼叫底層 LogStorage 儲存日誌 Entries。
同時 Replicator 把此條 Log 複製給其他的 Node 實現併發的日誌複製,當 Node 接收叢集中半數以上的 Node 傳回的“複製成功”的響應將這條 Log 以及之前的 Log 有序的傳送至狀態機裡面執行。
LogManager 呼叫日誌儲存 LogStorage 實現邏輯:
元資訊儲存
Metadata 儲存即元資訊儲存,用來儲存記錄 Raft 實現的內部狀態,譬如當前任期 Term、投票給哪個 PeerId 節點等資訊。
RaftMetaStorage 儲存實現
RaftMetaStorage 元資訊儲存實現,定義 Raft 元資料的 Metadata 儲存模組核心 API 介面包括:
-
設定/獲取 Raft 元資料的當前任期 Term;
-
分配/查詢 Raft 元資訊的 PeerId 節點投票。
Raft 內部狀態任期 Term 是在整個 Raft Group 裡單調遞增的 long 數字,用來表示一輪投票的編號,其中成功選舉出來的 Leader 對應的 Term 稱為 Leader Term,Leader 沒有發生變更期間提交的日誌都有相同的 Term 編號。
PeerId 表示 Raft 協議的參與者(Leader/Follower/Candidate etc.), 由三元素組成: ip:port:index,其中 ip 是節點的 IP, port 是埠, index 表示同一個埠的序列號。RaftMetaStorage 元資訊儲存實現介面定義入口:
-
com.alipay.sofa.jraft.storage.RaftMetaStorage
LocalRaftMetaStorage 基於 ProtoBuf 實現
Protocol Buffers 是一種輕便高效的結構化資料儲存格式,用於結構化資料序列化或者說序列化,適合做資料儲存或 RPC 資料交換格式,用於通訊協議、資料儲存等領域的語言無關、平臺無關、可擴充套件的序列化結構資料格式。使用者在 .proto 檔案定義 Protocol Buffer 的 Message 型別指定需要序列化的資料結構,每一個 Message 都是一個小的資訊邏輯單元包含一系列的鍵值對,每種型別的 Message 涵蓋一個或者多個唯一編碼欄位,每個欄位由名稱和值型別組成,允許 Message 定義可選欄位 Optional Fields、必須欄位 Required Fields、可重覆欄位 Repeated Fields。
RaftMetaStorage 預設實現 LocalRaftMetaStorage 是基於 ProtoBuf Message 本地儲存 Raft 元資料,初始化元資訊儲存 StorageFactory 根據 Raft 元資訊儲存路徑、 Raft 內部配置以及 Node 節點監控預設建立 LocalRaftMetaStorage 元資訊儲存。基於 ProtoBuf 儲存實現 LocalRaftMetaStorage 主要操作包括:
-
init():
獲取 Raft 元資訊儲存配置 RaftMetaStorageOptions 節點 Node,讀取命名為 raft_meta 的 ProtoBufFile 檔案載入 StablePBMeta 訊息,根據 StablePBMeta ProtoBuf 元資料快取 Raft 當前任期 Term 和 PeerId 節點投票資訊。
-
shutdown():
獲取記憶體裡 Raft 當前任期 Term 和 PeerId 節點投票構建 StablePBMeta 訊息,按照 Raft 內部是否同步元資料配置寫入 ProtoBufFile 檔案。
-
setTerm(term):
檢查 LocalRaftMetaStorage 初始化狀態,快取設定的當前任期 Term,按照 Raft 是否同步元資料配置把當前任期 Term 作為 ProtoBuf 訊息儲存到 ProtoBufFile 檔案。
-
getTerm():
檢查 LocalRaftMetaStorage 初始化狀態,傳回快取的當前任期 Term。
-
setVotedFor(peerId):
檢查 LocalRaftMetaStorage 初始化狀態,快取投票的 PeerId 節點,按照 Raft 是否同步元資料配置把投票 PeerId 節點作為 ProtoBuf 訊息儲存到 ProtoBufFile 檔案。
-
getVotedFor():
檢查 LocalRaftMetaStorage 初始化狀態,傳回快取的投票 PeerId 節點。
LocalRaftMetaStorage 基於 ProtoBuf 本地儲存 Raft 元資訊實現入口:
-
com.alipay.sofa.jraft.storage.impl.LocalRaftMetaStorage
快照儲存
當 Raft 節點 Node 重啟時,記憶體中狀態機的狀態資料丟失,觸發啟動過程重新存放日誌儲存 LogStorage 的所有日誌重建整個狀態機實體,此種場景會導致兩個問題:
-
如果任務提交比較頻繁,例如訊息中介軟體場景導致整個重建過程很長啟動緩慢;
-
如果日誌非常多並且節點需要儲存所有的日誌,對儲存來說是資源佔用不可持續;
-
如果增加 Node 節點,新節點需要從 Leader 獲取所有的日誌重新存放至狀態機,對於 Leader 和網路頻寬都是不小的負擔。
因此透過引入 Snapshot 機制來解決此三個問題,所謂快照 Snapshot 即對資料當前值的記錄,是為當前狀態機的最新狀態構建”映象”單獨儲存,儲存成功刪除此時刻之前的日誌減少日誌儲存佔用;啟動的時候直接載入最新的 Snapshot 映象,然後重放在此之後的日誌即可,如果 Snapshot 間隔合理,整個重放到狀態機過程較快,加速啟動過程。最後新節點的加入先從 Leader 複製最新的 Snapshot 安裝到本地狀態機,然後只要複製後續的日誌即可,能夠快速跟上整個 Raft Group 的進度。Leader 生成快照有幾個作用:
-
當有新的節點 Node 加入叢集不用只靠日誌複製、回放機制和 Leader 保持資料一致,透過安裝 Leader 的快照方式跳過早期大量日誌的回放;
-
Leader 用快照替代 Log 複製減少網路端的資料量;
-
用快照替代早期的 Log 節省儲存佔用空間。
Snapshot 儲存,用於儲存使用者的狀態機 Snapshot 及元資訊:
-
SnapshotStorage 用於 Snapshot 儲存實現;
-
SnapshotExecutor 用於管理 Snapshot 儲存、遠端安裝、複製。
SnapshotStorage 儲存實現
SnapshotStorage 快照儲存實現,定義 Raft 狀態機的 Snapshot 儲存模組核心 API 介面包括:
-
設定 filterBeforeCopyRemote ,為 true 表示覆制到遠端之前過濾資料;
-
建立快照編寫器;
-
開啟快照閱讀器;
-
從遠端 Uri 複製資料;
-
啟動從遠端 Uri 複製資料的複製任務;
-
配置 SnapshotThrottle,SnapshotThrottle 用於重盤讀/寫場景限流的,比如磁碟讀寫、網路頻寬。
LocalSnapshotStorage 基於本地檔案實現
SnapshotStorage 預設實現 LocalSnapshotStorage 是基於本地檔案儲存 Raft 狀態機映象,初始化元快照儲存 StorageFactory 根據 Raft 映象快照儲存路徑和 Raft 配置資訊預設建立 LocalSnapshotStorage 快照儲存。基於本地檔案儲存實現 LocalSnapshotStorage 主要方法包括:
-
init():
刪除檔案命名為 temp 的臨時映象 Snapshot,銷毀檔案字首為 snapshot_ 的舊快照 Snapshot,獲取快照最後一個索引 lastSnapshotIndex。
-
close():
按照快照最後一個索引 lastSnapshotIndex 和映象編寫器 LocalSnapshotWriter 快照索引重新命名臨時映象 Snapshot 檔案,銷毀編寫器 LocalSnapshotWriter 儲存路徑快照。
-
create():
銷毀檔案命名為 temp 的臨時快照 Snapshot,基於臨時映象儲存路徑建立初始化快照編寫器 LocalSnapshotWriter,載入檔案命名為 _raftsnapshot_meta 的 Raft 快照元資料至記憶體。
-
open():
根據快照最後一個索引 lastSnapshotIndex 獲取檔案字首為 snapshot_ 快照儲存路徑,基於快照儲存路徑建立初始化快照閱讀器 LocalSnapshotReader,載入檔案命名為 _raftsnapshot_meta 的 Raft 映象元資料至記憶體。
-
startToCopyFrom(uri, opts):
建立初始化狀態機快照複製器 LocalSnapshotCopier,生成遠端檔案複製器 RemoteFileCopier,基於遠端服務地址 Endpoint 獲取 Raft 客戶端 RPC 服務連線指定 Uri,啟動後臺執行緒複製 Snapshot 映象資料,載入 Raft 快照元資料獲取遠端快照 Snapshot 映象檔案,讀取遠端指定快照儲存路徑資料複製到 BoltSession,快照複製器 LocalSnapshotCopier 同步 Raft 快照元資料。
SnapshotExecutor 儲存管理
快照執行器 SnapshotExecutor 負責 Raft 狀態機 Snapshot 儲存、Leader 遠端安裝快照、複製映象 Snapshot 檔案,包括兩大核心操作:狀態機快照 doSnapshot(done) 和安裝快照 installSnapshot(request, response, done)。
StateMachine 快照 doSnapshot(done) 獲取基於臨時映象 temp 檔案路徑的 Snapshot 儲存快照編寫器 LocalSnapshotWriter,載入 _raftsnapshotmeta 快照元資料檔案初始化編寫器;構建儲存映象回呼SaveSnapshotDone 提供 FSMCaller 呼叫 StateMachine 的狀態轉換髮布 SNAPSHOTSAVE 型別任務事件到 Disruptor 佇列,透過 Ring Buffer 方式觸發申請任務處理器 ApplyTaskHandler 執行快照儲存任務,呼叫 onSnapshotSave() 方法儲存各種型別狀態機快照。
遠端安裝快照 installSnapshot(request, response, done) 按照安裝映象請求響應以及快照原資訊建立並且註冊快照下載作業 DownloadingSnapshot,載入快照下載 DownloadingSnapshot 獲取當前快照複製器的閱讀器 SnapshotReader,構建安裝映象回呼 InstallSnapshotDone 分配 FSMCaller 呼叫 StateMachine 的狀態轉換髮布 SNAPSHOT_LOAD 型別任務事件到 Disruptor 佇列,也是透過 Ring Buffer 觸發申請任務處理器 ApplyTaskHandler 執行快照安裝任務,呼叫 onSnapshotLoad() 操作載入各種型別狀態機快照。
SnapshotExecutor 狀態機快照和遠端安裝映象實現邏輯:
總結
本文從 Log 日誌儲存 LogStorage、Meta 元資訊儲存 RaftMetaStorage 以及 Snapshot 快照儲存 SnapshotStorage 三個方面詳述 SOFAJRaft 儲存模組實現細節,直觀刻畫 SOFAJRaft Server 節點 Node 之間儲存日誌、Raft 配置和映象流程。
朋友會在“發現-看一看”看到你“在看”的內容