字節跳動自研強一致在線 KV &表格存儲實踐 - 上篇

本文選自“字節跳動基礎架構實踐”系列文章。
“字節跳動基礎架構實踐”系列文章是由字節跳動基礎架構部門各技術團隊及專家傾力打造的技術乾貨內容,和大家分享團隊在基礎架構發展和演進過程中的實踐經驗與教訓,與各位技術同學一起交流成長。
自從 Google 發佈 Spanner 論文後,國內外相繼推出相關數據庫產品或服務來解決數據庫的可擴展問題。字節跳動在面對海量數據存儲需求時,也採用了相關技術方案。本次分享將介紹我們在構建此類系統中碰到的問題,解決方案以及技術演進。

由於篇幅受限,本系列文章分爲上下兩篇,上篇會涵蓋整體結構,接口和一部分關鍵技術,下篇會涵蓋另一部分關鍵技術和表格層相關內容。下篇會在明天更新,歡迎大家持續關注。

背景

互聯網產品中存在很多種類的數據,不同種類的數據對於存儲系統的一致性,可用性,擴展性的要求是不同的。比如,金融、賬號相關的數據對一致性要求比較高,社交類數據例如點贊對可用性要求比較高。還有一些大規模元數據存儲場景,例如對象存儲的索引層數據,對一致性,擴展性和可用性要求都比較高,這就需要底層存儲系統在能夠保證數據強一致的同時,也具有良好的擴展性。在數據模型上,有些數據比如關係,KV 模型足夠用;有些數據比如錢包、賬號可能又需要更豐富的數據模型,比如表格。

分佈式存儲系統對數據分區一般有兩種方式:Hash 分區和 Range 分區。Hash 分區對每條數據算一個哈希值,映射到一個邏輯分區上,然後通過另外一層映射將邏輯分區映射到具體的機器上,很多數據庫中間件、緩存中間件都是這樣做的。這種方式的優點是數據寫入一般不會出現熱點,缺點是原本連續的數據經過 Hash 後散落在不同的分區上變成了無序的,那麼,如果需要掃描一個範圍的數據,需要把所有的分區都掃描一遍。

相比而言,Range 分區對數據進行範圍分區,連續的數據是存儲在一起的,可以按需對相鄰的分區進行合併,或者中間切一刀將一個分區一分爲二。業界典型的系統像 HBase。這種分區方式的缺點是一、對於追加寫處理不友好,因爲請求都會打到最後一個分片,使得最後一個分片成爲瓶頸。優點是更容易處理熱點問題,當一個分區過熱的時候,可以切分開,遷移到其他的空閒機器上。

從實際業務使用的角度來說,提供數據強一致性能夠大大減小業務的負擔。另外 Range 分區能夠支持更豐富的訪問模式,使用起來更加靈活。基於這些考慮,我們使用 C++ 自研了一套基於 Range 分區的強一致 KV 存儲系統 ByteKV,並在其上封裝一層表格接口以提供更爲豐富的數據模型。

架構介紹

系統組件

整個系統主要分爲 5 個組件:SQLProxy, KVProxy, KVClient, KVMaster 和 PartitionServer。其中,SQLProxy 用於接入 SQL 請求,KVProxy 用於接入 KV 請求,他們都通過 KVClient 來訪問集羣。KVClient 負責和 KVMaster、PartitionServer 交互,KVClient 從 KVMaster 獲取全局時間戳和副本位置等信息,然後訪問相應的 PartitionServer 進行數據讀寫。PartitionServer 負責存儲用戶數據,KVMaster 負責將整個集羣的數據在 PartitionServer 之間調度。

集羣中數據會按照 range 切分爲很多 Partition,每個 Partition 有多個副本,副本之間通過 Raft 來保證一致性。這些副本分佈在所有的 PartitionServer 中,每個 PartitionServer 會存儲多個 Partition 的副本,KVMaster 負責把所有副本均勻的放置在各個 PartitionServer 中。各個 PartitionServer 會定期彙報自身存儲的副本的信息給 KVMaster,從而 KVMaster 有全局的副本位置信息。Proxy 接到 SDK 請求後,會訪問 KVMaster 拿到副本位置信息,然後將請求路由到具體的 PartitionServer,同時 Proxy 會緩存一部分副本位置信息以便於後續快速訪問。由於副本會在 PartitionServer 之間調度,故 Proxy 緩存的信息可能是過期的,這時當 PartitionServer 給 Proxy 迴應副本位置已經變更後,Proxy 會重新向 KVMaster 請求副本位置信息。

分層結構

如上圖所示是 ByteKV 的分層結構。

接口層 對用戶提供 KV SDK 和 SQL SDK,其中 KV SDK 提供簡單的 KV 接口,SQL SDK 提供更加豐富的 SQL 接口,滿足不同業務的需求。

事務層 提供全局一致的快照隔離級別(Snapshot Isolation),通過全局時間戳和兩階段提交保證事務的 ACID 屬性。

彈性伸縮層通 過 Partition 的自動分裂合併以及 KVMaster 的多種調度策略,提供了很強的水平擴展能力,能夠適應業務不同時期的資源需求。

一致性協議層 通過自研的 ByteRaft 組件,保證數據的強一致性,並且提供多種部署方案,適應不同的資源分佈情況。

存儲引擎層 採用業界成熟的解決方案 RocksDB,滿足前期快速迭代的需求。並且結合系統未來的演進需要,設計了自研的專用存儲引擎 BlockDB。

空間管理層 負責管理系統的存儲空間,數據既可以存儲在物理機的本地磁盤,也可以接入其他的共享存儲進行統一管理。

對外接口

KV 接口

ByteKV 對外提供兩層抽象,首先是 namespace,其次是 table,一個 namespace 可以有多個 table。具體到一個 table,支持單條記錄的 Put、Delete 和 Get 語義。其中 Put 支持 CAS 語義,僅在滿足某種條件時才寫入這條記錄,如僅在當前 key 不存在的情況下才寫入這條記錄,或者僅在當前記錄爲某個版本的情況下才寫入這條記錄等,同時還支持 TTL 語義。Delete 也類似。

除了這些基本的接口外,還提供多條記錄的原子性寫入接口 WriteBatch, 分佈式一致性快照讀 MultiGet, 非事務性寫入 MultiWrite 以及掃描一段區間的數據 Scan 等高級接口。WriteBatch 可以提供原子性保證,即所有寫入要麼全部成功要麼全部失敗,而 MultiWrite 不提供原子性保證,能寫成功多少寫成功多少。MultiGet 提供的是分佈式一致性快照讀的語義:MultiGet 不會讀到其他已提交事務的部分修改。Scan 也實現了一致性快照讀的語義,並且支持了前綴掃描,逆序掃描等功能。

表格接口

表格接口在 KV 的基礎上提供了更加豐富的單表操作語義。用戶可以使用基本的 Insert,Update,Delete,Select SQL 語句來讀寫數據,可以在 Query 中使用過濾(Where/Having)排序(OrderBy),分組(GroupBy),聚合(Count/Max/Min/Avg)等子句。同時在 SDK 端我們也提供了 ORM 庫,方便用戶的業務邏輯實現。

關鍵技術

以下我們將詳細介紹 Raft、存儲引擎、分佈式事務、分區自動分裂和合並、負載均衡這幾個技術點。(其中 Raft、存儲引擎 會在本篇詳述,其他幾個技術點會在下篇詳述)

自研 ByteRaft

作爲一款分佈式系統,容災能力是不可或缺的。冗餘副本是最有效的容災方式,但是它涉及到多個副本間的一致性問題。ByteKV 採用 Raft[1]作爲底層複製算法來維護多個副本間的一致性。由於 ByteKV 採用 Range 分片,每個分片對應一個 Raft 複製組,一個集羣中會存在非常多的 Raft Group。組織、協調好 Raft Group 組之間的資源利用關係,對實現一個高性能的存儲系統至關重要;同時在正確實現 Raft 算法基礎上,靈活地爲上層提供技術支持,能夠有效降低設計難度。因此我們在參考了業界優秀實現的基礎上,開發了一款 C++ 的 Multi-Raft 算法庫 ByteRaft。

日誌複製是 Raft 算法的最基本能力,ByteKV 將所有用戶寫入操作編碼成 RedoLog,並通過 Raft Leader 同步給所有副本;每個副本通過回放具有相同序列的 RedoLog,保證了一致性。有時服務 ByteKV 的機器可能因爲硬件故障、掉電等原因發生宕機,只要集羣中仍然有多數副本存活,Raft 算法就能在短時間內自動發起選舉,選出新的 Leader 進行服務。最重要的是,動態成員變更也被 Raft 算法所支持,它爲 ByteKV 的副本調度提供了基礎支持。ByteKV 的 KVMaster 會對集羣中不同機器的資源利用率進行統計彙總,並通過加減副本的方式,實現了數據的遷移和負載均衡;此外,KVMaster 還定期檢查機器狀態,將長時間宕機的副本,從原有的複製組中摘除。

ByteRaft 在原有 Raft 算法的基礎上,做了很多的工程優化。如何有效整合不同 Raft Group 之間的資源利用,是實現有效的 Multi-Raft 算法的關鍵。ByteRaft 在各種 IO 操作路徑上做了請求合併,將小粒度的 IO 請求合併爲大塊的請求,使其開銷與單 Raft Group 無異;同時多個 Raft Group 可以橫向擴展,以充分利用 CPU 的計算和 IO 帶寬資源。ByteRaft 網絡採用 Pipeline 模式,只要網絡通暢,就按照最大的能力進行日誌複製;同時 ByteRaft 內置了亂序隊列,以解決網絡、RPC 框架不保證數據包順序的問題。ByteRaft 會將即將用到的日誌都保留在內存中,這個特性能夠減少非常多不必要的 IO 開銷,同時降低同步延遲。ByteRaft 不單單作爲一個共識算法庫,還提供了一整套的解決方案,方便各類場景快速接入,因此除了 ByteKV 使用外,還被字節內部的多個存儲系統使用。

除了上述功能外,ByteRaft 還爲一些其他企業場景提供了技術支持。

Learner

數據同步是存儲系統不可或缺的能力。ByteKV 提供了一款事務粒度的數據訂閱方案。這種方案保證數據訂閱按事務的提交順序產生,但不可避免的導致擴展性受限。在字節內部,部分場景的數據同步並不需要這麼強的日誌順序保證,爲此 ByteRaft 提供了 Learner 支持,我們在 Learner 的基礎上設計了一款鬆散的按 Key 有序複製的同步組件。

同時,由於 Learner 不參與日誌提交的特性,允許一個新的成員作爲 Learner 加入 Raft Group,等到日誌差距不大時再提升爲正常的跟隨者。這個過程可以使得 KVMaster 的調度過程更爲平滑,不會降低集羣可用性。

Witness

在字節內部,ByteKV 的主要部署場景爲三中心五副本,這樣能夠保證在單機房故障時集羣仍然能夠提供服務,但是這種方式對機器數量要求比較大,另外有些業務場景只能提供兩機房部署。因此需要一種不降低集羣可用性的方案來降低成本。Witness 作爲一個只投票不保存數據的成員,它對機器的資源需求較小,因此 ByteRaft 提供了 Witness 功能。

有了 Witness,就可以將傳統的五副本部署場景中的一個副本替換爲 Witness,在沒有降低可用性的同時,節省出了部分機器資源。另外一些只有兩機房的場景中,也可以通過租用少量的第三方雲服務,部署上 Witness 來提供和三中心五副本對等的容災能力。更極端的例子場景,比如業務有主備機房的場景下,可以通過增加 Witness 改變多數派在主備機房的分佈情況,如果主備機房隔離,少數派的機房可以移除 Witness 降低 quorum 數目從而恢復服務。

存儲引擎

RocksDB

和目前大多數存儲系統一樣,我們也採用 RocksDB 作爲單機存儲引擎。RocksDB 作爲一個通用的存儲引擎,提供了不錯的性能和穩定性。RocksDB 除了提供基礎的讀寫接口以外,還提供了豐富的選項和功能,以滿足各種各樣的業務場景。然而在實際生產實踐中,要把 RocksDB 用好也不是一件簡單的事情,所以這裏我們給大家分享一些經驗。

Table Properties

Table Properties 是我們用得比較多的一個功能。RocksDB 本身提供一些內置的 SST 統計信息,並且支持用戶自定義的 Table Properties Collector,用於在 Flush/Compaction 過程中收集統計信息。具體來說,我們利用 Table Properties 解決了以下幾個問題:

  1. 我們的系統是採用 Range 切分數據的,當一個 Range 的數據大小超過某個閾值,這個 Range 會被分裂。這裏就涉及到分裂點如何選取的問題。一個簡單的辦法是把這個 Range 的數據掃一遍,根據數據大小找到一箇中點作爲分裂點,但是這樣 IO 開銷會比較大。所以我們通過 Table Properties Collector 對數據進行採樣,每隔一定的數據條數或者大小記錄一個採樣點,那麼分裂的時候只需要根據這些採樣點來估算出一個分裂點即可。
  2. 多版本數據進行啓發式垃圾回收的過程,也是通過 Table Properties 的採樣來實現的。在存儲引擎中,一條用戶數據可能對應有一條或多條不同版本的數據。我們在 Table Properties Collector 中採集了版本數據的條數和用戶數據的條數。在垃圾回收的過程中,如果一個 Range 包含的版本數據的條數和用戶數據的條數差不多,我們可以認爲大部分用戶數據只有一個版本,那麼就可以選擇跳過這個 Range 的垃圾回收。另外,垃圾回收除了要考慮多版本以外,還需要考慮 TTL 的問題,那麼在不掃描數據的情況下如何知道一個 Range 是否包含已經過期的 TTL 數據呢?同樣是在 Table Properties Collector 中,我們計算出每條數據的過期時間,然後以百分比的形式記錄不同過期時間的數據條數。那麼,在垃圾回收的過程中,給定一個時間戳,我們就能夠估算出某一個 Range 裏面包含了多少已經過期的數據了。
  3. 雖然 RocksDB 提供了一些參數能夠讓我們根據不同的業務場景對 compaction 的策略進行調整,比如 compaction 的優先級等,但是實際上業務類型多種多樣,很難通過一套單一的配置能夠滿足所有的場景。這時候其實我們也可以根據統計信息來對 compaction 進行一定的“干預”。比方說有的數據區間經常有頻繁的刪除操作,會留下大量的 tombstone。如果這些 tombstone 不能被快速的 compaction 清除掉,會對讀性能造成很大,並且相應的空間也不能釋放。針對這個問題,我們會在上層根據統計信息(比如垃圾數據比例)及時發現並主動觸發 compaction 來及時處理。
遇到的問題和解決辦法

除了上面提到的幾個用法以外,這裏我們再給大家分享 RocksDB 使用過程中可能遇到的一些坑和解決辦法:

  1. 你是否遇到過數據越刪越多或者已經刪除了很多數據但是空間長時間不能釋放的問題呢?我們知道 RocksDB 的刪除操作其實只是寫入了一個 tombstone 標記,而這個標記往往只有被 compact 到最底層才能被丟掉的。所以這裏的問題很可能是由於層數過多或者每一層之間的放大係數不合理導致上面的層的 tombstone 不能被推到最底層。這時候大家可以考慮開啓 level_compaction_dynamic_level_bytes這個參數來解決。
  2. 你是否遇到過 iterator 的抖動導致的長尾問題呢?這個可能是因爲 iterator 在釋放的時候需要做一些清理工作的原因,嘗試開啓 avoid_unnecessary_blocking_io 來解決。
  3. 你是否遇到過 ingest file 導致的抖動問題?在 ingest file 的過程中,RocksDB 會阻塞寫入,所以如果 ingest file 的某些步驟耗時很長就會帶來明顯的抖動。例如如果 ingest 的 SST 文件跟 memtable 有重疊,則需要先把 memtable flush 下來,而這個過程中都是不能寫入的。所以爲了避免這個抖動問題,我們會先判斷需要 ingest 的文件是否跟 memtable 有重疊,如果有的話會在 ingest 之前先 flush,等 flush 完了再執行 ingest。而這個時候 ingest 之前的 flush 並不會阻塞寫,所以也就避免了抖動問題。
  4. 你是否遇到過某一層的一個文件跟下一層的一萬個文件進行 compaction 的情況呢?RocksDB 在 compaction 生成文件的時候會預先判斷這個文件跟下一層有多少重疊,來避免後續會產生過大的 compaction 的問題。然而,這個判斷對 range deletion 是不生效的,所以有可能會生成一個範圍非常廣但是實際數據很少的文件,那麼這個文件再跟下一層 compact 的時候就會涉及到非常多的文件,這種 compaction 可能需要持續幾個小時,期間所有文件都不能被釋放,磁盤很容易就滿了。由於我們需要 delete range 的場景很有限,所以目前我們通過 delete files in range + scan + delete 的方式來替換 delete range。雖然這種方式比 delete range 開銷更大,但是更加可控。雖然也可以通過 compaction filter 來進一步優化,但是實現比較複雜,我們暫時沒有考慮。

由於篇幅有限,上面只是提了幾個可能大家都會遇到的問題和解決辦法。這些與其說是使用技巧,還不如說是“無奈之舉”。很多問題是因爲 RocksDB 是這麼實現的,所以我們只能這麼用,即使給 RocksDB 做優化往往也只能是一些局部調整,畢竟 RocksDB 是一個通用的存儲引擎,而不是給我們系統專用的。因此,考慮到以後整個系統的演進的需要,我們設計了一個專用的存儲引擎 BlockDB。

BlockDB

功能需求

BlockDB 需要解決的一個核心需求是數據分片。我們每個存儲節點會存儲幾千上萬個數據分片,目前這些單節點的所有分片都是存儲在一個 RocksDB 實例上的。這樣的存儲方式存在以下缺點:

  1. 無法對不同數據分片的資源使用進行隔離,這一點對於多租戶的支持尤爲重要。
  2. 無法針對不同數據分片的訪問模式做優化,比如有的分片讀多寫少,有的分片寫多讀少,那麼我們希望對前者採取對讀更加友好的 compaction 策略,而對後者採取對寫更加友好的 compaction 策略,但是一個 RocksDB 實例上我們只能選擇一種單一的策略。
  3. 不同數據分片的操作容易互相影響,一些對數據分片的操作在 RocksDB 中需要加全局鎖(比如上面提到的 ingest file),那麼數據分片越多鎖競爭就會越激烈,容易帶來長尾問題。
  4. 不同數據分片混合存儲會帶來一些不必要的寫放大,因爲我們不同業務的數據分片是按照前綴來區分的,不同數據分片的前綴差別很大,導致寫入的數據範圍比較離散,compaction 的過程中會有很多範圍重疊的數據。

雖然 RocksDB 的 Column Family 也能夠提供一部分的數據切分能力,但是面對成千上萬的數據分片也顯得力不從心。而且我們的數據分片還需要支持一些特殊的操作,比如分片之間的分裂合併等。因此,BlockDB 首先會支持數據分片,並且在數據分片之上增加資源控制和自適應 compaction 等功能。

除了數據分片以外,我們還希望減少事務的開銷。目前事務數據的存儲方式相當於在 RocksDB 的多版本之上再增加了一層多版本。RocksDB 內部通過 sequence 來區分不同版本的數據,然後在 compaction 的時候根據 snapshot sequence 來清除不可見的垃圾數據。我們的事務在 RocksDB 之上通過 timestamp 來區分不同版本的用戶數據,然後通過 GC 來回收對用戶不可見的垃圾數據。這兩者的邏輯是非常相似的,目前的存儲方式顯然存在一定的冗餘。因此,我們會把一部分事務的邏輯下推到 BlockDB 中,一方面可以減少冗餘,另一方面也方便在引擎層做進一步的優化。採用多版本併發控制的存儲系統有一個共同的痛點,就是頻繁的更新操作會導致用戶數據的版本數很多,範圍查找的時候需要把每一條用戶數據的所有版本都掃一遍,對讀性能帶來很大的影響。實際上,大部分的讀請求只會讀最新的若干個版本的數據,如果我們在存儲層把新舊版本分離開來,就能夠大大提升這些讀請求的性能。所以我們在 BlockDB 中也針對這個問題做了設計。

性能需求

除了功能需求以外,BlockDB 還希望進一步發揮高性能 SSD(如 NVMe)隨機 IO 的特性,降低成本。RocksDB 的數據是以文件單位進行存儲的,所以 compaction 的最小單位也是文件。如果一個文件跟下一層完全沒有重疊,compaction 可以直接把這個文件 move 到下一層,不會產生額外的 IO 開銷。可以想象,如果一個文件越小,那麼這個文件跟下一層重疊的概率也越小,能夠直接複用這個文件的概率就越大。但是在實際使用中,我們並不能把文件設置得特別小,因爲文件太多對文件系統並不友好。基於這一想法,我們在 BlockDB 中把數據切分成 Block 進行存儲,而 Block 的粒度比文件小得多,比如 128KB。這裏的 Block 可以類比爲 SST 文件裏的 Block,只是我們把 SST 文件的 Block 切分開來,使得這些 Block 能夠單獨被複用。但是以 Block 爲單位進行存儲對範圍掃描可能不太友好,因爲同一個範圍的數據可能會分散在磁盤的各個地方,掃描的時候需要大量的隨機讀。不過在實際測試中,只要控制 Block 的粒度不要太小,配合上異步 IO 的優化,隨機讀依然能夠充分發揮磁盤的性能。

另外,爲了進一步發揮磁盤性能,減少文件系統的開銷,BlockDB 還設計了一個 Block System 用於 Block 的存儲。Block System 類似於一個輕量級的文件系統,但是是以 Block 爲單位進行數據存儲的。Block System 既可以基於現有的文件系統來實現,也可以直接基於裸盤來實現,這一設計爲將來接入 SPDK 和進一步優化 IO 路徑提供了良好的基礎。

小結

以上,是我們對於自研強一致在線 KV&表格存儲的部分介紹,涵蓋整體結構,接口和關鍵技術中的 Raft、存儲引擎。下篇我們會繼續介紹關鍵技術中的分佈式事務、分區自動分裂和合並、負載均衡,以及表格層相關內容。歡迎大家持續關注,明天我們會準時更新。

本文轉載自公衆號字節跳動技術團隊(ID:toutiaotechblog)。

原文鏈接

https://mp.weixin.qq.com/s/jdPE9WClBuimIHVxJnwwUw

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章