3、日志与快照:Write-Ahead Log (WAL) 原理、快照生成策略、日志回放机制

好,咱们进入第三个核心话题。订单簿重建,说白了就两件事:一是怎么把数据安全地写下来,二是出问题后怎么快速恢复。这背后依赖的就是 WAL 和快照这对黄金搭档。

我个人习惯把 WAL 比作「记账本」,快照就是「定期汇总表」。记账本记录每一笔流水,汇总表告诉你某个时刻的总账目。两者缺一不可。

3.1 Write-Ahead Log (WAL) 原理

WAL 的核心思想很简单:先写日志,再写内存。你想想看,如果先改内存再写日志,机器突然挂了,内存数据丢了,日志里也没记,那订单簿就永远少了一笔。这谁受得了?

我在项目中遇到过这么个坑:早期版本为了追求低延迟,把 WAL 刷盘策略设成了「每秒刷一次」。结果有一次磁盘 IO 抖动,日志没来得及落盘,内存里的订单簿已经改了。恢复时发现少了几笔成交记录,排查了整整两天。嗯,从那以后我再也不敢在核心链路上用异步刷盘了。

WAL 的核心流程:
  1. 收到订单簿变更事件(新增、修改、删除订单)
  2. 将变更序列化为日志条目,追加写入 WAL 文件
  3. 等待 WAL 刷盘确认(同步或批量同步)
  4. 更新内存中的订单簿数据结构
  5. 返回操作成功

这里有个关键点:日志必须是追加写。为什么?因为顺序 IO 比随机 IO 快一个数量级。你想想看,机械硬盘寻道时间 10ms,顺序写几乎可以忽略。SSD 虽然好一些,但顺序写的吞吐量依然远高于随机写。

日志条目的结构,我一般这样设计:

// 日志条目结构
struct WALEntry {
    uint64_t sequence;      // 全局递增序列号
    int64_t  timestamp;     // 时间戳
    uint8_t  entry_type;    // 类型:ADD/UPDATE/DELETE/SNAPSHOT
    uint32_t data_length;   // 数据长度
    uint8_t  data[];        // 变长数据,序列化的订单信息
    uint32_t checksum;      // 校验和,用于检测数据损坏
};

校验和这玩意儿,我建议一定要加。曾经有一次磁盘静默错误,读出来的数据跟写进去的不一样,没有校验和根本发现不了。加了之后,至少能保证「要么正确,要么报错」,不会出现静默数据损坏。

3.2 快照生成策略

WAL 会无限增长吗?当然不会。你想想看,如果系统运行一个月,WAL 文件可能有几十 GB,回放起来得等到猴年马月。所以我们需要快照——定期把内存中的订单簿全量保存下来。

快照生成策略,我总结下来有几种:

策略 原理 优点 缺点
定时快照 每隔固定时间(如 5 分钟)生成一次 实现简单,时间可控 可能生成无用快照(无变更时)
日志量触发 WAL 达到一定大小(如 1GB)时触发 恢复时间可控 高峰期可能频繁触发
混合策略 定时 + 日志量双重触发 兼顾两者优点 实现稍复杂

我个人比较推荐混合策略。比如设定:每 5 分钟检查一次,如果 WAL 超过 500MB 就生成快照;如果没超过,就等到下一个周期。这样既不会频繁生成快照,也能保证恢复时间在可控范围内。

快照生成时的注意事项:
  • 快照生成期间,WAL 不能停。新来的变更继续写 WAL,同时记录一个「快照开始」标记。
  • 快照完成后,记录一个「快照结束」标记,包含快照对应的 WAL 序列号。
  • 旧快照可以删除了,但至少要保留一个完整快照 + 其后的 WAL。

快照的存储格式,我习惯用内存数据结构的序列化版本。比如订单簿用红黑树,那就把树的前序遍历结果存下来。恢复时直接反序列化,比一条条回放快得多。

3.3 日志回放机制

回放机制,说白了就是「从快照恢复 + 重放 WAL」。流程如下:

  1. 加载最新的完整快照,反序列化得到内存订单簿
  2. 找到快照对应的 WAL 序列号(快照结束标记中的值)
  3. 从该序列号之后开始,逐条读取 WAL 条目
  4. 对每条日志执行对应的操作(添加、修改、删除订单)
  5. 直到 WAL 末尾,完成重建

这里有个细节:回放必须是幂等的。什么意思?就是同一条日志回放多次,结果应该是一样的。为什么需要这个?因为回放过程中可能崩溃,重启后需要重新回放,如果日志不幂等,订单簿状态就乱了。

我在项目中遇到过这个问题:早期版本的回放逻辑是「先删除再插入」,结果有一次回放到一半崩溃了,重启后重新回放,发现某条日志对应的订单已经被删除了,再执行删除就报错了。后来改成了「先查询再操作」,如果订单不存在就跳过删除,这才解决了幂等问题。

回放性能优化:
  • 批量读取 WAL 条目,减少 IO 次数。我一般一次读 1024 条。
  • 使用内存映射文件(mmap)加速 WAL 读取。
  • 回放时关闭校验和检查(如果信任磁盘),可以提升 30% 速度。
  • 多线程回放?可以,但要注意订单之间的依赖关系。同个订单的变更必须串行。

还有一个容易被忽略的点:WAL 文件的清理。快照生成后,快照之前的 WAL 就可以删了。但要注意,不能删正在写的那个 WAL 文件。我一般用「文件轮转」的方式:写满一个文件就换下一个,快照生成后把旧的、不再需要的文件删掉。

3.4 核心逻辑流程图

下面我用一张 SVG 图来展示 WAL、快照和回放之间的协作关系。这张图我画了好几次才满意,希望能帮你理清思路。

WAL + 快照 + 回放 核心流程 正常写入流程 1. 收到订单变更 2. 序列化为 WAL 条目 3. 追加写入 WAL 文件 4. 等待刷盘确认 5. 更新内存订单簿 6. 返回成功 快照生成流程 1. 触发条件满足 2. 记录快照开始标记 3. 序列化内存订单簿 4. 写入快照文件 5. 记录快照结束标记 6. 清理旧 WAL 文件 回放恢复流程 1. 加载最新快照 2. 反序列化订单簿 3. 定位快照对应 WAL 位置 4. 逐条回放后续 WAL 5. 校验数据一致性 6. 完成重建 WAL 增长 快照文件 关键设计要点: • WAL 必须同步刷盘,不能异步。这是数据安全的底线。 • 快照生成时,WAL 继续写入,不能阻塞正常业务。 • 回放必须幂等,同一条日志回放多次结果一致。 • 快照和 WAL 文件要分开存储,避免单点故障。 • 定期做恢复演练,确保快照和 WAL 真的能重建订单簿。 避坑: 我曾经因为 WAL 和快照放在同一块磁盘上,磁盘坏了两个都丢了,血泪教训。 时间线 快照点 WAL 持续写入 恢复点

这张图从左到右展示了三个流程的协作关系。正常写入流程持续产生 WAL,快照流程定期「拍照」,回放流程在崩溃后从快照和 WAL 重建。三者环环相扣,缺一不可。

最后说一句:不要等到线上出问题了才去验证恢复流程。我建议每两周做一次恢复演练,模拟磁盘损坏、进程崩溃等场景,确保快照和 WAL 真的能重建出正确的订单簿。这个习惯,救过我很多次。


公众号:蓝海资料掘金营,微信deep3321