列举不能丢掉仍有仲裁的 null 版本
本文记录 2026-09-16 调查的对象列举漏项问题:未开启版本控制的存储桶在并发覆盖写下返回 HTTP 200 的列表,却漏掉了同一端点仍能正常 GET 的对象键。下文说明已确认的合并缺陷机制、提交 8d06424b1(随 Server 20260916 发布)的针对性修复与验证数据,以及在 #218 中跟踪、随后在 Server 20261018 中由 PR #239 关闭的滚动重启漏项边界(详见 LIST 恢复设计)。
缺陷机制
对象列举会合并被询问各块磁盘返回的版本流。当各盘状态不一致时,mergeXLV2Versions 选出一个最新版本并统计同意该版本的磁盘流数量。上游 2022 年的一处改动(PR #14125)让这一计票过程容忍自愈(Healing)留下的签名差异——这一容忍本身是必要的,但它的版本选择与计票方式对输入切片顺序敏感。当每块盘各持有一个普通 null 版本、而版本更新的少数盘恰好排在切片末尾时,旧一代版本的计票会被重置。原本实际达到读仲裁(Quorum)的旧一代版本因此被丢弃,合并函数返回空切片,解析器进而将该对象键当作不存在跳过。由于 HTTP 请求整体仍返回 200 OK,客户端会看到一份看似完整、实则漏掉有效键的列表;而对该键直接发起 GET 仍能成功,因为单对象读路径独立解析读仲裁。
修复前的代码在 9 种磁盘排列顺序下以此方式复现失败(具备完整的签名 LIST 报文证据),而完全相同的磁盘内容在其余排列顺序下则列举正常。
修复方案(8d06424b1)
提交 8d06424b1 新增的二次计票仅在原选择逻辑未能达到仲裁时触发,且严格限定于能够精确判定的输入场景:在裁剪前的原始输入上,每个非空磁盘流恰好持有一个普通 null 版本、不包含 free version,且所有磁盘流的纠删码参数完全一致。满足上述条件时,算法按元数据头部重新分组,唯有达到原始有效仲裁数的组才会返回。混合版本历史、显式版本 ID、删除标记、free version 与混合纠删布局均保持原有行为;严格模式、签名比对、平票决胜与代表条目选择规则保持不变,共享该合并函数的其他调用方对所有原本成功的输入均得到完全相同的结果。
8d06424b1 的验证数据:
- 9 个旧顺序反例与 7 个新顺序反例全部通过;5,620 个差分测试输入保持契约结果一致。
- 完整签名 HTTP 列举、对象读取、常规与
-race并发检查,以及扫描器(Scanner)、自愈(Healing)与迁移(Migration)等下游调用方测试全部通过(含 6 个顶层测试与 23 个嵌套子用例)。 - 在 4 节点 16 盘集群上叠加并发覆盖写,连续 20,000 次稳态成功
LIST零漏项。
滚动重启边界(#218,已由 20261018 的 #239 闭环)
在同一套 4 节点 16 盘集群上,使用 20260916 构建对四个节点执行滚动重启并叠加并发覆盖写时,27,966 次成功返回的 LIST 请求中有 2,624 次漏掉了 1 至 4 个对象键。这些被漏掉的键实际可读:8,192 次同端点 GET/HEAD 核查中有 8,186 次返回 200 OK 且内容为最后一次确认的写入。最典型样本发生在最后一个节点健康检查恢复正常 17.6 秒之后,所漏掉的键最后一次成功 PUT 已在 11.5 秒前确认完成且其后无新写入。原始 XML、HTTP 请求关联与响应体哈希均经独立复核,排除了分页与客户端解析因素。
这一滚动重启漏项发生在进入 mergeXLV2Versions 之前或周边的列举管线中——20260916 构建中仍存在将瞬态读取或锁竞争失败静默转为“键不存在”的分支:
listPathRaw获取命名空间读锁(NSLock)在高并发写竞争下达到 1 秒硬超时后直接失败或跳过;- 单块远端磁盘在流式列举中途断连,导致解析器拿到的有效条目少于仲裁数或缺少缓存元数据;
- 部分解析回调(Partial-Resolution Callback)在失效 reader 数量未超过整集容错阈值时静默丢弃单个条目。
Issue #218 专门跟踪了上述滚动重启调查,随后在 Server 20261018 的 PR #239 中通过移除 listPathRaw 的 1 秒读锁硬超时并引入有界 metacache 流恢复机制彻底解决。完整根因分析、修复设计与基准测试见节点重启与 NSLock 锁竞争下的 LIST 恢复设计。