← 返回文章归档

写到一半失败怎么办:MiniRedis 的异常安全与 AOF 提交顺序

用批量写入、日志同步和响应丢失几个具体窗口,区分内存原子性、持久化恢复和网络重试,以及 TTL 为什么需要两种时钟。

参数都合法,批量写入仍可能失败

一条 MSET a 1 b 2 通过参数检查后,仍可能在分配字符串或哈希节点时失败。如果直接按顺序修改原表,前一个键已经写入,后一个键却没成功,就留下了一半结果。

MiniRedis 这里练习的是强异常保证:失败后,客户端能观察到的键、值和 TTL 保持原状。它没有因此获得多命令事务,也不能直接宣称实现完整 ACID。

先准备相关键,再走到提交点

当前存储层先整理本批次的写集,重复键合并为最后一个值;然后准备节点、过期索引和回滚记录。新增键如果插入失败,就撤掉本次已插入的节点;已有键的值还没有正式替换。

全部准备成功后,再通过节点转移与字段交换提交。这里最关键的是:最后一步不能再依赖一次可能失败的新分配。否则“准备完成”只是把风险搬到了另一个位置。

只准备相关键也避免了每次小写入都复制全库。但新增键仍可能引发哈希表 rehash,释放大值也有成本,所以不能由“只处理写集”推导出任何写入都具有固定延迟。

项目在 tests/exception_tests.cpp 逐步注入分配失败,检查旧值、TTL 和新键是否保持预期。正常路径连续跑通,证明不了这些异常窗口。

开启日志后,确认顺序更重要

启用 AOF 时,可以把一次写入概括为:

解析与校验
  -> 准备响应及相关键的新状态
  -> 追加完整日志记录
  -> 刷新并同步文件
  -> 无分配提交内存状态
  -> 将响应交给网络层

fflush 把用户态缓冲交给操作系统,不能单独当作持久化保证。Linux 中的文件同步及目录同步语义,可以对照 fsync 手册。项目在相应平台调用同步接口,但最终仍依赖文件系统和设备的实际承诺。

这个顺序的价值,是把不同的崩溃窗口解释清楚:

失败位置可以观察到什么
写集准备失败不应发布半份键值变化
最后一条日志只写了一部分下次恢复处理不完整尾部
完整日志同步后、内存提交前退出重放后这条操作仍可能生效
内存提交后、响应传输中断服务已执行,客户端却无法确认

最后一种情况尤其容易被漏掉。客户端重试 INCR 可能再次加一;文件写得可靠,并没有让网络请求自动获得幂等性。这个问题与 FlashFlow 的幂等下单可以放在一起比较。

TTL 不能在重放时重新起算

假设 12:00:00 为一个键设置十秒有效期,12:00:20 才重启。若恢复时重新执行一个全新的“十秒”,本来已经失效的数据就被延长了。

运行期间的相对时间需要抵抗系统时钟跳变,跨进程恢复又需要可解释的绝对时间。当前数据库在启动时记录墙上时钟与单调时钟基点,用后者的经过时间推进逻辑 epoch 毫秒;重放时按历史记录时间恢复截止点,最后推进到当前时间并清理过期键。

这仍需要重启时的系统时间合理。它没有解决任意时钟漂移问题,也不能把一个进程里的单调时钟数值原样交给另一个进程解释。

尾部修复和跳过损坏不是一回事

项目日志使用专用 MR1/MR2 记录,带长度和校验。最后一条不完整时可修复尾部;遇到完整记录内容校验失败则拒绝启动,保留原文件供排查。FNV 校验用于发现意外损坏,不是安全认证。

AOF 重写也在同一个线程内同步执行:它生成并同步新文件,完成替换,并保留原来的绝对过期时间。代码因此更容易解释,但重写期间其他请求会等待,还需要额外磁盘空间。

读 src/storage/database.cpp 时,我会始终带着三个问题:哪一步之前还能放弃,哪一步之后必须认账,哪一种结果客户端可能不知道。把这三个位置找出来,比只记“先写日志再写内存”更有用。

✳

感谢阅读,下一篇再见。

回到文章开头 ↑