跳转到主要内容

LIST 漏项修复:只在有疑问时确认,并把等待控制住

源码已合并,尚未发布新版本: PR #239 于 2026-10-17 合入主干,合并提交为 fbcb51197,#218 已关闭。修复保留原作者提交;主干合并不会自动更新已安装的软件。

这是 #218 / #239 的最新设计决策。它承接九月的 null 版本仲裁修复,也替代早期“直接调用 GET 元数据路径”的恢复方案。原作者 mr javad seydi 在 #221 中提出的修复及原始签署提交保留在 #239 中。源码合并、正式发布、生产升级是三个独立步骤;本页不是生产版本发布公告。

问题是“成功的清单少了东西”

假设桶里有 reports/a.csv。客户端可以 GET 下载它,LIST 却返回 HTTP 200,结果里没有这个名字。备份和同步程序通常把成功的清单当成可信输入,可能误报缺文件;如果开启“删除目标端多余文件”,还可能删掉目标端的正确副本。这不等于服务端已经丢失对象数据,但比明确报错更容易让下游作出错误决定。

LIST 会收集不同磁盘在不同时刻看到的目录和元数据。覆盖写、删除、滚动重启或跨池迁移期间,有的盘返回旧记录,有的返回新记录,有的暂时没有记录。“仲裁”就是要求足够多的盘提供相容的证据;不能把不同代、不同纠删码布局的记录随意相加。当目录记录无法形成结论时,旧路径可能直接跳过对象,最后仍然报告成功。

这里的 LIST 是 ListObjects / ListObjectsV2,例如 GET /bucket?list-type=2&prefix=reports/。它与 #79 的 ListMultipartUploads 是不同接口。本修复不增加 prefix=true、环境变量或客户端参数。

采用什么方案

先照原来的方式合并目录记录。已经能确定的条目直接返回。只有列举最新版本、至少存在一个可解码对象条目、且“可解码条目数加失败的读取流数”达到原列举阈值的未决条目,才进入恢复。失败的流只决定是否值得进一步检查,绝不作为对象存在或达到读仲裁的证据。低于入口阈值的输入、完整版本历史列举仍保留原行为;这是有边界的修复,不承诺消除所有可能的 LIST 漏项原因。

对需要恢复的名字,在汇总各存储池结果时统一确认。临时给这个名字加读锁,阻止同名写入打断确认,然后读取当前元数据。结果有三种:

  • 确认存在: 按对象真实的纠删码布局和原有读仲裁选择当前版本;正常返回,删除标记留到跨池合并之后再过滤。
  • 确认已删除: 来源存储组每一个配置盘位都在这次原始读取中返回 FileNotFound,还要核对每个存储池当前应存放这个名字的存储组,才能从清单中省略它。
  • 仍然不能确认: 返回既有的失败响应,例如 HTTP 503 SlowDownRead,让客户端知道这次清单不能当成完整成功结果。离线盘、损坏元数据、缺卷和解码失败都不能冒充“已删除”。

完整的一页仍可先返回,不必为了第一页等待全桶扫描结束。客户端必须继续读取所有后续页;任一页失败,都不能把前面已收到的部分当成完整桶清单。这保留了流式分页的延迟和内存优势。

为什么删除要多查几个池?例如池 A 的目录读到 a.csv,池 B 的目录先前为空;随后迁移把文件复制到 B,并清掉 A。只看到 A 现在不存在就省略名字,会再次漏掉 B 中的当前对象。确认来源确实为空后,当前各池的查询结果必须替换整个同名旧结果组,不能再被先前的旧目录记录覆盖。同一次恢复中,每个存储组最多补读一次。

另一个真实反例是正常 DELETE:16 个目录输入中,6 份旧记录、6 份新记录、4 份缺失,目录本身无法仲裁;但 DELETE 已返回 204,GET 已返回 404,当前 16 个盘都明确不存在。早期 #239 把这个情况也当成不确定,返回 503。最终方案能够确认这种正常删除,同时保留故障情况下的报错。

元数据保存在哪里

不增加任何持久文件、xl.meta 字段、索引或迁移步骤。只在进程内的列表条目里增加一个指向来源存储组的指针,大致是:

普通条目:name="reports/a.csv", metadata=<已有元数据>, listingSet=nil
待确认条目:name="reports/a.csv", metadata=nil, listingSet=<来源存储组>

“待确认”状态仅在内部通道中流转,由汇总层消耗。两个缓存编码入口都会拒绝尚未确认的条目;它不会写进分页缓存或发送给客户端。因而旧服务端不需要理解新字段,磁盘布局和网络编码保持不变。

补读使用只读元数据函数,复用已有的版本选择和读仲裁规则,不调用完整 GET 路径里的机会性修复或残留删除。另有一个窄的内存重计数:只在原选择失败、最新 null 版本和已知纠删码布局符合条件时,分别统计相容记录组;不降低阈值,不混加不同布局的票数,不扩展到严格解析或版本历史。

具体改哪些东西

位置 改动及必要性
目录解析与跨池汇总 将待确认名字带到同名结果组;在那里统一补读,避免每个池分别判断导致漏项或旧副本复活
元数据读取 区分原始“所有盘都不存在”和读取故障;使用原有版本、读仲裁规则,避免引入 GET 的写副作用
对象锁 多池沿用池级锁;单池多个存储组时,按固定顺序取得首组和目标组的读锁,覆盖现有 DELETE/COPY 与 PUT 的不同路由;相同锁域只取一次
分页缓存 先公布列举的最终错误,再结束缓存;HTTP 错误响应不等待失败的远端缓存更新。远端更新失败仍保留本地原缓存 ID 和错误,防止误标成功
读取尚未生成的缓存块 失败后及时检查生产端状态,每轮只探测一个在线盘并轮转;探测成功后仍进行原有仲裁读取。正常读取不多做状态 RPC
取消与锁竞争 等待可被请求取消打断;仅对可识别的 LIST 恢复锁冲突保留一次短等待,降低短读锁触发长退避的概率
回归与 CI 覆盖真实删除、迁移时序、锁路由、分页终止、失败 RPC、取消及迟到锁授予,并将这些测试加入 race 检查

底层目录回调仍使用原接口。因为恢复错误现在由汇总层直接传播,扫描、修复、再平衡和退役迁移调用方不需要额外接口改造。已有正常写入的锁路由不变。

单池需要覆盖两个不同锁域时,拿到第一把锁后等待第二把,可能暂时阻塞首组路由上的 DELETE/COPY;任一锁域无法形成仲裁,这个名字的恢复就会失败。这是确认过程的可用性与等待代价,只由实际进入恢复的名字承担。

锁在编码结果、通道发送和缓存 RPC 之前释放,避免慢客户端或缓存拖长持锁时间。分布式解锁通知可能异步完成,因此“调用了解锁”不等于远端锁记录在同一纳秒全部消失。单池两次加锁时,如果第二次失败或请求取消,必须释放第一次;这也有回归测试。

备选方案与取舍

方案 好处 为什么没有采用
保持现状 零新增开销 仍可能成功返回漏项,备份和同步不能可靠判断
遇到分歧一律返回 503 改动最少,不额外读取对象 一次补读即可确认的对象也不可用;正常删除也可能反复失败
每个对象都加锁并补读 规则直观 把磁盘和锁开销摊给全部 LIST,影响大桶列举和并发写入
只在来源池确认,或不加锁就确认 少一次锁或少查几个池 覆盖写、删除和迁移可以打断确认,已有反例证明仍会漏项
调低所有写请求的重试间隔 可能降低部分长尾 扩大到全局写路径,增加锁 RPC 和竞争,难以把代价限定在本问题
对符合条件的分歧按需确认,配合局部短等待 正常路径轻;可恢复时返回结果,未知时明确报错 分歧路径仍有实际读取与锁等待,代码多于“一律报错”,需要针对性验收

选择最后一项。最小化指的是每一处行为变化都对应已复现的错误或成本,并保留原接口和快路径;不能只比较补丁行数,把错误推给使用者。

开销具体有多大

情况 额外代价
正常列举 本次 64 位构建中,单个内存条目从 56 增至 64 字节,即多 8 字节和少量判断;没有额外对象读锁或磁盘补读
某个名字需要恢复 对待确认来源组补读元数据;每组内部并发读盘。单池不同锁域需要两次锁获取,多池使用一次池级锁
来源明确不存在 再查询每个池当前目标组,复用已读结果;上限按来源组与当前目标组的去重并集计算,而不是扫描整个桶
很多名字都需要恢复 同名组按目录顺序串行确认,整次扫描会变慢;不能把单个补读很快解释为整个扫描无代价
恢复读锁与写锁冲突 每次符合条件的锁 RPC 名义等待 2 毫秒后重新判断一次,每个本地锁服务共享最多 64 个等待名额,等待时不持内部互斥锁
锁获取失败后的普通退避 为支持取消,等待改为计时器与上下文选择,增加少量分配;重试间隔不变,一次获取成功的请求没有这笔开销

假设同时有 1,000 个条目留在内存,这个字段本身约多 8 KB;它不是给整个桶建立常驻索引。额外指针也参与垃圾回收扫描,整进程的内存和 CPU 不能只由结构体大小推算。

短等待不是优先级队列,也不是每个 HTTP 请求最多等 2 毫秒。调度、网络、后续重试和持续写锁仍可能造成长尾。来源标记复用既有诊断字段;未知或旧格式安全地回到原有拒绝与重试方式,不影响锁的正确性或兼容性。测得新进入短等待的 RPC 约多 264 字节、4 次分配,普通无竞争加锁分配不变。请求取消可中断普通退避等待,原退避间隔和仲裁阈值不变。

取消循环也由普通锁获取复用,所以这项计时器开销不限于 LIST:它发生在已经失败、需要退避的获取上。大量普通锁竞争也应纳入部署前的负载评估。

单个名字恢复使用五秒上下文预算,不把它当作整个 HTTP 响应的硬实时上限。取消后的部分或迟到锁授予仍需清理,测试同时检查没有剩余锁记录和等待名额。

验证证据与边界

最终源码包含当前主干的 IAM 和 Multipart 修复。回归测试既验证“该返回什么”,也验证正常路径不会额外读盘或取锁、锁不会跨越输出阻塞、缓存不能把未决状态编码出去。混合纠删码输入保留与原算法的差分比较,版本历史和严格解析维持原合同。

本地 make test 已通过,包含 verifiers、构建和全包测试:1,773 项顶层测试通过、12 项既有跳过。目录恢复、分页缓存、锁路由以及分布式/本地锁取消与迟到授予的针对性 race 测试也通过。

提交 87c1c6b3d 的 8 项 PR 检查全部通过,包括 Go CI、DCO 和漏洞检查。不发布制品的 snapshot 流水线也通过;它明确跳过发布、镜像发布和签名。

p95 表示约 95% 的请求耗时不超过该值;比较的是同一测试条件下候选与主干的比例,不是给生产环境承诺绝对延迟。

当前源码与主干 7e9454456 的独立对照共 10 轮:先做两轮相同主干,再做四组交替顺序的主干/候选比较。每轮预热 20 秒、测量 120 秒;八进程、两池、32 个逻辑盘目录共用一块 NVMe,使用回环网络和小对象固定速率负载。负载期间完成 22,410 次完整扫描,零漏项、重复、内容损坏或健康状态下的 HTTP 错误;没有环境或调度告警。

指标 候选 / 主干的配对 p95 变化 95% 区间
完整 LIST 页面 +0.82% −3.53% ~ +5.36%
同名 DELETE −0.38% −1.80% ~ +1.05%
不同名 DELETE −0.55% −4.74% ~ +3.84%

严格的“退化不超过 5%”统计目标仍未确认。 LIST 区间上界为 5.36%,且两轮相同主干的不同名 DELETE p95 自身相差约 12%。因此整体结果记录为“不确定”,不能改称性能门禁通过。点估计支持主要常态指标变化较小,不能等同于保证所有场景都小于 5%。不修改门槛,不通过追加轮次挑选有利结果。

次要指标同样保留:完整分页扫描 p95 约 +0.47%,PUT p95 约 +2.82%,GET p95 约 −0.51%;每页最多 7 条的中间续页 p95 约 +12.60%,同名 DELETE p99 约 +18.95%。候选还出现一次 327.248 毫秒的同名 DELETE;四个配对基准轮次最大值为 24.153 毫秒,额外 A/A 基准中也记录过 148.186 毫秒。当前未给这个单次长尾作因果归因,不能声称长尾已消失。八进程 CPU 的配对变化约 −0.07%,RSS 合计约 +0.11%;RSS 是进程驻留内存;多进程合计会重复计算共享页,不等于实际物理内存。

首次尝试因种子目录的符号链接复制问题,在任何候选测量开始前被身份校验拦截。失败记录保留,种子从另一份完全匹配原始校验清单的副本恢复;上述结果来自更正后使用独立实体目录的完整固定对照,没有混入失败尝试的数据。

同一候选二进制完成了八进程逐一优雅重启的验收。负载期间有 5,235 次成功完整扫描,其中测量窗口内为 4,754 次;没有漏项、重复或内容损坏。139 次失败 HTTP 请求全部落在各自端点的停止至就绪窗口内;46 次失败扫描是失败分页请求的汇总,不能再重复计数。最终 80 个对象状态、8 个端点上每页上限为 1/7/1000 的分页检查、416 次成功 GET 与 512 次预期不存在的 GET,均与最后确认的写入或删除一致。这个结果通过了限定范围的滚动功能验收,但不意味着零中断,也不改变上面的性能统计结论。

源码合并的工程判断是接受这项有边界的正确性修复:主要常态指标的实测变化较小,正常条目没有额外对象 I/O 或锁,确定性测试覆盖了真实删除和锁域等缺口。严格性能统计仍不确定,个别长尾也需要保留,不能把这次合并写成生产性能验收已经完成。

前一轮冻结候选的 18 组实验必须保留其原结论:LIST、同名 DELETE、不同名 DELETE 的 p95 配对比例约为 1.0042、0.9963、0.9966,延迟指标的 95% 区间上界均低于 1.05;但一个测量区间出现持续的外部 CPU 活动,整轮性能结论仍是不确定,不能改写成通过。该候选的滚动测试记录了 124 次停机窗口内的失败 HTTP 请求,没有遗漏、重复或损坏内容;这证明了限定范围的功能与恢复行为,不证明滚动升级零中断。

强制制造恢复与同名写冲突的历史实验中,短等待改善了部分 DELETE 和 LIST 请求,但仍保留约 303 毫秒的 LIST 最大延迟,部分 PUT 长尾指标也变差。它降低短冲突被退避放大的概率,没有消除持续竞争;不能拿健康常态的结果承诺故障时的性能。

新的整合验收与上述历史数据分开,不挑掉慢样本,也不把旧二进制的结果冒充当前源码结果。所有单机多进程结果都不能替代真实跨主机网络、混合版本、大桶和持续故障的生产验收。实际生产中的恢复频率仍未测定;故障时明确返回错误是有意保留的可用性代价。

兼容性和交付决定

不变的是 S3 参数、分页令牌编码、存储格式、配置项、模块依赖和客户端调用方式。不需要重写旧对象,也不需要新开关。变化是符合入口条件、又无法确认的条目会使 LIST 失败;这是用现有错误类型纠正成功漏项,不是保证所有原本返回 200 的请求仍然返回 200。

修复不让整个桶获得某一时刻的快照,也不修复 #79 的取消竞态或容量问题。回退服务端无需转换数据,但会恢复旧版的漏项风险;正在执行的请求和缓存应按既有重试流程处理。

处理决定已执行:#239 合入主干,#218 关闭。#221 的原始签署提交也已随 #239 纳入主干,GitHub 将 #221 标记为已合并。严格性能统计未确认的部分保留到发布评估中,不改变成“全部通过”。生产版本发布、镜像推送和部署需单独执行;合并本身不授权这些动作。