備份的 “運算元下推”:BR 簡介丨TiDB 工具分享

PingCAP發表於2021-12-22

BR 選擇了在 Transaction KV 層面進行掃描來實現備份。這樣,備份的核心便是分佈在多個 TiKV 節點上的 MVCC Scan:簡單,粗暴,但是有效,它生來就繼承了 TiKV 的諸多優勢:分散式、利於橫向擴充、靈活(可以備份任意範圍、未 GC 的任意版本的資料)等等優點。

相較於從前只能使用 mydumper 進行 SQL 層的備份,BR 能夠更加高效地備份和恢復:它取消了 SQL 層的開銷,同時支援備份索引,而且所有備份都是已經排序的 SST 檔案,以此大大加速了恢復。

BR 的實力在之前的文章(https://pingcap.com/zh/blog/cluster-data-security-backup)中已經展示過了,本文將會詳細描述 BR 備份側的具體實現:簡單來講,BR 就是備份的“運算元下推”:通過 gRPC 介面,將任務下發給 TiKV,然後讓 TiKV 自己將資料轉儲到外部儲存中。

BR 的基本流程

介面

為了區別於一般的 MVCC Scan 請求,TiKV 提供一個叫做 Backup 的介面,這個介面與一般的讀請求不同——它不會返回資料給客戶端,而是直接將讀到的資料儲存到指定的儲存器(External Stroage)中:

service Backup {
    // 收到 backup 的 TiKV,將會將 Request 指定範圍中,所有自身為 leader
    // 的 region 備份,並流式地返回給客戶端(每個 region 對應流中的一個 item)。
    rpc backup(BackupRequest) returns (stream BackupResponse) {}
}

// NOTE:隱藏了一些不重要的 field 。
message BackupRequest {
    // 備份的範圍,[start_key, end_key)。
    bytes start_key = 2;
    bytes end_key = 3;
    // 備份的 MVCC 版本。
    uint64 start_version = 4;
    uint64 end_version = 5;
    
    // 限流介面,為了確保和恢復時候的一致性,限流限制儲存備份檔案的階段的速度。
    uint64 rate_limit = 7;
    
    // 備份的目標儲存。
    StorageBackend storage_backend = 9;
    // 備份的壓縮 -- 這是一個用 CPU 資源換取 I/O 頻寬的優化。
    CompressionType compression_type = 12;
    int32 compression_level = 13;
    // 備份支援加密。
    CipherInfo cipher_info = 14;
}

message BackupResponse {
    Error error = 1;
    // 備份的請求將會返回多次,每次都會返回一個已經完成的子範圍。
    // 利用這些資訊可以統計備份進度。
    bytes start_key = 2;
    bytes end_key = 3;
    // 返回該範圍備份檔案的列表,用於恢復的時候追蹤。
    repeated File files = 4;
}

客戶端

BR 客戶端會藉助 TiDB 的介面,根據使用者指定需要備份的庫和表,計算出來需要備份的範圍(“ranges”)。計算的依據是:

  1. 依據每個 table 的所有 data key 生成 range。(所有帶有 t{table_id}_r 字首的 Key)
  2. 依據每個 index 的所有 index key 生成 range。(所有帶有 t{table_id}_i{index_id} 字首的 Key)
  3. 如果 table 存在 partition(這意味著,它可能有多個 table ID),對於每個 partition,按照上述規則生成 range。

為了獲得最大的並行度,BR 客戶端會並行地向所有 TiKV 傳送這些 Range 上的備份請求。

當然,備份不可能一帆風順。我們在備份的時候不可避免地會遇到問題:例如網路錯誤,或者觸發了 TiKV 的限流措施(Server is Busy),或者 Key is Locked,這時候,我們必須縮小這些 range,重新傳送請求(否則,我們就要重複一遍之前已經做過的工作……)。

在失敗之後,選擇合適的 range 來重發請求的過程,在 BR 中被稱為 “細粒度備份(fine-grained backup)”,具體而言:

  1. 在之前的 “粗粒度備份” 中,BR 客戶端每收到一個 BackupResponse 就會將其中的 [start_key, end_key) 作為一個 range 存入一顆區間樹中(你可以把它想象成一個簡單的 BTreeSet<(Vec<u8>, Vec<u8>)>)。
  2. “粗粒度備份” 遇到任何可重試錯誤都會忽略掉,只是相應的 range 不會存入這顆區間樹中,因此樹中會留下一個 “空洞”,這兩步的虛擬碼如下。
func Backup(tree RangeTree) {
    // ... 
    for _, resp := range responses {
        if resp.Success {
            tree.Insert(resp.StartKey, resp.EndKey)  
        }
    }
}

// An example: 
// When backing up the ange [1, 5).
// [1, 2), [3, 4) and [4, 5) successed, and [2, 3) failed:
// The Tree would be like: { [1, 2), [3, 4), [4, 5) }, 
// and the range [2, 3) became a "hole" in it.
// 
// Given the range tree is sorted, it would be easy to 
// find all holes in O(n) time, where n is the number of ranges.
  1. 在 “粗粒度備份” 結束之後,我們遍歷這顆區間樹,找到其中所有 “空洞”,並行地進行 “細粒度備份”:
  • 找到包含該空洞的所有 region。
  • 對他們的 leader 發起 region 相應範圍的 Backup RPC。
  • 成功之後,將對應的 range 放入區間樹中。
  1. 在一輪 “細粒度備份” 結束後,如果區間樹中還有空洞,則回到 (3),在超過一定次數的重試失敗之後,報錯並退出。

在上述 “備份”流程完成之後,BR 會利用 Coprocessor 的介面,向 TiKV 請求執行使用者所指定表的 checksum。

這個 checksum 會在恢復的時候用作參考,同時也會和 TiKV 在備份期間生成的逐檔案的 checksum 進行對比,這個比對的過程叫做 “fast checksum”。

在 “備份” 的過程中,BR 會通過 TiDB 的介面收集備份的表結構、備份的時間戳、生成的備份檔案等資訊,儲存到一個 “backupmeta”中。這個是恢復時候的重要參考。

TiKV

為了實現資源隔離,減少資源搶佔,Backup 相關的任務都執行在一個單獨的執行緒池裡面。這個執行緒池中的執行緒叫做 “bkwkr”(“backup worker” 極其抽象的縮寫)。

在收到 gRPC 備份的請求之後,這個 BackupRequest 會被轉化為一個 Task

而後,TiKV 會利用 Task 中的 start_keyend_key 生成一個叫做 “Progress”的結構:它將會把 Task 中龐大的範圍劃分為多個子範圍,通過:

  1. 掃描範圍內的 Region。
  2. 對於其中當前 TiKV 角色為 Leader 的 Region,將該 Region 的範圍作為 Backup 的子任務下發。

Progress 提供的介面是一個使用 “拉模型” 的介面:forward。隨後,TiKV 建立的各個 Backup Worker 將會去並行地呼叫這個介面,獲得一組待備份的 Region,然後執行以下三個步驟:

  1. 對於這些 Region,Backup Worker 將會通過 RaftKV 介面,進行一次 Raft 的讀流程,最終獲得對應 Region 在 Backup TS 的一個 Snapshot。(“Get Snapshot”)
  2. 對於這個 Snapshot,Backup Worker 會通過 MVCC Read 的流程去掃描 backup_ts 的一致版本。這裡我們會掃描出 Percolator 的事務,為了恢復方便,我們會準備 “default” 和 “write” 兩個臨時緩衝區,分別對應 TiKV Percolator 實現中的 Default CF 和 Write CF。(“Scan”)
  3. 然後,我們會先將掃描出來的事務中兩個 CF 的 Raw Key 刷入對應緩衝區中,在整個 Region 備份完成(或者有些 Region 實在過大,那麼會在途中切分備份檔案)之後,再將這兩個檔案儲存到外部儲存中,記錄它們對應的範圍和大小等等,最後返回一個 BackupResponse 給 BR。(“Save”)

為了保證檔名的唯一性,備份的檔名會包括當前 TiKV 的 store ID、備份的 region ID、start key 的雜湊、CF 名稱。

備份檔案使用 RocksDB 的 Block Based SST 格式:它的優勢是,原生支援檔案級別的 checksum 和壓縮,同時可以在恢復的時候快速被 ingest 的潛力。

外部儲存是為了適配多種備份目標而存在的通用儲存抽象:有些類似於 Linux 中的 VFS,不過簡化了非常多:僅僅支援簡單的儲存和下載整個檔案的操作。它目前對主流的雲盤都做了適配,並且支援以 URL 的形式序列化和反序列化。例如,使用 s3://some-bucket/some-folder,可以指定備份到 S3 雲盤上的 some-bucket 之下的 some-folder 目錄中。

BR 的挑戰和優化

通過以上的基本流程,BR 的基本鏈路已經可以跑通了:類似於運算元下推,BR 將備份任務下推到了 TiKV,這樣可以合理利用 TiKV 的資源,實現分散式備份的效果。

在這個過程中,我們遇到了許多挑戰,在這一節,我們來談談這些挑戰。

BackupMeta 和 OOM

前文中提到,BackupMeta 儲存了備份的所有元資訊:包括表結構、所有備份檔案的索引等等。想象一下你有一個足夠大的叢集:比如說,十萬張表,總共可能有數十 TB 的資料,每張表可能還有若干索引。

如此最終可能產生數百萬的檔案:在這個時候,BackupMeta 可能會達到數 GB 之大;另一方面,由於 protocol buffer 的特性,我們可能不得不讀出整個檔案才能將其序列化為 Go 語言的物件,由此峰值記憶體佔用又多一倍。在一些極端環境下,會存在 OOM 的可能性。

為了緩解這個問題,我們設計了一種分層的 BackupMeta 格式,簡單來講,就是將 BackupMeta 拆分成索引檔案和資料檔案兩部分,類似於 B+ 樹的結構:

具體來講,我們會在 BackupMeta 中加上這些 Fields,分別指向對應的 “B+ 樹” 的根節點:

message BackupMeta {
    // Some fields omitted...
    // An index to files contains data files.
    MetaFile file_index = 13;
    // An index to files contains Schemas.
    MetaFile schema_index = 14;
    // An index to files contains RawRanges.
    MetaFile raw_range_index = 15;
    // An index to files contains DDLs.
    MetaFile ddl_indexes = 16;
}

MetaFile 就是這顆 “B+ 樹” 的節點:

// MetaFile describes a multi-level index of data used in backup.
message MetaFile {
    // A set of files that contains a MetaFile.
    // It is used as a multi-level index.
    repeated File meta_files = 1;
    
    // A set of files that contains user data.
    repeated File data_files = 2;
    // A set of files that contains Schemas.
    repeated Schema schemas = 3;
    // A set of files that contains RawRanges.
    repeated RawRange raw_ranges = 4;
    // A set of files that contains DDLs.
    repeated bytes ddls = 5;
}

它可能有兩種形態:一是承載著對應資料的 “葉子節點”(後四個 field 被填上相應的資料),也可以通過 meta_files 將自身指向下一個節點:File 是一個到外部儲存中其他檔案的引用,包含檔名等等基礎資訊。

目前的實現中,為了迴避真正實現類似 B 樹的分裂、合併操作的複雜性,我們僅僅使用了一級索引,將的表結構和檔案的後設資料分別儲存到一個個 128M 的小檔案中,如此已經足夠迴避 BackupMeta 帶來的 OOM 問題了。

GC, GC never changes

在備份掃描的整個過程中,因為時間跨度較長,必然會受到 GC 的影響。
不僅僅是 BR,別的生態工具也會遇到 GC 的問題:例如,TiCDC 需要增量掃描,如果初始版本已經被 GC 掉,那麼就無法同步一致的資料。

過去我們的解決方案一般是讓使用者手動調大 GC Lifetime,但是這往往會造成 “初見殺” 的效果:使用者開開心心備份,然後去做其他事情,幾個小時後發現備份因為 GC 而失敗了……

這會非常影響使用者的心情:為了讓使用者能更加開心地使用各種生態工具,PD 提供了一個叫做 “Service GC Safepoint” 的功能。各個服務可以通過 PD 上的介面,設定一個 “Safepoint”,TiDB 會保證,在 Safepoint 指定的時間點之後,所有歷史版本都不會被 GC。為了防止 BR 在意外退出之後導致叢集無法正常 GC,這個 Safepoint 還會存在一個 TTL:在指定時間之後若是沒有重新整理,則 PD 會移除這個 Service Safe Point。

對於 BR 而言,只需要將這個 Safepoint 設定為 Backup TS 即可,作為參考,這個 Safepoint 會被命名為 “br-<Random UUIDv4>”,並且有五分鐘的 TTL。

備份壓縮

在全速備份的時候,備份的流量可能相當大:具體可以看看開頭“秀肌肉”文章相關的部分。

如果你願意使用足夠多的核心去備份,那麼可能很快就會到達網路卡的瓶頸(例如,如果不經壓縮,千兆網路卡大約只需要 4 個核心就會被滿。),為了避免網路卡成為瓶頸,我們在備份的時候引入了壓縮。

我們複用了 RocksDB Block Based Table Format 中提供的壓縮功能:預設會使用 zstd 壓縮。壓縮會增大 CPU 的佔用率,但是可以減少網路卡的負載,在網路卡成為瓶頸的時候,可以顯著提升備份的速度。

限流與隔離

為了減少對其他任務的影響,如前文所說,所有的備份請求都會在單獨的執行緒池中執行。

但是即便如此,如果備份消耗了太多的 CPU,不可避免地會對叢集中其它負載造成影響:主要的原因是 BR 會佔用大量 CPU,影響其它任務的排程;另一方面則是 BR 會大量讀盤,影響寫任務刷盤的速度。

為了減少資源的使用,BR 提供了一個限流機制。當使用者帶有 --ratelimit 引數啟動 BR 的時候,TiKV 側的第三步“Save”,將會被限流,與此同時也會限制之前步驟的流量。

這裡需要注意一個點:備份資料的大小往往會遠遠小於叢集的實際空間佔用。原因是備份只會備份單副本、單 MVCC 版本的資料。通 ratelimit 限流施加於 Save 階段,因此是限制寫備份資料的速度。

在 “服務端” 側,也可以通過調節執行緒池的大小來限流,這個引數叫做 backup.num-threads,考慮到我們允許使用者側限流,它的預設值非常高:是全部 CPU 的 75%。如果需要在服務側進行更加徹底的限流,可以修改這個引數。作為參考,一塊 Intel(R) Xeon(R) CPU E5-2630 v4 @ 2.20GHz CPU 每個核心大概每秒能生成 10M 經 zstd 壓縮的 SST 檔案。

總結

通過 Service Safe Point,我們解決了手動調節 GC 帶來的“難用”的問題。

通過新設計的 BackupMeta,我們解決了海量表場景的 OOM 問題。
通過備份壓縮、限流等措施,我們讓 BR 對叢集影響更小、速度更快(即便二者可能無法兼得)。

總體上而言,BR 是在 “物理備份” 和 “邏輯備份” 之間的 “第三條路”:相對於 mydumper 或者 dumpling 等工具,它消解了 SQL 層的額外代價;相對於在分散式系統中尋找物理層的一致性快照,它易於實現且更加靈巧。對於目前階段而言,是適宜於 TiDB 的容災備份解決方案。

相關文章