Skip to content

语言: English | 简体中文

持久化 I:追加文件

学习目标

学完本章后,你将能够:

  • 追踪一次成功写入如何从 CommitBatch 到达 AOF 持久性屏障;
  • 区分 alwayseverysecno 的保证,而不是笼统地把三者都称为“持久化”;
  • 解释为什么 MiniRedis 只修复不完整的最后一条记录,而不修复任意损坏;
  • 运行崩溃—重启实验,并指出写入究竟在哪个时刻被确认;
  • 在不改命令 planner 的前提下,为 AOF 策略设计一个边界清晰的测试改动。

从内存提交到持久历史

纯内存数据库可能对 SET 返回成功,却在进程退出后忘掉它。AOF 通过按顺序记录每个已经提交的变更来缩小这个缺口。MiniRedis 不记录原始命令文本,而是记录应用到数据库、随后交给复制的同一个不可变 CommitBatch

src/miniredis/persistence/aof.pyAofWriter.append 中,方法接收 CommitBatch,通过 encode_aof_record 编码,把 _AppendWork 放入 writer 队列,然后等待名为 barrier 的 future:

barrier = asyncio.get_running_loop().create_future()
self._queue.put_nowait(
    _AppendWork(
        record=encode_aof_record(batch),
        seq=batch.seq,
        barrier=barrier,
    )
)
return await asyncio.shield(barrier)

关键不是入队,而是等待 future。入队只代表 AOF worker 接管了工作,并不代表字节已经达到配置要求的持久级别。src/miniredis/core/executor.pyCommandExecutor._commit_prepared 分配下一个序列号,等待 commit_barrier.append(batch),只在得到 AofAppendOk 后才应用 batch。追加失败会成为 DurabilityFailure,因此内存状态不会在日志失败时继续向前并向客户端报告虚假成功。

顺序可以概括为:

规划变更
  -> 创建 CommitBatch(seq)
  -> 等待 AOF 屏障
  -> 应用到 Database
  -> 保留并提供给复制
  -> 发布成功响应

未配置 AOF 时,src/miniredis/core/executor.pyNullCommitBarrier.append 立即返回 AofAppendOk。命令流水线不变,只替换屏障实现。这也说明持久化属于核心执行语义,而不属于 Python Direct 或 TCP 适配器。

自定义、分帧、二进制安全的文件

MiniRedis AOF 不是 Redis 兼容的命令流。文件以带版本的 header 开始,后面是逻辑记录帧。src/miniredis/persistence/codec.pyencode_aof_record 序列化 CommitBatch_encode_aof_payload_record 加上长度和校验和;scan_aof_bytes 扫描完整帧并返回有效前缀、state base、batch 以及尾帧是否截断。

长度让任意二进制值也能被准确分隔,校验和则区分完整记录和内容损坏。序列号同样重要:src/miniredis/persistence/recovery.pyrecover_database 安装暂存基线,只接受连续的后续 batch。若数据库在序列 7,下一条必须是 8;缺口或乱序会直接失败。

恢复过程不会先暴露半成品 runtime。它新建 Database,安装选中的镜像,重放连续记录,清除恢复时已过期的键,最后才返回。src/miniredis/runtime.pyMiniRedis._start_owned 在开放用户 admission 前安装它。

三种 fsync 策略,三种不同承诺

写入字节和在断电后仍保留字节不是同一件事。src/miniredis/persistence/aof.pyAofPolicy 明确区分:

策略 AofWriter._run_writer 确认前的行为 崩溃窗口
always 写帧并调用 fsync 已确认 batch 已跨过文件 fsync 屏障
everysec 写帧、标记 dirty,然后确认 突然崩溃可能丢失约一个尚未同步的间隔
no 写帧但不由应用调用 fsync 由操作系统决定何时落到稳定存储

everysec 会在 AofWriter.start 中创建 _run_everysec,周期调用 _sync_dirty;优雅 close 还会同步剩余 dirty 数据。AofWriter.crash_close 刻意不做这次最终同步。因此 MiniRedis.simulate_crash 模拟的是突然进程丢失,而不是完美模拟所有机器、文件系统和存储控制器。

不要过度解释 AofAppendOk:在 always 下,它意味着完成配置要求的 fsync;在 everysecno 下,它只意味着到达较弱的对应屏障。“已确认”描述服务端/客户端事件,策略才定义持久性承诺。src/miniredis/config.pyMiniRedisConfig 默认使用 everysec

崩溃恢复与唯一安全的修复

阅读 src/miniredis/persistence/aof.pyload_aof。不存在或零字节的 AOF 被视为空;完整文件原样返回。若 scanner 发现最后一帧不完整且 repair_truncated_tail=True,函数把文件截到 scan.valid_offset 并 fsync。

这种修复很保守:进程可能在写最后一帧中途死亡,因此删除该后缀有明确含义——保留所有完整提交,丢弃从未成为完整记录的提交。完整帧的校验和错误不同,它可能表示历史中部的位损坏。此时函数抛出 AofCorruption,不会猜测应该删多少数据。

tests/unit/persistence/test_aof_repair.py 验证了这一区别:test_repair_enabled_truncates_one_incomplete_tail 保留第一条、截断第二条;test_checksum_corruption_never_changes_the_file 要求报错且文件逐字节不变。兼容性边界见行为矩阵的 AOF 与 Recovery 行:机制被保留,但格式是自定义的。

与真实 Redis 对照

真实 Redis 的 AOF 主要位于 src/aof.c,用户配置包括 appendonly 以及 appendfsync always/everysec/no。不同版本可能使用 Redis 命令协议、多文件 manifest、base 文件和增量文件。

可迁移的核心是顺序契约:变更必须按可恢复顺序传播,服务端必须定义成功响应前完成了什么。但差异很大:

  • MiniRedis 记录逻辑 CommitBatch,不是 Redis 命令;
  • header、payload、帧和校验和均为项目自定义;
  • 它只有教学用进程内 writer,没有 Redis manifest 管理;
  • everysec 是直接的定时任务,不复制 Redis 的生产级 bio/fsync 调度与延迟控制。

这些差异记录在行为矩阵中。.mraof 不能由 Redis 打开,redis-check-aof 也不是它的修复工具。

动手实验:已确认写入经模拟崩溃后仍存在

仓库已经提供 examples/aof_crash_recovery.py

uv run python examples/aof_crash_recovery.py

实测输出:

1. SET before crash: Ok(message=b'OK')
2. Simulating a crash (no graceful AOF drain)...
3. GET after restart: Bytes(value=b'durable')
4. Recovery verified: True

脚本配置 AofPolicy.ALWAYS。第一行只会在 SET 走过 AofWriter._run_writer 的写入和 fsync 路径后打印。随后 MiniRedis.simulate_crash 使用 AofWriter.crash_close,而不是优雅关闭的最终同步。第二个 runtime 在 MiniRedis._start_owned 中执行 recover_database,所以 GET 看到了重放后的值。

这个实验验证当前 MiniRedis 进程及其文件操作契约,不证明所有物理存储都以相同方式实现 fsync,也不使 everysec 等价于 always

再运行聚焦回归:

uv run pytest -q tests/unit/persistence/test_aof_repair.py

预期仓库结果:

6 passed

练习

1. 理解题:定位确认边界

为什么 _queue.put_nowait 后立即返回不安全?写出把成功响应挡在配置屏障之后的两个函数。

参考答案

队列只表示 worker 接管请求。AofWriter.append 等待每项 barrier,CommandExecutor._commit_prepared 等待追加结果后才应用数据库 batch 并完成响应。

2. 理解题:分类失败窗口

alwayseverysecno 分别判断:客户端是否可能在该 batch 的应用级 fsync 前收到成功?

参考答案

always 不会;worker 先 fsync 再完成 barrier。everysec 会;周期任务稍后同步。no 会;MiniRedis 不为数据发起逐 batch 或周期 fsync。优雅关闭会缩小 everysec 的窗口,突然崩溃不会执行该清理。

3. 动手题:增加策略观察测试

任务边界:只在 tests/unit/persistence/ 增加一个测试,复用现有 fake file-operations;不改 src/。记录 alwaysno 各追加一次时的调用顺序。

验收:

uv run pytest -q tests/unit/persistence/test_aof_writer.py

新断言必须证明 always 在 append future 完成前调用 fsync,而 no 不调用。若只是阅读练习,完成后恢复工作树。

参考答案

复用 tests/unit/persistence/test_aof_writer.py 的 fake AofFileOps 与调用日志。每种策略启动一个 writer、追加单操作 batch、等待结果,然后在关闭前检查日志。start 后先清空日志,以忽略 header 的 fsync。预期 diff 只有一个参数化测试。

4. 动手题:证明修复仅限后缀

任务边界:创建含两条完整帧和第三条截断帧的临时 AOF,调用 load_aof(..., repair_truncated_tail=True);不改生产代码。

验收:返回两个 batch,修复后字节严格等于 header 加前两条完整记录。

参考答案

使用 tests/unit/persistence/test_framing.py 的 helper 构造序列 1、2、3。写入 AOF_HEADER + first + second + third[:-1],同时比较 log.batches 和文件字节。再翻转完整帧校验和中的一个字节,应抛出 AofCorruption 且不改文件。

小结

MiniRedis AOF 是有序 CommitBatch 记录上的持久性屏障,而不是 Redis 线协议命令副本。三种策略在不同持久级别确认;恢复只接受完整连续历史,并只修复含义明确的不完整尾帧。日志无限增长虽正确却低效,下一章将研究如何用稳定 state base 替换历史,同时不丢失重写期间到达的写入。