TigerBeetle State Sync 状态同步协议深度解析:落后副本如何追赶健康集群
【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle
导读
State Sync(状态同步)是 TigerBeetle 中让"落后副本"(lagging replica)追赶健康集群的核心机制。当某个副本因宕机、网络分区或运行缓慢而落后过多,导致其日志与集群当前日志不再相交、无法通过 WAL repair 追上进度时,State Sync 会以**检查点(checkpoint)**为单位,把超级块、网格(grid)、LSM 表数据与客户端应答整体同步到集群的最新状态。读完本文,你将掌握:State Sync 的触发条件与 WAL repair 的取舍边界、由四个子协议组成的完整同步流程、sync_op_min/sync_op_max/sync_tables_op_range等关键状态字段的语义,以及"同步副本"在复制协议中的特殊地位与存储确定性保证。
本文基于 docs/internals/sync.md 展开,并结合 src/vsr/replica.zig 与 src/vsr/superblock.zig 的源码实现进行验证与深化。
State Sync 的定位:WAL repair 之外的兜底协议
State Sync 解决的是这样一类问题:落后副本的日志与集群当前日志不再相交,WAL repair 无法再将其追赶上来。这里的"不相交"意味着,无论怎么按 WAL 逐条补齐,副本都无法从自己的历史日志延续到集群的最新进度——它的历史已经断开了。
在 Viewstamped Replication 的术语里,这一机制被称作 "state transfer",但 TigerBeetle 为了避免与项目中的业务转账(transfers)混淆,将其命名为State Sync。
"状态"具体指什么
在 State Sync 语境下,"状态"由四部分构成:
- 超级块(superblock)中的
vsr_state.checkpoint; - 网格(grid)中的 manifest、free set 与 client sessions 块;
- 网格中的 LSM 表数据(仅指已获取的块,acquired blocks);
- 客户端应答(client replies)。
与之对应,State Sync 由四个子协议组成,一一对应上述四类状态:
- Sync Superblock——同步第 1 项,对应 Protocol: Request View;
- Repair Grid——同步第 2 项,对应 Protocol: Repair Grid;
- Sync Forest——同步第 3 项,对应 Protocol: Sync Forest;
- Sync Client Replies——同步第 4 项,对应 Protocol: Sync Client Replies。
从 src/vsr/superblock.zig 中可以看到,CheckpointState结构体正是围绕这几类状态组织校验和的:它携带 checkpoint 的header(该检查点提交到状态机的最后一条 prepare,启动时从此处重放日志)、free set 的blocks_acquired/blocks_released校验和、client_sessions块校验和、manifest_oldest/manifest_newest校验和等。这一结构体会原样嵌入view消息,由主节点发送给同步中的副本(见 src/vsr/replica.zig 的view_message_checkpoint())。
两个关键性质
同步目标是"最新检查点"。superblock-sync 的目标是健康集群的最新检查点;当落后副本追赶到最新检查点(或非常接近它)时,就可以过渡回健康状态。
同步是"懒惰"(lazy)的。从逻辑上讲,一旦超级块同步完成,整个 State Sync 就算"完成"了——新超级块所指向的数据可以在后续按需(on-demand)传输。这大大降低了同步的峰值带宽与延迟成本。
状态与 WAL 原子更新。新检查点状态(superblock)与 WAL 的更新是原子的——view消息同时携带两者,保证副本不会出现"超级块是新检查点、但 WAL 还是旧内容"的错乱中间态。
术语表:同步副本与检查点
副本角色(Replica roles):
- 同步副本(syncing replica):正在执行 superblock-sync 的副本,即处于同步算法第 1–5 步中的副本;
- 健康副本(healthy replica):未执行 superblock-sync 的副本,是活跃集群的组成部分。
检查点(Checkpoints):
- 检查点标识(checkpoint id / checkpoint identifier):唯一标识某个特定检查点,并且可以在不同副本上**可重现地(reproducibly)**生成。它是整个状态(
CheckpointState)的哈希; - 持久检查点(durable checkpoint):其状态至少在复制法定人数(replication quorum)个不同副本上都已存在的检查点。
检查点标识会被附加到多种消息类型上(详见下文"存储确定性"一节),正是依赖它,集群才能跨副本验证状态是否一致。
同步算法:六个步骤的完整流程
整体算法可概括为以下步骤:
- 第 0 步:判定是否需要同步(见"触发场景");
- 第 1 步:响应
view消息触发同步(见"触发方式"); - 第 2 步:中断进行中的提交流程:
- 等待写操作完成;
- 取消可能停滞的读操作(参见
Grid.cancel()); - 等待取消完成;
- 第 3 步:将新检查点及匹配的 headers 安装进超级块:
- 将
vsr_state.checkpoint.header提升为同步目标 header; - 将
vsr_state.checkpoint.parent_checkpoint_id提升为早于同步目标的那个检查点 id(注意:它不是"我们自己的上一个检查点"); - 提升
replica.commit_min; - 将
vsr_state.sync_op_min设为尚未修复的最小 op; - 将
vsr_state.sync_op_max设为尚未修复的最大 op; - 若
replica.sync_tables_op_range尚未设置,则设置它(见下文);
- 将
- 第 4 步:修复数据:修复在
vsr_state.sync_op_{min,max}区间内创建的应答、free set / client sessions / manifest 块,并修复在replica.sync_tables_op_range区间内创建的表块; - 第 5 步:收尾:作为下一个检查点的一部分,更新超级块:
sync_op_min = 0、sync_op_max = 0。
两条重要的恢复规则:
- 如果副本启动时发现
vsr_state.sync_op_max ≠ 0,直接跳到第 4 步继续修复(这正是"启动时恢复未完成的同步"); - 如果同步旧目标的过程中收到新的同步目标,
replica.sync_tables_op_range不会立即更新——必须先完成旧同步范围内的全部表同步,才开始同步新范围。这就是文档中强调的"state sync ratchet"(状态同步棘轮),避免反复重做同步工作。源码中的体现位于 src/vsr/replica.zig:若已有sync_tables_op_range,则继续沿用旧区间(op_range.max < sync_op_max),只有在新启动同步时才从trigger + 1开始设置新区间。
为什么第 3 步要设置parent_checkpoint_id为"早于同步目标"的那个检查点
这一步看起来反直觉,但很关键:State Sync 是"跳"到目标检查点的,而不是一步步走过去的。如果我们把parent_checkpoint_id设成自己上一个检查点,那么新旧检查点之间就存在一条本副本并未实际经历的路径;而把 parent 指向"集群中确实存在于同步目标之前"的那个检查点,才能保证检查点链在集群范围内是真实、一致的。
sync_op_min/sync_op_max的精确语义
这两个字段持久化在超级块的VSRState中。源码注释给出了精确定义(src/vsr/superblock.zig):
- 当
sync_op_max为零时,表示所有网格块与应答都已同步完成(此时sync_op_min也必为零); - 当
sync_op_max非零时,表示必须修复在sync_op_min到sync_op_max(含两端)之间提交过程中本应写入的网格块与客户端应答——这些数据因 State Sync "跳过"了这段日志而没有被正常写入。
这两个字段同样暴露为 trace gauge(replica_sync_op_min/replica_sync_op_max,见 src/vsr/replica.zig),便于观测同步进度。
第 0 步:何时需要 State Sync
需要同步的两类典型场景
- 副本长时间宕机 / 分区 / 缓慢:集群其余部分持续前进,落后副本落后过多,已无法通过 WAL repair 追赶;
- 新副本刚格式化并加入集群(通过重配置加入):同样落后过多,无法通过 WAL repair 追赶。
WAL repair 与 State Sync 的取舍边界
决定走哪条路的判据以"检查点"为参照物:
- 若副本比主节点落后超过一个检查点,必须使用 State Sync;
- 若副本与主节点处于同一检查点,只能走 WAL repair;
- 若副本恰好落后一个检查点,则两种方案都可能,需要谨慎判断:
- 只用 State Sync 是错误的:如果下一个检查点只有单一其他副本拥有,那么那个超前副本的状态本身可能就是损坏的,不能作为同步来源;
- 只用 WAL repair 是错误的:如果所有可达的对等副本都已经环绕(wrap)了它们的日志,把前一个检查点中的部分 prepares 驱逐了,则 WAL 修复拿不到数据;
- 结论:只要下一个检查点是持久的(即已在复制法定人数的副本上完成复制),落后副本最终必须走 State Sync。
这一判断背后的逻辑是:State Sync 要求目标检查点"足够可信",而持久检查点(durable checkpoint)恰好满足这一要求;反之,当检查点不持久时,数据来源可能不可靠,只能退而求其次用 WAL repair 补日志。
第 1 步:触发方式与超时兜底
主动触发:当副本收到携带更先进检查点的view消息时,即触发 State Sync。view消息正文中嵌有完整的CheckpointState(见 src/vsr/replica.zig 的view_message_checkpoint()),同步副本据此得知目标检查点。
被动兜底(超时触发):如果副本因为某个网格块或 prepare 长时间无法修复,导致提交无法推进,它会主动发送get_view来发起同步——这就是repair_sync_timeout的作用。源码实现位于 src/vsr/replica.zig:on_repair_sync_timeout()中,副本会记录commit_min的推进情况,若检测到repair_stuck()(修复停滞),则记录告警日志"on_repair_sync_timeout: request sync; lagging behind cluster"并向主节点发送get_view请求。
同步副本在复制协议中的特殊地位
同步副本并不是被"隔离"起来的:它们正常参与复制——可以追加 prepares、可以提交、有资格成为主节点。特别地,同步副本可以在正常提交流程中推进自己的检查点。
唯一限制是:同步副本不计入自己检查点的复制法定人数。也就是说,要让集群整体推进检查点,必须至少有复制法定人数的健康副本。
发现"足够复制(持久)的检查点"的机制依赖prepare_ok消息:发送prepare_ok表明该副本已经完整同步了最近一个检查点。因此,观察到某个commit_max充分领先于某检查点,即可推断该检查点已经持久。正因如此,同步副本会扣留(withhold)prepare_ok,直到commit_max确认自己的检查点已在法定人数的不同副本上完整复制为止——相关字段为op_prepare_max、op_prepare_ok_max与op_repair_min。这一机制保证:检查点是否"持久"的判断依据是客观的复制进度,而不是某个副本的单方面声明。
第 5 步:为何要等到下一个检查点才收尾
一个值得深究的设计问题是:为什么sync_op_{min,max}的复位要等到下一个检查点,而不是像视图变更(view change)那样立即更新?
文档给出了一个非常具体的故障场景(数字编号为原文顺序):
- 开始同步到检查点
X; - 在检查点
X之上继续提交,但还没走完到下一个检查点Y; - 在 op
X+a处,一次提交或压缩(compaction)使用了表t。此时表t即将被同步但尚未完成(修复走的是grid.read_global_queue); - 在 op
X+b处,作为压缩的一部分,表t被释放(release)。假设这发生在t完成同步之前; - 同步完成(此时仍处在
X与Y之间); - 崩溃,重启;
- 在
X之上重放提交。尽管同步已完成、存储也没有损坏,我们却丢失了表t。
需要说明的是:经由read_global_queue的修复通常会写回网格,但并不保证如此(例如当GridBlocksMissing已满时,修复可能不会落盘)。因此不能依赖"修复一定会持久化"这一假设。
还有一个该场景的变体:未来支持快照(snapshots)后,将可能在从未需要表t的情况下就释放它(即省略第 3 步)。这种情况下,重启瞬间(重放之前)超级块会声称数据文件是干净的(没有待处理的 state sync),但实际上却缺失了被引用的块。
结论:正是为了避免"同步已完成但表块缺失"这种悬挂状态,才必须等到下一个检查点才把sync_op_{min,max}复位为零。在这之前,同步范围仍然记录在超级块中,重启后可恢复未完成的同步(回到第 4 步)。
检查点标识与存储确定性
检查点标识的附加位置
checkpoint id是超级块CheckpointState的哈希,被附加在以下消息类型上:
command=commit:携带发送方当前的检查点 id;command=ping:携带发送方当前的检查点 id;command=prepare:携带的是该 prepare 最初被准备时所在检查点的 id;command=prepare_ok:携带的是该 prepare 最初被准备时所在检查点的 id。
这种"prepare 携带其出生检查点 id"的设计,使得集群能在复制过程中追溯每个操作所属的检查点世代,为下述确定性校验提供依据。
存储确定性:不一致即 panic
在一切正常的情况下,TigerBeetle 的存储是确定性的:同一检查点的CheckpointState在任何副本上哈希结果都应一致。如果通过检查点 id 不匹配检测到非确定性,检测到不匹配的副本会 panic。这是一个刻意的"fail-fast"设计——静默继续运行只会放大数据损坏。
文档明确提示运维人员:一旦出现这种 panic,应当进行调查并进行人工干预。这也解释了为什么本文开头强调 checkpoint id"可重现地(reproducibly)"生成——可重现是确定性校验的前提。
从源码看同步的实际执行链路
为了让上述算法与真实代码对应起来,这里梳理一下 src/vsr/replica.zig 中同步状态机的关键函数(函数名与行号可自行在仓库中检索核对):
sync_start_from_committing()(src/vsr/replica.zig):从"正常提交"过渡到"同步中",根据当前commit_stage决定是直接取消网格操作,还是等待不可中断的提交步骤完成;sync_dispatch()(src/vsr/replica.zig):每次同步状态机迁移的枢纽,负责在SyncStage各状态间切换(如.canceling_commit、.canceling_grid、.updating_checkpoint、.idle);sync_cancel_grid_callback()(src/vsr/replica.zig):网格取消完成后回调,断言各类队列(read_queue、read_global_queue、write_queue)均已清空、IO 计数归零,随后提取view消息中的检查点并进入.updating_checkpoint;sync_superblock_update_start()(src/vsr/replica.zig):执行第 3 步——重置状态机、free set、client sessions,计算sync_op_max(基于目标检查点 op 的 trigger),并设置/沿用sync_tables_op_range;sync_superblock_update_finish()(src/vsr/replica.zig):断言新检查点已就位、commit_min已对齐,随后打开网格、恢复消息接收,并以日志"sync: ops={}..{}/{}..{}"输出同步范围(src/vsr/replica.zig);sync_content()(src/vsr/replica.zig):在第 4 步之后,遍历森林(forest)统计待同步的表数量(按 LSM 层级分布),并记录"sync: {} tables (by level: {any})"日志,随后将缺失的 LSM 表块入队修复;sync_content_done()(src/vsr/replica.zig)、sync_client_replies_done()、sync_grid_done():分别判定内容同步、客户端应答同步与网格同步是否完成,驱动第 5 步的收尾;sync_enqueue_tables()/sync_reclaim_tables()(src/vsr/replica.zig / src/vsr/replica.zig):将待同步表入队、并在完成后回收/推进sync_tables_op_range。
此外,sync_tables_op_range字段在 src/vsr/replica.zig 声明,其与sync_tables的成对关系(sync_tables_op_range == null当且仅当sync_tables == null)在多个断言中反复校验(如 src/vsr/replica.zig),可见"同步中的表"与其 op 范围是强绑定的。
小结:State Sync 的关键设计要点
| 要点 | 说明 |
|---|---|
| 适用条件 | 副本落后超过一个检查点,或日志已与集群不相交,WAL repair 无法追赶 |
| 同步单位 | 检查点(checkpoint),目标为健康集群的最新持久检查点 |
| 四大子协议 | Sync Superblock、Repair Grid、Sync Forest、Sync Client Replies |
| 原子性 | 新检查点状态(superblock)与 WAL 一起通过view消息原子更新 |
| 懒惰性 | 逻辑上超级块同步完成即算同步完成,其余数据按需传输 |
| 持久化范围 | sync_op_min/sync_op_max持久化在超级块中,重启后从第 4 步续传 |
| 棘轮约束 | 收到新同步目标时不立即更新sync_tables_op_range,先完成旧区间 |
| 法定人数 | 同步副本不参与自己检查点的复制法定人数;prepare_ok反映检查点持久性 |
| 确定性 | 检查点 id 是CheckpointState的哈希,跨副本不一致即 panic,需人工介入 |
对运维与开发者而言,理解 State Sync 有助于:判断副本落后时该"修日志"还是"整状态";读懂 replica 日志中sync: ops=.../...与sync: N tables (by level: ...)的输出;以及在遭遇"检查点 id 不匹配导致 panic"时,第一时间意识到这是存储确定性受损的信号,而非普通故障。更完整的协议细节可继续阅读 docs/internals/vsr.md(特别是 Repair Grid / Sync Forest / Sync Client Replies 一节)与 docs/internals/ARCHITECTURE.md。
【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考