真盘崩溃矩阵第一跑:恢复流程删掉了自己的决策日志

这个月我们在 KernLab.Tier 的 FS 层做冷热分层:热根、温根、冷根三级根域,目录域三态(钉死 / 自动 / 只冷),数据迁移走”暂存—定案—落实”三步事务。设计定稿,按惯例上崩溃矩阵——迁移事务在几个关键点上各注入一次崩溃,把卷重新挂载起来,看磁盘上的字节回来后收敛成什么样。

矩阵第一次在真盘上跑,红的那一项不是”数据丢了”,而是恢复流程把自己的决策日志当垃圾删了。

崩溃矩阵长什么样

先说测试形态,因为这一篇的教训全在这里面。

真盘崩溃测试和内存测试的差别不是”更慢”,而是三个约定:

  • 崩溃 = 进程死 + 字节留盘。 不收拾现场、不优雅关闭。分层矩阵把协调器丢在事务半途,然后连恢复都不走就把卷弃掉——磁盘字节冻结在崩溃那一刻;
  • 恢复 = 重挂载 + 走生产恢复路径。 在同一个目录上重新挂载,执行的就是线上那份 RecoverAsync,没有测试专用捷径;
  • 断言 = 磁盘字节级事实。 不说”恢复成功”这种软话:打洞后的热根原始字节必须全零;升温崩在落实半途,冷副本必须消失且不复活;降冷崩在暂存点,孤儿冷副本必须被对账收走;账面终态(哪些区间记着冷占位)必须和崩溃窗口一一对应。

具体到分层,矩阵一共九项:迁移事务的崩溃窗口(降冷三种、升温两种)、多 region 混合窗、写取代崩溃窗、Move 决策窗、易失热根掉电重挂载。前五组是 Theory 参数化的同构用例,后面四组各有各的不变式。

这个形态有先例。上一波做统一事务内核时,我们用真盘矩阵演练过 raft 崩溃:崩溃 = 节点整体消亡,然后在同目录重启,跑重建协调器的完整装配。两个矩阵的区别只在于”崩溃”怎么模拟(注入点 vs 整机消亡),断言是同一句话:磁盘上的字节回来后,状态收敛。

锚根:一条日志落在冷根里

分层的迁移事务,定案点是一条记录——写进”锚根 journal”的移交记录。锚根是挂载时声明的耐久根(缺省就是冷根),这条 journal 是恢复的唯一权威:谁降冷了、谁升温了、哪个区间的数据现在住在哪,全看它的重放。

恢复自检的形态是”日志重放 + 四方对账”:洞、账、归属表、冷根对象四张表互相对——多出来的冷文件是孤儿(收走);账上有而冷根没有是介质故障(拒绝启动)。

“多出来的冷文件是孤儿,收走”——就是这个动作出的问题。

第一跑:共享违例

红的第一项,在降冷事务的 AfterStage 窗口:暂存阶段写完冷副本、决策还没定案时崩溃,恢复自检应该把这个没有账的冷副本当孤儿收走——这一项恰好会走到孤儿回收。测试在真盘上跑到那一步,抛了一个异常:

FileIOException(IOError.SharingViolation)

共享违例——在 Windows 上,那是”你要删的文件有人正开着”。

被删的文件叫 tiering.journal,锚根 journal 自己。

链条不复杂:锚根缺省就是冷根,所以 journal 文件就落在冷根目录里;而孤儿回收的枚举是”冷根全部文件”,判据是”不在归属表里”;journal 当然不属于任何冷占位账——于是被判孤儿,删除。恢复流程先读自己的日志,然后把自己的日志删了。

而且是缺省配置。 没有一个用户会绕开它——只是没有人会在内存介质上看见它。

内存介质为什么全绿

同样这些窗口,内存形态的契约组一直是绿的。这不是巧合,是两种介质对”删除”的现实不一样。

memfs 的删除是 POSIX unlink 语义:名字被摘掉,数据还在——已经握着句柄的人继续写,直到最后一个观察者关闭,那块数据才真正消失。删一个开着句柄的文件?合法,静默成功。

于是在内存介质上,事故的完整形态是:恢复流程删掉了 journal 的”名字”,自己的句柄还在写那块已经无名的数据;此后任何一次重挂载,都会在同一个路径上新建一个空 journal——一个在无名数据里继续生长,一个在路径上从零开始。恢复的权威长出了两个自己。这叫分裂脑,但 mem 上看不到它,因为删除这个动作”成功”了,异常从未发生。

真盘不给你这个许可。Windows 的共享语义直接抛 ERROR_SHARING_VIOLATION(32),被 IO 错误映射包成 SharingViolation——测试红。

介质是配置不是适配器,这是我们自己立的规矩。但这一天的教训是另一面:同一份代码,在不同的介质上有不同的物理法则;而法则最宽松的那个介质,决定了你的 bug 能藏多久。

修复,和三层加固

修起来是一行:孤儿回收的删除面加保留名白名单,枚举到 journal 直接跳过。

但一行修不掉”为什么会被判孤儿”。当天下午接着做了三层加固,方向是把 journal 从”普通文件”的生存环境里请出去:

  • 改名点前缀:tiering.journal → .tiering.journal(连同收敛用的 .compact 中转件),内部文件统一约定点前缀,避免和业务文件名撞;
  • 门面四入口守卫:Open 写访问、CreateFile、Delete、Move 四个入口命中保留名一律 AccessDenied——journal 是恢复唯一权威,业务层不得删、不得移、不得改写;只读打开放行,观测无害;
  • 内核文件标记:journal 构造时标记属主为 sys——业务账户没有权限位,就算绕开门面也动不了它。

一句话:恢复的权威文件,不能共享”普通文件”的生存环境。

同一天的后半场:日志本身也要收敛

崩溃矩阵逼着我们把 journal 的重放路径整个看了一遍,顺手暴露了第二个问题:journal 只追加、不收敛,恢复重放是 O(决策历史)——跑得越久,重启越慢。长稳之前必须解决。

方案是”活账快照重写 + 原子换名”:当前活着的账全量重写成等价新日志(序号从 1 重编),写中文件、落盘,然后原子换名覆盖主文件。崩溃安全的口径写得很干净:

  • 换名之前崩溃:旧主文件完整保留——重放旧文件,零损失;
  • 中文件半写:下一次收敛截断重写——白做,无害;
  • 换名之后目录落盘缺失的窗口:还是重放旧文件——白做,零损失。

触发放在恢复自检末尾,阈值缺省 1024 条——恢复重放从 O(历史) 收敛到 O(活账)。

引擎侧的同构收敛

巧的是,同一天引擎侧的恢复语义也收敛到了同一句话:不确定的时候,问物理事实,不问缓存下来的元数据。

一件是恢复水位:陈旧元组(引擎的 1.5K 元数据摘要)滞后于物理写入时,min(元组, fileSize) 会把真实在盘的数据判到可见范围之外——丢数据。裁定干脆:非预分配介质的写入高水位恒等于 fileSize,它是物理权威;元组只做参考,超前了钳回去防越界即可。测试里手工把元组拨回 2048、物理是 4096——恢复必须信 4096。

另一件是区间布局:元组彻底丢失时,不再粗粒度重建成”大段连续”,而是用分配位面实测反推——打过洞的区域物理未分配、预分配过的已分配,fs 位面天然区分,洞的边界根本不用元数据告诉它。这里还有个和分层侧同款的小判例:内存槽的记账会夸大覆盖范围,实测时要按文件长度钳制——同一个坑,分层与引擎两侧各留了一句同款钳制。

收尾:一条可迁移的判据

如果只能留一句给未来的自己:

内存介质不是”更快的地盘”,是”更宽容的世界”——它会把你在真盘上绝不敢写的路径,放行成绿色。崩溃矩阵的强度,等于你最苛刻的那块介质。

九项用例,它当场抓出一个真 bug,而且在缺省配置上。如果矩阵只在内存上跑,这个 bug 会漂到某块客户的机械盘上、以”恢复自检莫名其妙删自己日志”的形态爆炸——那时我们要花几天,才能把它缩回一行 continue。


本文事实来自 KernLab.Tier 的 fs-tiering 设计稿(v1.1)与 F1–F3 交付、真盘崩溃矩阵(迁移事务各崩溃窗口与四类专项用例,共九项)、TieringAnchorJournal 的收敛实现与保留名守卫,以及引擎侧恢复口径裁定与”洞的物理事实回退”对抗测试。

评论

← 全部文章