多池删除:失败时保住最新副本
2026 年 10 月 24 日决定:合并修正后的 PR #257。 提交 aafe4f8886 修复了 issue #256 报告的多池删除缺陷,并补齐请求开始之后的执行期故障保护。核心不变量是:先完成其他池的旧副本清理,再删除决定当前读取结果的权威副本。
Denis Babchuk 报告了该问题并提交了初始补丁。代码评审复现了故障,发现批量删除在通过在线盘数预检查后仍存在执行期故障窗口,随后补充分阶段执行、逐项响应身份保持、部分删除重试以及共享分层对象保护。
背景与前置工作
SILO 采用按剩余容量加权的选池策略。覆盖写入无版本对象时,新对象可能写入池 B,而池 A 中仍保留旧物理副本。读取路径会跨池选择最新的逻辑对象,因此旧副本平时不可见;但一旦后续删除中途遭遇故障,旧副本就可能重新暴露。
此前合入的多池一致性工作与本次修改的关系如下:
| 已有工作 | 保护范围 | 与本次修改的关系 |
|---|---|---|
| #178 | 条件删除和指定版本操作的共享对象锁与跨池协调 | 提供本次复用的锁与协调基础 |
| #199,由 #207 修复 | 条件 PUT 针对跨池当前逻辑对象判断条件 |
保留按容量加权的分配策略 |
| #248,随 Server 20261018 合入 | 跨池迁移不得用旧载荷覆盖新副本,也不得丢失删除标记 | 保护 rebalance 与 decommission 迁移路径 |
| #245,已关闭未合并 | 提议覆盖写优先留在原池 | 两轮实验均未达到性能目标;本次修复不依赖覆盖亲和 |
既有协调机制尚未覆盖普通无条件删除与批量删除。共享锁与迁移设计详见多池对象一致性,保留原分配器的评估过程详见多池覆盖亲和。
故障现象:删除失败后,已被覆盖的旧值重新可见
假设初始状态如下:
若两个池并行执行删除,池 B 可能先完成删除,池 A 随后返回 I/O 错误。此时单对象 DELETE 虽然向客户端返回失败,但最新写入的 "latest" 已被物理移除,下一次 GET 会读回已被覆盖的 "old"。
Issue #256 报告了四类具体表现:
| 操作 | 原有故障行为 |
|---|---|
普通无版本 DELETE |
删除最新副本后返回失败,旧池中的旧值重新可见 |
批量 DeleteObjects |
某池删除成功掩盖了另一池的删除失败 |
| 批量显式版本删除 | 指定版本仍残留在另一池,却向客户端返回成功 |
版本化单个 DELETE |
在不满足写 quorum 的持有池尝试创建删除标记,未切换到另一可读可写的持有池 |
运行作者提交的 11 个回归子测试:基线 main 有 10 项失败,初始 PR 全部通过。不过,代码评审进一步发现批量删除在通过事前检查后仍存在执行期故障窗口。
为什么事前检查不够
初始补丁在批量删除开始前检查目标纠删集是否有足够的在线磁盘,能够提前拦截已知的离线 quorum 缺失,但无法应对预检查通过后在元数据读写或磁盘删除阶段才发生的 I/O 错误。
评审构造了 45 个故障注入场景:预检查时所有磁盘均在线,先放行最新副本所在池完成删除,再在旧副本所在池的 DeleteVersions 内注入故障(覆盖模拟 EIO、模拟存储断连与真实文件权限错误,遍历两池和三池下权威副本的每个位置,以及无版本、显式 null 与 UUID 版本对象):
- 基线
main(000e653038):在全部 45 个场景中吞掉了逐项删除错误。 - 初始 PR(
a30270d9ba):能返回错误,但在全部 30 个无版本与显式null场景中删掉了最新副本,导致旧值暴露;15 个 UUID 场景因同版本副本内容相同且仍有副本保留,未丢失最新值。
评审同时发现了另一处响应构造缺陷:提前失败的 DeletedObject 遗漏了 ReplicationState,而 HTTP handler 需要依赖该字段还原原始请求项身份。当签名批量请求包含两个受阻的复制版本清除项时,响应会错误地拆成一个错误项和一个空的删除成功项。仅保留 key 和版本 ID 不足以保证错误项准确对应。
删除一致性契约
修复在既有共享对象写锁下,逐项维护以下执行顺序:
- 解析本次操作指定的对象或版本,确定其权威副本所在池。
- 先完成所有非权威池的旧副本清理(阶段 0),再尝试删除权威副本(阶段 1)。
- 若非权威池清理失败,立即向对应的请求项返回错误,且不触碰该项的权威副本。
- 同一批次中互不相关的其他对象正常完成删除。
各边界行为说明如下:
- 非跨项事务回滚:若某对象在三个池中都有副本,阶段 0 可能先删掉其中一份旧副本,随后在清理第二份旧副本时失败。此时阶段 1 不会执行,健康的权威副本仍然完好;同一批次中已成功的其他对象不会回滚。
- 权威副本自身的执行期错误:当阶段 0 全部成功、进入阶段 1 删除权威副本时,若底层磁盘在阶段 1 部分写入后报错,调用方应在存储恢复后检查对象状态并重试。两阶段顺序解决的核心问题是绝不在已知其他池清理失败的情况下提前删掉权威副本。
- 显式历史版本删除:显式指定
versionId删除某个历史版本或删除标记后,更早的保留版本成为当前版本,这属于正常的 S3 版本控制语义。
最终实现
单个对象删除
普通无版本删除按先旧后新的顺序依次清理各池副本,遇到错误立即停止。条件删除与显式版本删除继续走已有的 deleteObjectReconciled。若选中的无版本对象已完成分层(tier)迁移,同样进入 deleteObjectReconciled,防止本地清理破坏共享的远端分层引用。
普通单对象创建删除标记时,若多个池持有该对象,协调层会跳过已知不满足删除 quorum 的池,优先选择通过检查的可读持有池写入删除标记。
批量数据删除分成两个阶段
新增的 deleteObjectsReconciled 直接复用既有命名空间写锁和 objectPoolInfos 解析器,不引入新的锁域。
对象元数据查询并发上限为 32。每项查询读取所有池中的指定版本并按 ModTime 排序;时间戳完全相同时以较小的池索引决胜,与读取路径保持一致。不可读元数据直接作为错误处理,绝不视为“该池无此对象”。
每个阶段内部仍跨池并行执行,继续调用现有的纠删集与磁盘层批量接口。由于同一批次内不同对象的权威副本可能分布在不同存储池,固定全局池顺序无法满足要求,必须按每项各自的权威池划分阶段 0 与阶段 1。
阶段 0 同样包含查询时报告对象不存在的非权威池:读 quorum 看到“不存在”并不能证明某块暂时离线的磁盘上没有残留旧物理副本。若该池无法完成删除,对应请求项会保守返回错误。
所有结果从一开始就完整保留原始 key、版本 ID 和 ReplicationState,过滤与重新分批均通过显式下标映射还原请求顺序。在线磁盘预检查继续用于提前拒绝已知的 quorum 缺失,运行期安全性则由两阶段顺序保障。
版本配置与删除标记顺序
批量入口沿用底层版本判断规则,区分物理删除与删除标记创建:
| 请求 | 执行动作 |
|---|---|
显式 versionId=null 或 UUID |
物理删除指定版本;多池批量请求执行两阶段清理 |
| 无版本 ID,普通无版本 key | 两阶段物理删除对象数据 |
| 无版本 ID,已启用版本控制 | 创建删除标记,保留历史版本 |
| 无版本 ID,已暂停版本控制 | 创建或替换 null 删除标记 |
| 配置了前缀级版本规则 | 对当前 key 计算 PrefixEnabledFn,并结合暂停状态判断 |
物理删除先于删除标记创建执行,从而保证同一批次内先显式清除 null 版本、再为同一 key 创建新标记时的先后顺序。纯标记批次继续走既有路径(降级场景下的已知边界见下文兼容性、重试与运行边界)。
部分磁盘清理后的重试
当某池仅部分磁盘完成了版本删除、其余磁盘失败时,客户端发起重试会遇到第二类问题:一部分磁盘返回“删除成功”,已删过该版本的磁盘则返回“文件或版本不存在”。若将两类响应分开计票,两边都可能达不到 quorum。
erasureObjects.DeleteObjects 的修正仅针对纯物理删除将成功移除与 errFileNotFound / errFileVersionNotFound 合并计入删除 quorum(未知 I/O 错误不计入)。对于创建删除标记或更新复制状态的操作,仍要求实际写入成功达到 quorum,避免把“目标不存在”误判为新标记或元数据状态已落盘。
单版本与批量删除路径(含此处的删除标记写入)的 quorum 为 floor(N/2) + 1(N 为纠删集磁盘数)。
共享分层对象
不同池的物理副本可能指向同一个远端分层对象(包括某份副本已临时恢复到本地的情况)。清理非权威本地副本时,只要保留的权威副本仍依赖该远端对象,就不能将其排入远端垃圾回收。
因此,批量请求中包含已完成分层迁移副本的项会转入既有的逐对象协调路径,完整保留 SkipFreeVersion、复制状态与前缀版本选项,并按对象串行执行(同时避免同一批次内的重复项并发改写元数据)。普通本地对象继续走两阶段批量路径。新增回归测试覆盖了共享与独立远端引用、已恢复副本、权威副本删除前失败以及显式 SkipFreeVersion 请求。
最终版本的验证
各演进提交与验证角色如下:
| 版本 | 角色 |
|---|---|
000e653038 |
对照基线 main(不含 #245 覆盖亲和) |
a30270d9ba |
初始 PR head,复现预检查后的执行期故障窗口 |
3c82748e14 |
补充分阶段批量清理、结果身份保持与部分删除重试 |
a2073d2514 |
最终 PR head,补齐共享分层对象保护 |
aafe4f8886 |
合并后的 main(源码树与 a2073d2514 一致) |
入库的故障测试在全盘在线状态下进入 DeleteVersions 并注入错误,同时断言返回正确的逐项错误且权威副本的删除尝试次数为零,并逐一验证故障期间的 GET、恢复后的 GET 与版本列表、重试成功及无关项正常删除:
| 覆盖范围 | 合并源码中的测试入口 |
|---|---|
预检查后 I/O 故障,两池/三池、全部权威位置、无版本/null/UUID |
TestPoolsBulkDeleteKeepsLatestOnIOFailure |
| 带复制状态的签名 HTTP 响应身份 | TestPoolsBulkDeleteReplicationIdentity |
| 混合 null 清除/标记、部分清理与重试、不同权威池 | 批量顺序与重试测试 |
相同时间戳、不可读元数据、共享锁/并发 PUT、对象层取消 |
选择与并发测试 |
| 物理删除 quorum 与标记/复制写入的区别 | TestBulkDeleteQuorumEvidence |
| 共享分层引用和已恢复对象 | TestPoolsRetiringCopyPreservesSharedTierObject |
| 原始单删、批量错误、已知 quorum 缺失与标记选池回归 | 原贡献的回归测试 |
新增回归测试在初始 PR 代码上可稳定复现失败;最终合入的删除、分层与 quorum 套件连续通过三轮 -race 测试,合并提交后再次通过复测。补充检查覆盖了单池对照组、前缀版本配置、目录与重复 key、Object Lock、版本清除及迁移回归。
最终 PR head 的 Go CI 与合并后 main 的 Go CI 均通过完整 cmd / internal 测试、race 测试、静态检查及 Linux、macOS、Windows 的 amd64 / arm64 交叉编译;DCO 与 VulnCheck 同步通过。
回归测试命令:
实机 mcli 验证使用单进程两池(每池四个目录盘):先用基线版本写入数据,重启切换至修复版验证读取、批量删除、UUID 版本删除、启用/暂停版本控制下的删除标记与新写入,最后切回旧服务端重新打开同一数据目录,升级与回退均正常。
性能:实测删除代价
性能基准直接对比基线 000e653038 与最终实现 a2073d2514(与合并后的 main 源码一致)。
测量方法
- 硬件环境:Apple M5 Max(18 物理核心、128 GiB 内存),单块 8 TB Apple SSD(APFS,测试时空间使用率约 95%,所有逻辑目录盘共享该物理 SSD)。
- 配置参数:每池 4 个目录盘(EC 2+2),4 KiB 对象,8 个并发 worker,
GOMAXPROCS=8,对象层直接调用(不含 HTTP、鉴权与跨节点 RPC)。 - 测试矩阵:5 种“池数/副本数”布局(此处的“副本”指一个逻辑对象在不同池中的物理副本数,而非纠删码分片),分别测试单对象
DELETE(每轮 1,024 次请求)与每批 64 项的批量删除(每轮 64 次请求、共 4,096 个对象)。 - 采样方式:每种布局和操作执行 3 对交替运行(基线→修复版、修复版→基线、基线→修复版),每轮重建相同初始布局(准备与清理不计入耗时)。共采集 60 个运行样本、30,720 次单删请求、1,920 次批删请求,累计删除 153,600 个逻辑对象,零请求错误。
结果与波动
吞吐量单位为“逻辑对象/秒”,表中给出三轮中位数及实测范围;配对变化为三对交替样本百分比变化的中位数。
| 池数/副本;操作 | 基线中位数 [范围] | 修复版中位数 [范围] | 配对变化 |
|---|---|---|---|
1 / 1;DELETE |
1,286 [1,193–1,292] | 1,347 [1,269–1,371] | +6.1% |
| 1 / 1;bulk 64 | 1,786 [1,739–1,809] | 1,818 [1,680–1,982] | +4.5% |
2 / 1;DELETE |
1,350 [1,272–1,363] | 1,304 [1,270–1,327] | −1.7% |
| 2 / 1;bulk 64 | 1,477 [1,468–1,740] | 1,453 [1,149–1,604] | −7.8% |
2 / 2;DELETE |
560 [546–641] | 534 [469–534] | −16.3% |
| 2 / 2;bulk 64 | 606 [528–695] | 620 [546–707] | +2.2% |
3 / 1;DELETE |
988 [722–994] | 1,053 [974–1,130] | +13.6% |
| 3 / 1;bulk 64 | 1,338 [1,269–1,383] | 1,120 [1,055–1,163] | −16.9% |
3 / 3;DELETE |
364 [323–371] | 354 [335–364] | −2.0% |
| 3 / 3;bulk 64 | 404 [367–450] | 365 [313–378] | −14.5% |
请求级延迟(单位:毫秒,bulk 64 为整批 64 个对象的单次请求耗时,方向为基线→修复版):
| 池数/副本;操作 | P50,ms | P99,ms |
|---|---|---|
1 / 1;DELETE |
5.69 → 5.50 | 15.06 → 14.37 |
| 1 / 1;bulk 64 | 277.28 → 280.00 | 406.23 → 385.23 |
2 / 1;DELETE |
5.46 → 5.47 | 14.09 → 14.88 |
| 2 / 1;bulk 64 | 317.10 → 346.26 | 635.16 → 463.78 |
2 / 2;DELETE |
12.91 → 13.76 | 35.86 → 45.54 |
| 2 / 2;bulk 64 | 823.19 → 752.78 | 1,315.81 → 1,297.48 |
3 / 1;DELETE |
7.23 → 6.42 | 22.09 → 20.20 |
| 3 / 1;bulk 64 | 361.73 → 444.34 | 669.84 → 732.27 |
3 / 3;DELETE |
20.35 → 21.00 | 50.75 → 48.92 |
| 3 / 3;bulk 64 | 1,236.42 → 1,392.63 | 1,697.38 → 2,079.27 |
P99 采用 nearest-rank 算法(每轮批量请求共 64 次,P99 接近单轮最大值)。单池控制组的调度路径未变,无性能回退;三池批量删除吞吐量下降约 15–17%,三池三副本的 P99 延迟增加约 22.5%;两池两副本的单对象删除吞吐下降 16.3%,同布局批量删除则在波动范围内。
代价来自哪里
单对象删除需要按先旧后新的顺序串行清理各池副本;批量删除则增加了跨池元数据解析以及阶段 0 到阶段 1 的先后等待(阶段内部仍保持跨池并行与磁盘批处理)。对于批次大小 $B$、池数 $P$、每纠删集磁盘数 $N$,元数据解析需读取 $B \times P$ 个池级对象状态,在线盘预检查至多检查 $B \times P \times N$ 个磁盘状态。由于共享 key 写锁贯穿上述流程,额外耗时也会相应延后针对同一 key 的并发写入。为守住删除失败不丢最新副本的一致性底线,这一多池删除开销是必要且可接受的。
兼容性、重试与运行边界
- 错误码与客户端重试:单对象删除的写 quorum 失败通常返回 HTTP 503;批量
DeleteObjects则在 HTTP 200 响应体内逐项返回SlowDownWrite等错误码。实测 S3 SDK 在MaxRetries=4下收到包含逐项SlowDownWrite的 HTTP 200 响应时不会自动重发整批请求,mcli rm则会直接报告失败项。调用方必须解析批量响应中的错误列表,在存储恢复后携带原versionId重试失败项。 - 并发与请求取消:既有共享对象写锁将多池删除与跨池写入串行化。HTTP 批量删除 handler 使用脱离客户端连接的后台 context,客户端中途断连不会中止已开始的批次。
- 暂停版本桶的降级批量标记边界:补充测试记录了一处在基线
main中既有的边界行为:在两池和三池布局下,仅含 UUID 历史版本的暂停版本桶在磁盘降级期间执行批量删除标记创建,待磁盘恢复后显式清除null标记仍可能返回 quorum 错误(一次运行暴露出保留的 UUID 历史版本,另一次运行保留了删除标记)。本次修复未改动纯标记批量路径的这一既有行为。 - Object Lock 与条件删除:Object Lock 与保留期权限检查保持不变;批量 XML
DeleteObjects的 ETag 条件仍不受支持,详见条件 DELETE。 - 升级与回退:本次修改不涉及 S3 参数、RPC 协议、磁盘格式、外部依赖、选池分配规则或配置开关变更。混合版本滚动升级期间,尚未升级的协调节点仍按旧逻辑处理其接收的删除请求;回退旧版本不会恢复此前已丢失的副本。
备选方案取舍
| 备选方案 | 不采纳原因 |
|---|---|
| 仅汇总各池错误 | 只能修复“删除失败却报成功”,无法挽回已被提前删掉的最新权威副本 |
| 仅做在线磁盘预检查 | 无法拦截预检查通过后在元数据读写或删除执行阶段才发生的磁盘 I/O 错误 |
| 按固定全局池顺序删除 | 同一批次内不同对象的权威副本可能位于不同存储池,固定池顺序无法兼顾 |
| 所有批量删除全部改为逐对象串行 | 不必要地放弃了普通本地对象的纠删集与磁盘层批处理吞吐;当前仅对含分层引用的对象走逐对象协调路径 |
| 依赖覆盖亲和(#245) | 覆盖亲和会带来常规小对象写入回退,且仍需处理存量跨池副本、迁移副本与中断清理,无法替代删除阶段的顺序保护 |
| 引入跨池回滚事务或配置开关 | 逐项两阶段清理已能保住权威副本,无需引入复杂的分布式回滚状态机或额外开关 |