Checkpoint 与 Recovery:日志引擎如何把恢复路径做实

围绕 checkpoint sidecar、active log verified scan 与 truncate 恢复路径,分析 seastar-log-engine 如何在异常退出后尽快找回一条一致且可继续追加的日志尾部。

恢复路径为什么不能只靠"重启后再扫一遍"

append-only 日志看起来简单,但真正麻烦的地方往往出现在异常退出之后。进程可能已经把一部分数据推进写路径,但文件尾部还没完全落稳;checkpoint 可能刚写到一半;active log 可能留下了不完整尾段。如果恢复时只做一件事,比如"从头扫描 active log",当然也能得到结果,但代价可能很高。

seastar-log-engine 的恢复设计更像两层保护:

  • checkpoint sidecar 负责提供一个最近的稳定位置
  • active log verified scan 负责对当前活跃文件做最终确认

两者组合起来,目标不是做得花哨,而是尽快找到"可以继续写"的边界,并且在这个边界上重新建立内部状态。

checkpoint 里到底存了什么

当前 checkpoint sidecar 的内容很克制,只记录少量必要元数据:

logical_size=...
sequence=...
rotation_index=...

它不是为了替代 active log,也不是为了持有额外索引,而是给恢复流程一个可信的起点。写入时采用 *.tmprename 的方式落盘,避免直接覆盖时留下半写状态;如果写 checkpoint 失败,对应的失败计数也会进入指标。

这个 checkpoint 设计的特点是"轻"。它不追求承载过多语义,因此恢复时也更容易验证和丢弃。

checkpoint 什么时候写

这里最容易想当然的地方,是把 checkpoint 理解成一个固定周期任务。但当前实现并不是"每隔 N 秒自动写一次 checkpoint"的模式,也没有围绕 checkpoint_interval_seconds 搭一套周期机制。

checkpoint 更像写入生命周期中的边界动作,典型会出现在:

Rendering diagram...
  • 合适的 flush 之后
  • rotate 附近
  • AsyncWriter::stop() 的收尾阶段

这种做法的思路很实际:checkpoint 最有价值的时机,本来就不是任意时刻,而是"系统刚完成一段稳定写入之后"。与其做一个机械定时器,不如把它绑定在真正有语义的状态边界上。

启动恢复的入口在哪里

当 writer 启动且 truncate_on_start == false 时,会进入恢复路径:

co_await recover_from_checkpoint();

恢复过程的核心之一,是 LogManager::recover_active_file(...)。它会针对当前 active log 做 verified scan,确认文件尾部哪些内容仍然有效。

这一步返回的结果里,关键字段包括:

  • valid_size
  • valid_records
  • next_sequence
  • clean_end
  • trailing_bytes

也就是说,恢复过程并不是只问一句"checkpoint 在不在",而是进一步确认"checkpoint 所指向的位置,和 active log 当前真实状态到底是否一致"。

verified scan 为什么必要

如果 checkpoint 恰好是完整的,active log 也正好停在一个干净边界上,恢复当然很省事。但现实里,异常退出时最常见的情况恰恰是"checkpoint 看起来还在,文件尾部却未必干净"。

verified scan 的作用,就是把这个不确定性收回来。

它会检查 active log 的有效尾部,给出当前真正可以信任的 valid_size。后续恢复逻辑再根据 checkpoint 与 verified 结果之间的关系,决定是否直接采用 checkpoint,还是回退到 verified scan 的判断。

这让恢复过程不必在两个极端之间二选一:

  • 不是盲信 checkpoint
  • 也不是每次都从零开始做最重的恢复

checkpoint 什么时候会被判定为不可信

恢复阶段会显式处理几类问题:

Rendering diagram...
  • incomplete_checkpoint
  • stale_checkpoint

如果 checkpoint 不完整,或者它记录的 logical_size 与 verified scan 得到的 valid_size 对不上,系统就不会继续把它当成事实来源,而是转向恢复 fallback。

这种处理非常关键,因为 checkpoint 本身只是辅助文件。它应该帮助系统更快恢复,而不是在异常情况下把恢复路径带偏。真正可靠的事实,始终还是 active log 里经过验证的有效内容。

truncate 是恢复完成前的最后一步

恢复结果最终会落到 AppendWriter 上,典型调用形态是:

co_await _append_writer.truncate_to(recovery.logical_size, recovery.tail_buffer);

这一步的含义很明确:把文件状态、logical size 和内存中的 tail buffer 统一到同一个恢复边界上。只有这样,后续追加写才能继续在一致状态下进行。

换句话说,恢复不是"确认一下能不能读",而是要把 writer 内部状态重新摆正,包括:

  • active log 文件长度
  • 逻辑可见长度
  • 可能还需要继续保留的 tail buffer

没有这一步,恢复流程就只完成了一半。

读路径也会受恢复策略影响

恢复不仅关系到写入继续不继续,也会直接影响查询行为。因为 query/read path 既要读 active log,也要读 archive;一旦 active log 尾部没有正确收敛,查询结果就可能受到污染。

从这个角度看,checkpoint 和 verified scan 并不是只为了"进程能重启",它们也是读路径正确性的前提。尤其是在 archive、gzip 和 active segment 同时存在时,恢复边界如果判断错误,问题往往不是立刻 crash,而是静悄悄地把错误数据暴露给查询层。

这套恢复设计体现了什么取舍

seastar-log-engine 在恢复问题上的思路很稳:

  • checkpoint 提供快速起点
  • verified scan 提供最终裁决
  • truncate 把写入状态重新落回可继续追加的位置

它没有试图用一个重量级元数据系统去覆盖所有异常场景,而是承认 active log 才是最终事实来源,再用 sidecar checkpoint 缩短恢复路径。

这种设计的好处,是恢复逻辑和写入逻辑之间保持了很强的一致性。前面写路径坚持 append-only,后面恢复路径就围绕"找到最后一个可信 append 边界"展开。对于一个日志引擎来说,这比堆更多恢复参数更有说服力。