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,也不是为了持有额外索引,而是给恢复流程一个可信的起点。写入时采用 *.tmp 加 rename 的方式落盘,避免直接覆盖时留下半写状态;如果写 checkpoint 失败,对应的失败计数也会进入指标。
这个 checkpoint 设计的特点是"轻"。它不追求承载过多语义,因此恢复时也更容易验证和丢弃。
checkpoint 什么时候写
这里最容易想当然的地方,是把 checkpoint 理解成一个固定周期任务。但当前实现并不是"每隔 N 秒自动写一次 checkpoint"的模式,也没有围绕 checkpoint_interval_seconds 搭一套周期机制。
checkpoint 更像写入生命周期中的边界动作,典型会出现在:
- 合适的 flush 之后
- rotate 附近
AsyncWriter::stop()的收尾阶段
这种做法的思路很实际:checkpoint 最有价值的时机,本来就不是任意时刻,而是"系统刚完成一段稳定写入之后"。与其做一个机械定时器,不如把它绑定在真正有语义的状态边界上。
启动恢复的入口在哪里
当 writer 启动且 truncate_on_start == false 时,会进入恢复路径:
co_await recover_from_checkpoint();
恢复过程的核心之一,是 LogManager::recover_active_file(...)。它会针对当前 active log 做 verified scan,确认文件尾部哪些内容仍然有效。
这一步返回的结果里,关键字段包括:
valid_sizevalid_recordsnext_sequenceclean_endtrailing_bytes
也就是说,恢复过程并不是只问一句"checkpoint 在不在",而是进一步确认"checkpoint 所指向的位置,和 active log 当前真实状态到底是否一致"。
verified scan 为什么必要
如果 checkpoint 恰好是完整的,active log 也正好停在一个干净边界上,恢复当然很省事。但现实里,异常退出时最常见的情况恰恰是"checkpoint 看起来还在,文件尾部却未必干净"。
verified scan 的作用,就是把这个不确定性收回来。
它会检查 active log 的有效尾部,给出当前真正可以信任的 valid_size。后续恢复逻辑再根据 checkpoint 与 verified 结果之间的关系,决定是否直接采用 checkpoint,还是回退到 verified scan 的判断。
这让恢复过程不必在两个极端之间二选一:
- 不是盲信 checkpoint
- 也不是每次都从零开始做最重的恢复
checkpoint 什么时候会被判定为不可信
恢复阶段会显式处理几类问题:
incomplete_checkpointstale_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 边界"展开。对于一个日志引擎来说,这比堆更多恢复参数更有说服力。