跳转到主要内容

局部视图不能把存在的对象报成不存在

本记录说明在下一版本地验收中发现的一个缺陷。下一版的本地集成和 69fcf50cd 上都能复现。目前还没有对应的 GitHub issue。

**状态:**修复已通过对抗评审(附若干小意见,均已采纳),并已在本地分支实现。尚未合并或发布。

简短回答

刚重启的节点,或者短暂与部分节点断开连接的节点,只能看到纠删集里的一部分盘。如果它能看到的盘上,对象元数据分属两个修订版本,而哪个版本都凑不够读 quorum,GET 和 HEAD 就会对一个存在的对象返回 404 NoSuchKey。

数据不会丢失,也不会被删除。但 S3 客户端、缓存和复制都把 404 当作确定结论:备份工具可能把这个 key 记成已删除,缓存可能存下这个否定答案,条件写入可能基于一个错误的“不存在”做决定。从这样的局部视图出发,正确的回答是 503 SlowDownRead,客户端会重试。

修复只改 cmd/erasure-object.go 一个文件,新增 37 行、改动 2 行:

  • 只有纠删集里每块盘都应答了,元数据不一致才判为“不存在”;
  • 版本合并找不到达到 quorum 的版本、且有盘没有应答时,持有版本的盘报告读 quorum 错误,而不是“不存在”。

在足够多的盘应答、且每块应答的盘上都没有这个 key 时,仍然返回 404。所有盘都应答的情况下,行为不变。在这种视图下执行的删除、复制和维护操作,现在会失败并重试,而不是把对象当作不存在;代价与兼容性列出了这些情况。

定性

方面 评估
性质 降级读取时的错误映射不对。继承自上游 MinIO。
影响 存在的对象被报成 404,直到节点重新连上所有盘(启动后最长约 16 秒),或 heal 让各盘副本一致。
数据安全 不删除任何数据。有盘未应答时,悬挂对象检查本来就拒绝动作。
严重度 S1:客户端可见的错误“不存在”。概率低:需要部分写入和局部视图同时出现。
工作量 小:一个文件,不改存储 RPC,不改盘上格式,不改配置。

何时发生

两个条件必须同时成立:

  1. 一次写入在写 quorum 上提交,但没有写到所有盘,而且改动了元数据 quorum 会比较的字段。恢复、转储、恢复副本到期、覆盖未开版本控制的对象都属于这类。写入时离线的盘保留旧版本,直到 heal。
  2. 某个节点通过局部视图读取:它至少能连上一半的盘,但连上的盘分属两个版本,没有哪个版本达到读 quorum。8 块盘、EC 4+4 时,能看到 4 或 5 块盘的节点就可能遇到。

发现它的那次验收:4 个节点,每个节点 2 块盘,EC 4+4。节点 1 和节点 2 同时重启。经节点 1 发起的 t1 恢复只提交到 8 块盘中的 6 块,因为当时还连不上节点 2 的盘。节点 2 只能连上自己的两块盘(未恢复)和节点 4 的两块盘(已恢复)。经节点 2 的 GET 和 HEAD 返回 404。69fcf50cd 上也出现了同样的失败。

根因

行号对应下一版的本地集成;69fcf50cd 上是同一条代码路径。

  1. 启动时连不上的盘,在节点的纠删集里留空(cmd/erasure-sets.go:420),不到 1 秒后重连一次,此后每 15 秒一次(:287-290、:349)。
  2. getObjectFileInfo 里,没有应答的盘保持 errDiskOngoingReq(cmd/erasure-object.go:792, 820),quorum 统计忽略这个错误(:57)。
  3. 版本合并和 objectQuorumFromMeta 接受 8 块盘中的 4 个应答(cmd/erasure-object.go:659-661、cmd/erasure-metadata.go:560)。
  4. findFileInfoInQuorum 只对远端版本把转储字段算进哈希(cmd/erasure-metadata.go:346)。恢复副本不是远端版本,所以 2 个已恢复副本和 2 个未恢复副本分成两组,每组 2 个,低于读 quorum 4,返回 InsufficientReadQuorum{RQInconsistentMeta}(:386)。
  5. getObjectFileInfo 把这个错误转成 errFileNotFound(cmd/erasure-object.go:1007-1016),API 返回 NoSuchKey。

未开版本控制的对象被覆盖时,还有第二条路径通向同样的 404:没有版本达到合并 quorum 时,pickLatestQuorumFilesInfo 把所有应答过的盘都标记为“不存在”,包括持有对象的盘(cmd/erasure-object.go:661-668)。

与 tier 无关。tier 连接被拒绝会映射成 XMinioBackendDown(HTTP 400),不会是 NoSuchKey;而且读取在到达 tier 之前就已经失败。

来源

  • RQInconsistentMeta 转“不存在”来自上游 #20522(dc86b8d9d)。它的提交说明假定“所有盘都给出了足够的应答”,代码却没有检查。
  • 只对远端版本哈希转储字段来自上游 #19165(c599c11e7)。
  • 合并失败后把所有盘标记为“不存在”来自上游 d48002271。

方案

方案 A:只有所有盘都应答,才把元数据不一致转为“不存在”

补上缺失的前提:每个位置都必须是元数据,或者是明确的“没有这个文件、版本、卷,或元数据损坏”。这修复恢复和转储造成的分裂。新增 15 行、改动 2 行。

方案 B:局部视图下合并失败时,保留读 quorum 错误

合并找不到版本、且有盘没有应答时,持有版本的盘报告 errErasureReadQuorum,而不是 errFileNotFound。这修复未开版本控制对象的覆盖路径。新增 20 行。

方案 C:对所有转储版本都哈希转储字段

这会把已恢复和未恢复的副本合成一组,再由下标顺序决定返回哪一个。它不覆盖未开版本控制对象的覆盖,还会改变 #19165 之后的 heal 判断。不采用。

方案 D:让 /minio/health/cluster 反映本节点自己的视图

集群检查按各节点自报的本地盘计数,所以本节点还连不上部分节点时,它也可能报告“健康”。改它不能消除 404(任何局部视图都会触发),还会改变编排系统依赖的语义。对这个缺陷不采用。

方案 E:启动后更快地重连盘

只能缩小窗口。可以作为单独的改动。

修复方案

在 cmd/erasure-object.go 中同时采用方案 A 和 B:

  • 新增辅助函数 allDrivesAnswered(errs):每个位置都是 nil,或 errFileNotFound、errFileVersionNotFound、errFileCorrupt、errVolumeNotFound 之一时为真。这些都是盘给出的明确答复。元数据损坏也算作答复,是因为它已不可用,而不是因为它证明对象从未存在。请求仍在进行、盘缺失或故障、盘未格式化、访问错误、传输错误、context 被取消,都不算答复;
  • getObjectFileInfo 中,RQInconsistentMeta 的转换要求 allDrivesAnswered;
  • pickLatestQuorumFilesInfo 中,合并以“不存在”失败、且不是所有盘都应答时,xl.meta 中持有可读版本的盘得到 errErasureReadQuorum。

为什么充分且必要

  • **充分。**把局部视图变成 404 的两条路径,现在都要求所有盘都应答过。否则读取保留 quorum 错误,API 返回 503。
  • **必要。**只有方案 A,未开版本控制对象的覆盖仍会 404(路径 B);只有方案 B,恢复和转储仍会 404(路径 A)。缺任何一半,复现测试都会失败。
  • **最小。**不改存储 RPC、元数据哈希和健康检查语义,悬挂对象清理删除的东西不多也不少。真正不存在的 key,或所有盘都应答的对象,回答都不变。

代价与兼容性

  • 代价:只在错误路径上多遍历一次错误数组,没有额外 I/O。
  • S3:S3 承诺强读后写一致性,所以 NoSuchKey 必须意味着 key 不存在。503 SlowDown 是 SDK 会重试的临时错误码。
  • 混合版本:改动只在处理请求的节点本地生效。滚动升级期间,旧节点在这种状态下仍返回 404,已升级节点返回 503,不需要协调。
  • 有意的行为变化:某些盘离线时被删除的未开版本控制对象,如果从一个“旧副本和已删除副本恰好一样多”的视图读取,在节点重连或 heal 之前返回 503 而不是 404。从这个视图看,两种历史无法区分。
  • 多池(依据代码推断):读 quorum 错误被视为“对象在这个池里”,所以某个节点处于局部视图时,PUT 不会再在另一个池里创建重复对象。
  • 其他经过这种视图读取的操作,现在返回 503 并重试,而不是把对象当作不存在:
    • 多池下删除一个副本以这种方式分裂的未开版本控制对象(deleteObjectFromAllPools),以前可能走受保护的盲删路径;
    • CopyObject 读取源对象,以及标签和元数据更新;
    • decommission 和 rebalance,会重试或报告这个对象,而不是把它当作缺失而跳过。

遗留限制

  • 在写 quorum 上提交的带版本覆盖,通过这样的局部视图仍可能读到旧版本。这是上游既有行为,不在本次范围内。

  • 永久损坏的盘。 errDiskNotFound 算作没有应答的盘。因此在有一块永久坏盘的纠删集中,元数据分裂、且没有任何修订达到 quorum 的对象不再返回 404:

    • GET 和 HEAD 持续返回 503;
    • 在开启版本控制或版本控制暂停的桶中,它的 DELETE 返回 InsufficientWriteQuorum,dangling 对象清理也因为缺盘而拒绝执行;
    • 在未开版本控制的桶中,它的 DELETE 仍可走盲删。

    更换这块盘之后,纠删集的每块盘都会重新应答。但这本身并不能恢复这个对象:只有足够多的存活盘对它的元数据达成一致并持有它的数据时,heal 才能重建它。否则,一旦每块盘都应答,这次不一致的读取可能变成“不存在”。恢复步骤:更换这块盘,等盘的 heal 完成,然后读取该对象并列出它的版本加以确认;不要假定它已经恢复。

  • /minio/health/cluster 可能在节点连上所有对端盘之前就报告健康。验收脚本应改为经每个节点探测一次写和读。

验证

一个确定性的单元测试构建 8 块盘、EC 4+4 的纠删集和内存 tier,在隐藏两块盘的情况下提交恢复、转储或覆盖,再通过只有 4 块盘的视图读取:

情况 修复前 修复后
恢复提交到 8 块盘中的 6 块,带版本和不带版本的桶 404 503
转储提交到 8 块盘中的 6 块 404 503
未开版本控制对象的覆盖提交到 8 块盘中的 6 块 404 503
不存在的 key,同样的视图 404 404
副本不一致,但所有盘都应答 404 404

恢复、转储和未开版本控制的覆盖三种情况,断言返回读 quorum 错误、HTTP 503。带版本的覆盖可以返回旧版本,但不能报告为不存在。撤掉修复后这三种情况都失败;只保留方案 A 时,未开版本控制的覆盖仍然失败。

修复后约 100 个相关测试通过,覆盖读 quorum、悬挂对象、恢复、列举和删除标记清除。

评审记录

  • 对抗评审第 1 轮:批准,附若干小意见。评审逐一核对了判断条件对盘位上每种可能错误的处理、版本合并的所有调用方,以及悬挂对象清理。小意见已全部采纳:测试改为断言 503,而不只是“不是 404”;本记录补充列出了会失败而不是把对象当作不存在的降级删除、复制和维护操作,也不再把元数据损坏当作对象不存在的证据。

参考