深入解析 TiDB BR 备份恢复设计:KV Scan 原理、SST 存储与 Key Rewrite 恢复机制
本文以 TiDB 仓库中 BR(Backup & Restore)模块 2019 年的新版备份恢复设计文档为主体,系统讲解基于 KV Scan 的分布式备份原理、SST 文件流转、GC 安全点保护、Key Rewrite 与 Split & Scatter 等恢复核心机制,并结合当前仓库 br/pkg 下的实际源码印证该设计在 BR 客户端中的落地形态。读完本文后,你将能够理解 TiDB 全量/增量备份的一致性保证来源、恢复时为何必须改写 SST 的 Key,以及备份/恢复流程中每一环的设计动机与源码依据。
设计背景:为什么要做 KV Scan 新方案
设计文档(2019-08-05-new-design-of-backup-restore.md,最后更新于 2020-03-07)开篇说明:由于 BR 需要和 TiDB CDC 在技术上整合,方案的基本思路是采用 KV Scan 的备份方式。它与传统 mydumper 类的 SQL 层导出有四点本质区别:
- 分布式导出数据:数据由各个 region 的 leader 生成,理论上备份的吞吐能达到集群极限;
- 数据形态是 SST:SST 格式能快速恢复,同时可以直接复用 RocksDB 自带的数据压缩/加密能力;
- 直写第三方存储:备份数据直接保存到 S3、GCS 等外部存储;
- 一致性保证是 SI(快照隔离):保证能恢复到 point-in-time 的状态。
可以理解为,备份的语义本质上是一次 select *,但执行位置从 TiDB SQL 层下推到了 TiKV 存储层,从而绕开了 SQL 层的逐行传输瓶颈。
备份原理与整体流程
整个备份过程由独立于 TiKV 的外部程序推进(该程序即 BR,入口是 SQL/命令行)。整体流程分五步:
- 外部程序根据用户指定备份范围下发备份命令到 TiKV;
- TiKV 接受备份请求后交由 region leader 处理,并使用一个特殊的 iterator 读取数据;
- TiKV 读取的数据被组织成 SST 文件,这里需要控制 IO 和内存使用;
- TiKV 把 SST 文件写到外部存储(本地存储、S3 等),这里需要流控;
- TiKV 把执行信息汇报给外部程序。
外部程序(BR 客户端)侧的基本流程则是对应的聚合-重试逻辑:
- 下推备份到所有 TiKV;
- 接受 TiKV Streaming 发过来的备份结果;
- 聚合检查是否有范围没有备份到或发生错误;
- 如果有范围错误或者没有备份到则重试;
- 重试需要精细化——查询 PD 对应的 leader 然后下发;
- 如果需要备份 table 或 database,除了数据外还需要备份 schema。
从源码看:BR 客户端的轮次化备份主循环
当前仓库中,上述"下推—聚合—查漏补缺—重试"的流程由 br/pkg/backup/client.go 中的 RunLoop 实现,其结构与文档描述高度吻合:
- 轮次(round)机制:
RunLoop以"round"为单位循环,每一轮"尝试在所有 TiKV store 上备份所有剩余范围",理想情况下备份在一轮内完成(源码注释:one backup round try to backup all ranges on all tikv stores); - 查漏补缺:每轮通过
loop.GlobalProgressTree.GetIncompleteRanges()计算尚未备份完成的范围(SubRanges),对应文档第 3 步"聚合检查是否有范围没有备份到";当SubRanges为空时判定"all range backuped"并结束; - 精细化重试:每个 store 有独立的 gRPC 客户端与结果 channel(
storeBackupResultChMap),当某个 store 的备份 goroutine 失败时,通过BackupRetryPolicy{One: storeID}只对单个 store 重发请求;若集群状态变化(如新 store 加入、store 重启导致 leader 漂移),则通过{All: true}通知整轮重建连接,对应文档"重试需要精细化,查询 PD 对应的 leader 然后下发"; - 事务锁处理:一轮结束时集中 resolve 本轮收集到的 txn lock(
ResolveLocksForRead),并把已解决的锁放入下一轮请求的Context.ResolvedLocks,使 TiKV 扫描器可以跳过这些锁——这正是文档"可恢复异常:KeyLocked,一般由事务冲突造成"在实现层面的处理; - 客户端结构:
Client结构体(client.go)持有storage/backend(外部存储后端)、cipher(SST 元信息加密)、checkpointRunner(断点续备)、gcTTL(GC 保护时长)等字段,分别对应文档中外部存储、加密、重试与 GC 时间四个主题。
备份下推:如何把命令细分到 region
文档给出两种下推方式:
- region info accessor 下推(推荐):直接把备份命令下发到 TiKV,利用 region info accessor 把命令细分到各个 region leader 上执行 scan。其最大的问题是可能备份多余的数据(比如 leader 在备份期间迁移了),但文档明确"这不是什么正确性问题";
- 按 region 逐个下发:也很简单,但效率没有前者高,同样存在备份多余文件的可能,只是概率更低。
无论哪种方式,下推完成后都需要"查漏补缺"确保所有 key range 都被覆盖,这与上文 GetIncompleteRanges 的进度树机制一致。
Iterator:percolator 事务模型下的备份读取
这是整个方案中最具 TiDB 特色的部分。由于 TiDB/TiKV 的事务模型是 percolator,数据需要存放在 3 个 CF(Column Family)中:default、lock、write。因此 TiKV 想把数据放进 SST,至少要扫出 write 和 default 的数据。现有 iterator 不适用,原因有二:
- 它只能吐出
default的 key-value; - 它吐出的是 point-in-time 的数据(合并了多版本)。
文档对全量与增量分别定义了 Iterator 的扫描语义:
- 全量备份:TiKV 需要根据一个指定的 ts(称为
backup_ts)扫出write和default的 key-value。Iterator 吐出的 key 需要是 data key,即 key 的第一个字节是z——这是为了在恢复时可以直接 ingest,不用 rewrite key(data key 本身不含 tableID 版本信息的问题留给恢复期统一处理)。 - 增量备份:需要扫出一段时间的增量数据,时间段为
(backup_ts, current_ts]。由于备份的一致性保证是 SI,所有增量数据可以直接通过扫writeCF 得到。需要吐出的记录:- Put:新增的数据;
- Delete:删除的数据。
- 不需要吐出的记录:Lock(
select for update写的记录,实际上没有任何数据变更)、Rollback(清理由提交失败造成的垃圾数据)。
性能考量
性能是 KV Scan 方案最头疼的问题之一:全表扫描势必对线上业务造成影响,而增量备份的实现同样是全表扫(需要扫描整段的 write CF),这与传统数据库基于 binlog/WAL 的增量完全不同。文档给出的优化方向是借鉴 CockroachDB 在 SST 上加上 max ts 的 table property,从而在扫描时可以跳过时间戳上界不在增量范围内的 SST 文件。
外部存储:内存缓冲直写
备份存储设计了一个接口,下层可以有不同的实现(本地磁盘、S3 等)。由于外部存储不一定是真正的磁盘,TiKV 在生成 SST 时不会先把数据写到本地磁盘,而是先缓存在内存中,生成完毕后直接写到外部存储,避免一次本地落盘 IO,提高整体吞吐——当然需要内存控制。这一"内存生成 SST、直传对象存储"的设计是当前 br/pkg 中 Client.storage/backend(client.go)抽象的源头。
异常处理:可恢复与不可恢复
备份期间的异常与 select * 一样,分为可恢复和不可恢复两类,且都可以复用 TiDB 现有的重试机制:
可恢复异常(发生时备份进度不会被打断):
| 异常 | 常见成因 |
|---|---|
| RegionError | region split/merge、not leader |
| KeyLocked | 事务冲突 |
| Server is busy | TiKV 太忙 |
除此之外的其他错误都是不可恢复异常,发生后打断备份进度。
在实现层面,这类异常被 TiKV 逐 region 上报,BR 客户端将其转化为下一轮对未完成范围的重发(RunLoop 中 GetIncompleteRanges 驱动);而 KeyLocked 这类锁冲突还会被聚合后经 ResolveLocksForRead 统一解决,如前述源码所示。
超出 GC 时间:备份窗口与 GC 的博弈
"超出 GC 时间"指需要备份的数据已经被 GC 回收,这种情况一般发生在增量备份上,会导致增量不完整。发生该错误时,外部程序需要重新执行一次全量备份。
文档指出的关键问题是:默认 GC 时间是 10 分钟,实在太短——一旦开启全量备份,增量备份必须马上接上且运行间隔必须在 10 分钟内,这个频率集群肯定受不了;CDC 也会遇到同样问题。因此在开始备份或 CDC 的集群上需要适当延长 GC 时间,比如延长到 6 小时或 12 小时。
当前仓库中这一机制的具体实现是:备份期间 BR 通过 PD 的 service GC safepoint API 把安全点抬到 backup_ts - 1,见 br/pkg/gc/manager_global.go:
lastSafePoint, err := m.pdClient.UpdateServiceGCSafePoint(ctx, sp.ID, sp.TTL, sp.BackupTS-1)
即把 GC 安全点固定在备份起点之前一个版本、带上 TTL,备份结束后再清零释放。备份客户端也暴露了 SetGCTTL/GetGCTTL(client.go)用于控制该保护的 TTL,与文档"延长 GC 时间"的诉求一一对应。
恢复:从 SST 到可用数据
恢复所需的工作有五件事:
- 创建需要恢复的 database 和 table;
- 根据 table 和 SST 文件元信息,进行 Split & Scatter Region;
- 将备份下来的 SST 文件按需读取到 TiKV 各节点;
- 根据新 table 的 ID 对 SST 进行 Key Rewrite;
- 将处理好的 SST 文件 Ingest 到 TiKV。
Key Rewrite:tableID 编码决定了必须改写
TiDB 的数据在 TiKV 中的保存形式是(见 pkg/tablecodec):
Key: tablePrefix{tableID}_recordPrefixSep{rowID}
Value: [col1, col2, col3, col4]
Key: tablePrefix{tableID}_indexPrefixSep{indexID}_indexedColumnsValue
Value: rowID
由于 Key 中编码了 tableID,不能把备份下来的 SST 文件不经处理直接恢复到 TiKV,否则可能因 tableID 对不上而导致数据错乱。解决方案是在恢复前对 SST 文件做 key 改写:把原有 tableID 替换为新创建的 tableID,indexID 也需要同样处理。
在后续的 importSST 设计文档(2019-09-17-design-of-reorganize-importSST-to-TiKV.md)中,Key Rewrite 被进一步具体化为前缀替换规则,一个 BR 的 SST 可能包含多个 table,所以必须支持多条 Rewrite Rule 同时生效;且 SST 可能来自非 TiDB 系统,Importer 不应有 key 编码格式假设。建议的数据结构:
message RewriteRule {
bytes old_prefix = 1; // this can be empty for universal prefix insertion!
bytes new_prefix = 2; // these are _not_ just an integer!
}
message RestoreRequest {
...
repeated RewriteRule rewrite_rules = N;
...
}
配套的正向改写与反向还原函数:
// 正向替代一个 Key
fn rewrite_key(rules: &[RewriteRule], key: &[u8]) -> Cow<[u8]> {
for rule in rules {
if key.starts_with(rule.old_prefix) {
return Cow::Owned(rule.new_prefix + key[rule.old_prefix.len()..])
}
}
Cow::Borrowed(key)
}
// 反向还原一个 Key
fn undo_rewrite_key(rules: &[RewriteRule], key: &[u8]) -> Cow<[u8]> {
for rule in rules {
if key.starts_with(rule.new_prefix) {
return Cow::Owned(rule.old_prefix + key[rule.new_prefix.len()..])
}
}
Cow::Borrowed(key)
}
关于"在哪个位置做 rewrite"的完整权衡(源数据必然是 rewrite 前的、PD 和 Ingest 后的结果必然是 rewrite 后的),见 2019-09-09-BR-key-rewrite-disscussion.md,其结论是"Key rewrite before ingest in every replica":每个 Peer 独自在 ingest 之前进行 Key Rewrite,Importer 侧写盘/读盘/网络传输代价最小,rewrite 只发生 R+1 次(pre-split + Raft-Ingest 前)。
Split & Scatter:按 SST 范围预分裂 region
TiKV 对 Region 的大小是有限制的,默认为 96MB,超出阈值则需要分裂(Split)。集群刚启动时只有少量 region,恢复时不能把所有数据都恢复到这些 region 中,因此需要提前将 Region 分裂(Split)并打散(Scatter)。
由于备份 SST 文件是按照 region 生成的,天然就有合适的大小和对应的数据范围,所以可以根据各个 SST 文件中的数据范围对集群中的 region 进行分裂;分裂完成后还需要打散新分裂出来的 region,防止发生数据倾斜。
恢复流程的演进:弃用 tikv-importer
原设计文档记录了恢复方案的一次关键演进:不再使用 tikv-importer(预计 4.0 移除),转而使用 TiKV 一组能够下载/导入 SST 文件的新 API(相关内容见 2019-09-17-design-of-reorganize-importSST-to-TiKV.md,即把 key rewrite 下推到 TiKV 的 sst-importer 工作线程)。恢复流程因此精简为:
- 使用备份的 schema 信息创建新表;
- 通过备份的元数据构建 key-value 的范围,同时依照新表 ID 构建 rewrite rules;
- 依照 rewrite rules 和 key 范围分割并打散 regions;
- 调用 download 和 ingest API 完成导入。
该方案的具体执行分两个阶段(引自 importSST 设计文档):
- Process:客户端向 region peers 所处的 TiKV 发起 Process 请求,TiKV 中的 importer worker 根据请求下载 SST 并 Rewrite SST;
- Ingest:客户端发现所有 TiKV 准备好后,向 region leader 发起 ingest 请求,Leader 通过 Raft 复制给 follower,各 TiKV 执行 ingest sst,导入结束。
错误处理上:Download SST 和 Rewrite SST 两步不会对集群数据产生修改,出错时客户端重试即可;真正修改数据的 Ingest 部分,可恢复错误包括 NotLeader、IngestEpochNotMatch(均重试),Ingest 执行失败则视为不可恢复错误。
当前仓库中恢复侧的代码主体位于 br/pkg/restore,其中 restorer.go、split/、ingestrec/ 等目录分别对应恢复主控、region 分裂打散与 ingest 回执管理等职责,与上述流程的阶段划分相呼应。
两个遗留问题:在线恢复与原集群恢复
原设计文档"问题"一节提出了两个当时尚未完全解决的难点:
- 在线恢复:困境在于 ingest SST 失败会导致各个副本之间的数据不一致;文档给出的方向是通过 placement rule 实现资源隔离(把恢复中的表调度到独立资源,从而降低对在线业务与副本一致性的冲击);
- 原集群恢复:涉及各种 ID 冲突问题,最简单办法是 rewrite key——把 key 中老 ID 替换成新 ID(CockroachDB 采用同类方案)。文档指出"我们通过 Key rewrite 在理论上支持原集群恢复",即恢复目标与原集群相同时,用新分配的 tableID 整体替换旧 ID,避免与现存数据冲突。
附录:Backup/Import gRPC 服务
文档附录说明:备份 RPC 的协议定义位于 kvproto 仓库的 backup.proto(backuppb),新的恢复 RPC 见 import_sstpb.proto。当前仓库源码中也可以看到这些协议的直接引用,例如 br/pkg/backup/client.go 顶部的 backuppb "github.com/pingcap/kvproto/pkg/brpb" 导入,以及 MainBackupLoop 中通过 backuppb.BackupClient 与每个 TiKV store 建立的 gRPC streaming 连接(GetBackupClient/ResetBackupClient,见 client.go 的 ClientMgr 接口)——这正是文档第 2 步"TiKV Streaming 发过来的备份结果"在客户端一侧的形态。
小结:从 2019 设计到当前实现的映射
| 设计文档概念 | 当前仓库中的对应实现 |
|---|---|
| 外部程序聚合-重试主循环 | br/pkg/backup/client.go 的 RunLoop 轮次机制 |
| 查漏补缺、精细化重试 | ProgressRangeTree.GetIncompleteRanges() + 单 store 级 BackupRetryPolicy 重发 |
| KeyLocked 可恢复异常 | 轮末 ResolveLocksForRead 并回填 Context.ResolvedLocks |
| 延长 GC 时间保护备份数据 | br/pkg/gc/manager_global.go 的 UpdateServiceGCSafePoint(..., sp.BackupTS-1) |
| 外部存储抽象、内存生成 SST 直写 | Client.storage/Client.backend(storeapi.Storage 与 backuppb.StorageBackend) |
| Key Rewrite / Split & Scatter / Ingest | br/pkg/restore(restorer.go、split/、ingestrec/ 等) |
需要说明的是:本文主体文档写于 2019-2020 年的 BR 3.x 设计期,部分表述(如"4.0 移除 tikv-importer")是当时语境的展望;但 KV Scan 备份原理、SST 直写外部存储、GC 安全点保护、Key Rewrite 恢复这些核心机制,在 br/pkg/backup 与 br/pkg/restore 中依然构成了 BR 备份恢复的基础架构。阅读这份设计文档,是理解 TiDB 备份恢复"为什么长这样"的最佳入口。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00