Abase2:位元組跳動新一代高可用 NoSQL 資料庫

語言: CN / TW / HK

動手點關注 乾貨不迷路  :point_up_2:

背景

自 2016 年以來,為了支撐線上推薦的儲存需求而誕生的——位元組跳動自研高可用 KV 儲存 Abase,逐步發展成支撐包括推薦、廣告、搜尋、抖音、西瓜、飛書、遊戲等公司內幾乎所有業務線的 90% 以上的 KV 儲存場景 ,已成為公司內使用最廣泛的線上儲存系統之一。

Abase 作為一款由位元組跳動完全自研的高效能、大容量、高可用的 KV 儲存系統,支撐了業務不斷快速增長的需求。但隨著公司的持續發展,業務數量、規模持續快速增長,我們業務對系統也提出了更高的要求,比如:

  • 極致高可用:相對於一致性,資訊流等業務對可用性要求更高,希望消除宕機選主造成的短時間不可用,和慢節點問題;

  • 全球部署:無論是邊緣機房還是不同地域的機房,同一個 Abase2 叢集的使用者都可以就近訪問,獲取極快的響應延遲;

  • CRDT 支援:確保多寫架構下的資料能自動解決衝突問題,達成最終一致;

  • 更低成本:通過資源池化解決不同使用者資源使用不均衡,造成資源利用率不足問題,降低成本;

  • 極致高效能:相同的資源使用下,要求提供儘可能高的寫/讀吞吐,和較低的訪問延遲。適配 IO 裝置和 CPU 效能發展速度不匹配趨勢,極致高效對 CPU 的使用;

  • 相容 Redis 協議:為了讓 Redis 使用者可以無障礙的接入 Abase,以滿足更大容量的儲存需求,我們需要完全相容 Redis 協議。

在此背景下,Abase 團隊於 2019 年年底開始孵化第二代 Abase 系統。結合業界的先進架構方案及公司內部實踐過程中的積累和思考,團隊推出了資源池化,支援多租戶、多寫、CRDT 的軟硬體一體化設計的新一代 NoSQL 資料庫 —— Abase2。

架構概覽

資料模型

Abase 支援 Redis 的幾種主要資料結構與相應介面:

  • String:支援 Set、Append、IncrBy,是位元組線上使用最為廣泛的資料模型;

  • Hash/Set:使用率僅次於 String,在部分更新/查詢的結構化資料存取場景中廣泛使用;

  • ZSet:廣泛應用於榜單拉鍊等線上業務場景,區別於直接使用 String+Scan 方式進行包裝,Abase 在 ZSet 結構中做了大量優化,從設計上避免了大量 ZIncrBy 造成的讀效能退化;

  • List/TTLQueue:佇列介面語義使業務在對應場景下非常方便地接入。

架構檢視

圖 1:Abase2 整體架構圖

Abase2 的整體架構主要如上圖所示,在使用者、管控面、資料面三種視角下主要包含 5 組核心模組。

RootServer

線上一個叢集的規模大約為數千臺機器,為管理各個叢集,我們研發了 RootServer 這個輕量級元件。顧名思義,RootServer 擁有全叢集視角,它可以更好地協調各個叢集之間的資源配比,支援租戶在不同叢集之間的資料遷移,提供容災檢視併合理控制爆炸半徑。

MetaServer

Abase2 是多租戶中心化架構,而 MetaServer 則是整個架構的總管理員,它主要包括以下核心功能:

  • 管理元資訊的邏輯檢視:包括 Namespace,Table,Partition,Replica 等狀態和配置資訊以及之間的關係;

  • 管理元資訊的物理檢視:包括 IDC,Pod,Rack,DataNode,Disk,Core 的分佈和 Replica 的位置關係;

  • 多租戶 QoS 總控,在異構機器的場景下根據各個租戶與機器的負載進行副本 Balance 排程;

  • 故障檢測,節點的生命管理,資料可靠性跟蹤,在此基礎上進行節點的下線和資料修復。

圖 2: 叢集物理檢視

圖 3: 叢集邏輯檢視

DataNode

DataNode 是資料儲存節點。部署時,可以每臺機器或者每塊盤部署一個 DataNode,為方便隔離磁碟故障,線上實際採用每塊盤部署一個 DataNode 的方式。

DataNode 的最小資源單位是 CPU Core(後簡稱 Core),每個 Core 都擁有一個獨立的 Busy Polling 協程框架,多個 Core 共享一塊盤的空間與 IO 資源。

圖 4:DataNode 資源視角

一個 Core 包含多個 Replica,每個 Replica 的請求只會在一個 Core 上 Run-to-Complete,可以有效地避免傳統多執行緒模式中上下文切換帶來的效能損耗。

Replica 核心模組如下圖所示,整個 Partition 為 3 層結構:

  • 資料模型層:如上文提到的 String, Hash 等 Redis 生態中的各類資料結構介面。

  • 一致性協議層:在多主架構下,多點寫入勢必會造成資料不一致,Anti-Entropy 一方面會及時合併衝突,另一方面將協調衝突合併後的資料下刷至引擎持久化層並協調 WAL GC。

  • 資料引擎層:資料引擎層首先有一層輕量級資料暫存層(或稱 Conflict Resolver)用於儲存未達成一致的資料;下層為資料資料引擎持久化層,為滿足不同使用者多樣性需求,Abase2 引設計上採用引擎可插拔模式。對於有順序要求的使用者可以採用 RocksDB,TerarkDB 這類 LSM 引擎,對於無順序要求點查類使用者採用延遲更穩定的 LSH 引擎。

圖 5: Replica 分層架構

Client/Proxy/SDK

Client 模組是使用者側視角下的核心元件,向上提供各類資料結構的介面,向下一方面通過 MetaSync 與 MetaServer 節點通訊獲取租戶 Partition 的路由資訊,另一方面通過路由資訊與儲存節點 DataNode 進行資料互動。此外,為了進一步提高服務質量,我們在 Client 的 IO 鏈路上集成了重試、Backup Request、熱 Key 承載、流控、鑑權等重要 QoS 功能。

結合位元組各類程式語言生態豐富的現狀,團隊基於 Client 封裝了 Proxy 元件,對外提供 Redis 協議(RESP2)與 Thrift 協議,使用者可根據自身偏好選擇接入方式。此外,為了滿足對延遲更敏感的重度使用者,我們也提供了重型 SDK 來跳過 Proxy 層,它是 Client 的簡單封裝。

DTS (Data Transfer Service)

DTS 主導了 Abase 生態系統的發展,在一二代透明遷移、備份回滾、Dump、訂閱等諸多業務場景中起到了非常核心的作用,由於篇幅限制,本文不做更多的詳細設計敘述。

關鍵技術

一致性策略

我們知道,分散式系統難以同時滿足 強一致性、高可用性和正確處理網路故障 (CAP )這三種特性,因此係統設計者們不得不做出權衡,以犧牲某些特性來滿足系統主要需求和目標。比如大多數資料庫系統都採用犧牲極端情況下系統可用性的方式來滿足資料更高的一致性和可靠性需求。

Abase2 目前支援兩種同步協議來支援不同一致性的需求:

多主模式(Multi-Leader):相對於資料強一致性,Abase 的大多數使用者們則對系統可用性有著更高的需求,Abase2 主要通過多主技術實現系統高可用目標。在多主模式下,分片的任一副本都可以接受和處理讀寫請求,以確保分片只要有任一副本存活,即可對外提供服務。同時,為了避免多主架構按序同步帶來的一些可用性降低問題, 我們結合了無主架構的優勢,在網路分割槽、程序重啟等異常恢復後,併發同步最新資料和老資料。此外,對於既要求寫成功的資料要立即讀到,又不能容忍主從切換帶來的秒級別不可用的使用者,我們提供無更新場景下的寫後讀一致性給使用者進行選擇。實現方式是通過 Client 配置 Quorum 讀寫(W+R>N),通常的配置為 W=3,R=3,N=5。

單主模式(Leader&Followers):Abase2 支援與一代系統一樣的主從模式,並且,半同步適合於對一致性有高要求,但可以忍受一定程度上可用性降低的使用場景。與 MySQL 半同步類似。系統將選擇唯一主副本,來處理使用者的讀寫請求,保證至少 2 個副本完成同步後,才會通知使用者寫入成功。以保證讀寫請求的強一致性,並在單節點故障後,新的主節點仍然有全量資料。

未來也會提供更多的一致性選擇,來滿足使用者的不同需求。

讀寫流程

下面我們將詳細介紹在多主模型下 Abase 的資料讀寫流程以及資料最終一致的實現方案。

對於讀請求,Proxy 首先根據元資訊計算出請求對應的分片,再根據地理位置等資訊將請求轉發到該分片某一個合適的 Replica 上,Replica Coordinator 根據一致性策略查詢本地或遠端儲存引擎後將結果按照衝突解決規則合併後返回給 Proxy,Proxy 根據對應協議將結果組裝後返回給使用者。

對於寫請求,Proxy 將請求轉發到合適的 Replica 上,Replica Coordinator 將寫請求序列化後併發地傳送至所有 Replica,並根據一致性策略決定請求成功所需要的最少成功響應數 W。可用性與 W 成反比,W=1 時可獲得最大的寫可用性。

如圖 6 所示,假設分片副本數 N=3,當用戶寫請求到達 Proxy 後,Proxy 根據地理位置等資訊將請求轉發到分片的某一個副本(Replica B),Replica B 的 Coordinator 負責將請求寫入到本地,且併發地將請求 forward 到其他 Replica,當收到成功寫入的響應數大於等於使用者配置的 W 時(允許不包括本地副本),即可認為請求成功,若在一定時間內(請求超時時間)未滿足上述條件,則認為請求失敗。

在單個副本內,資料首先寫入到 WAL 內,保證資料的持久化,然後提交到引擎資料暫存層。引擎在達到一定條件後將快取資料下刷到持久化儲存,然後 WAL 對應資料即可被 GC。

一個 Core 內所有 Replica 共享一個 WAL,可以儘量合併不同 Replica 的碎片化提交,減少 IO 次數。引擎層則由 Replica 獨佔,方便根據不同業務場景對引擎層做精細化配置,同時也便於資料查詢、GC 等操作。

圖 6: 寫流程示意圖

使用者可以根據一致性、可用性、可靠性與效能綜合考慮 NWR 的配比,W(R)為 1 時可獲得最大的寫(讀)可用性與效能;調大 W/R 則可在資料一致性和可靠性方面取得更好的表現。

Anti-Entropy

由上述寫流程可以看到,當 W<N 時,部分副本寫入成功即可認為請求成功,而由於網路抖動等原因資料可能並未在所有副本上達成一致狀態,我們通過 Anti-Entropy 機制非同步地完成資料一致性修復。

為了便於檢測分片各個 Replica 間的資料差異,我們在 WAL 之上又構建了一層 ReplicaLog(索引),每個 Replica 都對應一個由自己負責的 ReplicaLog,並會在其他 Replica 上建立該 ReplicaLog 的副本,不同 Replica 接收的寫請求將寫到對應的 ReplicaLog 內,並分配唯一嚴格遞增的 LogID,我們稱為 Seqno。

每個 Replica 的後臺 Anti-Entropy 任務將定期檢查自身與其他 Replica 的 ReplicaLog 的進度,以確定自身是否已經擁有全部資料。流程如下:

  1. 獲取自身 ReplicaLog 進度向量[Seqno1, Seqno2..., SeqnoN];

  2. 與其他 Replica 通訊,獲取其他 Replica 的進度向量;

  3. 比對自身與其他 Replica 進度向量,是否有 ReplicaLog 落後於其他 Replica,如果是則進入第 4 步,否則進入第 5 步;

  4. 向其他 Replica 發起資料同步請求,從其他 Replica 拉取缺少的 ReplicaLog 資料,並提交到引擎層

  5. 若已就某 ReplicaLog 在 SeqnoX 之前已達成一致,回收 SeqnoX 之前的 ReplicaLog 資料。

另外,正常情況下副本間資料能做到秒級達成一致,因此 ReplicaLog 通常只需要構建在記憶體中,消耗極少的記憶體,即可達到資料一致的目的。在極端情況下(如網路分割槽),ReplicaLog 將被 dump 到持久化儲存以避免 ReplicaLog 佔用過多記憶體。

與 DynamoDB、Cassandra 等通過掃描引擎層構建 merkle tree 來完成一致性檢測相比,Abase 通過額外消耗少量記憶體的方式,能更高效的完成資料一致性檢測和修復。

衝突解決

多點寫入帶來可用性提升的同時,也帶來一個問題,相同資料在不同 Replica 上的寫入可能產生衝突,檢測並解決衝突是多寫系統必須要處理的問題。

為了解決衝突,我們將所有寫入資料版本化,為每次寫入的資料分配一個唯一可比較的版本號,形成一個不可變的資料版本。

Abase 基於 Hybrid Logical Clock 演算法生成全域性唯一時間戳,稱為 HLC timestamp,並使用 HLC timestamp 作為資料的版本號,使得不同版本與時間相關聯,且可比較。

通過業務調研,我們發現在發生資料衝突時,大部分業務希望保留最新寫入的資料,部分業務自身也無法判斷哪個版本資料更有意義(複雜的上下游關係),反而保留最新版本資料更簡潔也更有意義,因此 Abase 決定採用 Last Write Wins 策略來解決寫入衝突。

在引擎層面,最初我們採用 RocksDB 直接儲存多版本資料,將 key 與版本號一起編碼,使得相同 key 的版本連續儲存在一起;查詢時通過 seek 方式找到最新版本返回;同時通過後臺版本合併任務和 compaction filter 將過期版本回收。

在實踐中我們發現,上述方式存在幾個問題:

  1. 多版本資料通常能在短時間內(秒級)決定哪個版本最終有效,而直接將所有版本寫入 RocksDB,使得即使已經確定了最終有效資料,也無法及時回收無效的版本資料;同時,使用 seek 查詢相比 get 消耗更高,效能更低。

  2. 需要後臺任務掃描所有版本資料完成無效資料的回收,消耗額外的 CPU 和 IO 資源。

  3. 引擎層與多版本耦合,使得引擎層無法方便地做到外掛化,根據業務場景做效能優化。

為了解決以上問題,我們把引擎層拆分為資料暫存層與通用引擎層,資料多版本將在暫存層完成衝突解決和合並,只將最終結果寫入到底層通用引擎層中。

得益於 Multi-Leader 與 Anti-Entropy 機制,在正常情況下,多版本資料能在很小的時間視窗內決定最終有效資料,因此資料暫存層通常只需要將這個時間視窗內的資料快取在記憶體中即可。Abase 基於 SkipList 作為資料暫存層的資料結構(實踐中直接使用 RocksDB memtable),週期性地將衝突資料合併後寫入底層。

圖 7:資料暫存層基本結構示意圖

CRDTs

對於冪等類命令如 Set,LWW 能簡單有效地解決資料衝突問題,但 Redis String 還需要考慮 Append, Incrby 等非冪等操作的相容,並且,其它例如 Hash, ZSet 等資料結構則更為複雜。於是,我們引入了 CRDT 支援,實現了 Redis 常見資料結構的 CRDT,包括 String/Hash/Zset/List,並且保持語義完全相容 Redis。

以 IncrBy 為例,由於 IncrBy 與 Set 會產生衝突,我們發現實際上難以通過 State-based 的 CRDT 來解決問題, 故而我們選用 Operation-based 方案,並結合定期合併 Operation 來滿足效能要求。

為了完全相容 Redis 語義,我們的做法如下:

  1. 給所有 Operation 分配全球唯一的 HLC timestamp,作為操作的全排序依據;

  2. 記錄寫入的 Operation 日誌(上文 ReplicaLog), 每個 key 的最終值等於這些 Operation 日誌按照時間戳排序後合併的結果。副本間只要 Operation 日誌達成一致,最終狀態必然完全一致;

  3. 為了防止 Operation 日誌過多引發的空間和效能問題,我們定期做 Checkpoint,將達成一致的時間戳之前的操作合併成單一結果;

  4. 為了避免每次查詢都需要合併 Operation 日誌帶來的效能開銷,我們結合記憶體快取,設計了高效的查詢機制,將最終結果快取在 Cache 中,保證查詢過程不需要訪問這些 Operation 日誌。

圖 8:Operation-based CRDT 資料合併示意圖

完整 CRDT 的實現演算法和工程優化細節我們將在後續 Abase2 介紹文章中詳細說明。

全球部署

結合多主模式,系統可以天然支援全球部署,同時,為了避免網狀同步造成的頻寬浪費,Abase2 在每個地域都可以設定一個 Main Replicator,由它來主導和其它地域間的資料同步。典型的應用場景有多中心資料同步場景以及邊緣計算場景。

圖 9: 多資料中心部署

圖 10: 邊緣-中心機房部署

多租戶 QoS

為了實現資源池化,避免不同租戶間資源獨佔造成浪費,Abase2 採用大叢集多租戶的部署模式。同時,為了兼顧不同場景優先順序的資源隔離需求,我們在叢集內部劃分了 3 類資源池,按照不同服務等級進行部署。如圖:

圖 11:資源池分類示意圖

在資源池內的多租戶混部要解決兩個關鍵問題:

1、DataNode 的 QoS 保障

DataNode 將請求進行分類量化:

  • 使用者的請求主要歸為 3 類:讀、寫、Scan,三類請求優先順序各不相同;

  • 不同資料大小的請求會被分別計算其成本,例如一個讀請求的資料量每 4KB 會被歸一化成 1 個讀取單位。

所有的使用者請求都會通過這兩個條件計算出 Normalized Request Cost(NRC)。基於 NRC 我們構建了 Quota 限制加 WFQ 雙層結構的服務質量控制模組。

圖 12:IO 路徑上的 QoS 示意圖

如上圖所示,使用者請求在抵達租戶服務層之前需要邁過兩道關卡:

  1. Tenant Quota Gate: 如果請求 NRC 已經超過了租戶對應的配額,DataNode 將會拒絕該請求,保證 DataNode 不會被打垮;

  2. 分級 Weight Fair Queue: 根據請求型別分發至各個 WFQ,保證各個租戶的請求儘可能地被合理排程。

圖 13(1):正常狀態延遲

圖 13(2):突增流量湧入後延遲

如圖 13(2)所示,部分租戶突增流量湧入後(藍綠線)並未對其它租戶造成較大影響。流量突增的租戶請求延遲受到了一定影響,並且出現請求被 Tenant Quota Gate 攔截的現象,而其它租戶的請求排程卻基本不受影響,延遲基本保持穩定。

2、多租戶的負載均衡

負載均衡是所有分散式系統都需要的重要能力之一。資源負載實際上有多個維度, 包括磁碟空間、IO 負載, CPU 負載等。我們希望排程策略能高效滿足如下目標:

  • 同一個租戶的 Replica 儘量分散,確保租戶 Quota 可快速擴容;

  • 不會因為個別慢節點阻塞整體均衡流程;

  • 最終讓每個機器的各個維度的資源負載百分比接近。

負載均衡流程的概要主要分為 3 個步驟:

  1. 根據近期的 QPS 與磁碟空間使用率的最大值,為每個 Core 構建二維負載向量;

  2. 計算全域性最優二維負載向量,即資源池中所有 Core 負載向量在兩個維度上的平均值;

  3. 將高負載 Core 上的 Replica 排程到低負載 Core 上,使高、低負載的 Core 在執行 Replica 排程後,Core 的負載向量與最優負載向量距離變小。

圖 14 (1) : 某叢集均衡排程前的負載分佈

圖 14 (2) : 某叢集均衡排程後的負載分佈

上圖是線上負載均衡前後各的負載分佈散點圖,其中:紅點是最優負載向量,橫縱分別表示 Core 負載向量的第一和第二維度,每個點對應一個 Core。從圖可以看出,各個 Core 的負載向量基本以最優負載向量為中心分佈。

現狀與規劃

目前 Abase2 正在逐漸完成對第一代 Abase 系統的資料遷移和升級,使用 Abase2 的原生多租戶能力,我們預計可提升 50%的資源使用率。通過對異地多活架構的改造,我們將為 Abase 使用者提供更加準確、快速的多地域資料同步功能。同時,我們也在為火山引擎上推出 Abase 標準產品做準備,以滿足公有云上使用者的大容量、低成本 Redis 場景需求。

未來的 Abase2 會持續向著下面幾個方向努力,我們的追求是

技術先進性:在自研多寫架構上做更多探索,通過支援 RDMA/io_uring/ZNS SSD/PMEM 等新硬體新技術,讓 Abase2 的各項指標更上一個臺階。

易用性:建設標準的雲化產品,提供 Serverless 服務,和更自動的冷熱沉降,更完善的 Redis 協議相容,更高魯棒性的 dump/bulkload 等功能。

極致穩定:在多租戶的 QoS 實踐和自動化運維等方面不斷追求極致。我們的目標是成為像水和電那樣,讓使用者感覺不到存在的基架產品。

結語

隨著位元組跳動的持續發展,業務數量和場景快速增加,業務對 KV 線上儲存系統的可用性與效能的要求也越來越高。在此背景下,團隊從初期的拿來主義演進到較為成熟與完善的 Abase 一代架構。 秉持著追求極致的位元組範兒,團隊沒有止步於此,我們向著更高可用與更高效能的目標繼續演進 Abase2。 由於篇幅限制,更多的細節、優化將在後續文章中重點分期講述。

團隊介紹

NoSQL 團隊為公司提供穩定可靠的線上儲存服務。目前已經覆蓋公司幾乎所有業務線,支援百億級請求處理能力。團隊依靠公司業務的快速發展浪潮,背靠基礎架構的綜合技術力量支援,結合最新硬體/技術發展趨勢,致力於做使用者喜愛的、技術領先的、追求極致的 KV 儲存標杆產品。歡迎更多志同道合的同學加入我們:

  點選“閱讀原文”檢視崗位詳情!