土法炼钢兴趣小组的算法知识备份

【TiKV / HTAP 内核】Snapshot、log GC 与副本迁移

文章导航

分类入口
databasestorage
标签入口
#tikv#raft-snapshot#log-gc#witness#learner#replica-migration#raftstore

目录

一个 TiKV 副本如果掉线几分钟再重新上线,或者一个新副本刚被 PD 调度到某个节点,它面对的问题是同一个:本地状态远远落后于 Leader,靠一条一条重放 Raft 日志追赶太慢,甚至日志早就被清理掉了。TiKV 的解法是 Raft Snapshot——直接把 Leader 当前的状态整体打包发给落后的副本,让它跳过日志重放,直接从一个已知一致的状态点继续。

这一篇(系列第 6 篇)讲两件配套的事:Snapshot 怎么生成和接收,以及为什么 Raft 日志不能无限累积、要靠 log GC 截断——这两件事互相牵制,截断得太早会让 Snapshot 变成追赶落后副本的唯一手段(开销更大),截断得太晚又会占用不必要的磁盘。文末简述 witness 副本这种更轻量的成员形态,并把 TiFlash Learner 的列存链路预告给第 14 篇。

本文是「TiKV / HTAP 内核」系列第 6 篇(共 18 篇)。→ 系列目录

篇目 核心内容
第 5 篇 · raftstore 写路径 propose → apply
第 6 篇 · Snapshot / log GC 副本追赶、日志截断、witness
第 7 篇 · PD 元数据与调度 heartbeat、调度算子(待发布)

版本锚定:TiKV 7.x/8.x(raftlog_gc.rs 源码与配置项 raft-log-gc-*snap-gc-timeout,A 级;tikv/tikv issue #12876 Support 2 Replicas With 1 Log Only Mechanism,witness 特性设计,A 级,随 TiKV 7.1 落地)。


一、为什么需要 Snapshot:日志重放不是永远可行的

Raft 论文(Ongaro & Ousterhout, USENIX ATC 2014)本身就指出,如果一直靠增量重放日志追赶落后节点,日志会无限增长,最终没有节点能负担得起存储和重放全部历史操作的开销——协议层面必须有「日志压缩」(Log Compaction)机制,状态机在某个时间点的快照可以替代它之前的全部日志。TiKV 的实现方式是:一旦某个副本的日志进度落后超过阈值(或者是一个全新加入的副本,完全没有历史日志),Leader 判断增量重放已经不划算,转而生成一份 Region Snapshot,把当前的 Key-Value 数据整体发给它。

sequenceDiagram
  participant L as Leader
  participant F as Lagging Follower
  L->>L: detect follower log index too far behind
  L->>L: generate region snapshot from local RocksDB
  L->>F: send snapshot (region data + metadata)
  F->>F: apply snapshot, discard stale local state
  F->>L: ack, resume normal log replication

Snapshot 的内容本质上是这个 Region 当前 Key 区间在 RocksDB 里的数据(含三 CF,第 2 篇),以及必要的元数据(当前的 apply index、RegionEpoch 等)。接收方应用 Snapshot 之后,本地状态直接跳到 Leader 生成 Snapshot 那一刻的水位,之后再靠正常的 Raft 日志复制继续追赶差距。

1.1 Snapshot 的开销来源与生成时机

生成 Snapshot 需要扫描本地 RocksDB、序列化数据、通过网络发送给目标副本——这是一次实打实的 I/O 和带宽开销,Region 数据量越大,开销越高。这也是为什么 TiKV 对触发 Snapshot 的阈值和频率有专门的配置项(如 snap-mgr-gc-tick-intervalsnap-gc-timeout,官方配置文档,A 级)来控制过期 Snapshot 文件的清理节奏,避免 Snapshot 文件堆积占满磁盘。

Snapshot 常见的触发场景:


二、log GC:Raft 日志不能只增不减

commit 之后的日志被 apply 到状态机(第 5 篇),但日志本身并不会在 apply 完成后立刻删除——因为已经落后或者暂时不可达的 Follower,可能仍然需要靠重放这些日志来追赶进度,过早删除反而会强迫这些 Follower 全部退化成走 Snapshot。TiKV raftstore 有一个专门的后台任务(raftlog_gc worker,源码 raftlog_gc.rs,A 级)按周期检查每个 Region 的日志,把确认不再需要的旧日志条目从 Raft 日志引擎里删除。

判断「不再需要」的核心依据是两类阈值(TiKV 配置文档 Raftstore Config,A 级):

配置项 含义 作用
raft-log-gc-tick-interval 触发日志 GC 检查的周期(默认 10s) 控制 GC 任务的调度频率
raft-log-gc-threshold 残留日志条数的软限制(默认 50) 低于此数量不必立刻 GC
raft-log-gc-count-limit 残留日志条数的硬限制 超过后强制 GC,即便还有慢 Follower 未追上
raft-log-gc-size-limit 残留日志总大小的硬限制(默认约为 Region 大小的 3/4) 同上,按体积而不是条数控制
flowchart LR
  applied["apply index advances"]
  check["raftlog_gc worker checks<br/>replicated index of all peers"]
  safe["safe truncate point =<br/>min(replicated index across peers)"]
  gc["delete log entries<br/>before safe point"]
  applied --> check --> safe --> gc

这里的关键约束是:日志截断点不能超过所有副本(包括暂时慢一点但仍在正常追赶的副本)已经复制到的位置,否则慢副本下次想靠增量日志追赶时,会发现需要的日志已经被删了,只能转为走 Snapshot。TiKV 在实现里把这类日志需求也纳入 GC 判断(witness 副本相关的设计文档提到 replicated index 概念,用于避免因为个别慢副本卡住整体日志 GC 的节奏,同时又不至于让快副本无限制积压日志等它——这是硬限制阈值存在的原因)。

2.1 log GC 与 write stall 是两个不同的磁盘压力来源

Raft 日志和 RocksDB 的 SST 文件是两套独立的存储(TiKV 的 Raft Engine 与 KV RocksDB 实例分离)。log GC 处理的是 Raft 日志引擎的磁盘占用;RocksDB 层面的 write stall、compaction 落后是另一套独立的机制(详见 RocksDB 内核系列)。两者都可能表现为「磁盘占用高」或「延迟抖动」,但根因和处理手段完全不同,这是第 17 篇排障时要先分层判断的一个典型例子。


三、副本迁移:add-peer、remove-peer 与 Joint Consensus

PD 决定把某个 Region 的副本从节点 A 迁到节点 B(比如 A 负载过高,或者要下线 A),并不是一次 remove-peer 就完成——迁移的中间态如果处理不当,会短暂降低这个 Region 的容错能力。TiKV 早期采用与 etcd 相同的做法:一次成员变更只加一个或减一个 peer(tikv/tikv issue #1468,A 级)。这种「一步到位」的方式在特定拓扑下有安全隐患:如果目标是把 3 副本 {A, B, C} 换成 {A, B, D},若把 add D 和 remove C 分成两步顺序执行,中间会经过 {A, B, C, D} 这样的 4 副本状态,多数派门槛从 2 提升到 3,如果这段时间内一个节点故障,可用性反而下降。

3.1 Joint Consensus 如何降低迁移窗口的风险

TiKV 后续引入了 Joint Consensustikv/tikv issue #3314、#7587,PD 侧 enable-joint-consensus 配置默认开启,A 级):把一次「加一个、减一个」的副本替换,当成一次原子的联合配置变更来处理,PD 通过 ChangePeerV2EnterChangePeerV2Leave 两步算子驱动(PD 源码/issue 记录,A 级),中间状态下新旧两组配置同时需要满足多数派确认,而不是简单叠加成员数:

sequenceDiagram
  participant PD
  participant Old as Old config {A,B,C}
  participant New as New config {A,B,D}
  PD->>Old: ChangePeerV2Enter (joint: old+new)
  Note over Old,New: Both old and new majority<br/>must agree during joint state
  PD->>New: D catches up via snapshot
  PD->>Old: ChangePeerV2Leave (commit to new config)
  Note over New: Now only {A,B,D} majority required

值得注意的是,即便有 Joint Consensus,3 副本集群做「替换 1 个节点」时仍然存在一个理论上的容错窗口约束(tikv/tikv issue #1468 讨论记录,A 级):只要变更前后共享的副本数减去容错数不大于新增或删减的副本数,就无法在单步内避免容错能力下降;这是 3 节点集群天然的数学约束,而不是 TiKV 实现的缺陷——4 节点以上的集群才能在单步 Joint Consensus 内完整保持容错能力。这一点作为边界说明,具体的调度算子生成策略留给第 7 篇 PD 调度机制展开。

3.2 迁移中的角色:新副本先当 Learner

无论是否启用 Joint Consensus,新增副本几乎总是先以 Learner 身份加入(tikv/tikv issue #3314 描述的流程,A 级):Learner 只接收日志、不参与投票,等它通过第一节的 Snapshot(或后续日志重放)追上 Leader 的进度后,PD 才会把它提升为正式的 Voter 副本。这一步的意义是:避免把一个数据还是空的新副本直接算进多数派——如果直接算进多数派,短暂的「等待追上数据」窗口里,多数派确认可能包含一个实质上不能正确响应读写的成员,等价于降低了真实容错能力。第 6 篇标题提到的「副本迁移」,在协议层面正是「先加 Learner、追赶数据、再转 Voter、最后删旧副本」这样一套组合动作,而不是一次简单的成员替换。


四、副本形态:Voter、Learner、Witness

到目前为止讨论的都是标准的 Voter 副本——参与 Raft 选举投票、参与日志提交判定、apply 数据到本地。TiKV 在此之外支持更轻量的副本角色:

角色 参与选举投票 apply 数据 存储开销 典型用途
Voter 完整 标准副本,默认 3 个
Learner 是(只读) 完整 只接收日志、不投票,常用于安全地扩容新副本或跨系统同步(TiFlash 即是 Learner,第 14 篇)
Witness 是(投票权重与 Voter 相同) (只存 Raft 日志,不 apply 到本地 KV) 极低(不含实际数据) 降低容灾场景下的存储成本(tikv/tikv issue #12876,TiKV 7.1 起支持)

Witness 副本的设计目标很具体:在需要跨机房/跨可用区维持多数派容灾能力,但又不想为每个额外副本都存一份完整数据的场景下,用「只存日志、不存数据」的轻量副本占住一个投票席位。tikv/tikv issue #12876(A 级,任务分解列表)明确列出了 witness 相关的限制:禁止对 witness 副本读写、拒绝把 Leader 转移给 witness、witness 跳过 apply 写命令、witness 作为 Leader 时拒绝生成 Snapshot(因为它本身没有完整数据可发)。当集群检测到某个非 witness 副本长期故障或数据落后过多时,PD 可以把 witness 提升为完整 Voter 以恢复容灾能力(tikv/tikv issue #12876,A 级)。

这一节只点到为止——witness 的调度策略、与 PD 副本管理的交互细节超出本篇范围。TiFlash Learner 是本系列另一个重点角色,它同样不参与选举投票,但会把 Raft 日志转换成列存格式而不是原样 apply 到 RocksDB,这条链路和它带来的分析读新鲜度问题,留给第 14 篇专门展开。

常见误解

  1. 「Snapshot 就是备份」:Region Snapshot 是 Raft 协议内部用于同步落后副本状态的机制,生命周期很短(追赶完成即可清理),不是面向灾备/恢复设计的持久化备份。TiKV 生态里的备份恢复走的是另一套工具链(BR 等),不在本篇讨论范围。
  2. 「日志 GC 越激进越好,能省磁盘」:过度激进的日志 GC 会让暂时离线一小段时间就恢复的副本也被迫走 Snapshot 全量同步,反而增加瞬时 I/O 与带宽压力。阈值(raft-log-gc-threshold 等)的存在正是为了在「省磁盘」和「给慢副本留追赶空间」之间取一个折中,不是数值越小越好。
  3. 「Learner 和 Witness 是一回事」:两者都不参与选举投票的说法只对 Learner 成立——Witness 保留投票权重,只是不 apply 实际数据;Learner 不参与投票,但会完整 apply 数据只是不满足成为 Leader 的资格。二者解决的问题完全不同:Learner 解决「安全地引入新副本/跨系统同步而不影响现有多数派」,Witness 解决「维持容灾投票席位但省数据存储」。

五、Snapshot 的物理格式:按 CF 拆分的 SST 与 ingest 写入

第一节把 Snapshot 描述成”把 Region 数据整体打包发送”,但没有细说这个包具体长什么样。tikv/tikv 源码(components/raftstore/src/store/snap.rs,A 级)里,生成 Snapshot 时会按第 2 篇讲过的三个 CF 分别处理,且不是所有 CF 都用同一种物理格式

CF 物理格式 原因
CF_DEFAULT SST 文件 数据量大,SST 自带压缩,适合批量传输
CF_WRITE SST 文件 MVCC 提交记录,结构化数据,同样受益于 SST 压缩
CF_LOCK Plain 文件(未压缩的原始格式) 数据量小且频繁变化,SST 的构建开销相对不划算

每个 CF 文件的命名遵循固定规则(源码 SnapKey/文件命名逻辑,A 级):生成端文件名形如 gen_{region_id}_{term}_{index}_{cf}.sst,接收端落地后重命名为 rev_{region_id}_{term}_{index}_{cf}.sst,另有一个 .meta 文件记录整份 Snapshot 的元数据与校验和。

flowchart TD
  TRIGGER["Leader detects need for snapshot"]
  TRIGGER --> BUILD["SnapManager.build:<br/>iterate kv_snap per CF"]
  BUILD --> DEF["CF_DEFAULT -> SST file"]
  BUILD --> WRITE["CF_WRITE -> SST file"]
  BUILD --> LOCK["CF_LOCK -> plain file"]
  DEF --> META["write .meta<br/>(checksum, index, term)"]
  WRITE --> META
  LOCK --> META
  META --> SEND["gRPC stream to follower"]
  SEND --> RECV["Follower: write to temp files,<br/>verify checksum"]
  RECV --> APPLY{"snapshot size ><br/>snap-min-ingest-size?"}
  APPLY -->|"yes"| INGEST["ingest_external_file_cf:<br/>SST files linked directly into RocksDB"]
  APPLY -->|"no"| DIRECT["Direct write:<br/>iterate and Put each KV"]

接收方应用 Snapshot 时有两条路径(TiKV Configuration File,snap-min-ingest-size,A 级,默认 2 MiB):数据量超过阈值时走 RocksDB 的 ingest_external_file_cf——把已经生成好的 SST 文件直接”接入”RocksDB 的 LSM 结构,不需要逐条重写 Key-Value;数据量小于阈值时则直接逐条 Put,因为对小文件而言,ingest 本身的元数据操作开销反而不划算。这个”大文件走 ingest、小文件走直接写”的分支逻辑,和 RocksDB 生态里批量导入数据常用的 IngestExternalFile 思路是同一套机制的复用(详见 RocksDB 内核系列),Snapshot 只是 TiKV 内部触发这条路径的一种场景,不是另起的独立实现。

snap-generator-pool-size(默认取决于版本,A 级配置项)控制的是生成端并行构建 Snapshot 的线程数——当一个节点同时要给多个 Region 的落后副本生成 Snapshot(例如故障恢复期间大批量重建副本),这个线程池大小直接决定了恢复速度的上限,与第 3.1 节讲过的”Snapshot 开销随 Region 数据量增长”是两个独立的维度:一个是单次 Snapshot 有多贵,一个是能同时生成多少份。


六、学术谱系:Cheap Paxos 与 TiKV Witness 的相似与差异

第四节的 Witness 副本——“参与投票但不存数据”——容易让人联想到分布式共识文献里一个更早的经典想法:Cheap Paxos(Lamport, L. & Massa, M., Cheap Paxos, DSN 2004,A 级)。Cheap Paxos 的动机是同一个方向:标准 Paxos 容忍 \(f\) 个故障需要 \(2f+1\) 个参与者,Lamport 观察到其中只有 \(f+1\) 个”主处理器(main processor)“真正需要维护系统状态,剩下 \(f\) 个”辅助处理器(auxiliary processor)“可以是更便宜、更弱的机器——用更少的”全功能”副本换取整体成本下降。

但把 Witness 直接等同于 Cheap Paxos 的辅助处理器是不准确的,两者有一个关键差异:

维度 Cheap Paxos 辅助处理器 TiKV Witness
正常运行时是否参与协议 不参与,保持空闲,只在主处理器故障、需要重新配置时才被唤醒投票 持续参与:每一轮 Raft 日志复制都要投票确认,只是不 apply 数据
触发条件 故障发生后才介入 无论是否有故障,始终在 Raft 组的投票路径里
代价换取的对象 减少「始终维护状态」的全功能副本数量 减少「始终存储数据」的副本存储开销,但不减少参与投票的实时性要求
可用性代价 主处理器故障期间需要等待辅助处理器完成重新配置才能继续(牺牲部分活性 liveness) 不额外引入这类等待窗口——Witness 一直在线投票,只是故障后提升为 Voter 时需要补数据

这个对比揭示的是同一个大问题(如何用更少的”全功能副本”维持容错能力)在不同约束下的两种解法:Cheap Paxos 选择让部分副本在时间维度上休眠(不忙的时候不参与),TiKV Witness 选择让部分副本在空间维度上”瘦身”(一直参与投票,但不存储数据)。两者都不是免费的午餐——Cheap Paxos 用活性换存储成本,Witness 用”故障后需要补数据才能真正扛住下一次故障”这个恢复窗口换存储成本。TiKV 官方设计文档没有直接引用 Cheap Paxos 作为设计来源,这里的对照是本文基于两者机制特征做出的类比,用于帮助读者定位 Witness 在经典共识优化谱系里的位置,不代表 TiKV 团队声明过这一继承关系。


七、小结

三句话小结

  1. Raft Snapshot 让严重落后或全新的副本跳过逐条日志重放,直接同步到 Leader 当前状态,代价是一次性的扫描、序列化和网络传输开销,因此 TiKV 只在日志重放明显不划算时才触发。
  2. log GC 按副本复制进度和阈值配置截断已经不再需要的 Raft 日志,截断点必须晚于所有仍在正常追赶副本的复制位置,否则会把可修复的慢副本逼成必须走 Snapshot 的全量同步。
  3. TiKV 支持 Voter、Learner、Witness 三种副本形态,分别对应「标准投票+存数据」「只接收日志不投票」「只投票不存数据」三种取舍,TiFlash 作为 Learner 的具体列存链路留给第 14 篇。

下一篇转向 PD:Region 心跳如何汇报给 PD、PD 又如何据此生成调度决策——PD 元数据与调度


参考资料

规范 / 文档

  1. Ongaro, D. & Ousterhout, J., In Search of an Understandable Consensus Algorithm, USENIX ATC 2014(Raft 论文,日志压缩/Snapshot 的协议动机,A 级,仅引用结论)。
  2. TiKV 源码,raftlog_gc.rs(raftlog GC worker 实现,A 级)。
  3. TiKV Documentation, Raftstore Configraft-log-gc-*snap-mgr-gc-tick-intervalsnap-gc-timeout 等配置项,A 级)。
  4. tikv/tikv GitHub issue #12876, Support 2 Replicas With 1 Log Only Mechanism(witness 副本设计与限制列表,A 级,TiKV 7.1 起支持)。
  5. tikv/tikv GitHub issue #1468、#3314,Joint consensus for cluster membership change(A 级,成员变更安全性讨论与容错窗口约束)。
  6. PD Configuration File Documentation,enable-joint-consensus(A 级,默认开启的联合共识调度)。
  7. tikv/tikv 源码,components/raftstore/src/store/snap.rssrc/server/snap.rs(A 级,Snapshot 按 CF 生成 SST/Plain 文件、gRPC 传输与应用流程)。
  8. TiKV Configuration File,snap-min-ingest-sizesnap-generator-pool-size(A 级,ingest 阈值与生成线程池大小)。

论文(A 级)

  1. Lamport, L. & Massa, M., Cheap Paxos, Proceedings of DSN 2004(辅助处理器与主处理器的容错成本权衡,本文用于对照 Witness 副本,非 TiKV 官方声明的设计来源)。

站内

  1. RocksDB 内核系列(write stall/compaction、IngestExternalFile 批量导入机制与 Raft 日志 GC 的边界)。
  2. 第 5 篇 raftstore 写路径本系列 index

上一篇raftstore 写路径:propose 到 Apply

下一篇PD 元数据与调度

返回 系列目录 · 数据库索引

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-16 · database / storage

【TiKV / HTAP 内核】Split / Merge / 热点:Region 何时该切、何时该并

拆解 TiKV Region 何时触发 size/key-count 分裂、如何用 BatchSplit 在不拷贝数据的前提下切分 RocksDB key range;PrepareMerge/CommitMerge 两阶段合并;Load Base Split 与 PD hot-region-scheduler 两层热点机制的分工,并对齐 distributed/27 的负载分裂叙事。


By .