一个 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/tikvissue #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-interval、snap-gc-timeout,官方配置文档,A
级)来控制过期 Snapshot 文件的清理节奏,避免 Snapshot
文件堆积占满磁盘。
Snapshot 常见的触发场景:
- 新增副本(PD 调度 add-peer),新副本没有任何历史数据,只能靠 Snapshot 起步。
- 副本长时间离线后重新上线,日志进度落后超过配置阈值。
- Region 发生 split/merge 之后,新产生的 Region 的某些副本需要重新对齐状态(第 8 篇具体讨论 split/merge 调度)。
二、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
Consensus(tikv/tikv issue
#3314、#7587,PD 侧 enable-joint-consensus
配置默认开启,A
级):把一次「加一个、减一个」的副本替换,当成一次原子的联合配置变更来处理,PD
通过 ChangePeerV2Enter 与
ChangePeerV2Leave 两步算子驱动(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 篇专门展开。
常见误解
- 「Snapshot 就是备份」:Region Snapshot 是 Raft 协议内部用于同步落后副本状态的机制,生命周期很短(追赶完成即可清理),不是面向灾备/恢复设计的持久化备份。TiKV 生态里的备份恢复走的是另一套工具链(BR 等),不在本篇讨论范围。
- 「日志 GC
越激进越好,能省磁盘」:过度激进的日志 GC
会让暂时离线一小段时间就恢复的副本也被迫走 Snapshot
全量同步,反而增加瞬时 I/O
与带宽压力。阈值(
raft-log-gc-threshold等)的存在正是为了在「省磁盘」和「给慢副本留追赶空间」之间取一个折中,不是数值越小越好。 - 「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 团队声明过这一继承关系。
七、小结
三句话小结
- Raft Snapshot 让严重落后或全新的副本跳过逐条日志重放,直接同步到 Leader 当前状态,代价是一次性的扫描、序列化和网络传输开销,因此 TiKV 只在日志重放明显不划算时才触发。
- log GC 按副本复制进度和阈值配置截断已经不再需要的 Raft 日志,截断点必须晚于所有仍在正常追赶副本的复制位置,否则会把可修复的慢副本逼成必须走 Snapshot 的全量同步。
- TiKV 支持 Voter、Learner、Witness 三种副本形态,分别对应「标准投票+存数据」「只接收日志不投票」「只投票不存数据」三种取舍,TiFlash 作为 Learner 的具体列存链路留给第 14 篇。
下一篇转向 PD:Region 心跳如何汇报给 PD、PD 又如何据此生成调度决策——PD 元数据与调度。
参考资料
规范 / 文档
- Ongaro, D. & Ousterhout, J., In Search of an Understandable Consensus Algorithm, USENIX ATC 2014(Raft 论文,日志压缩/Snapshot 的协议动机,A 级,仅引用结论)。
- TiKV 源码,
raftlog_gc.rs(raftlog GC worker 实现,A 级)。 - TiKV Documentation, Raftstore
Config(
raft-log-gc-*、snap-mgr-gc-tick-interval、snap-gc-timeout等配置项,A 级)。 tikv/tikvGitHub issue #12876, Support 2 Replicas With 1 Log Only Mechanism(witness 副本设计与限制列表,A 级,TiKV 7.1 起支持)。tikv/tikvGitHub issue #1468、#3314,Joint consensus for cluster membership change(A 级,成员变更安全性讨论与容错窗口约束)。- PD Configuration File
Documentation,
enable-joint-consensus(A 级,默认开启的联合共识调度)。 tikv/tikv源码,components/raftstore/src/store/snap.rs、src/server/snap.rs(A 级,Snapshot 按 CF 生成 SST/Plain 文件、gRPC 传输与应用流程)。- TiKV Configuration
File,
snap-min-ingest-size、snap-generator-pool-size(A 级,ingest 阈值与生成线程池大小)。
论文(A 级)
- Lamport, L. & Massa, M., Cheap Paxos, Proceedings of DSN 2004(辅助处理器与主处理器的容错成本权衡,本文用于对照 Witness 副本,非 TiKV 官方声明的设计来源)。
站内
- RocksDB
内核系列(write
stall/compaction、
IngestExternalFile批量导入机制与 Raft 日志 GC 的边界)。 - 第 5 篇 raftstore 写路径、本系列 index。
上一篇:raftstore 写路径:propose 到 Apply
下一篇:PD 元数据与调度
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【TiKV / HTAP 内核】Multi-Raft:一 Region 一 Raft 组
拆解 TiKV 的 Multi-Raft 架构:为什么每个 Region 是独立的 Raft 组,与 etcd 单 Raft 组对照差在哪;跨 Region 事务代价作为开放问题收束,不重写 Raft 协议本身。
【TiKV / HTAP 内核】raftstore 写路径:propose 到 Apply
拆解一次 TiKV 写请求经 propose、append、commit、apply 四个阶段落到 RocksDB 的路径,钉住 raft 线程与 apply 线程的异步边界;不编造未实测的 QPS 数字。
【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 的负载分裂叙事。
【TiKV / HTAP 内核】生产排障:Region 过多、热点、TSO 抖动、锁冲突、apply 积压
用官方 Grafana 指标与系统表钉住五类高频生产故障的根因链:Region 过多、写/读热点、PD TSO 抖动、悲观锁冲突、raftstore apply 积压;给出症状到机制的决策树,不重复调参手册。