撮合 WAL、快照与确定性回放:崩溃一致性实战¶
30 秒版(开场)¶
撮合恢复先要选定唯一事实:记录 已排序命令,或记录 确定性结果事件,不能两套都声称权威。 本例采用命令日志:结构校验与分配序号后,先追加带长度、版本和校验和的 WAL,并达到声明的 fsync/复制策略,再应用确定性状态机,最后发布事件与响应。重启时读取校验快照,跳过快照水位, 连续重放 WAL 后缀;只允许显式修复不完整尾记录,完整记录校验和错误必须停机调查。
3 分钟版(精讲深度)¶
validate envelope
-> assign/verify sequence
-> append WAL + durability barrier
-> apply deterministic state
-> enqueue derived events
-> acknowledge externally
- 写前日志:不能先改内存,再异步“尽力写盘”,却对外声称订单已经不可撤销。
- 记录 framing:
length + versioned payload + CRC32C,才能区分完整记录、撕裂尾部和内容损坏。 - 快照原子性:临时文件写完并 fsync,rename 后再 fsync 目录;快照包含最后应用序号。
- 恢复连续性:只重放
seq > snapshotSeq,且第一条及后续都必须连续。 - 发布独立水位:状态恢复水位和 MQ 发布水位不是一回事;恢复可能重新产生事件,下游必须幂等。
10 分钟版(原理 + 图示)¶
sequenceDiagram
participant Q as Sequencer
participant W as WAL
participant E as Engine
participant P as Publisher
participant C as Client
Q->>Q: validate + seq=N
Q->>W: append framed command
W-->>Q: fsync/quorum reached
Q->>E: apply command N
E-->>Q: deterministic events
Q->>P: enqueue events N:0..k
Q-->>C: accepted/result
命令日志与结果日志¶
| 方案 | 优点 | 风险 |
|---|---|---|
| 已排序命令日志 | 可重建状态、便于规则回放 | 状态机必须确定;规则/schema 要版本化 |
| 结果事件日志 | 下游事实清晰,恢复无需再次撮合 | 事件必须足以重建完整订单簿,写放大更大 |
二者都可行。错误做法是:命令和结果分别写入两个不能原子协调的系统,却无法回答故障后 以谁为准。
尾部撕裂不等于任意损坏¶
- header/payload/checksum 只写了一部分:属于 torn tail,可在人工或受控恢复流程中截到最后完整 offset。
- 完整 frame 的 checksum 不匹配:可能是介质、内存或软件损坏,不能自动截断后继续交易。
- 中间序号缺口:不是“跳过坏消息”,而是权威历史不完整,应停止该分区。
快照与日志截断¶
快照只有在持久化完成且可校验后,才允许推进可截断 WAL 水位。生产还要考虑:
- 至少保留一份已验证旧快照和对应 WAL;
- 快照 schema/撮合规则版本迁移;
- 跨机器复制与恢复演练;
- 截断前确认发布器、审计和异地副本的保留要求。
fsync 到底保证什么¶
Write 成功通常只表示数据进入内核缓存,不等于掉电后仍在。单机 fsync、批量 group
commit、同步副本 quorum 的延迟和 RPO 不同。讲解时应说清 确认点对应哪一种持久性,
不要笼统说“有 WAL 所以零丢失”。
生产场景¶
- 低延迟场景可 group commit,但必须给出批次窗口、尾延迟和客户端确认语义。若所有请求仍在 共享 fsync/quorum 完成后才 ACK,group commit 本身不会扩大已确认请求的 RPO; 若为了更低延迟在 durability barrier 前 ACK,那是异步提交,必须单独声明确认后数据损失窗口。
- 主备复制要同时 fencing 旧主,防止两边继续分配同一序号。
- 每次发布都保存稳定事件 ID;Kafka 重试或灾备重放不应重复入账。
- 定期执行“生产快照 + WAL 副本”离线恢复,并比较最终状态 hash。
排查与工具¶
先只读扫描 WAL,报告最后完整 offset、首个 checksum 错误、sequence 缺口和 schema 版本。 不要让恢复程序自动修改唯一副本。复制原文件后再做尾部修复,并保留审计记录。
可运行测试:
go test -race ./examples/senior/walreplay/...
测试覆盖快照后缀重放、尾部撕裂显式修复、checksum 损坏拒绝和非法命令不落 WAL。
架构取舍¶
每条记录 fsync 容易解释但延迟高;group commit 用一次 barrier 承担多条提交,通常提升吞吐, 但会引入等待凑批/排队的延迟与同批故障域。只有 barrier 前提前 ACK 才会扩大已确认数据的 丢失窗口。同步复制提高节点故障容忍度但不能替代一致的 leader fencing。选择必须从业务可接受 的 RPO/RTO、P99 延迟和故障模型反推。
深挖问答¶
- 业务拒单要写 WAL 吗? → 若命令已进入权威全序,拒绝结果也应可确定重放。
- 快照完成后能立即删全部旧 WAL 吗? → 不能默认;先验证快照、复制与所有恢复/审计水位。
- 发布 Kafka 失败怎么办? → 状态不回滚;从持久化发布水位重试,消费者按事件 ID 幂等。
- Apply 在 WAL 成功后失败怎么办? → 视为状态机 invariant breach,停分区;不能跳到下一条。
- CRC 是安全证明吗? → 不是密码学认证,只用于发现意外损坏。
反模式与事故¶
- 内存撮合成功并响应后才异步写日志。
- 把
Write返回 nil 等同于已经落盘。 - 把 group commit 与“落盘前提前 ACK”混为一谈。
- 遇到任意 checksum 错误都自动 truncate。
- 快照没有 sequence/schema/checksum,恢复时猜测边界。
- 状态重放正确,却忘记事件发布水位,导致漏账或重复账。
代码示例¶
func (p *Processor) Apply(cmd matchingengine.Command) ([]matchingengine.Event, error) {
if err := matchingengine.ValidateCommand(cmd, p.Engine.LastSequence()+1); err != nil {
return nil, err
}
if err := p.WAL.Append(cmd); err != nil {
return nil, err
}
return p.Engine.Apply(cmd)
}
完整实现见 examples/senior/walreplay/。示例用本地文件说明崩溃边界,不等价于生产级
复制日志、磁盘隔离、leader 选举或异地灾备。