跳转到主要内容

生命周期版本历史:同一个版本,两个发行版写的头部

本记录说明维护者复查 RELEASE.2026-11-09T00-00-00Z(d99829238)时发现的一个缺陷。它由该版本中的 9f7b49369 引入,这个提交是多池生命周期修复(#278)的一部分。修复见 PR #332,没有单独的 GitHub issue。

**状态:**已通过 PR #332 合并到 main,提交为 36c8d1683;合并前在第二轮对抗评审中获得通过,第一轮评审改动了修复方案,见评审记录。尚未发布:RELEASE.2026-11-09T00-00-00Z 受影响。

简短回答

从 20261109 起,凡是要看同一个 key 其他版本才能决定的生命周期过期,都会先从纠删集的各块盘上读出这个 key 的版本历史,并且只接受“足够多的盘持有同一个修订”的版本。修订的标识由两部分组成:盘上存的版本头部,加上元数据的摘要。

问题在于,老版本在各块盘上存的头部并不一样。一块盘上存的头部,是当初把这个版本写到这块盘上的那个发行版写的,而 RELEASE.2024-04-28 之前的 MinIO 写的头部里没有纠删码字段。换盘之后,自愈会用当前的头部把版本写到新盘上,其他盘不动。读取和自愈都把这两种头部当成同一个版本,版本历史却把它们算成两个修订;哪一个都凑不够盘数时,整个 key 就被拒绝。

这种拒绝是永久的:扫描器每一轮都会重试,每一轮都以同样的方式失败;GET 正常,自愈报告没有需要修的东西。数据不会丢失,也不会被删除,只是该过期的版本一直留着。

修复只在一个函数里新增三行,即 cmd/erasure-server-pool-lifecycle.go 里的 versionRevision:修订标识里那两个随写入版本而变的头部字段,改为按当前发行版的算法从版本的元数据计算,不再取盘上存的值。升级即可恢复,盘上的内容不需要任何改动。

定性

方面 评估
性质 RELEASE.2026-11-09T00-00-00Z 引入的回归:新的版本历史对“同一个修订”的判定过严。失败方向是拒绝,不是误删。
受影响的部署 从 MinIO 接手的数据,其版本头部由 RELEASE.2021-11-24 到 RELEASE.2024-04-28 之前的发行版写入;并且所在纠删集此后换过一部分盘(不是绝大多数)并完成了自愈。
受影响的过期 多池:版本化或暂停版本化的桶里的所有 ILM 过期,恢复副本的过期除外。任意池数(包括单池):ExpiredObjectAllVersions、DelMarkerExpiration,以及清除全部版本的批量过期。
不受影响 SILO 写入的数据,以及 RELEASE.2024-04-28 及之后的 MinIO 写入的数据;没有盘被自愈过的纠删集;单池上针对单个版本或单个删除标记的过期(Expiration、NoncurrentVersionExpiration、ExpiredObjectDeleteMarker);读、写、自愈和复制。
数据安全 不删除、不隐藏任何数据。受影响的版本始终可读。
严重度 命中时为高:受影响 key 上的规则悄悄停止生效,空间持续增长,保留期限得不到执行。命中面窄:需要老数据,并且自愈过的盘恰好占到特定比例。
工作量 小:一个函数,不改存储 RPC,不改盘上格式,不改配置。

何时发生

三个条件必须同时成立:

  1. 旧头部。 版本在 xl.meta 里的条目,是由 RELEASE.2021-11-24(第一个带逐版本头部的发行版)到 RELEASE.2024-04-28(第一个把纠删码写进头部的发行版)之前的 MinIO 写入的。之后的发行版重写 xl.meta 时,不会改动没有变化的版本的头部,所以即使这个 key 后来又加了新版本,老版本仍然保留旧头部。
  2. 部分自愈。 纠删集里有一部分盘被更换,或者因为别的原因丢了这个 key,自愈把版本重新写了上去。这些盘上是当前的头部。
  3. 两组都不够大。 一个修订要被接受,必须同时出现在纠删集过半的盘上和该版本读 quorum 数量的盘上,即 max(n/2+1, n − 校验盘数) 块盘。
纠删集 一个修订需要的盘数 自愈过这么多块盘时被拒绝
16 盘,EC:4 12 5 到 11(已复现)
12 盘,EC:4 8 5 到 7
8 盘,EC:4 5 4
4 盘,EC:2 3 2
2 盘 2 1

只有第一行实际跑过,其余几行按同一条规则推算。小纠删集最容易命中:两盘的纠删集换过一块盘就够了。

对于受影响的 key,运维能看到的现象是:

  • minio_ilm_expiry_incomplete_total{reason="history_unavailable"} 每一轮扫描都在增长;
  • ILM trace 报告 result=failed 和同样的原因;审计日志里有带 ilm-incomplete-reason=history_unavailable 标签的 ILMExpiry 条目;服务端日志说这次过期没有完成,原因是存储资源不足以完成写操作;
  • 对这个版本的 GET 和 HEAD 正常,对这个 key 做 heal 显示每块盘都健康。

根因

行号以 d99829238 为准。

  1. objectVersions 从纠删集的每块盘读出 xl.meta,把各盘的版本列表交给 authoritativeHistory(cmd/erasure-server-pool-lifecycle.go:74-133)。
  2. authoritativeHistory 按 versionRevision 把每个版本所在的盘分组,只有最大的一组同时达到过半和读 quorum 时才接受(:342)。只要有一个版本没有这样的组,整个 key 就以读 quorum 错误被拒绝。
  3. versionRevision 把盘上存的头部原样放进标识:key := revisionKey{header: ver.header}(:222)。只有旧格式(xl.json)版本的签名会被替换,因为它的签名覆盖了每个分片各自的校验和。
  4. 版本头部有七个字段,其中两个取决于是哪个发行版写的:
字段 不同发行版之间的差异
EcN、EcM 由上游 #19534 加入头部,首次发布于 RELEASE.2024-04-28。在此之前写入的头部读出来是 0/0。
Signature 版本元数据的 32 位哈希。在上游 #14125(首次发布于 RELEASE.2022-01-25)之前,全为空的分片 ETag 也被算进签名,而 RELEASE.2021-11-24 之前用普通 PUT 写入的版本正好带着这样的 ETag。这两个发行版之间的版本为这类版本写下的头部,带着一个之后任何发行版都不会再算出来的签名。
VersionID、ModTime、Type 每块盘上都相同。
Flags 由元数据推出。其中“转储版本是否使用自己的数据目录”这一位,还取决于这块盘写入条目时恢复副本是否已经过期。它与发行版无关,在标识里保留盘上存的值,见方案。
  1. 自愈按解码后的元数据做判断,不看头部。它把版本写到缺失的盘上,头部由自己这个发行版计算,并认为其余的盘是健康的。此后没有任何流程会去重写那些盘上的头部。

服务端的其他部分早已把这些盘当成同一个版本:matchesNotStrict 不比较签名;matchesEC 接受一边有纠删码、一边没有;列举路径的比较函数 metaCacheEntry.matches 在只有一边带纠删码时“不考虑 EC”再比较。列举路径的这种容忍,是和加入这两个字段的上游提交一起来的,matchesEC 随后由上游 #20351 加入。20261109 新增的修订标识没有把它继承过来。

这两个字段表达的内容,摘要里都有:纠删码本身就是版本元数据的一部分,签名则是对同一份元数据的一个更弱的哈希。把它们留在标识里不增加任何信息,只是把标识和“写这块盘的发行版”绑在了一起。

行为的来源

溯源

由 SILO 引入,首次出现在 RELEASE.2026-11-09T00-00-00Z。 更早的 SILO 发行版和上游 MinIO 的任何发行版,都没有权威版本历史和修订标识。

成分 提交 PR 首个发行版
xl.meta 的逐版本头部,带签名 faf013ec8(Klaus Post) #13573 RELEASE.2021-11-24T23-19-33Z
签名不再计入空的分片 ETag 0012ca8ca(Klaus Post) #14125 RELEASE.2022-01-25T19-56-04Z
头部加入纠删码;比较时容忍缺失 5f774951b(Klaus Post) #19534 RELEASE.2024-04-28T17-53-50Z
包含盘上头部的修订标识 9f7b49369(SILO) #278 修复的一部分 RELEASE.2026-11-09T00-00-00Z

方案

方案 1:用元数据重建整个头部

这是最初的建议:像 restoreNeutralRevision 对“去掉恢复字段的版本”所做的那样,用解码后的版本重新计算头部。不采纳。“转储版本是否使用自己的数据目录”这一位,是用恢复到期时间和当前时钟算出来的。这样重建的标识会在恢复副本到期的那一刻发生变化,而原生恢复要拿提交前取得的标识和提交后读到的标识做比较。fileInfoRevisions 正是因此才同时接受这一位的两种取值,并且依赖标识里带的是盘上存的那个值。

方案 2:只从元数据取纠删码

两行,能修好报告里的情形。但不充分:RELEASE.2021-11-24 之前用普通 PUT 写入的版本,如果它的 key 被 RELEASE.2022-01-25 之前的发行版重写过,之后又有一部分盘被自愈,各盘上仍然有两种签名,仍然被拒绝。针对这种头部的单元测试,只用这个方案时不通过。

方案 3:把盘上的旧头部重写掉

让自愈或扫描器把每块盘都改成当前的头部。不采纳:为了修一处比较而重写所有老对象的元数据,会让自愈在健康的盘上找出活来干;而且在它跑完之前,过期照样被拒绝。

方案 4:把头部从标识里去掉

只用摘要标识修订。不采纳:盘上存的“数据目录”标志位不在摘要里,而给历史排序的那次合并要求同一修订的各盘带同一个头部。改动更大,结果相同。

方案 5:签名取自摘要

这是本修复的第一版:纠删码取自元数据,签名则对所有版本都取自摘要,标识对旧格式版本本来就是这么做的。评审后不采纳。签名同时也是合并时给“修改时间相同的两个版本”排序的依据,而每块盘的版本列表仍然保持盘上原有的次序。对于各在 16 盘中 12 盘上的两个同时间版本,试过的 40 种布局 20261109 全部接受,这个方案拒绝了其中 21 种。

方案 6:两个字段都按当前发行版的算法计算

对象版本的纠删码和签名,都用今天写头部时用的同一套函数,从解码后的元数据算出来。对于当前发行版写入的版本,算出来的就是盘上存的头部,所以对它们没有任何变化。采纳。

修复方案

在 versionRevision 里,可解码版本的摘要算出来之后:

switch v.Type {
case LegacyType:
	copy(key.header.Signature[:], key.digest[:])
case ObjectType:
	key.header.Signature = v.ObjectV2.Signature()
	key.header.EcN, key.header.EcM = uint8(v.ObjectV2.ErasureN), uint8(v.ObjectV2.ErasureM)
}

旧格式版本的分支没有变化,对象版本的分支是新增的。删除标记保留盘上存的头部:它在任何发行版下都不带纠删码,也没有哪个发行版改过“同样的元数据算出什么签名”。元数据无法解码的版本保留盘上存的头部,仍然只和字节完全相同的条目算作同一个修订。

authoritativeHistory 返回的历史里,每块盘上的条目带的都是被接受的那个标识的头部,这一点和以前一样。不向任何盘写入任何东西,标识也不落盘:每次读取都重新计算。

为什么充分且必要

  • 充分。 头部字段里,只有纠删码和签名取决于写入它的发行版,现在标识里的这两项都按当前发行版的算法计算。修订标识的两处使用者走的是同一个函数:生命周期的版本历史,以及原生恢复选定的那一代元数据。修复后,无论自愈过多少块盘,从零到全部,版本历史都接受老版本。
  • 必要。 只改纠删码,16 盘里自愈 5 到 11 块时,旧签名的头部仍被拒绝。只改签名,报告里的情形仍被拒绝。
  • 安全。 摘要覆盖了纠删码,也覆盖了签名所哈希的全部内容,所以在这两方面有差异的修订仍然是两个标识;有一个对照测试专门检查“同一头部下另一种纠删码”。真正的分裂(比如只到达一半盘的元数据更新)和以前一样被拒绝。
  • 对当前数据无变化。 当前发行版写入的版本,其标识里的头部与盘上存的完全一致;端到端测试里有一个对照,在实际写入的对象上检查这一点。对这类数据,版本历史的判断与 20261109 相同。
  • 最小。 一个函数,新增三行。不改存储 RPC,不改盘上格式,不改配置,不改自愈和任何读路径。

代价与兼容性

  • 开销:每块盘的每个对象版本多算一次签名,即对摘要本来就要读的元数据再编码一次、做一次 64 位哈希。没有额外的 I/O。
  • 盘上:没有任何变化,旧头部保持原样。
  • 滚动升级:每个节点用自己的代码做判断。仍在运行 20261109 的节点继续拒绝受影响的 key,升级后的节点会让它们过期。不需要协调。
  • 升级之后,扫描器会把之前被拒绝的过期重新排队并执行。受影响已有一段时间的部署,应当预期这些被推迟的删除会在升级后集中发生。
  • 原生恢复:readRestoreGeneration 原先把这样的盘当成“恢复状态写入失败”,走后备路径,得到一代不含恢复字段的元数据。现在它直接找到同一代。恢复副本的过期会拒绝经后备路径找到的那一代,所以按代码推断,它不再拒绝这类版本;这条过期路径没有实际跑过。

遗留限制

  • 自愈之前已经到期的恢复副本。 这是另一个形态相同的独立缺陷,本修复没有解决它。标识里保留盘上原值的那个标志位,表示转储版本是否使用自己的数据目录。恢复副本还在盘上时写入这个版本的盘,这一位是置位的;按代码,恢复副本到期之后才自愈的盘,这一位是清零的。当 16 块盘里这两种头部各占 8 块时,版本历史被拒绝,恢复的元数据代也只能经后备路径找到,而恢复副本的过期会拒绝这样找到的代。于是副本不会被清除,各盘也不会自行趋于一致。这项检查用的头部是手工构造的,没有实际跑过会产生这种头部的自愈。它要求在恢复副本到期之后、扫描器清除它之前,有足够多的盘被自愈;将另立记录处理。
  • 旧签名下同一时间的两个版本。 对于盘上签名是当前发行版算不出来的那类版本,标识里带的签名与盘上不同,而合并靠它给修改时间相同的版本排序。同一个 key 上两个这样的版本,各自又在一些盘上缺失时,可能在 20261109 接受的情况下被拒绝。不会删除任何东西。
  • 测试夹具。 测试是在当前格式的 xl.meta 里把纠删码字段清零来模拟旧头部,并按上述发行版的规则计算旧签名。没有使用那些发行版实际写出的 xl.meta。

验证

场景 20261109 修复后
多池,非当前版本过期,16 盘,自愈 5 到 11 块 拒绝 过期
同上,自愈 4 块或 12 块(对照) 过期 过期
单池,过期全部版本,16 盘自愈 8 块 拒绝 过期
按 RELEASE.2022-01-25 之前的规则签名的头部,16 盘自愈 5 到 11 块 拒绝 接受;只用方案 2 时仍拒绝
同一头部下另一种纠删码(对照) 另一个修订 另一个修订
16 盘自愈 8 块时的恢复元数据代 分裂 同一代
同一时间的两个版本,各在 16 盘中的 12 盘上,40 种布局(对照) 40 种接受 40 种接受;方案 5 为 19 种
本发行版写入的版本的标识(对照) 盘上头部 盘上头部
数据目录标志位在 8 块和 8 块盘上不同(见遗留限制) 拒绝,分裂 拒绝,分裂

cmd/erasure-server-pool-lifecycle-authority_test.go 新增两个测试。TestPoolsLifecycleHeaderEras 针对两种旧头部、自愈盘数从 0 到 16 的每一种情况,构造 16 块盘的版本列表。TestPoolsLifecycleHealedOldHeaders 写入两个版本,先检查它们的标识带的就是盘上的头部,再给它们换上旧头部,清空并自愈一半的盘,先确认读取正常、自愈无事可做,然后在两个池上过期一个非当前版本,在一个池上过期全部版本。两个测试在 20261109 上和方案 5 下都失败,修复后通过。同时间版本和数据目录标志位这两行是一次性的探针,没有并入测试集。

生命周期、恢复、delete-all、分层、迁移和 xl.meta 格式这几组共 192 个既有测试在修复后通过,go vet 无告警。PR #332 的 CI 跑了完整的 cmd 测试集、race 选集和交叉编译,全部通过。没有用旧版 MinIO 的真实数据跑过。

评审记录

  • 对抗评审第 1 轮,针对第一版(方案 5):在给出结论前用完了额度。它的笔记里有两条发现,都已用测试证实。
    • 签名取自摘要会改变合并时同时间版本的次序,使 20261109 能接受的历史被拒绝。已采纳:以方案 6 取代方案 5,测试现在在方案 5 下会失败。
    • 只在数据目录标志位上不同的头部仍然是分裂的,恢复副本的过期也一直被拒绝。本记录原先写的是这种差异会自行消失,这是错的,已在遗留限制中更正。本修复不处理。
  • 对抗评审第 2 轮,针对修订后的修复(方案 6):通过。它核对了以下几点:对当前发行版写入的每一种对象,解码后重算的签名和纠删码与盘上存的一致,包括加载时对旧压缩索引和旧复制时间戳的修补;没有两个不同的修订会共用一个标识;删除标记和 free version 没有随发行版而变的头部字段;使用该标识的恢复路径保持一致。它的只读沙箱无法运行测试。它对本记录提的一条意见已采纳:单池上哪些过期受影响,现在按规则名称写明。

参考

表格阅读