跳转到主要内容

大型单池集群的锁域

本文覆盖在审查恢复副本修复(恢复副本:过期的是副本,不是对象)时发现的锁域缺陷,评估基线为 main 上的 69fcf50cd。目前尚未提交 issue。

状态: 修复已通过审查,已在本地提交。它已和下个版本的其他修复放在同一个集成分支上一起测试:带 kqueue,dev 标签的本地完整测试套件、本地执行的 CI 竞态检测选集,以及针对修复之间相互作用的对抗性审查,全部通过。尚未合并或发布。

简短结论

锁域分裂是真实的,继承自上游 MinIO,并且只在一个池中的各纠删集并不使用相同主机时才存在。按自动布局,这意味着单池有 18 台或更多主机;不超过 17 台主机时,每个集都包含所有主机,锁组完全相同。

在这样的池里,DELETE、批量删除、CopyObject 以及 upload ID 守卫使用的对象锁,取自第一个纠删集的 locker。PUT、分片上传完成、标签/保留/法定保留/元数据更新、恢复(restore)、转储(transition)和修复(heal)使用的锁,取自键所哈希到的集的 locker。对于哈希到主机与第一个集不同的集的键,两把锁永远不会相遇,所以这些操作之间并不互斥。xlStorage 自身没有按键的串行化:RenameData、DeleteVersion、UpdateMetadata、WriteMetadata 都是读出 xl.meta、在内存中修改、再写回。

探针让每个集使用各自独立的锁表,在同一个键上让 PUT 与 DELETE、以及与仅元数据的 CopyObject 竞争,共享锁表 2100 次迭代,分裂锁表 2100 次迭代。共享锁表下没有任何异常。分裂锁表下:

  • 在刻意收紧的竞争中,多数迭代出现已确认的版本或删除标记在该集部分磁盘上缺失;
  • 已确认的 PUT 只落在少于读仲裁数的磁盘上,甚至一块也没有;
  • 未版本化的对象只留在 16 块盘中的 1 到 5 块上;
  • 仅元数据的复制在两个调用都返回成功后,写回了旧内容,或使对象不可读。

把单池的 DELETE 与 CopyObject 的锁路由到哈希集,同一探针中的全部异常消失。修复对所有原本使用第一个集的单池对象锁都这样做:各种形式的 DELETE、批量删除(每个集一把锁,各集并发删除)、COPY 的目标、SelectObjectContent、批量密钥轮换以及 upload ID 守卫。COPY 处理器先检查源名称,再在读取源对象之前一起获取源锁和目标锁。它不改变线上协议,但只有所有节点都运行它之后才能保护一个池,所以受影响的池需要排空后重启,而不是滚动升级(滚动升级)。

定性

方面 评估
性质 池层与集层之间的锁域不一致。自 RELEASE.2024-10-02T17-50-41Z 起继承自上游 MinIO,不是 SILO 的回归。SILO 增加了依赖该锁的功能(If-Match DELETE),并删除了一条警示注释,但从未改变过路由。
受影响部署 分布式、单池,且某些集使用的主机组与 0 号集不同的自动或显式布局。对 http://host{1...N}/disk{1...D},即 N 大于等于 18,与 D 无关。更小的集群只有在集大小小于主机数时才受影响(MINIO_ERASURE_SET_DRIVE_COUNT,或手工排序的参数列表)。
不受影响 单节点部署(所有集共享一张进程内锁表)。多池部署(写路径同样获取池级锁)。自动布局下不超过 17 台主机的池。
受影响操作 DELETE 对 PUT、CopyObject、标签/保留/法定保留/元数据更新、restore、transition、heal、复制写入;ILM 过期对覆盖写;If-Match DELETE 对任何写者;批量删除;complete、abort、list-parts 的 upload ID 守卫对 PutObjectPart。
严重程度 影响高,触及面窄。同一键上的并发竞争可能丢失已确认的写入或删除标记,使对象只留在少数磁盘上,或使成功的元数据复制把数据回滚。不影响其他键,也不存在跨租户暴露。
可达性 竞争需要两个写者在几毫秒的提交窗口内操作同一个键。普通 S3 客户端很少这样做,但 ILM 过期遇到刚发生的覆盖写、复制、重试风暴,以及对同一个键反复覆盖又删除的客户端(队列、锁、临时文件)都可能触发。强制时很容易复现,正常流量中少见。在 18 台主机的集群中,大约一半的键(其集的主机组与 0 号集不同)暴露。
发布紧迫度 计划在下一个版本修复,而非热修复。改动局限于池层和 COPY 处理器;受影响的池以排空后重启的方式升级(滚动升级)。
剩余工作量 修复与测试已编写。剩余:远端 CI,以及在分布式池上的验收运行。

锁组如何构建

每个纠删集有自己的 locker 列表:该集端点涉及的每个不同主机一个 locker(newErasureSets)。分布式锁需要该列表的严格多数,主机上的锁服务器对同一资源名一次只授予一个持有者。两个针对同一键的请求,只有在询问相同主机时才会互斥。两个列表相同,则互斥;完全不相交,则各自拿到自己的多数,两个请求都被授予;部分重叠时,只要两个仲裁数能放进并集,即 qA + qB <= |A ∪ B|,仍然可以同时被授予。

端点展开时最左侧的省略号变化最快,所以在 http://host{1...N}/disk{1...D} 中,第 i 个端点的主机是 i mod N。集是 S 个连续端点的切片,S 是 N*D 的、不超过 16 且使集数最少的最大约数。用真实的 GetAllSets 检查得到:

布局 集大小 S 锁组
N = 2 到 17,D 为 1 到 32 的任意值 S >= N 每个集都含所有主机,锁组相同。
N = 18,任意 D 9 0 号集:主机 1-9。1 号集:主机 10-18。互不相交。
N = 20 10 两个互不相交的一半,在各集之间交替。
N = 24 12 两个互不相交的一半。
N = 32 16 主机 1-16 与 17-32,交替。一半的键暴露。
N = 21 7 三个互不相交的三分之一。
N = 25 5 五个互不相交的组。五分之四的键暴露。
单节点,任意 D 任意 所有集共享一张锁表。

扫描覆盖了 N 从 2 到 64;N 不超过 17 时 D 取到 32,其上 D 取 {1, 2, 3, 4, 6, 8, 12, 16}。N 大于等于 18 的每个有效布局锁组都不同,且在每一个中两个仲裁都可以同时被授予。当集大小被强制设为小于 N 时,更小的 N 也是如此。配置文件布局以同样的方式把磁盘轮流分配到各节点。

操作表

单池,main 上 69fcf50cd。“0 号集”指 erasureServerPools.NewNSLock(serverPools[0].sets[0]);“哈希集”指 getHashedSet(object) 的 erasureObjects.NewNSLock。

操作 使用的锁 锁域
PutObject(池层在任何池锁之前直接返回 serverPools[0].PutObject;putObject 在集层加锁) er.NewNSLock 哈希集
带条件或带副本调和的 PutObject er.NewNSLock 哈希集
CompleteMultipartUpload,对象提交 er.NewNSLock(object) 哈希集
CompleteMultipartUpload,upload ID 守卫 z.NewNSLock(object/uploadID) 0 号集
AbortMultipartUpload、GetMultipartInfo、ListObjectParts 守卫 z.NewNSLock(object/uploadID) 0 号集
PutObjectPart(分片锁、upload ID 读锁) er.NewNSLock 哈希集
DeleteObject(池层总是加锁;集层不加) z.NewNSLock 0 号集
DeleteObjects 批量(对所有名称一把锁) z.NewNSLock 0 号集
CopyObject,仅元数据与重写(池锁覆盖带 NoLock 的回退 PutObject) z.NewNSLock 0 号集
PutObjectTags、PutObjectMetadata(保留、法定保留)、DeleteObjectTags er.NewNSLock 哈希集
RestoreTransitionedObject、TransitionObject er.NewNSLock(restore 复用带锁的 PutObject、分片、CompleteMultipartUpload、CopyObject) 哈希集
Heal(healObjectInPool 仅在 !SinglePool() 时取池锁) er.NewNSLock 哈希集
目标端的复制写入(PutObject、元数据、复制) 同上述操作;另有经 objectAPI.NewNSLock 的 /[replicate]/<key> 哈希集;串行器在 0 号集
复制的 DELETE z.NewNSLock 0 号集
ILM 过期(带 Expiration 的 DeleteObject;规则在锁内重新评估) z.NewNSLock 0 号集
批量密钥轮换(NewNSLock,然后带 NoLock 的 CopyObject) z.NewNSLock 0 号集
SelectObjectContent(读锁) z.NewNSLock 0 号集
GetObject、HeadObject er.NewNSLock 读锁 哈希集
列举恢复(listingRLock,SILO) 0 号集,然后源集 两者

在多池部署中,PutObject、CompleteMultipartUpload、标签与元数据更新、transition、restore、heal 也会取池锁并向下传递 NoLock,所以所有写入都与 DELETE 共用 0 号集。分裂的根源是单池捷径 if z.SinglePool() { return z.serverPools[0].X }:它让写者跳过池锁,但没有让 DELETE 与 CopyObject 跳过。

根因

两个选择叠加在一起。

  1. 池层自己为 DELETE 与 CopyObject 加锁,并且总是通过第一个集。 erasureServerPools.NewNSLock 返回 serverPools[0].NewNSLock,erasureSets.NewNSLock 返回 sets[0].NewNSLock。当每个写入也都取池层锁时(多池就是这样),这是正确的。erasureObjects.DeleteObject 自己不加锁。
  2. 单池的写者跳过池锁,使用它所哈希到的集的锁。 该集的 locker 由它自己端点的主机构成。

当两个锁组是同一份主机列表时,这两个选择描述的是同一个锁域。当它们不同时,同一键上的 DELETE 与 PUT 持有的锁互不相遇。

xlStorage 不会修复这一点。RenameData 读取目标 xl.meta,加入版本,写到暂存路径,再重命名。DeleteVersion 读取,移除版本,把数据目录移入回收站,再写回或删除。UpdateMetadata 与 WriteMetadata 读取、修改、写入。除调用方持有的命名空间锁之外,没有任何东西在同一个键、同一块盘上串行化它们。上层还有各自的先检查后行动:DeleteObject 依据未加锁的读取决定写标记还是清除,悬空对象清理会清除一个在提交中途看起来不完整的版本,仅元数据的 CopyObject 会写回它先前读到的 FileInfo 快照。

行为的来源

上游起源,分几步形成。 下列提交都是上游最后一个 master 7aac2a2c5 的祖先,且上游 master 今天的代码与此相同。

成分 提交 日期 首个上游版本
每个集独立的 locker 组 e9b2bf00a,minio/minio#8492 2019-11-13 RELEASE.2019-12-17T23-16-33Z
每个集内每台主机一个 locker 4550ac6ff,minio/minio#11052 2020-12-10 RELEASE.2020-12-12T08-39-07Z
批量删除通过 getHashedSet("") 加锁(批量与单键的分裂由此开始) ab7d3cd50,minio/minio#8974 2020-02-21 RELEASE.2020-02-27T00-23-05Z
CopyObject 取池锁 200caab82,minio/minio#13280 2021-09-22 RELEASE.2021-09-23T04-46-24Z
DeleteObject 的锁从集层移到池层 d350b666f,minio/minio#15521 2022-08-18 RELEASE.2022-08-22T23-53-06Z
池锁总是落在 0 号池 6c746843a,minio/minio#20356 2024-08-30 RELEASE.2024-09-09T16-59-28Z
集锁总是落在 0 号集:造成单键分裂 afea40cc0 2024-09-27 RELEASE.2024-10-02T17-50-41Z

直到 RELEASE.2024-09-13T20-26-02Z,只给一个名称时 erasureSets.NewNSLock 返回 getHashedSet(objects[0]).NewNSLock。因此单池的池锁落在哈希集,与 PutObject 同域,单键 DELETE 与 CopyObject 是一致的。只有多名称锁(批量删除)使用另一个集 getHashedSet("")。afea40cc0(Harshavardhana,2024-09-27)把它换成了 sets[0],使多对象删除能与授予其他调用者的锁竞争。当每个写者都取池层锁时(多池如此),这是对的;对于写者在集层加锁的单池则是错的。该提交标题带有“(#93)”,这不是公开 minio/minio 仓库中的编号,所以没有可链接的上游 pull request。

首批受影响版本。 上游:单键 DELETE 与 CopyObject 自 RELEASE.2024-10-02T17-50-41Z;仅批量删除更早。SILO:afea40cc0 与 d350b666f 是首个 SILO 版本 RELEASE.2025-12-03T12-00-00Z 的祖先,也是此后直至 RELEASE.2026-10-26T00-00-00Z 每个版本的祖先。每个 SILO 版本都带有这一分裂。

改变了风险的 SILO 提交。

提交 日期 首个 SILO 版本 影响
40bee4b7b If-Match DELETE(issue #10,PR #145) 2026-09-06 RELEASE.2026-09-16T00-00-00Z 放大。条件检查与删除现在依赖池锁与写者之间的原子性。提交写明了限制(“单个纠删集(默认部署)”),但在分裂的集群中,写者可在检查与删除之间替换对象,过期的 If-Match 仍可能删掉较新的对象。
7a060cab1 基于访问的 ILM 2026-08-15 与其回退 9b76a2167(2026-09-15)一起包含在 RELEASE.2026-09-16T00-00-00Z 中 对分裂无影响。它的回滚代码直接写明了这一分裂(“顶层命名空间锁取自 0 号池 / 0 号集……客户端 PutObject 锁哈希集……locker 按集划分”),说明维护者八月就已知晓。
e59a3d938 多池串行化(PR #178) 2026-09-11 RELEASE.2026-09-16T00-00-00Z 没有改变单池锁域。它为多池写者加了池锁,并删除了 DeleteObject 中说该锁“仅在单个集内”与写路径共享命名空间的注释。警示没了,它所描述的行为没有变。
87c1c6b3d 列举恢复(issue #218,PR #239) 2026-10-17 RELEASE.2026-10-18T00-00-00Z 缩小,仅针对一种读者。列举恢复按固定顺序取 0 号集与源集的读锁,以同时覆盖两个锁域。
d2eb0ade9、cd1f6cec3、dcc2f20ba 2026-10-25 与 26 RELEASE.2026-10-25T00-00-00Z(前两个)、RELEASE.2026-10-26T00-00-00Z 移动了 PutObject 取集锁的位置,又移回。锁域不变。
1748f62c7、60c7179dd、7ce7fc8d1、5e7d60308 2026-09 与 10 RELEASE.2026-10-18、RELEASE.2026-10-18、未发布(2026-10-29)、RELEASE.2026-09-16 仅多池与数据迁移路径,对单池无变化。

公开设计记录 Restored Copy Expiry 已把“大型单池部署中的锁域”列为继承的、范围之外的问题,Listing Recovery Lock Cost 描述了列举的绕行方案。

简而言之。 缺陷不是 SILO 引入的。造成单键分裂的提交是上游 2024-09-27 的 afea40cc0,首发于 RELEASE.2024-10-02T17-50-41Z。SILO 在 RELEASE.2025-12-03T12-00-00Z 中继承了它,在其上建立了功能(40bee4b7b),在注释里保留警示直到 e59a3d938,此后只在列举恢复中做了绕行。

探针结果

探针在 main 的一次性 worktree 中运行。它构建一个由 32 块本地盘组成的池,分成两个各 16 块盘的集,并选取哈希到 1 号集的键。于是 DELETE 与 CopyObject 通过 0 号集加锁,PUT 通过 1 号集加锁。生产本地配置下两个集共享一张锁表,这模拟各集共享主机的分布式集群。分裂配置给每个集独立的锁表,沿用现有 TestNativeRestoreConcurrentCompletion 的技术,模拟完全不相交的锁组。每次迭代先写入种子,然后在屏障后同时启动 PUT 与另一个操作,第二个操作带最多 2 毫秒的随机延迟(另一次运行:最多 20 毫秒),两个调用返回后立即读取每块盘上的 xl.meta。机器当时很忙(负载均值 32),使用 -p 2。绝对比率不代表生产环境的比率。

窗口最多 2 毫秒,每行 300 次迭代:

竞争 共享锁表 分裂锁表
版本化 PUT 对 DELETE 0 次异常 188 次迭代中已确认的版本或标记在部分磁盘上缺失;70 次中已确认的 PUT 落在少于读仲裁数(16 中的 8)的磁盘上;127 次 DELETE 对本来存在的键返回“未找到”
未版本化 PUT 对 DELETE 0 84 次迭代结束时 xl.meta 只存在于 16 块盘中的一部分(例如 1、3、4 或 5 块),而不是全有或全无;94 次 DELETE 错误
未版本化 PUT 对仅元数据 CopyObject 0 30 次迭代在两个调用都成功后读回旧内容;155 次读到“未找到”;48 次复制错误

窗口最多 20 毫秒,每行 400 次迭代:分裂行降到 34 次有缺失项、10 次磁盘状态混杂,以及 3 次旧内容加 24 次对象不可读;共享行保持为零。竞争是真实的,但对时序敏感,符合预期。

分裂锁表下一次版本化竞争后的典型逐盘状态(16 块盘):11 块盘同时有种子、新版本和标记;3 块盘只有种子和标记(新版本丢失);2 块盘只有种子和新版本(标记丢失)。另一次迭代中,PUT 已被确认,其版本在 16 块盘上一块都没有,而 DELETE 返回“未找到”。最后这条路径没有追踪到具体行;它与删除路径在提交中途读取对象、并清除它当作悬空版本的内容的情形一致。

使用只在探针中生效的改动,把 DELETE 与 CopyObject 路由到哈希集(单池),同样三种竞争各 300 次迭代,在共享与分裂两种配置下,每一行异常数均为零。

探针没有使用 dsync、网络或部分重叠的锁组;没有测试批量删除、upload ID 守卫、restore,也没有测试混合版本集群。修复的测试覆盖了除网络和混合二进制以外的全部这些情形;见验证。

方案比较

方案 1:把单池的对象锁路由到哈希集(采用)

凡是池层原本在第一个集上加锁的单键对象名,都改为在对象所哈希到的集上加锁,也就是它的写者加锁的地方,并且只在那里加锁。池层的一个辅助函数返回这把锁(NewObjectNSLock):单池时返回 serverPools[0].getHashedSet(object) 的锁,否则不变地返回 z.NewNSLock。它也加入了 ObjectLayer 接口,供 SelectObjectContent 与批量密钥轮换使用。upload ID 守卫按其对象选集,锁名保持 object/uploadID,与 PutObjectPart 一致。批量删除每个集取一把锁,各集并发删除,最多同时 16 个;每个集的分组只持有自己的锁。COPY 处理器在打开源对象之前,一起获取源锁和目标锁。通用的 NewNSLock 为非对象名称保留一个稳定的锁域。

单键操作的锁 RPC 数量不变;一把锁询问的是一个集的主机,而不是 0 号集的主机,这也分散了负载。多池部署的锁域不变;各集共享主机的单池,锁询问的主机也与以前相同。

方案 2:让 NewNSLock 把单名称路由到哈希集

这在一处恢复 afea40cc0 之前的行为,一次修好所有调用方。多名称锁仍需决定(上游当初正是为此修改);多池的池锁会从 0 号池的 0 号集移到 0 号池的哈希集,改变所有多池锁的锁域;object/uploadID 这类复合名称会按整个名称哈希,而不是按其对象。风险超出了缺陷本身的范围。否决。

方案 3:所有对象锁使用同一个锁组

对每个对象锁使用池中全部主机,或 0 号集的主机,包括集层的锁。互斥可以成立,但每把锁的锁组会增大到 N 台主机,或把所有锁集中在 0 号集的主机上。它还改变了写者,所以下面的混合版本问题也适用于 PUT。否决。

方案 4:单池在集层加锁

让 erasureObjects.DeleteObject 与 CopyObject 像 2022 年之前那样自己加锁。池层的先检查后删除逻辑(标记还是清除、If-Match、ILM 重新评估)将在加锁之前运行,这些功能依赖的原子性就丢失了。否决。

方案 5:写文档并给出建议

声明 18 台或更多主机的单池有此限制,并建议池不超过 17 台主机,集群增长时增加新池。这没有任何代价,但让已经运行这种池的运维者继续承受缺陷,而他们无法重构一个池。不能替代修复,因此否决;发布说明仍然包含下面的升级步骤。

方案 6:两把锁都取(双重加锁)

保留 DELETE、COPY 和 upload 守卫在 0 号集上的锁,再加上哈希集的锁,始终按这个顺序,使滚动升级期间新旧节点仍然互斥。设计的对抗性审查用探针否决了它,理由有两个:

  • 重叠的锁组会自我阻塞。 两个锁组共享主机时(显式布局和一部分自动布局都会这样),第一把锁占用的仲裁可能使同名第二把锁剩下的空闲主机不够。等价的锁组会直接阻塞。要做到安全,需要一个覆盖两个锁组并集的原子锁,而 SILO 没有。
  • 批量删除会与分片上传完成形成环。 持有覆盖一个批次全部名称的 0 号集锁、再去取哈希集的锁,会与这样的完成操作成环:它持有哈希集中的对象锁,等待 0 号集中的 upload 守卫;因为 parent/uploadID 也是批次可以列出的合法对象键。源和目标都在批次中的旧节点 COPY 也会形成类似的环。

建议

在下一个版本采用方案 1。修复实现的就是它。

  1. 路由。 单池中,下列锁从 0 号集移到对象自己的集:

    • DeleteObject 的每个分支:普通删除、If-Match、ILM 过期、数据迁移,以及复制删除;
    • CopyObject 的目标锁;
    • SelectObjectContent 的读锁;
    • 批量密钥轮换的锁;
    • CompleteMultipartUpload、AbortMultipartUpload、GetMultipartInfo 与 ListObjectParts 的 upload ID 守卫。

    名称按存储保存的方式规范化(目录对象为 name__XLDIR__),用于路由和锁名。只取一把锁:不会同时持有 0 号集的锁。真正的前缀删除(强制删除)对前缀名称保留 0 号集的锁,这把锁从来就不覆盖前缀之下的对象。多池部署不变。通用的 NewNSLock 对桶 .lck、桶 metadata.lock、IAM 修订锁、format 与 leader 协调,以及 /[replicate]/<key> 串行器保持不变。

  2. 批量删除。 单池把 DeleteObjects 的名称按所属集分组,各组并发执行,最多同时 16 组,与改动之前一次批量删除中各集的删除方式相同。每组通过一次多名称锁请求获取本集覆盖本组全部名称的锁,删除自己的子批次,然后释放锁。每组只持有本集的锁,持有期间不等待其他锁,因此各组不会互相卡住。每组的锁都在请求的上下文中获取,绝不在已释放锁的上下文中获取。拿不到锁的组整体失败,其他组照常执行。请求被取消之后才开始的组直接失败,不做任何存储操作。结果保持原有位置;校验错误、重复名称、版本与复制身份、以及同一键先清除后加标记的顺序都与以前相同。复制、事件和 ILM 审计仍由调用方在合并后的结果上处理。多池路径不变。

    单节点的本地锁获取多个名称时逐个进行,等待后面的名称时继续持有前面的名称。因此,所有集共用一张本地锁表的单节点,保留今天的单把批量锁。其他本地锁表按尝试获取,每次尝试整体有界,失败时释放已持有的全部名称。分布式锁对整个名称列表要么整体授予,要么整体拒绝。

  3. COPY。 处理器在加任何锁之前先检查源名称,用的是改动之前最先执行的那项检查;只有目标名称合法时才锁目标。非法目标仍在复制自身的目标检查中失败,排在源不存在(404)或源前置条件不满足(412)之后。处理器在打开源对象之前算出它的锁:源对象在其所属集上的读锁,以及本地目标在其所属集上的写锁。在同一张锁表内,请求按 locker 看到的资源(清理后的桶名和对象名)合并,所以复制到自身只取一把写锁。这些锁以事务方式获取:按锁域、再按桶和对象排出一个顺序;第一把之后的每把锁只做有界等待。在持有前面的锁时,如果这次有界等待超时,复制按相反顺序释放已持有的锁,用新的实例单独等待那把锁直到截止时间,拿到后立即释放,再从第一把锁重新开始。其他失败的尝试同样按相反顺序释放已持有的全部锁。每次新的尝试都在随机短暂停顿之后进行,并使用新的锁实例和新的上下文;所有尝试共用一个截止时间。之所以要用新的实例,是因为锁实例标识它的持有者,而分布式解锁会在后台重试:前一次尝试的解锁如果迟到重试,不能释放下一次尝试取得的锁。随后在这些锁之下读取源对象,包括前置条件和内联数据。Object Lock 保留检查与分层清理器对目标的查询,以及内部的 CopyObject 和 PutObject,都不再重复加锁。锁下的所有工作都在这些锁的上下文中执行,任何一把锁的租约丢失都会中止复制,包括其中的查询。复制写完后即释放锁,然后才可能有同步复制去读取目标。

  4. listingRLock 保留它的两把读锁(先 0 号集,再源集),以及有界等待与清理。改动之后,源集就是所有单池写者加锁的地方;在早期版本的节点仍可能运行期间,0 号集的锁继续保留,只有在强制的版本边界之后才能去掉。

  5. 注释。 e59a3d938 从 DeleteObject 删除的警示已恢复,并按新的路由和混合版本限制重写。声称只在单个纠删集内具有原子性的 If-Match 段落已更正。

  6. 发布说明。 说明哪些部署受影响、下面的升级步骤,以及增加新池只改变之后写入的加锁方式。

锁顺序

改动之后,每个单池对象锁都在该对象写者的锁域中,每个名称只有一把。现有嵌套保持不变:先取 /[replicate]/<key> 串行器,再在其内取对象锁,反过来不会发生。单池的分片上传完成先取 upload 锁,在提交之前才取对象锁,二者都在对象所属的集中,与 main 相同;多池时先取对象锁、再取 upload 锁。原生 restore 不再经过分片上传完成:它先取 restore 锁,再取对象锁(两阶段原生恢复)。批量删除的每个分组只持有本集的锁。

COPY 是唯一先后获取两个对象的锁的操作;批量删除对每个集的名称只发出一次多名称锁请求。COPY 对锁排序,所以两个复制之间不会形成循环等待;它等待第二把锁的时间有界,超时即释放第一把,所以按不同顺序加锁的操作(例如早期版本节点上的复制)只会延迟它,不会让它永远阻塞。之后它单独等待被争用的那把锁:复制在等待被争用的源对象时不持有任何锁,目标对象的读写照常进行。改动之前,处理器先打开源对象(这会持有它的读锁),之后才去取目标锁。因此,只要两把锁在同一个锁域,两个在非内联对象之间反向进行的复制就已经会互相等待直到锁超时:单节点、各集共享主机的池,以及多池部署都是如此。测试在基线上复现了这一点。如果只把目标锁移到它自己的集,跨集的键之间也会出现同样的环;审查用探针复现了这一点。有序获取把两者都消除了。

滚动升级

锁名称与锁 RPC 不变;变化的只是一把锁询问的主机。只有所有节点都运行修复后,修复才能保护一个池。

  • 谁受影响。 某个集使用的主机与 0 号集不同的单池部署。即主机数为 18 台或更多的所有自动布局,以及集大小小于主机数的显式布局。例如,http://host{1...18}/disk{1...4} 自动选择集大小 9,得到不相交的两半;显式指定集大小 12,则各集的主机窗口部分重叠。各集都共享主机的池以及多池部署,锁询问的主机与以前相同,可以照常升级。
  • 混合版本。 新旧节点同时服务一个受影响的池时,旧节点的 DELETE 或 COPY 在 0 号集加锁,新节点的在哈希集加锁,因此两者在同一键上不再互斥,而以前它们是互斥的。旧节点的 DELETE 对任何 PUT 的暴露与今天相同。方向相反的新节点 COPY 与旧节点 COPY 还可能互相等待,直到新节点的有界等待释放它的第一把锁;旧节点之间的复制保持原有行为。
  • 步骤。 受影响的池用排空后重启的方式升级:暂停新的请求;排空或停止进行中的操作和后台操作(复制、ILM、批处理作业、修复);在任何新进程开始服务之前停止所有旧进程;然后恢复。回滚也照此进行。仅仅同时重启所有节点是不够的,如果新进程启动时旧进程仍在服务。
  • 增加新池 只改变之后经过池锁的写入的加锁方式。它不能拆分已有的池,也不能修复已经丢失的数据。
  • 代价。 COPY 可能需要不止一次获取,并可能重试。批量删除对涉及的每个集各有一次锁往返,而不是总共一次,但各集并发执行,最多同时 16 个,与改动之前相同。

剩余限制

  • 测试在单个进程中运行。它们在共享的测试磁盘上,用独立的本地锁表和进程内 dsync locker(不相交以及部分重叠)来模拟分布式池的锁组。没有运行 18 台或更多主机的真实集群,也没有混合二进制;上面的混合版本行为来自设计探针和代码。
  • 竞争探针中的比率来自在繁忙机器上刻意收紧的竞争。它们证明竞争可能发生以及结果是什么,而不是它在生产环境中多久发生一次。
  • 单节点的本地锁仍然像以前一样逐个获取批量删除的名称。一个批次如果同时列出某个对象和与其他操作的锁名同名的名称(例如 parent/uploadID/1),可能等待该操作直到锁超时。分布式池不受影响。
  • 源对象一直被写锁占用时,COPY 最多等到它的操作超时,等待期间不持有任何锁。
  • 在受影响的布局中,如果一个集失去了锁仲裁,该集上的 DELETE 现在会等到删除操作超时(1 到 5 分钟),然后返回 503 RequestTimeout。按旧的路由,这样的 DELETE 在 0 号集上取锁,只要 0 号集健康,就会立即以 InsufficientWriteQuorum 失败;但当时 0 号集是所有 DELETE 和 COPY 的单点故障。
  • mcli admin top locks --json 把 COPY 和批量删除的锁的 source 显示为 erasure-server-pool-locks.go 中的 getNSLocks,而不是持有锁的处理器。这一点来自阅读代码。
  • 原生 restore 不再把不加锁读取的快照交给 CopyObject。它在不持有对象锁的情况下暂存副本,提交时在锁下重新检查该版本(两阶段原生恢复)。
  • 已经造成的损害(对象只留在少数磁盘上)由 heal 像处理任何部分写入一样处理。heal 取的是哈希集的锁,修复之后它也是 DELETE 的锁。
  • 探针中“已确认的 PUT 在所有磁盘上都没有”的路径与悬空对象清理一致,但未追踪到行。
  • 我们没有测量把对象锁分散到各集的主机、而不是都放在第一个集上的负载效应。

验证

在修复所基于的提交上,于一次性工作树中执行:

  • 回归测试把一个测试池拆成两个集,它们共享磁盘,但像分布式集一样加锁,共四种模式:一张共享的本地锁表(如同单节点,作对照)、每个集一张本地锁表、没有共同 locker 的进程内 dsync,以及三个 locker 中共享一个、每个集仅靠自己就能凑够仲裁的进程内 dsync。一个操作被卡在存储提交之中,另一个必须等待。覆盖的情形有:PUT 对 DELETE、If-Match DELETE(随后必须因条件不满足而失败)、批量 DELETE、仅元数据和重写数据的 CopyObject;DELETE 与仅元数据 COPY 对标签更新;restore 对 DELETE 与 COPY;PutObjectPart 对 complete 与 abort,get-info 与 list-parts 作为不应等待的对照;分片上传完成对批量删除,批次中有无 upload ID 别名两种;批量删除对分片锁名的别名;批量分组的失败与取消;COPY 处理器对其目标的 PUT,以及复制到自身时必须读取 PUT 提交的版本;经由处理器的反向复制;处理器在启用 Object Lock 且配置了分层的桶中对目标的查询;SelectObjectContent;批量密钥轮换。一个差分测试执行带标记、清除、重复、非法名称、目录对象和不存在键的版本化批量删除,并把分组结果与单把批量锁的结果比较。
  • 在基线上只应用这些测试时,所有互斥情形在拆分模式下都失败,因为第二个操作在第一个持有提交时照常执行。复制到自身在共享锁表下也失败:它把加锁之前读到的版本的元数据写到了新数据之上。批量分组情形失败,因为基线没有在写者的锁域中加锁。反向复制在两把锁共享一个锁域时(共享锁表,以及两个池)在基线上失败:每个复制持有自己源对象的读锁,等待对方的,直到请求结束。其余对照在两棵树上都通过。
  • 单元测试覆盖辅助函数:对象锁与 upload 锁的路由;事务式获取(部分获取会被释放、总超时、取消、租约丢失会结束上下文并以新上下文重试,以及回复丢失的解锁在后台迟到重试时不能释放下一次尝试的锁);覆盖多个名称的一次本地尝试的上限;有界的本地分组获取;以及租约丢失时 COPY 处理器对目标的查询随之结束。消融实验确认,这些测试在去掉对应修复后都会失败:查询再次加锁、分组以一次无界获取进行、锁实例在多次尝试间复用、按名称而非整次尝试设上限,以及查询运行在请求的上下文中。
  • 有一个已有测试在对象层的 CopyObject 内部注入保留或法定保留的修改,位置在处理器的 Object Lock 检查与写入之间。现在处理器在目标的写锁下做这项检查,这个窗口已不存在,而注入的写入会等待其调用方自己持有的锁。该测试现在在处理器获取锁之前注入修改,这是并发写者能到达的最晚时点;它预期的拒绝结果和对象保持不变都与以前相同。一个新的回归测试覆盖这个窗口本身:它让一个 COPY 停在写入之中,要求对目标的保留或法定保留更新等待该复制完成后再成功,覆盖普通复制与复制到自身,四种模式以及两个池。在基线上,该更新在每种拓扑下都落在检查与写入之间,随后后端自己的复查拒绝了这次复制。
  • 2026-11-06 的发布审查发现了这个修复的三处回归(见审查记录)。它们的修复加入了下列测试,去掉对应的改动部分时每一个都会失败:
    • TestLockDomainCopyRejectsAliasNames、TestLockDomainCopyErrorPrecedence 与 TestLockObjectsMergesByResource:别名形式的源名和目标名在加任何锁之前就以 400 拒绝,所有锁布局和双池都是如此;非法目标保持 404 与 412 的优先顺序;别名与它的规范名称合并,在共享锁表中别的集的别名也是如此;
    • TestGetNSLocksWaitsHoldingNothing、TestGetNSLocksIdleWaitCancel、TestGetNSLocksIdleWaitDeadline 与 TestLockDomainCopyDoesNotStarveDestination:在整个等待期间,排在被争用锁之前的锁始终可供他人获取;取消或截止时间结束等待时不持有任何锁;复制等待源对象期间,对其目标的 HEAD 能够成功;TestGetNSLocksNonAtomicKeepsBoundedRetry 是对照;
    • TestDeleteObjectsInSetsRunsGroupsConcurrently 与 TestDeleteObjectsInSetsQueuedGroupsCancel:两个集的磁盘调用相互重叠;取消之后,排队的组不做磁盘调用,已在执行的组保留自己的结果。
  • gofmt 与 go vet ./cmd 无问题。新测试在竞态检测器下通过;./cmd 中已有的锁、复制、删除、分片上传、select、列举、生命周期、退役与再平衡测试通过。

合并前仍需运行:远端 CI,以及在各集使用不同主机的分布式单池上的验收运行。

集成验证。 这个修复已摘取到下个版本的同一个集成分支上,位于 #278 和 #281 之后,与 Heal 演练、#259、#285 和 Console 的修复放在一起。所有摘取都没有冲突。在该 head 上,完整的 go test -tags kqueue,dev -p 4 ./cmd/... ./internal/... 套件通过,CI 的竞态检测选集和覆盖全部新测试的竞态选集也都通过。针对组合的对抗性审查没有发现缺陷,锁域、清理路径以及清除与恢复之间的交互都用实际执行的探针检查过。

本地通过记录使用 kqueue,dev 标签。最终提交在 Ubuntu CI 上的无标签命令 CGO_ENABLED=0 MINIO_API_REQUESTS_MAX=10000 go test ./cmd/ -count=1 -timeout 30m 尚待验证;本地带标签的结果不能替代该项验收。

审查记录

轮次 结论
设计审查,第 1 轮 同意但需修改。upload 守卫按其对象选集并保留锁名;按存储方式规范化名称;区分真正的前缀删除(强制删除)与针对单个对象的删除;明确批量删除的失败语义。两个阻断问题:COPY 处理器在哈希集中持有源读锁的同时去取 0 号集的目标锁,只移动目标会在反向复制之间形成环(已复现);“滚动升级期间不会更差”的说法不成立,因为旧的 DELETE 与 COPY 以前在 0 号集中互斥(已复现)。
设计审查,第 2 轮 提出双重加锁(方案 6),以保持新旧节点互斥。审查用探针否决:重叠的锁组会阻塞同名的第二把锁;持有 0 号集批量锁时再取哈希集锁,会通过 parent/uploadID 别名与分片上传完成成环。COPY 必须在打开源对象之前取锁,对源读取传入 NoLock,并对锁排序。
设计审查,第 3 轮 放弃双重加锁;同意单锁域路由加排空升级。修改:本地锁后端逐个获取名称,所以单节点保留其单把批量锁,其他本地锁表使用有界尝试;不同集的资源仍可能共享主机;COPY 的每个嵌套调用(包括目标查询)都传入 NoLock;COPY 使用单一截止时间、新的上下文并处理租约丢失;为升级与回滚给出真正的排空步骤;补充测试。前提更正:host{1...18}/disk{1...4} 自动选择集大小 9,显式指定 12 才得到重叠窗口。
对抗性审查,补丁第 1 轮 要求修改;四项发现全部接受。重试复用了同一个锁实例,而实例标识其持有者,所以一次回复丢失的解锁在后台重试时,可能释放下一次尝试取得的锁(已复现);现在每次尝试都使用新的实例。目标查询运行在请求的上下文中,在租约丢失后仍会继续;现在锁下的工作都运行在锁的上下文中。对覆盖多个名称的本地锁的一次尝试,会对每个名称各等待一次上限,从而超出截止时间;现在整次尝试共用一个上限。一个测试假定分布式解锁立即完成;现在它会等待锁被释放。
对抗性审查,补丁第 2 轮 通过;没有新的发现。四项修复都得到验证,包括竞态检测器下的运行、补充探针以及 restore 集成测试。本轮中本文更正了三处表述:COPY 是唯一先后获取两个对象的锁的操作(批量删除对一个集的名称只发出一次多名称锁请求);按不同顺序加锁的操作是早期版本节点上的复制,而不是分片复制;这样的复制只会让新的复制等到新节点的有界等待释放其第一把锁为止,而不是等到锁超时。
对抗性审查,补丁第 3 轮(仅本文) 通过,附小建议。更正后的三处表述和审查记录与代码一致。已采纳的小建议:“一次获取”改为“一次多名称锁请求”,因为本地后端逐个获取多个名称;状态行在修复提交之后成立,提交在本轮之后完成。
对抗性审查,补丁第 4 轮(一个已有测试) 通过,附小建议。把改写后的保留测试的注入点移到获取锁之前是合理的,但仅凭它无法发现 COPY 在检查与写入之间释放了锁的回归。已采纳:新增回归测试,要求保留或法定保留更新等待一个停在写入之中的 COPY。
发布审查(2026-11-06) 发现这个修复的三处回归:源名是目标别名的复制(例如把 a//x 复制到 a/x)对同一资源加了两次锁,自己等自己直到操作超时;等待被争用源对象的复制在大部分时间里持有目标的写锁;批量删除逐个执行各集的分组。分别用源名检查与按资源合并、不持有任何锁的等待,以及并发分组修复。两轮实现审查补齐了测试缺口:无竞态的超时与锁实例、共享锁表中其他集的别名,以及先探测写者再探测读者。

参考