有一类故障切换很反直觉:日志显示新节点已经获得多数票,become leader 也出现了,但业务请求仍在一段时间内返回 NotLeader 或"节点未就绪"。继续优化选举参数没有效果,因为选举早已结束。
问题通常出在"协议上的 Leader"和"状态机可提供服务"之间。
Raft的三大index
Raft 节点至少有三条容易混淆的进度线:
| 指标 | 含义 |
|---|---|
last_log_index |
本地 Raft Log 已写入的最大 Index |
commit_index |
已被多数派确认提交的最大 Index |
last_applied_index |
已经应用到本地状态机的最大 Index |
它们满足:
text
last_applied_index <= commit_index <= last_log_index
commit 只说明多数节点已经对日志达成共识,并不代表每个节点的业务状态已经更新。两者的差值就是常说的 Apply Backlog:
text
apply_backlog = commit_index - last_applied_index
如果新 Leader 的 last_log_index 已经追平,而 last_applied_index 仍落后很多,它在协议层面有资格参选,状态机却还要补课。
Leader Transfer 检查了什么
braft 的公开文档描述了 Leader Transfer 的主要过程:旧 Leader 暂停接收新写入,等待目标 Follower 的日志追上,然后发送 TimeoutNow,让目标节点立即进入选举。
公开实现中的核心判断也围绕日志复制进度:目标节点尚未追到指定的 Log Index,就继续等待;追上后,才发送 TimeoutNow。用伪代码可以表示为:
text
if target.log_index < required_log_index:
wait_for_replication()
else:
send_timeout_now()
这个检查回答的是"目标节点有没有缺 Raft Log",不是"目标状态机是否已经 Apply 完这些日志"。所以一次 Leader Transfer 可以在复制层面完全成功,同时留下很大的 Apply Backlog。
为什么 Backlog 会拖住 Leader 就绪
braft 的状态机回调按顺序执行。官方文档明确说明,on_apply、on_snapshot_*、on_leader_start 等接口不需要自行保证线程安全,因为框架会串行调用;代价是一个耗时回调会挡住后续回调。
FSMCaller 使用执行队列消费这些事件。其公开实现会合并连续的 COMMITTED 事件并调用 do_committed。当队列里已有待处理的提交事件时,框架会先推进状态机,再处理排在后面的 LEADER_START。
一个常见时间线是:
text
T+0 发起 Leader Transfer
T+很短 目标节点的 Raft Log 追平 (commit idx)
T+很短 目标节点获得多数票,成为 Leader
T+... FSMCaller 顺序处理已提交但未 Apply 的日志
T+较久 Apply Backlog 清空,on_leader_start 得到执行
T+较久 业务层将节点标记为 Ready (apply idx)
如果业务把 on_leader_start 作为设置 Ready 的条件,become leader 与 Ready 之间就会出现明显空档。这不是第二次选举,也不意味着日志复制失败。它是状态机消费速度落后造成的排队。
Backlog 从何而来
最本质的原因是提交速度持续高于 Apply 速度。
text
backlog 增长速度 = commit_rate - apply_rate
下面几种情况经常同时出现:
- 节点重启后需要回放一段已提交日志;
- 多个 Raft Group 在同一节点上高强度写入;
- 状态机的单批处理包含解码、校验和存储引擎写入,单条成本偏高;
- Snapshot、压缩或后台刷盘与 Apply 争抢同一块磁盘;
- 存储介质出现长尾延迟,平均吞吐看起来正常,P99 已经恶化;
- 状态机回调里执行了阻塞操作,拖住同一执行队列上的后续事件。
只看 CPU 利用率很容易误判。Apply 可能受磁盘带宽、队列深度、写放大或单线程顺序约束限制,CPU 仍然不高。
日志证明
1. 同时采集三条 Index
对旧 Leader、目标 Follower 和新 Leader,按固定周期记录:
text
last_log_index
commit_index
last_applied_index
通过日志分析发现选举前目标节点的 last_log_index 接近旧 Leader,但 last_applied_index 明显落后,基本已经抓到根因。
2. 计算两个 Gap
text
replication_gap = leader.last_log_index - target.last_log_index
apply_gap = target.commit_index - target.last_applied_index
Leader Transfer 只盯 replication_gap 会漏掉第二个问题。排障面板应把两者并排展示。
3. 时间对齐
text
transfer requested
become leader
on_leader_start
first successful request
become leader -> on_leader_start 很长,同时 last_applied_index 持续前进,说明节点不是卡死,而是在追平 Backlog。
4. 检查资源竞争
把 Apply 吞吐和延迟与以下指标放在同一时间轴:
- 磁盘吞吐、延迟和队列深度;
- Snapshot 保存或加载状态;
- 后台压缩、刷盘和检查点任务;
- 每个 Raft Group 的 Apply Gap;
- FSM 当前任务及单批 Apply 耗时。
如果 Snapshot 开始后 Apply 吞吐下降,或者多个 Group 同时回放时磁盘队列升高,因果关系会比一段错误日志清楚得多。
多层修复
目标节点准入
执行 Leader Transfer 前,不只检查复制进度,还要检查目标节点的状态机进度和资源状态。一个实用的准入条件可以写成:
text
replication_gap <= replication_threshold
apply_gap <= apply_threshold
snapshot_state == idle
disk_pressure <= pressure_threshold
阈值应根据业务允许的切换停顿和实测 Apply 吞吐计算,不能简单照搬其他集群。若目标不满足条件,应换一个节点或等待,而不是强行迁移。
就绪语义
把"Raft 角色是 Leader"和"可以承接业务流量"拆成两个状态。前者供协议诊断,后者供负载均衡和客户端路由。
对于线性一致性读,常见做法是先取得安全的 Read Index,再等待本地 last_applied_index 追到该位置后返回。写请求可以在库和业务定义的安全条件满足后进入提交流程,但客户端成功响应仍应以提交并应用完成为准。
Leader Transfer 命令返回成功,也只表示迁移动作已被接受或协议流程完成到某个阶段。运维系统还需要等待业务 Ready,而不是把命令返回当成流量切换完成。
Apply 吞吐
优化前先确认瓶颈在哪:
- 状态机支持批量写时,扩大合理批次,减少每条日志的固定开销;
- 避免在
on_apply中执行不受控的网络请求或长时间锁等待; - 不同 Raft Group 可以并行,但单个 Group 内仍要保持日志顺序;
- 对 Snapshot 做限速,避免它吞掉 Apply 所需的磁盘带宽;
- 条件允许时,将 Raft WAL、状态机数据和 Snapshot 的 I/O 路径隔离;
- 为 Apply Gap、单批耗时和追平预计时间设置告警。
不要只追求更大的批次。批次过大也会增加单次回调延迟,让 LEADER_START 等事件等待更久。吞吐和事件响应时间需要一起测。
测试完善
故障切换测试不应依赖固定 sleep。机器快时浪费时间,机器慢时制造偶发失败。更可靠的断言是等待一组可观察条件:
text
新 Leader 已产生
新 Leader 已进入业务 Ready
apply_gap 低于阈值
一次读写探测成功
测试还应主动覆盖慢路径:节点重启后立即迁主、写入高峰中迁主、Snapshot 期间迁主、磁盘延迟注入以及多个 Group 同时追赶。只有正常路径的切主测试,很难发现 Apply Backlog。
小结
Raft 的 replicate、commit 和 apply 是三段不同的进度。日志追平只说明目标节点具备一致的 Raft Log,不代表本地状态机已经追平。
故障切换的完整准入条件应同时看复制进度和 Apply 进度;完整完成条件则应落到业务 Ready 和真实请求成功。把这两个边界补上,become leader 后仍然不可用的问题才会从偶发故障变成可观测、可解释、可控制的工程状态。