做者:管延信linux
上期回顧:深刻解析 Raft 模塊在 ZNBase 中的優化改造(上)git
云溪數據庫 ZNBase 是由浪潮開源的一款 NewSQL 分佈式數據庫,具有 HTAP 特性,擁有強一致、高可用的分佈式架構。其中,ZNBase 各方面的強一致性都依靠 Raft 算法實現。咱們在上一篇文章中介紹了 Raft 一致性算法在分佈式數據庫中發揮的重要做用,以及 ZNBase 根據自身需求對 Raft 算法進行了優化改造,爲其新增了三種角色設計。本文將繼續介紹 ZNBase 研發團隊在落地 Raft 模塊的過程當中對其進行的其餘優化改進。算法
前文提到,在項目開發前期,ZNBase 中的 Raft 算法採用的是開源的 etcd-raft 模塊,可是在後續的生產實踐中,ZNBase 研發團隊逐漸發現 etcd-raft 的模塊仍存在諸多限制,因而陸續開展了以下多個方面的優化工做,具體包括:數據庫
- 新增 Raft 角色
- 新增 Leader 親和選舉
- 混合序列化
- Raft Log 分離與定製存儲
- Raft 心跳與數據分離
本文將着重介紹新增 Leader 親和選舉、混合序列化、Raft Log 分離與定製存儲、Raft 心跳與數據分離這四大優化改進。網絡
ZNBase 對 Raft 模塊的改進
新增 Leader 親和選舉
ZNBase 基於 Raft 算法對外提供強一致,對於存儲的全部讀寫操做,均須要經過 Raft Leader 處理。在以多地多數據中心模式部署的狀況下,客戶端但願常常訪問的數據通過本地網絡就能夠訪問到 Raft Leader,對數據進行訪問,避免跨地區訪問較高的網絡延遲。所以,項目團隊針對 ZNBase 中的 etcd-raft 模塊爲其增長了親和選舉功能,即選舉 Raft Leader 時,根據親和配置干預選舉,使親和性更高的副本當選爲 Raft Leader。 架構
在本來的 etcd-raft 模塊中,進行 Leader 選舉以前,Raft group 中全部副本均爲 Follower,當某個節點的選舉計時器超時後會發起一次選舉。NewSQL 中選舉計時器超時的時間單位爲 Tick,每一個 Tick 默認是 200ms (defaultRaftTickInterval),默認選舉超時 Ticks 爲 15 (defaultRaftElectionTimeoutTicks),最後得出超時時間爲分佈式
[15 * 200ms, (2*15 – 1) * 200ms] == [3000ms, 5800ms]
區間內隨機的200ms的倍數。因爲選舉超時時間是徹底隨機的,那麼優先發起選舉的節點也是徹底隨機的。性能
基於原有邏輯,研發團隊提出了「增長一輪選舉超時時間」的策略(如圖 3 所示):學習
圖3:增長一輪選舉超時時間示意圖測試
即當選舉計時器超時後,在發起選舉前對親和配置進行檢查,檢查的策略以下:
- 若是沒有親和配置,直接發起選舉。
- 若是有親和配置,親和配置與當前節點 locality 標籤相符,得出當前節點爲親和節點,直接發起選舉。
- 若是有親和配置,親和配置與當前節點 locality 標籤不符,得出當前節點爲非親和節點,重置選舉計時器,不發起選舉,等待下一輪選舉超時後,即便不是親和節點也當即發起選舉。
等待一輪選舉超時後,即便不是親和節點也當即發起選舉的目的是:保證集羣必定的可用性,通常狀況下,在兩次選舉計時器超時間隔內能夠選出 Raft Leader。考慮到可能存在的其餘極端狀況,提出「控制隨機選舉超時時間範圍」的補充策略(如圖 4 所示),即在選舉超時時間的隨機範圍內,親和節點選舉超時時間的區間小於非親和節點,親和節點會先超時並發起選舉。經過約束親和與非親和副本的隨機選舉超時時間範圍,使得親和副本選舉計時器先超時,提升優先發起 Raft Leader 選舉的機率。這樣就保證了親和副本比非親和副本先發起一輪選舉(在親和副本能當選爲 Leader 時,優先當選爲 Leader),即便親和的副本因爲日誌較舊,沒法當選爲 Leader, 非親和的副本在兩次選舉計時器超時時間內能當選爲 Leader,保障了可用性。
圖4 控制選舉超時時間範圍圖
親和選舉對 Raft 的影響有:增長一輪選舉超時時間能夠看似爲一次選舉失敗,選舉計時器重置,等待下一次選舉;控制隨機選舉超時時間範圍是在原有許可範圍內,對親和與非親和的節點進行更細的範圍劃分,劃分後還是在原有的範圍內。
混合序列化
在 etcd-raft 模塊中節點之間經過 gRPC 協議實現通訊,序列化方式則採用 protobuf 進行序列化,相對 colfer 而言,protobuf 是一種較慢的序列化方式。使用給定配置的機器(Intel Core i5 CPU@2.9 GHz、內存 8GB、Go1.15.7 darwin/amd64)利用 protobuf、gogoprotobuf 和 colfer 協議分別對給定數據進行了在序列化和反序列化,詳細的實驗數據以下表所示:
表1 protobuf、gogoprotobuf 以及 colfer 協議性能對比
benchmark |
iter |
time/iter |
bytes/op |
allocs/op |
ProtobufMarshal |
1761278 |
674 ns/op |
52 |
152 |
ProtobufUnmarshal |
1916198 |
627 ns/op |
52 |
192 |
GogoprotobufMarshal |
9089631 |
131 ns/op |
53 |
64 |
GogoprotobufUnmarshal |
6390816 |
190 ns/op |
53 |
96 |
ColferMarshal |
10938900 |
108 ns/op |
51 |
64 |
ColferUnmarshal |
7260112 |
166 ns/op |
52 |
112 |
爲了提升序列化的效率,根據實驗結果採用 protobuf+colfer 的混合序列化方式,當須要序列化操做時,調用 protobuf 的序列化方式,當將數據進行序列化時則採用 colfer 方式加快序列化的效率,這比單獨使用 protobuf 序列化提升了 40% 的性能,表 2 是 gogoprotobuf 和混合序列化在相同測試環境(Intel Core i7 CPU@1.8GHz × 4 、內存 8G、Go1.14 linux/amd64)下的性能對比。
表2 gogoprotobuf和混合序列化性能對比
benchmark |
iter |
time/iter |
bytes/op |
allocs/op |
GogoprotobufMarshal |
9799002 |
121 ns/op |
32 |
1 |
GogoprotobufUnmarshal |
6607788 |
182 ns/op |
120 |
2 |
混合序列化Marshal |
16514504 |
69.6 ns/op |
32 |
1 |
混合序列化Unmarshal |
10472240 |
103 ns/op |
85 |
2 |
Raft Log 分離與定製存儲
在 etcd-raft 模塊中,Raft Group 中的 Leader 節點接收客戶端發來的 Request,將 Request 封裝成 Raft Entry(Raft Log 的基本組成單元)追加到本地,並經過 gRPC 將 Raft Entry 發送給 Raft Group 中其餘 Follower 節點,當 Follower 節點收到 Raft Entry 後進行追加、刷盤以及回覆處理結果的同時,Leader 將本地 Raft Entry 進行刷盤,二者同步進行。等 Leader 節點收到過半數節點的確定回覆後,提交 Raft Entry 並將其應用到狀態機(將 Raft Entry 中包含的業務數據進行持久化),而後將處理結果返回客戶端。
從上面的分析中能夠看出 Raft Log 在副本之間達成共識、節點重啓以及節點故障恢復等環節都起到相當重要的做用,Raft Log 與業務數據共同存儲在同一個 RocksDB 中,在查詢高峯期必然會發生磁盤 I/O 資源爭搶,增長查詢等待時延,下降數據庫的總體性能。在 TPCC 場景下進行了 Raft Log 與業務數據寫入量的測試,測試場景以下:在物理機(CPU:6240,72 核, 內存:384G,系統硬盤:480G,數據盤:375G+SSD,硬盤:2T*7)上啓動單節點 ZNBase 服務,系統穩定後 init 6000 倉 TPCC 數據,觀察整個過程當中業務數據與 Raft Log 寫入量的大小,測試結果如表 3 所示。
表3 TPCC 初始化過程數據量統計
TPCC倉數 |
業務數據量 |
Raft Log數據量 |
Raft Log寫入量 |
6000 |
458GiB |
11GiB |
1.7TiB |
同時開展了 TPCC 場景下針對 Raft Log 各項操做數量的測試,測試場景以下:啓動 3 個節點的 ZNBase 集羣,系統穩定後 init 40 倉 TPCC 數據,觀察網關節點在整個初始化過程當中 Raft Log 各項操做數量的變化,測試結果如表 4 所示。將 RaftEntryCache 的大小從 16MiB 增長到 1GiB 後,相同場景下 Term 與 Entry 的查詢數量降低到 0。
表4 TPCC 初始化過程Raft Log各項操做統計
插入 |
刪除 |
查詢RaftLogSize |
查詢LastIndex |
查詢Term |
查詢Entry |
6.616M |
6.423M |
160K |
0 |
1.264K |
27 |
根據上述測試以及測試結果,可將 ZNBase 中 Raft Log 的操做特色總結以下:
- 在正常運行過程當中,插入和刪除操做是最多的且數量也很接近,說明 Raft Log 持久化的時間很短。查詢 RaftLogSize 也是較爲常規操做,其餘操做都是在特殊場景下觸發的,幾乎能夠忽略不計。
- 查詢 Term 與查詢 Entry 的操做次數取決於 RaftEntryCache 的大小,是由 ZNBase 內部實現機制決定的,Entry和Term的查詢通常先去Unstable中查找,查找不到再去RaftEntryCache中查找,仍是查找不到就到底層存儲中查找。一般狀況下RaftEntryCache大小設置合理的話能夠命中全部查找。
- ZNBase 實際運行過程當中產生的 Raft Log 比真正持久化的業務數據多不少(5~10倍),並且只要數據庫持續運行(即便沒有任何用戶查詢)就會源源不斷的產生 Raft Log。Raft Log 是用戶數據的載體,爲了保證數據完整性和一致性 Raft Log 必須持久化。
- ZNBase 中存儲 raft Log 的引擎面臨的真正挑戰是頻繁寫入、刪除以及短暫的存儲給系統帶來的性能損耗。
經過對 ZNBase 中 Raft Log 操做場景的詳細分析,總結 Raft Log 存儲引擎應該知足以下特徵:
- 儘量將待查詢數據保存在內存中,減小沒必要要磁盤I/O;
- 寫入的數據可以及時落盤,保證故障恢復後數據的完整性和一致性;
- 可以及時的清理被刪除數據或是延遲清理被刪除數據,減小沒必要要的資源佔用。
針對這個問題,業內分別有不一樣的解決方案。以 TiDB 爲例,目前 TiDB 的解決方案是:每一個 TiKV 實例中有兩個 RocksDB 實例,一個用於存儲 Raft 日誌(一般被稱爲 raftdb),另外一個用於存儲用戶數據以及 MVCC 信息(一般被稱爲 kvdb)。同時,TiDB 團隊還開發了基於 RocksDB 的高性能單機 Key-Value 存儲引擎 —— Titan。
而在 ZNBase 中直接使用 RocksDB 來存儲 Raft Log 是不合適的,不能很好地知足 Raft Log 的具體使用場景。RocksDB 內部採用 LSMTree 存儲數據,在 Raft Log 頻繁寫入快速刪除而且還會持續進行隨機查詢的場景下,形成嚴重讀放大和寫放大,不可以充分發揮出 RocksDB 的優點,也對系統總體性能形成不利影響。
ZNBase 研發團隊在詳細調研分析 LevelDB、RocksDB、Titan、BadgerDB、FlashKey 以及 Aerospike 的具體架構與特徵後,決定在 BadgerDB v2.0 的基礎上進行定製優化,做爲ZNBase 中 Raft Log 的專用存儲引擎。Raft Log 定製存儲實現瞭如下基本功能:
- Raft Log 的批量寫入與持久化;
- Raft Log 的順序刪除與延遲 GC;
- Raft Log 的迭代查詢,包括:RaftLogSize 查詢、Term 查詢、LastIndex 查詢以及 Entry 查詢;
- 相關 Metrics 的可視化;
- 多引擎場景下的用戶數據完整與一致保證策略。
ZNBase 中 Raft Log 定製存儲總體部署架構如圖 5 所示:
圖 5:Raft Log 定製存儲總體部署架構
部署時 BadgerDB 須要與 RocksDB 並列進行部署,即一個 Node 上部署相等數量的 RocksDB 實例和 BadgerDB 實例(由目前 ZNBase 中副本平衡策略所決定)。
查詢請求的大體處理流程是先將 Raft Log 寫入 BadgerDB,等待集羣過半數節點達成共識後,再將 Raft Log 應用到狀態機,即將 Raft Log 轉化成用戶數據寫入到 RocksDB,用戶寫入成功後再將 BadgerDB 中已應用的 Raft Log 刪除,同時將狀態數據更新到 RocksDB 中。
ZNBase 中 Raft Log 定製存儲的寫、讀流程如圖 6 所示:
圖 6:RaftLog 定製存儲寫、讀流程
因爲Raft Log 定製存儲採用 Key-Value 分離的策略,完整的 Key-Value 數據首先寫入 VLog 並落盤(若是是刪除操做則在落盤成功後由 IfDiscardStats 更新內存中維護各 VLog File 的刪除數據的統計信息,這些統計信息也會按期落盤,避免了 BadgerDB 中 SSTable 壓縮不及時致使統計信息滯後的問題),而後將 Key 以及元數據信息寫入 Memtable(skiplist)。將 Level 0 SSTable 放入內存,同時將須要頻繁查詢的信息(RaftLogSize、Term 等)記錄到元數據放入內存,加快隨機讀取的效率,減小沒必要要的 I/O。
已刪除 Key 的清理依賴 SSTable 的壓縮,對應 Value 的清理則須要 ZNBase 週期性調用接口,首先根據訪問 IfDiscardStats 在內存中維護的 VLog file 的 discardStats,對備選文件進行排序,順序遍歷進行採樣,若能夠進行 GC 則遍歷 VLog File 中的 Entry,同時到 Mentable (或 SSTable)查看最新元數據信息肯定是否須要進行重寫,須要重寫則寫入新的 VLog File,不須要則直接跳過,Raft Log 定製存儲中 GC 處理流程如圖 7 所示:
圖 7 RaftLog 定製存儲 VLog GC 流程
在將 Raft Log 進行獨立儲存後,必需要考慮多個存儲引擎數據保持一致性的策略。Raft Log 存在的目的是爲了保證業務數據的完整,所以在 Raft Log 與業務數據分開存儲後不追求二者徹底一致,而是 Raft Log 保持必定 的「冗餘」。具體策略是每一個 range 上的 Raft log 在被應用到狀態機以後不會馬上被刪除,會保留一段時間(例如:默認每一個 range 默認保留 50 個 Raft Entry),進而知足用戶數據完整性的各項要求。同時,若是發現所須要的 Raft Log 在本地存儲中找不到,則發送消息給 Leader 去請求,經過 MsgApp 或是 Snapshot 獲取所需的 Raft Log。
ZNBase 研發團隊完成上述優化後,開展了「迭代查詢性能對比測試」與「TPCC 場景性能測試」。在「迭代查詢性能對比測試」中,測試場景以下:啓動單機單節點 ZNBase 服務,系統穩定運行後 init 40 倉 TPCC 數據,記錄迭代器查詢 RaftLogSize 與 Term 的總耗時。Raft Log 分別存儲在 RocksDB、BadgerDB 以及 Raft Log 定製存儲中,其中 ValueThreshold 設置爲1KB,其餘設置均採用默認值。
表5 迭代查詢測試結果彙總
指標 |
RocksDB存儲Raft Log |
BadgerDB存儲Raft Log |
定製存儲Raft Log |
迭代查詢總延遲 |
463.6ms |
549.9ms |
48.5ms |
RocksDB讀放大 |
約10 |
— |
約7 |
從上述測試結果來看,在對 Badger 迭代器進行優化後,針對元數據的迭代查詢速率獲得大幅提高,相比 RocksDB 迭代查詢延遲下降了約 90%。
在「TPCC場景性能測試」中,測試場景以下:在物理機(CPU:6240 72核 內存:384G 系統硬盤:480G 數據盤:375G+SSD硬盤:2T*7)啓動單節點 ZNBase 服務,系統穩定後 init 6000 倉 TPCC 數據,觀察整個過程當中相應監控指標。
表6 TPCC 壓測監控數據彙總表
指標 |
RocksDB存儲Raftlog |
定製存儲Raftlog |
定製存儲Raftlog(暫停GC) |
||
Init |
Raft命令提交延遲 |
305ns |
275ns |
___ |
|
RocksDB讀放大 |
103 |
35 |
___ |
||
無負載 |
Raft命令提交延遲 |
360ns |
280ns |
___ |
|
壓測 |
Raft日誌提交延遲 |
50ms |
15ms |
15ms |
|
Raft命令提交延遲 |
240ns |
180ns |
180ns |
||
RocksDB讀放大 |
10 |
7 |
6 |
從上述測試結果來看,Raft Log 採用定製存儲後,raft Log 提交延遲降低約 60%,raft Log 應用延遲下降約 25%,RocksDB 讀放大下降約 60%(高負載),同時沒有明顯增長資源消耗。
綜上,利用鍵值分離的思想優化 LSM 樹,藉助索引模塊提高迭代查詢性能,使用統計前置的策略提高系統 GC 的效率,可以很好知足 ZNBase 中 Raft Log 在各類操做場景下的性能要求。
Raft 心跳與數據分離
在 etcd-raft 模塊的實現邏輯中,負責處理節點間心跳請求以及負責處理用戶、系統請求的Processor 共享同一個資源池,因爲儲存消息請求的隊列採用 FIFO 執行方式,這樣就可能會致使全部的資源被用戶、系統請求佔用從而致使節點間心跳請求被延遲等待處理,過長的延遲處理時間可能會致使集羣之間因爲沒法及時響應心跳請求出現節點失效狀況的發生。
所以,爲了保證集羣的穩定性,在此將 Raft Processor 的處理邏輯進行分離,負責處理節點間心跳請求的 Processor 將被分配必定份額的資源,這一部分資源只用於 Processor 處理節點間心跳消息。
分離 Raft Scheduler 爲 Tick、ReadyRequest(Ready 與 Request )兩類,兩類 Scheduler 各自擁有本身的資源池(Gouroutine)以及 RangeID 消息隊列,同時對 Raft Scheduler 處理消息流程進行分離,將處理 tick 請求的流程從以前的總流程中拆分,從而有效下降 Raft 心跳延遲,測試結果顯示優化後 Tick 處理的平均延遲降低 30%,具體以下圖所示:
Tick 處理的平均延遲(優化前)
Tick 處理的平均延遲(優化後)
總結
本系列文章介紹了 Raft 一致性算法在分佈式 NewSQL 數據庫 ZNBase 中發揮的重要做用,以及 ZNBase 項目團隊根據自身業務特性與需求,在落地 Raft 一致性協議的過程當中對其作出的五大優化改造,但願能對開發者進一步學習 Raft 一致性協議在分佈式數據庫場景下的實踐過程有所幫助。
關於 ZNBase 的更多詳情能夠查看:
官方代碼倉庫:https://gitee.com/ZNBase/zn-kvs
ZNBase 官網:http://www.znbase.com/
對相關技術或產品有任何問題歡迎提 issue 或在社區中留言討論。
同時歡迎更多對分佈式數據庫感興趣的開發者加入咱們的團隊!
聯繫郵箱:haojingyi@inspur.com
延伸閱讀