LIST 漏项修复:仅对存疑对象补读确认并收敛锁等待开销
随 SILO Server 20261018 发布的 PR #239(合并提交 fbcb51197,关闭 #218 并保留 mr javad seydi 在 #221 中的原始签署提交)以及 10 月 18 日的目录与公共前缀跟进修复(3dd53eb81 与 2080b3d03),解决了并发读写期间 ListObjects 与 ListObjectsV2 静默漏报对象的问题。本设计承接九月的 null 版本仲裁修复,并替代了早期“直接调用普通 GET 元数据路径”的恢复方案。
成功的目录列举可能漏掉真实存在的对象
假设桶中存在 reports/a.csv,客户端可以通过 GET 正常下载,但 LIST 请求却返回 HTTP 200 OK 且结果中不包含该文件名。备份与资产清点工具通常将成功的列表响应视为权威输入,从而误报文件缺失;若同步工具开启了“删除源列表不存在的目标端对象”,甚至会误删目标端的有效副本。相比明确返回错误,一个返回 HTTP 200 但漏项的列表往往会造成更严重的下游破坏。
LIST 会收集不同磁盘在不同时刻看到的目录与元数据记录。在覆盖写、删除、滚动重启或跨池迁移期间,部分磁盘返回旧记录,部分返回新记录,还有部分暂时没有记录。达成列举法定人数(quorum)要求足够多的磁盘提供彼此相容的证据;不同代际或不同纠删码布局的记录不能简单将票数相加。此前,当某条目录记录无法形成法定人数结论时,旧路径会直接跳过该记录,而整个 LIST 请求依然返回成功。
本修复针对 ListObjects 与 ListObjectsV2(例如 GET /bucket?list-type=2&prefix=reports/),与 #79 的 ListMultipartUploads 分属不同接口。修复不新增 prefix=true、环境变量或任何客户端参数。
选定的恢复设计
列举流水线首先执行原有的目录合并逻辑,已达成仲裁的条目直接沿快路径返回。仅当条目属于只列举最新版本、存在至少一条可解码对象记录、且“可解码记录数加失败读取流数”达到原始列举阈值但仍未决时,才进入恢复流程。失败的读取流仅作为触发进一步确认的依据,绝不计作对象存在或满足读仲裁的证据。低于入口阈值的输入以及完整版本历史列举(ListObjectVersions)保持原有行为。
符合恢复条件的未决对象名会被传递至存储池汇总层(pool merger),按完整同名组统一确认。汇总层先获取该对象名的读锁以阻止并发同名修改打断确认过程,随后重新读取当前磁盘元数据。确认结果分为三类:
- 确认存在: 按对象实际的纠删码布局和原有读仲裁规则选出当前版本,并保留删除标记直至跨池合并阶段再行过滤旧副本。
- 确认已删除: 来源存储组(source set)的每一个配置盘位都必须在本次原始读取中返回
FileNotFound,且汇总层还须核对每个存储池当前应存放该对象名的目标存储组(target set)均无存活对象后,才能从列表结果中省略该名称。 - 仍无法确认: 返回既有的失败响应(例如 HTTP 503
SlowDownRead),确保客户端不会将不确定的状态误认为完整清单。离线盘、损坏元数据、缺失卷与解码失败均不得当作“已删除”处理。
流式分页仍可在全桶扫描结束前先返回已满的页面。客户端必须读取所有后续分页;若任一分页失败,此前已收到的页面均不能视作完整桶清单。
异步缓存清理同样保留原始请求身份:提交 2a4db6a4f 在启动后台清理任务前捕获缓存节点并按值传递列举选项,防止前台重置缓存 ID 时与后台清理任务发生竞态或向 RPC 发送空 ID。缓存格式与分页令牌编码均保持不变。
为何确认来源存储组不存在后还要检查其他存储池?假设在双池集群中,池 A 的旧目录扫描包含 a.csv,而池 B 先前的目录扫描为空;随后跨池迁移将对象复制到池 B 并删除了池 A 的副本。如果仅因池 A 当前为空就省略该名称,就会漏掉池 B 中的当前对象。因此在确认来源存储组严格为空后,汇总层会查询各池当前的目标存储组,并用最新结果替换整个同名旧结果组。同一次恢复中,每个不同的存储组最多补读一次。
正常并发 DELETE 是另一个典型反例:假设 16 个目录输入中有 6 份旧记录、6 份新记录、4 份无记录,目录扫描本身无法达成仲裁;但此时 DELETE 已返回 204、GET 已返回 404,且当前 16 块磁盘均明确返回 FileNotFound。#239 的早期版本曾将这种健康的删除后状态也判定为未决并返回 503。最终方案通过核对全盘原生 FileNotFound 准确放行正常删除,同时对真正的磁盘或解码故障保持失败关闭。
对象缺失不得吞掉同名目录
10 月 18 日合入的 3dd53eb81 修复了同名组中对象已确认不存在、但同名目录条目依然有效的场景:仅移除缺失的对象,保留目录(公共前缀)。后续 2080b3d03 覆盖了待恢复对象、普通对象与目录混合出现时的排序处理:重复目录折叠为一个公共前缀,仍存活的对象保持对象结果,避免将“确认对象不存在”误扩大为“整组目录不存在”。
内存元数据表示
本修复不新增任何持久化文件、xl.meta 字段、磁盘索引或迁移步骤,仅在内存 metacache 条目结构体中增加一个指向来源存储组的指针:
“待确认”状态仅在进程内部通道中流转,由池汇总层消费。两个缓存编码入口均显式拒绝尚未确认的条目,listingSet 绝不会写入分页缓存或发送给客户端,因此磁盘布局与网络编码完全不变。
补读使用只读元数据辅助函数,复用既有的版本选择与读仲裁规则,避免触发完整 GET 路径中的机会性修复(healing)或残留清理副作用。此外还包含一项窄范围的内存重计数逻辑:仅当原始选择失败、且最新 null 版本具有已知纠删码布局时,按相容的纠删码布局分组重新对照原始阈值统计票数,既不降低仲裁阈值,也不混加不同布局的票数。
各子系统具体改动
| 位置 | 改动及原因 |
|---|---|
| 目录解析与跨池汇总 | 将待确认名称传递至完整同名结果组并在汇总层统一补读,防止单池私自裁决导致漏项或旧副本复活。 |
| 元数据读取 | 区分原生的“全盘均不存在(FileNotFound)”与读取/解码故障;复用版本与读仲裁规则,避免引入 GET 的写副作用。 |
| 对象锁 | 多池场景沿用池级锁;单池多存储组场景按固定顺序获取首组与目标组读锁,同时覆盖 DELETE/COPY 与 PUT 的锁域;相同锁域只获取一次。 |
| 分页缓存 | 先发布列举的终态错误再结束缓存,使 HTTP 错误响应无需等待失败的远端缓存更新;远端更新失败时保留本地原缓存 ID 与错误。 |
| 读取待生成缓存块 | 读取失败后及时检查生产端状态,每次重试仅轮转探测一块在线盘,探测成功后再发起完整仲裁读取;正常读取不增加额外状态 RPC。 |
| 取消与锁竞争 | 允许上下文取消打断锁重试等待;仅对可明确识别的 LIST 恢复锁冲突提供一次 2 毫秒短等待与重检,降低短暂读锁触发长退避的概率。 |
| 回归测试与 CI | 在 -race 下覆盖真实删除、跨池迁移时序、双存储组锁路由、分页终止、失败 RPC、请求取消及迟到锁授予。 |
底层目录遍历回调保留原有函数签名。由于恢复错误统一由汇总层传播,扫描器、自愈、再平衡(rebalance)与退役(decommission)调用方无需修改接口。
当单池恢复需要跨两个不同锁域加锁时,持有第一把读锁等待第二把读锁期间可能会短暂延迟首组路由上的 DELETE 或 COPY 请求。恢复流程会在编码结果、发送通道与发起缓存 RPC 之前释放锁,避免慢客户端拖长持锁时间。若第二次加锁失败或请求取消,会立即释放第一把锁。
评估与否决的替代方案
| 候选方案 | 优点 | 否决原因 |
|---|---|---|
| 保持现状 | 零新增运行时开销 | 仍可能在 HTTP 200 响应中静默漏项,导致备份与同步工具误判。 |
| 遇到分歧一律返回 503 | 补丁最小,无需补读元数据 | 一次补读即可确认的对象也会报错;正常并发删除后也会反复返回 503。 |
| 对每个列举对象都加锁并补读 | 规则简单统一 | 将磁盘读取和锁开销强加给所有 LIST 请求,严重拖慢大桶列举与并发写入。 |
| 仅在来源池确认,或不加锁确认 | 减少锁获取或跨池查询次数 | 并发覆盖写、删除与跨池迁移仍可打断确认过程,实测反例证明依然会漏项。 |
| 全局调低所有写请求的重试间隔 | 可降低部分锁竞争长尾 | 影响面扩大到全局写路径,显著增加集群锁 RPC 流量与竞争。 |
| 对符合条件的分歧按需确认,配合局部短等待(已选定) | 正常路径保持轻量;可恢复时返回准确结果,不可确认时明确报错 | 分歧路径需承担实际补读与锁等待开销,实现与验证复杂度高于直接报错。 |
运行时开销分析
| 触发条件 | 额外代价 |
|---|---|
| 正常列举条目 | 在 64 位构建中,单个内存条目从 56 字节增至 64 字节(新增一个 8 字节指针)及少量分支判断;无额外对象读锁或磁盘补读。 |
| 单个名称进入恢复 | 对待确认来源存储组并发补读磁盘元数据;单池跨锁域需获取两次读锁,多池使用一次池级锁。 |
| 来源存储组明确不存在 | 进一步查询每个池的当前目标存储组(复用已读结果),读取上限为来源组与目标组的去重并集,而非扫描全桶。 |
| 大量名称同时需要恢复 | 同名组按目录字典序串行确认,高比例分歧会拉长整次扫描耗时。 |
| 恢复读锁与写锁冲突 | 每次识别出的恢复锁冲突 RPC 最多在互斥锁外名义等待 2 毫秒后重检一次,每个本地锁管理器共享最多 64 个并发等待名额(每次等待约增加 264 字节、4 次分配)。 |
| 锁获取失败后的普通退避 | 为支持上下文取消,将休眠改为计时器与 select,仅在初次取锁失败需要退避时增加少量分配;一次加锁成功的快路径无此开销。 |
以内存中同时驻留 1,000 个条目计算,新增指针字段本身约占用 8 KiB。单个对象名的恢复使用 5 秒上下文超时预算,取消后若有部分或迟到的锁授予也会立即清理。
验证证据与基准测试边界
本地 make test 通过全部 1,773 项顶层测试,且针对目录恢复、分页缓存、锁路由、分布式与本地锁取消及迟到授予的专项 -race 测试全部通过。在提交 87c1c6b3d 上,8 项 PR 检查全部通过(包括 Go CI、DCO、漏洞检查 与快照构建流水线)。
针对主干 7e9454456 的独立 10 轮配对基准测试(2 轮相同主干 A/A 对照 + 4 组交替顺序的主干/候选配对;每轮预热 20 秒、测量 120 秒,采用 8 进程、2 存储池、单块 NVMe 上的 32 个逻辑盘目录与回环网络小对象固定速率负载)共完成 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% |
次要指标配对变化如下:完整分页扫描 p95 约 +0.47%,PUT p95 约 +2.82%,GET p95 约 −0.51%,每页上限 7 条的中间续页 p95 约 +12.60%,同名 DELETE p99 约 +18.95%(候选版本出现一次 327.248 毫秒的同名 DELETE 峰值,而额外 A/A 基线对照中也曾出现 148.186 毫秒样本)。8 进程总 CPU 配对变化约 −0.07%,RSS 合计变化约 +0.11%。由于完整 LIST 页面 p95 的 95% 置信区间上界为 +5.36%(略超严格的 5% 门禁线),且两轮相同主干的不同名 DELETE p95 自身存在约 12% 波动,严格的“不超过 5% 退化”统计门禁记录为待定(inconclusive),而点估计表明主要稳态指标变化极小。
同一候选二进制还完成了 8 进程逐一优雅重启的滚动验证:负载期间完成 5,235 次完整扫描(测量窗口内 4,754 次),零漏项、零重复、零内容损坏;139 次失败 HTTP 请求全部落在对应端点的停机至就绪窗口内;最终 80 个对象状态、8 个端点在分页上限 1/7/1000 下的分页检查、416 次预期存在的 GET 与 512 次预期不存在的 GET 均与最后确认的写入或删除一致。
兼容性
S3 请求参数、分页令牌编码、磁盘存储格式、配置项与客户端调用方式完全保持不变。无需重写存量对象或启用新配置开关。进入恢复流程但因故障仍无法确认的条目会使 LIST 返回既有错误码(如 503 SlowDownRead),以此替代原先漏项却返回 HTTP 200 的行为。降级服务端无需转换数据。