SOFAStack Scalable Open Financial Architecture Stack 是螞蟻金服自主研發的金融級分布式架構,包含了建構金融級雲原生架構所需的各個元件,是在金融場景裡錘煉出來的最佳實踐。

SOFAJRaft 是一個基于 Raft 一緻性算法的生産級高性能 Java 實作,支援 MULTI-RAFT-GROUP,适用于高負載低延遲的場景。
本文為《剖析 | SOFAJRaft 實作原理》第五篇,本篇作者袖扣,來自螞蟻金服。
《剖析 | SOFAJRaft 實作原理》系列由 SOFA 團隊和源碼愛好者們出品,項目代号:
<SOFA:JRaftLab/>
,文章尾部有參與方式,歡迎同樣對源碼熱情的你加入。
SOFAJRaft :
https://github.com/alipay/sofa-jraft前言
RheaKV 是首個以 JRaft 為基礎實作的一個原生支援分布式的嵌入式鍵值(key、value)資料庫,現在本文将從 RheaKV 是如何利用 MULTI-RAFT-GROUP 的方式實作 RheaKV 的高性能及容量的可擴充性的,進而進行全面的源碼、執行個體剖析。
MULTI-RAFT-GROUP
通過對 Raft 協定的描述我們知道:使用者在對一組 Raft 系統進行更新操作時必須先經過 Leader,再由 Leader 同步給大多數 Follower。而在實際運用中,一組 Raft 的 Leader 往往存在單點的流量瓶頸,流量高便無法承載,同時每個節點都是全量資料,是以會受到節點的存儲限制而導緻容量瓶頸,無法擴充。
MULTI-RAFT-GROUP 正是通過把整個資料從橫向做切分,分為多個 Region 來解決磁盤瓶頸,然後每個 Region 都對應有獨立的 Leader 和一個或多個 Follower 的 Raft 組進行橫向擴充,此時系統便有多個寫入的節點,進而分擔寫入壓力,圖如下:
此時磁盤及 I/O 瓶頸解決了,那多個 Raft Group 是如何協作的呢,我們接着往下看。
選舉及複制
RheaKV 主要由 3 個角色組成:PlacementDriver(以下成為 PD) 、Store、Region。由于 RheaKV 支援多組 Raft,是以比單組場景多出一個 PD 角色,用來排程以及收集每個 Store 及 Region 的基礎資訊。
PlacementDriver
PD 負責整個叢集的管理排程、Region ID 生成等。此元件非必須的,如果不使用 PD,設定 PlacementDriverOptions 的 fake 屬性為 true 即可。PD 一般通過 Region 的心跳傳回資訊進行對 Region 排程,Region 處理完後,PD 則會在下一個心跳傳回中收到 Region 的變更資訊來更新路由及狀态表。
Store
通常一個 Node 負責一個 Store,Store 可以被看作是 Region 的容器,裡面存儲着多個分片資料。Store 會向 PD 主動上報 StoreHeartbeatRequest 心跳,心跳交由 PD 的 handleStoreHeartbeat 處理,裡面包含該 Store 的基本資訊,比如,包含多少 Region,有哪些 Region 的 Leader 在該 Store 等。
Region
Region 是資料存儲、搬遷的最小單元,對應的是 Store 裡某個實際的資料區間。每個 Region 會有多個副本,每個副本存儲在不同的 Store,一起組成一個Raft Group。Region 中的 Leader 會向 PD 主動上報 RegionHeartbeatRequest 心跳,交由 PD 的 handleRegionHeartbeat 處理,而 PD 是通過 Region的Epoch 感覺 Region 是否有變化。
RegionRouteTable 路由表元件
MULTI-RAFT-GROUP 的多 Region 是通過 RegionRouteTable 路由表元件進行管理的,可通過 addOrUpdateRegion、removeRegion 進行添加、更新、移除 Region,也包括 Region 的拆分。目前暫時還未實作 Region 的聚合,後面會考慮實作。
分區邏輯與算法 Shard
“讓每組 Raft 負責一部分資料。”
資料分區或者分片算法通常就是 Range 和 Hash,RheaKV 是通過 Range 進行資料分片的,分成一個個 Raft Group,也稱為 Region。這裡為何要設計成 Range 呢?原因是 Range 切分是按照對 Key 進行位元組排序後再做每段每段切分,像類似 scan 等操作對相近 key 的查詢會盡可能集中在某個 Region,這個是 Hash 無法支援的,就算遇到單個 Region 的拆分也會更好處理一些,隻用修改部分中繼資料,不會涉及到大範圍的資料挪動。
當然 Range 也會有一個問題那就是,可能會存在某個 Region 被頻繁操作成為熱點 Region。不過也有一些優化方案,比如 PD 排程熱點 Region 到更空閑的機器上,或者提供 Follower 分擔讀的壓力等。
Region 和 RegionEpoch 結構如下:
class Region {
long id; // region id
// Region key range [startKey, endKey)
byte[] startKey; // inclusive
byte[] endKey; // exclusive
RegionEpoch regionEpoch; // region term
List<Peer> peers; // all peers in the region
}
class RegionEpoch {
// Conf change version, auto increment when add or remove peer
long confVer;
// Region version, auto increment when split or merge
long version;
}
class Peer {
long id;
long storeId;
Endpoint endpoint;
}
Region.id:為 Region 的唯一辨別,通過 PD 全局唯一配置設定。
Region.startKey、Region.endKey:這個表示的是 Region 的 key 的區間範圍 [startKey, endKey),特别值得注意的是針對最開始 Region 的 startKey,和最後 Region 的 endKey 都為空。
Region.regionEpoch:當 Region 添加和删除 Peer,或者 split 等,此時 regionEpoch 就會發生變化,其中 confVer 會在配置修改後遞增,version 則是每次有 split 、merge(還未實作)等操作時遞增。
Region.peers:peers 則指的是目前 Region 所包含的節點資訊,Peer.id 也是由 PD 全局配置設定的,Peer.storeId 代表的是 Peer 目前所處的 Store。
讀與寫 Read / Write
由于資料被拆分到不同 Region 上,是以在進行多 key 的讀、寫、更新操作時需要操作多個 Region,這時操作前我們需要得到具體的 Region,然後再單獨對不同 Region 進行操作。我們以在多 Region上 scan 操作為例, 目标是傳回某個 key 區間的所有資料:
我們首先看 scan 方法的核心調用方法 internalScan 的異步實作:
例如:com.alipay.sofa.jraft.rhea.client.DefaultRheaKVStore#scan(byte[], byte[], boolean, boolean)
我們很容易看到,在調用 scan 首先讓 PD Client 通過 RegionRouteTable.findRegionsByKeyRange 檢索 startKey、endKey 所覆寫的 Region,最後傳回的可能為多個 Region,具體 Region 覆寫檢索方法如下:
檢索相關變量定義如下:
我們可以看到整個 RheaKV 的 range 路由表是通過 TreeMap 的進行存儲的,正呼應我們前面講過所有的 key 是通過對應位元組進行排序存儲。對應的 Value 為該 Region 的 RegionId,随後我們通過 Region 路由 regionTable 查出即可。
現在我們得到 scan 覆寫到的所有
Region:List<Region>
在循環查詢中我們看到有一個“retryCause -> {}”的 Lambda 表達式很容易看出這裡是加持異常重試處理,後面我們會講到,接下來會通過 internalRegionScan 查詢每個 Region 的結果。具體源碼如下:
這裡也同樣有一個重試處理,可以看到代碼中根據目前是否為 Region 節點來決定是本機查詢還是通過RPC進行查詢,如果是本機則調用 rawKVStore.scan() 進行本地直接查詢,反之通過 rheaKVRpcService 進行 RPC 遠端節點查詢。最後每個 Region 查詢都傳回為一個 future,通過 FutureHelper.joinList 工具類 CompletableFuture.allOf 異步并發傳回結果
List<KVEntry>
。
我們再看看寫入具體流程。相比 scan 讀,put 寫相對比較簡單,隻需要針對 key 計算出對應 Region 再進行存儲即可,我們可以看一個異步 put 的例子。
例如:com.alipay.sofa.jraft.rhea.client.DefaultRheaKVStore#put(java.lang.String, byte[])
我們可以發現 put 基礎方法是支援 batch 的,即可成批送出。如未使用 batch 即直接送出,具體邏輯如下:
通過 pdClient 查詢對應存儲的 Region,并且通過 regionId 拿到 RegionEngine,再通過對應存儲引擎 KVStore 進行 put,整個過程同樣支援重試機制。我們再回過去看看 batch 的實作,很容易發現利用到了 Disruptor 的 RingBuffer 環形緩沖區,無鎖隊列為性能提供了保障,代碼現場如下:
Split / Merge
什麼時候 Region 會拆分?
前面我們有講過,PD 會在 Region 的 heartBeat 裡面對 Region 進行排程,當某個 Region 裡的 keys 數量超過預設閥值,我們即可對該 Region 進行拆分,Store 的狀态機 KVStoreStateMachine 即收到拆分消息進行拆分處理。具體拆分源碼如下:
KVStoreStateMachine.doSplit 源碼如下:
StoreEngine.doSplit 源碼如下:
我們可以輕易的看到從原始 parentRegion 切分成 region 和 pRegion,并重設了 startKey、endKey 和版本号,并添加到 RegionEngineTable 注冊到 RegionKVService,同時調用 pdClient.getRegionRouteTable().splitRegion() 方法進行更新存儲在 PD 的 Region 路由表。
什麼時候需要對 Region 進行合并?
既然資料過多需要進行拆分,那 Region 進行合并那就肯定是 2 個或者多個連續的 Region 資料量明顯小于絕大多數 Region 容量則我們可以對其進行合并。這一塊後面會考慮實作。
RegionKVService 結構及實作分析
StoreEngine
通過上面我們知道,一個 Store 即為一個節點,裡面包含着一個或者多個 RegionEngine,一個 StoreEngine 通常通過 PlacementDriverClient 對 PD 進行調用,同時擁有 StoreEngineOptions 配置項,裡面配置着存儲引擎和節點相關配置。
- 我們以預設的 DefaultRheaKVStore 加載 StoreEngine 為例,DefaultRheaKVStore 實作了 RheaKVStore 接口的基礎功能,從最開始 init 方法,根據 RheaKVStoreOptions 加載了 pdClient 執行個體,随後加載 storeEngine。
- 在 StoreEngine 啟動的時候,首先會去加載對應的 StoreEngineOptions 配置,建構對應的 Store 配置,并且生成一緻性讀的線程池 readIndexExecutor、快照線程池 snapshotExecutor、RPC 的線程池 cliRpcExecutor、Raft 的 RPC 線程池 raftRpcExecutor,以及存儲 RPC 線程池 kvRpcExecutor、心跳發送器 HeartbeatSender 等,如果打開代碼,我們還能看到 metricsReportPeriod,打開配置可以進行性能名額監控。
- 在 DefaultRheaKVStore 加載完所有工序之後,便可使用 get、set、scan 等操作,還包含對應同步、異步操作。
在這個過程中裡面的 StoreEngine 會記錄着 regionKVServiceTable、regionEngineTable,它們分别掌握着具體每個不同的 Region 存儲的操作功能,對應的 key 即為 RegionId。
RegionEngine
每個在 Store 裡的 Region 副本中,RegionEngine 則是一個執行單元。它裡面記錄着關聯着的 StoreEngine 資訊以及對應的 Region 資訊。由于它也是一個選舉節點,是以也包含着對應狀态機 KVStoreStateMachine,以及對應的 RaftGroupService,并啟動裡面的 RpcServer 進行選舉同步。
這個裡面有個 transferLeadershipTo 方法,這個可被調用用于平衡目前節點分區的 Leader,避免壓力重疊。
DefaultRegionKVService 是 RegionKVService 的預設實作類,主要處理對 Region 的具體操作。
RheaKV FailoverClosure 解讀
需要特别講到的是,在具體的 RheaKV 操作時,FailoverClosure 擔任着比較重要的角色,也給整個系統增加了一定的容錯性。假如在一次 scan 操作中,如果跨 Store 需要多節點 scan 資料的時候,任何網絡抖動都會造成資料不完整或者失敗情況,是以允許一定次數的重試有利于提高系統的可用性,但是重試次數不宜過高,如果出現網絡堵塞,多次 timeout 級别失敗會給系統帶來額外的壓力。這裡隻需要在 DefaultRheaKVStore 中,進行配置 failoverRetries 設定次數即可。
RheaKV PD 之 PlacementDriverClient
PlacementDriverClient 接口主要由 AbstractPlacementDriverClient 實作,然後 FakePlacementDriverClient、RemotePlacementDriverClient 為主要功能。FakePlacementDriverClient 是當系統不需要 PD 的時候進行 PD 對象的模拟,這裡主要講到 RemotePlacementDriverClient。
- RemotePlacementDriverClient 通過PlacementDriverOptions 進行加載,并根據基礎配置重新整理路由表;
- RemotePlacementDriverClient 承擔着對路由表RegionRouteTable 的管控,例如擷取Store、路由、Leader節點資訊等;
- RemotePlacementDriverClient 還包含着 CliService,通過 CliService 外部可對複制節點進行操作運維,如 addReplica、removeReplica、transferLeader。
總結
由于很多傳統存儲中間件并不原生支援分布式,是以一直少有體感,Raft 協定是一套比較比較好了解的共識協定,SOFAJRaft 通俗易懂是一個非常好的代碼和工程範例,同時 RheaKV 也是一套非常輕量化支援多存儲結構可分片的嵌入式資料庫。寫一篇代碼分析文章也是一個學習和進步的過程,由此我們也可以窺探到了一些資料庫的基礎實作,祝願社群能在 SOFAJRaft / RheaKV 基礎上建構更加靈活和自治理的系統和應用。