背景
在有贊早期的時候,當時只有 MySQL 做儲存,codis 做快取,隨著業務發展,某些業務資料用 MySQL 不太合適, 而 codis 由於當快取用, 並不適合做儲存系統, 因此, 急需一款高效能的 NoSQL 產品做補充。考慮到當時運維和開發人員都非常少, 我們需要一個能快速投入使用, 又不需要太多維護工作的開源產品。 當時對比了幾個開源產品, 最終選擇了 aerospike 作為我們的 KV 儲存方案。 事實證明, aerospike 作為一個成熟的商業化的開源產品承載了一個非常好的過渡時期 在很少量的開發和運維工作支援下, 一直穩定執行沒有什麼故障, 期間滿足了很多的業務需求, 也因此能抽出時間投入更多精力解決其他的中介軟體問題。
然而隨著有讚的快速發展, 單純的 aerospike 叢集慢慢開始無法滿足越來越多樣的業務需求。 雖然效能和穩定性依然很優秀, 但是由於其索引必須載入到記憶體, 對於越來越多的海量資料, 儲存成本會居高不下。 更多的業務需求也決定了我們將來需要更多的資料型別來支援業務的發展。 為了充分利用已有的 aerospike 叢集, 並考慮到當時的開源產品並無法滿足我們所有的業務需求, 因此我們需要構建一個能滿足有贊未來多年的 KV 儲存服務。
設計與架構
在設計這樣一個能滿足未來多年發展的底層 KV 服務, 我們需要考慮以下幾個方面:
- 需要儘量使用有大廠背書並且活躍的開源產品, 避免過多的工作量和太長的週期
- 避免完全依賴和耦合一個開源產品, 使得無法適應未來某個開源產品的不可控變化, 以及無法享受將來的技術迭代更新和升級
- 避免使用過於複雜的技術棧, 增加後期運維成本
- 由於業務需要, 我們需要有能力做方便的擴充套件和定製
- 未來的業務需求發展多樣, 單一產品無法滿足所有的需求, 可能需要整合多個開源產品來滿足複雜多樣的需求
- 允許 KV 服務後端的技術變化的同時, 對業務介面應該儘量穩定, 後繼升級不應該帶來過多的遷移成本。
基於以上幾點, 我們做了如下的架構設計:
為了整合和方便以後的擴充套件, 我們使用 proxy 遮蔽了具體的後端細節, 並且使用廣泛使用的 redis 協議作為我們對上層業務的介面, 一方面充分利用了開源的 redis 客戶端產品減少了開發工作量, 一方面減少了業務的接入學習成本, 一方面也能對已經使用的 aerospike 叢集和 codis 叢集做比較平滑的整合減少業務遷移工作量。 在此架構下, 我們未來也能通過在 proxy 層面做一些協議轉換工作就能很方便的利用未來的技術成果, 通過對接更多優秀的開源產品來進一步擴充套件我們的 KV 服務能力。
有了此架構後, 我們就可以在不改動現有 aerospike 叢集的基礎上, 來完善我們目前的KV服務短板, 因此我們基於幾個成熟的開源產品自研了 ZanKV 這個分散式 KV 儲存。 自研 ZanKV 有如下特點:
- 使用 Golang 語言開發, 利用其高效的開發效率, 也能減少後期維護難度, 方便後期定製。
- 使用大廠且成熟活躍的開源元件 etcd raft,RocksDB 等構建, 減少開發工作量
- CP 系統和現有 aerospike 的 AP 系統結合滿足不同的需求
- 提供更豐富的資料結構
- 支援更大的容量, 和 aerospike 結合在不損失效能需求的前提下大大減少儲存成本
自研 ZanKV 的整體架構圖如下所示:
整個叢集由 placedriver + 資料節點 datanode + etcd + rsync 組成。 各個節點的角色如下:
- PD node: 負責資料分佈和資料均衡, 協調叢集裡面所有的 zankv node 節點, 將後設資料寫入 etcd
- datanode: 負責儲存具體的資料
- etcd: 負責儲存後設資料, 後設資料包括資料分佈對映表以及其他用於協調的後設資料
- rsync: 用於傳輸 snapshot 備份檔案
下面我們來一一講述具體的內部實現細節。
實現內幕
DataNode 資料節點
首先, 我們需要一個單機的高效能高可靠的 KV 儲存引擎作為基石來保障後面的所有工作的展開, 同時我們可能還需要考慮可擴充套件性, 以便未來引入更好的底層儲存引擎。 在這一方面, 我們選擇了 RocksDB 作為起點, 考慮到它的介面和易用性, 而且是 FB 經過多年的時間打造的一個已經比較穩定的開源產品, 它同時也是眾多開源產品的共同選擇, 基本上不會有什麼問題, 也能及時響應開源社群的需求。
RocksDB 僅僅提供了簡單的 Get,Set,Delete 幾個有限的介面, 為了滿足 redis 協議裡面豐富的資料結構, 我們需要在 KV 基礎上封裝更加複雜的資料結構, 因此我們在 RocksDB 上層構建了一個資料對映層來滿足我們的需求, 資料對映也是參考了幾個優秀的開源產品(pika, ledis, tikv 等)。
完成單機儲存後, 為了保證資料的可靠性, 我們通過 raft 一致性協議來可靠的將資料複製到多臺機器上, 確保多臺機器副本資料的一致性。 選擇 raft 也是因為 etcd 已經使用 Golang 實現了一個比較完整且成熟的 raft library 供大家使用。但是 etcd 本身並不能支援海量資料的儲存, 因此為了能無限擴充套件儲存能力, 我們在 etcd raft 基礎上引入了 raft group 分割槽概念, 使得我們能夠通過不斷增加 raft 分割槽的方法來實現同時並行處理多個 raft 複製的能力。
最後, 我們通過 redis 協議來完成對外服務, 可以看到, 通過以上幾個分層 ZanKV DataNode 節點就能提供豐富的資料儲存服務能力了, 分層結構如下圖所示:
Namespace 與分割槽
為了支援海量資料, 單一分割槽的 raft 叢集是無法滿足無限擴充套件的目標的, 因此我們需要支援資料分割槽來完成 scale out。 業界常用的分割槽演算法可以分為兩類: hash 分割槽和 range 分割槽, 兩種分割槽演算法各有自己的適用場景, range 分割槽優勢是可以全域性有序, 但是需要實現動態的 merge 和 split 演算法, 實現複雜, 並且某些場景容易出現寫熱點。 hash 分割槽的優勢是實現簡單, 讀寫資料一般會比較均衡分散, 缺點是分割槽數一般在初始化時設定為固定值, 增減分割槽數需要遷移大量資料, 而且很難滿足全域性有序的查詢。 綜合考慮到開發成本和某些資料結構的順序需求, 我們目前採取字首 hash 分割槽演算法, 這樣可以保證字首相同的資料全域性有序滿足一部分業務需求的同時, 減少了開發成本保證系統能儘快上線。
另外, 考慮到有贊今後的業務會越來越多, 未來需要能方便的隔離不同業務, 也方便不斷的加入新的特性同時能平滑升級, 我們引入了 namespace 的概念。 namespace 可以動態的新增到叢集, 並且 namespace 之間的配置和資料完全隔離, 包括副本數, 分割槽數, 分割槽策略等配置都可以不同。 並且 namespace 可以支援指定一些節點放置策略, 保證 namespace 和某些特性的節點繫結(目前多機房方案通過機架感知方式實現副本至少分佈在一個以上機房)。 有了 namespace, 我們就可以把一些核心的業務和非核心的業務隔離到不同的 namespace 裡面, 也可以將不相容的新特性加到新的 namespace 給新業務用, 而不會影響老的業務, 從而實現平滑升級。
PlaceDriver Node 全域性管理節點
可以看到, 一個大的叢集會有很多 namespace, 每個 namespace 又有很多分割槽數, 每個分割槽又需要多個副本, 這麼多資料, 必須得有一個節點從全域性的視角去優化排程整個叢集的資料來保證叢集的穩定和資料節點的負載均衡。 placedriver 節點需要負責指定的資料分割槽的節點分佈,還會在某個資料節點異常時, 自動重新分配資料分佈。 這裡我們使用分離的無狀態 PD 節點來實現, 這樣帶來的好處是可以獨立升級方便運維, 也可以橫向擴充套件支援大量的後設資料查詢服務, 所有的後設資料儲存在 etcd 叢集上。 多個 placedriver 通過 etcd 選舉來產生一個 master 進行資料節點的分配和遷移任務。 每個 placedriver 節點會 watch 叢集的節點變化來感知整個叢集的資料節點變化。
目前資料分割槽演算法是通過 hash 分片實現的, 對於 hash 分割槽來說, 所有的 key 會均衡的對映到設定的初始分割槽數上, 一般來說分割槽數都會是 DataNode 機器節點數的幾倍, 方便未來擴容。 因此 PD 需要選擇一個演算法將分割槽分配給對應的 DataNode, 有些系統可能會使用一致性 hash 的方式去把分割槽按照環形排列分攤到節點上, 但是一致性 hash 會導致資料節點變化時負載不均衡, 也不夠靈活。 在 ZanKV 裡我們選擇維護對映表的方式來建立分割槽和節點的關係, 對映表會根據一定的演算法並配合靈活的策略生成。
從上圖來看, 整個讀寫流程: 客戶端進行讀寫訪問時, 對主 key 做 hash 得到一個整數值, 然後對分割槽總數取模, 得到一個分割槽 id, 再根據分割槽 id, 查詢分割槽 id 和資料節點對映表, 得到對應資料節點, 接著客戶端將命令傳送給這個資料節點, 資料節點收到命令後, 根據分割槽演算法做驗證, 並在資料節點內部傳送給本地擁有指定分割槽 id 的資料分割槽的 leader 來處理, 如果本地沒有對應的分割槽 id 的 leader, 寫操作會在 raft 內部轉發到 leader 節點, 讀操作會直接返回錯誤(可能在做 leader 切換)。 客戶端會根據錯誤資訊決定是否需要重新整理本地 leader 資訊快取再進行重試。
可以看到讀寫壓力都在分割槽的 leader 上面, 因此我們需要儘可能的確保每個節點上擁有均衡數量的分割槽 leader, 同時還要儘可能減少增減節點時發生的資料遷移。 在資料節點發生變化時, 需要動態的修改分割槽到資料節點的對映表, 動態調整對映表的過程就是資料平衡的過程。 資料節點變化時會觸發 etcd 的 watch 事件, placedriver 會實時監測資料節點變化, 來判斷是否需要做資料平衡。 為了避免影響線上服務, 可以設定資料平衡的允許時間區間。 為了避免頻繁發生資料遷移, 節點發生變化後, 會根據緊急情況, 判斷資料平衡的必要性, 特別是在資料節點升級過程中, 可以避免不必要的資料遷移。 考慮以下幾種情況:
- 新增節點: 平衡優先順序最低, 僅在允許的時間區間並且沒有異常節點時嘗試遷移資料到新節點
- 少於半數節點異常: 等待一段時間後, 才會嘗試將異常節點的副本資料遷移到其他節點, 避免節點短暫異常時遷移資料。
- 叢集超過半數節點異常: 很可能發生了網路分割槽, 此時不會進行自動遷移, 如果確認不是網路分割槽, 可以手動強制調整叢集穩定節點數觸發遷移。
- 可用於分配的節點數不足: 假如副本數配置是 3, 但是可用節點少於 3 個, 則不會發生資料遷移
穩定叢集節點數預設只會增加, 每次發現新的資料節點, 就自動增加, 節點異常不會自動減少。 如果穩定叢集節點數需要減少, 則需要呼叫縮容API進行設定, 這樣可以避免網路分割槽時不必要的資料遷移。 當叢集正常節點數小於等於穩定節點數一半時, 自動資料遷移將不會發生, 除非人工介入。
資料過期的實現
資料過期作為 redis 的功能特性之一,也是 ZanKV 需要重點考慮和設計支援的。與 redis 作為記憶體儲存不同,ZanKV 作為強一致性的持久化儲存,面臨著需要處理大量過期的落盤資料的場景,在整體設計上,存在著諸多的權衡和考慮。
首先,ZanKV 並不支援毫秒級的資料過期(對應 redis 的 pexpire 命令),這是因為在實際的業務場景中很少存在毫秒級資料過期的需求,且在實際的生產網路環境中網路請求的 RTT 也在毫秒級別,精確至毫秒級的過期對系統壓力過大且實際意義並不高。
在秒級資料過期上, ZanKV 支援了兩種資料過期策略,分別用以不同的業務場景。使用者可以根據自己的需求,針對不同的 namespace 配置不同的過期策略。下面將詳細闡述兩種不同過期策略的設計和權衡。
一致性資料過期
最初設計資料過期功能時,預期的設計目標為:保持資料一致性的情況下完全相容 redis 資料過期的語義。一致性資料過期,就是為了滿足該設計目標所做的設計方案。
正如上文中提到的,ZanKV 目前是使用 rocksdb 作為儲存引擎的落盤儲存系統,無論是何種過期策略或者實現,都需要將資料的過期資訊通過一定方式的編碼落盤到儲存中。在一致性過期的策略下,資料的過期資訊編碼方式如下:
如上圖所示,在存在過期時間的情況下,任何一個 key 都需要額外儲存兩個資訊:
- key 對應的資料過期時間。我們稱之為表1
- 使用過期時間的 unix 時間戳為字首編碼的 key 表。我們稱之為表2
rocksdb 使用 LSM 作為底層資料儲存結構,掃描按照過期時間順序儲存的表2速度是比較快的。在上述資料儲存結構的基礎上,ZanKV 通過如下方式實現一致性資料過期: 在每個 raft group 中,由 leader 進行過期資料掃描(即掃描表2),每次掃描出至當前時間點需要過期的資料資訊, 通過 raft 協議發起刪除請求,在刪除請求處理過程中將儲存的資料和過期後設資料資訊(表1和表2的資料)一併刪除。在一致性過期的策略下,所有的資料操作都通過 raft 協議進行,保證了資料的一致性。同時,所有 redis 過期的命令都得到了很好的支援,使用者可以方便的獲取和修改 key 的生存時間(分別對應 redis 的 TTL 和 expire 命令),或者對 key 進行持久化(對應 redis 的 persist 指令)。但是,該方案存在以下兩個明顯的缺陷:
在大量資料過期的情況下,leader 節點會產生大量的 raft 協議的資料刪除請求,造成叢集網路壓力。同時,資料過期刪除操作在 raft 協議中處理,會阻塞寫入請求,降低叢集的吞吐量,造成寫入效能抖動。
目前,我們正在計劃針對這個缺陷進行優化。具體思路是在過期資料掃描由 raft group 的 leader 在後臺進行,掃描後僅通過 raft 協議同步需要過期至的時間戳,各個叢集節點在 raft 請求處理中刪除該時間戳之前的所有過期資料。圖示如下:
該策略能有效的減少大量資料過期情況下的 raft 請求,降低網路流量和 raft 請求處理壓力。有興趣的讀者可以在 ZanKV 的開源專案上幫助我們進行相應的探索和實現。
另外一個缺點是任何資料的刪除和寫入,需要同步操作表1和表2的資料,寫放大明顯。因此,該方案僅適用於過期的資料量不大的情況,對大量資料過期的場景效能不夠好。所以,結合實際的業務使用場景,又設計了非一致性本地刪除的資料過期策略。
非一致性本地刪除
該策略的出發點在於,絕大多數的業務僅僅關注資料保留的時長,如業務要求相關的資料保留 3 個月或者半年,而並不關注具體的資料清理時間,也不會在寫入之後多次調整和修改資料的過期時間。在這種業務場景的考慮下,設計了非一致性本地刪除的資料過期策略。
與一致性資料過期不同的是,在該策略下,不再儲存表1的資料,而僅僅保留表2的資料,如下圖所示:
同時,資料過期刪除不再通過 raft 協議發起,而是叢集中各個節點每隔 5 分鐘掃描一次表2中的資料,並對過期的資料直接進行本地刪除。
因為沒有表2的資料,所以在該策略下,使用者無法通過 ttl 指令獲取到 key 對應的過期時間,也無法在設定過期時間後重新設定或者刪除 key 的過期時間。但是,這也有效的減少了寫放大,提高了寫入效能。
同時,因為刪除操作都由本地後臺進行,消除了同步資料過期帶來的叢集寫入效能抖動和叢集網路流量壓力。但是,這也犧牲了部分資料一致性。與此同時,每隔 5 分鐘進行一次的掃描也無法保證資料刪除的實時性。
總而言之,非一致性本地刪除是一種權衡後的資料過期策略,適用於絕大多數的業務需求,提高了叢集的穩定和吞吐量,但是犧牲了一部分的資料一致性,同時也造成部分指令的語義與 redis 不一致。
使用者可以根據自己的需求和業務場景,在不同的 namespace 中配置不同的資料過期策略。
字首定期清理
雖然非一致性刪除通過優化, 已經大幅減少了服務端壓力, 但是對於資料量特別大的特殊場景, 我們還可以進一步減少服務端壓力。 此類業務場景一般是資料都有時間特性, 因此 key 本身會有時間戳資訊 (比如日誌監控這種資料), 這種情況下, 我們提供了字首清理的介面, 可以一次性批量刪除指定時間段的資料, 進一步避免服務端掃描過期資料逐個刪除的壓力。
跨機房方案
ZanKV 目前支援兩種跨機房部署模式,分別適用於不同的場景。
單個跨多機房叢集模式
此模式, 部署一個大叢集, 並且都是同城機房, 延遲較小, 一般是 3 機房模式。 部署此模式, 需要保證每個副本都在不同機房均勻分佈, 從而可以容忍單機房當機後, 不影響資料的讀寫服務, 並且保證資料的一致性。
部署時, 需要在配置檔案中指定當前機房的資訊, 用於資料分佈時感知機房資訊。不同機房的資料節點, 使用不同機房資訊, 這樣 placedriver 進行副本配置時, 會保證每個分割槽的幾個副本都均勻分佈在不同的機房中。
跨機房的叢集, 通過 raft 來完成各個機房副本的同步, 發生單機房故障時, 由於另外 2 個機房擁有超過一半的副本, 因此 raft 的讀寫操作可以不受影響, 且資料保證一致。 等待故障機房恢復後, raft 自動完成故障期間的資料同步, 使得故障機房資料在恢復後能保持同步。此模式在故障發生和恢復時都無需任何人工介入, 在多機房情況下保證單機房故障的可用性的同時,資料一致性也得到保證。 此方式由於有跨機房同步, 延遲會有少量影響。
多個機房內叢集間同步模式
如果是異地機房, 或者機房網路延遲較高, 使用跨機房單叢集部署方式, 可能會帶來較高的同步延遲, 使得讀寫的延遲都大大增加。 為了優化延遲問題, 可以使用異地機房叢集間同步模式。 由於異地機房是後臺非同步同步的, 異地機房不影響本地機房的延遲, 但同時引入了資料同步滯後的問題, 在故障時可能會發生資料不一致的情況。
此模式的部署方式稍微複雜一些, 基本原理是通過在異地機房增加一個 raft learner 節點非同步的拉取 raft log 然後重放到異地機房叢集。 由於每個分割槽都是一個獨立的 raft group, 因此分割槽內是序列回放, 各個分割槽間是並行回放 raft log。 異地同步機房預設是隻讀的, 如果主機房發生故障需要切換時, 可能發生部分資料未同步, 需要在故障恢復後根據 raft log 進行人工修復。 此方式缺點是運維麻煩, 且故障時需要修資料, 好處是減少了正常情況下的讀寫延遲。
效能調優經驗
ZanKV 在初期線上執行時, 積累了一些調優經驗, 主要是 RocksDB 引數的調優和作業系統的引數調優, 大部分調優都是參考官方的文件, 這裡重點說明以下幾個引數:
- block cache: 由於 block cache 裡面都是解壓後的 block, 和 os 自帶檔案 cache 功能有所區別, 因此需要平衡兩者之間的比例(一些壓測經驗建議10%~30%之間)。 另外分割槽數很多, 因此需要配置不同 RocksDB 例項共享來避免過多的記憶體佔用。
write buffer: 這個無法在多個 rocksdb 例項之間共享, 因此需要避免太多, 同時又不能因為太小而傳送寫入 stall。 另外需要和其他幾個引數配合保證:
level0_file_num_compaction_trigger * write_buffer_size * min_write_buffer_number_tomerge = max_bytes_for_level_base
來減少寫放大。 - 後臺 IO 限速: 這個主要是使用 rocksdb 自帶的後臺 IO 限速來避免後臺 compaction 帶來的讀寫毛刺。
- 迭代器優化: 這個主要是避免 rocksdb 的標記刪除特性影響資料迭代效能, 在迭代器上使用
rocksdb::ReadOptions::iterate_upper_bound
引數來提前結束迭代, 詳細可以參考這篇文章: https://www。cockroachlabs。com/blog/adventures-performance-debugging/ - 禁用透明大頁 THP: 作業系統的透明大頁功能在儲存系統這種訪問模式下, 基本都是建議關閉的, 不然讀寫毛刺現象會比較嚴重。
# echo never > /sys/kernel/mm/redhat_transparent_hugepage/enabled
# echo never > /sys/kernel/mm/redhat_transparent_hugepage/defrag
複製程式碼
Roadmap
雖然 ZanKV 目前已經在有贊內部使用了一段時間, 但是仍然有很多需要完善和改進的地方, 目前還有以下幾個規劃的功能正在設計和開發:
二級索引
主要是在 HASH 這種資料型別時實現如下類似功能, 方便業務通過其他 field 欄位查詢資料
IDX。FROM test_hash_table WHERE “age>24 AND age<31"
複製程式碼
優化 raft log
目前 etcd 的 raft 實現會把沒有 snapshot 的 raft log 儲存在 memory table 裡面, 在 ZanKV 這種多 raft group 模式下會佔用太多記憶體, 需要優化使得大部分 raft log 儲存在磁碟, 記憶體只需要保留最近少量的 log 用於 follower 和 leader 之間的互動。 選擇 raft log 磁碟儲存需要避免雙層 WAL 降低寫入效能。
多索引過濾
二級索引只能滿足簡單的單 field 查詢, 如果需要高效的使用多個欄位同時過濾, 來滿足更豐富的多維查詢能力, 則需要引入多索引過濾。 此功能可以滿足一大類不需要全文搜尋以及精確排序需求的資料搜尋場景。 業界已經有支援 range 查詢的壓縮點陣圖來實現的開源產品, 在索引過濾這種特殊場景下, 效能會比倒排高出不少。
資料實時匯出和 OLAP 優化
主要是利用 raft learner 的特點, 實時的把 raft log 匯出到其他系統。 進一步做針對性的場景, 比如轉換成列存做 OLAP 場景等。
以上特性都有巨大的開發工作量, 目前人力有限, 歡迎有志之士加入我們或者參與我們的開源專案, 希望能充分利用開源社群的力量使得我們的產品快速迭代, 提供更穩定, 更豐富的功能。
總結
限於篇幅, 以上只能大概講述 ZanKV 幾個重要的技術思路, 還有很多實現細節無法一一講述清晰, 專案已經開源: github.com/youzan/ZanR… , 歡迎大家通過閱讀原始碼來進一步瞭解細節, 並貢獻原始碼來共同構建一個更好的開源產品, 也敬請期待後繼更佳豐富的功能特性實現細節介紹。