两阶段原生恢复
本文覆盖 issue #284,以及 #281 第一版原生恢复防护带来的两处回归;那些防护记录在恢复副本:过期的是副本,不是对象中。本文基于 main 69fcf50cd 和下个版本的集成分支评估。这个设计取代 #281 防护中私有 upload 的部分,其余部分保留。
状态: 设计经六轮审查通过,实现经两轮审查通过,已在下个版本的集成分支上本地提交。尚未合并或发布。
简短结论
原生恢复(对已转储版本发起 POST ?restore)会从 tier 下载该版本,并写出一份本地副本。两个问题来自这次写入如何加锁,以及副本放在哪里:
- 读写要等待下载结束(#284)。 在有多个池的部署中,已发布的代码在整个下载期间持有对象写锁。#281 的第一版防护让单池也这样做,以免下载期间设置的 legal hold 被覆盖,并且把"对象锁被占用"当作"有恢复正在运行"。于是在恢复一个旧版本时,同一个 key 的所有 GET、HEAD 和写入,包括对其他版本的读取,都要等待下载结束。
- 失败的普通上传会卡住。 同一批防护把恢复数据放在一个用私有标记隐藏的分段上传里。普通上传的完成操作在暂存元数据之后失败时,这个标记就无法再区分两类上传。此后该普通上传对所有调用方都返回
InvalidUploadID,一小时内也无法中止。
下一个版本分两个阶段恢复:
- 不持对象锁暂存。 worker 把 tier 副本纠删编码写入该版本所在纠删集的 tmp 卷,写法与 heal 写入它重建的分片相同。该 key 上的其他请求不必等待。
- 短暂持有对象锁提交。 worker 重新读取该版本,确认它的尝试仍拥有该版本、暂存的内容仍是该版本的内容,然后逐盘发布副本。发布没有达到写 quorum 时,先在同一次持锁期间尝试收敛;这一轮没能理顺的,之后在重新取得的锁下重试,重试用尽或进程崩溃后仍可能留下未解决的状态。
判断是否有恢复正在运行的,改为每个版本一把的恢复锁,而不是对象锁。分段上传完成回到 main 上的代码,普通上传的行为与 #281 防护之前相同。
定性
| 方面 | 判断 |
|---|---|
| 性质 | 原生恢复的可用性缺陷,加上未发布的 #281 防护带来的两处回归。不丢数据。 |
| 受影响 | 所有部署上的原生恢复。已发布的代码中,多池恢复本来就在下载期间持有对象锁;单池持锁和卡住的普通上传只存在于未发布的集成分支上。 |
| 不受影响 | 不触发恢复的已转储对象读取;没有远端 tier 的部署。 |
| 严重程度 | 对可用性影响高:恢复一个旧版本,就可能在一次 tier 下载的时长内卡住对该 key 的所有访问。 |
| 发布紧急程度 | 随下个版本发布。它在私有 upload 设计发布之前取代该设计。 |
| 剩余工作 | 代码、两轮实现审查和带 kqueue,dev 标签的本地完整测试套件均已完成。还需要远端 CI,以及在分布式部署上的验收运行。 |
根因
两处回归都来自 #281 第一版防护的两个选择:
- 恢复复用了公开的分段上传命名空间。 恢复 upload 与普通 upload 的唯一区别,是
xl.meta中的一个键。完成操作会删除这个键,而失败的完成操作会在 upload 目录里留下合并后的对象元数据。发生这种失败之后两者无法再区分,只能隐藏所有已暂存的 upload,普通 upload 也在其中。 - 下载期间持有对象锁,而且这把锁代表 worker。 POST 根据对象锁是否被占用,推断是否有恢复在运行。因此在给 worker 另一种表明自己存活的方式之前,无法缩短这把锁的持有时间。
设计
先恢复锁,再对象锁
每个版本有一把恢复锁 R,它是系统桶下按桶名、对象名和版本 ID 命名的分布式锁。POST 先取 R,最多等待两秒,再取对象锁 O,持有到登记完它的尝试为止。新的尝试把 R 移交给 worker,worker 在整个运行期间持有它。除此之外没有任何路径取 R,所以不存在持有 O 时等待 R 的情况:顺序总是先 R 后 O。
R 只是存活提示,不保证排他。分布式锁的刷新失败按节点离线处理,所以在网络分区期间,第一个 worker 仍在运行时,另一个节点可能取得 R。因此正确性只依赖提交在 O 下所做的核验:被取代的 worker 会发现它的尝试已不再拥有该版本,于是什么也不写。它的暂存只属于它自己,不会干扰新的 worker。
在等待时间内取不到 R 的 POST,会不加锁地读取各盘:各盘显示该版本正在恢复时返回 409 RestoreAlreadyInProgress,没有显示或无法判断时返回可重试的错误。它从不启动第二个 worker。
在对象锁之外暂存
worker 在持有该尝试所指版本的池、以及对象哈希到的纠删集中暂存。它读取 tier 数据流,按版本各分段的大小切分,确认它们的总和等于对象大小,并按该版本自己的纠删布局把每个分段写到 tmp/<id>/<data dir>/part.N,使用与分段上传和 heal 相同的流式 bitrot 哈希。暂存期间不持有对象锁。
在所有分段的写入中逐盘跟踪:
- 某块盘在任一分段的写入、编码或关闭时出错,就被排除,之后不再为它创建写入器;
- 提交之前,对每块剩余的盘运行
CheckParts,每个分段都必须报告成功,所以 tmp 卷被移走、又被后续写入重建的盘也会被排除; - 剩余的盘少于写 quorum 时,暂存失败,该尝试清除自己的标记,客户端可以重新发送 POST。
不做内联:恢复副本总是使用新的数据目录。
在对象锁下提交
随后 worker 取 O,等待时间与普通操作一样有界。取不到时,它清除自己的标记并删除暂存。在 O 下,它重新解析池(必须仍是暂存所在的池),读取该版本的权威修订,然后核验:
| 核验 | 必须与暂存时一致的内容 | 不一致时 |
|---|---|---|
| 尝试 | 该尝试拥有这个版本。各盘的恢复字段不一致时,它的尝试必须是唯一正在进行的尝试。 | 停止,不写入;不触碰更新的尝试。 |
| 版本 | 版本 ID、不是删除标记、修改时间、ETag、大小。 | 停止;不复活已删除的版本,不覆盖新的 null 版本。 |
| tier 副本 | 转储已完成,tier、远端名称和远端版本都相同。 | 停止。 |
| 分段 | 分段编号与大小。 | 停止。 |
| 纠删布局 | 算法、数据块与校验块数、块大小、分布。 | 停止。 |
标签、retention、legal hold 和复制状态在下载期间可以变化。提交采用它在 O 下读到的修订中的这些字段,因此下载期间已确认生效的保护会保留。在按 ID 指定的版本之上出现更新的删除标记,不会让恢复停止;只有版本本身消失才会。
逐盘发布
提交在每块持有权威修订、且保留了暂存的盘上发布。它像 heal 一样直接调用该盘的 RenameData,从不经过纠删层的 rename:后者对被替换版本的回滚可能删掉新的数据目录,却留下指向它的元数据。每块盘写入的是提交在该盘上读到的版本,换上新的数据目录、已完成的恢复状态,以及在所有盘上相同的恢复字段。内联数据标记会被删除,因为更早的恢复可能把它留了下来。
副本达到写 quorum W 时提交成功:
- 被替换的数据目录只在返回了它的盘上删除,并且只有当这些盘返回的都是同一个目录时才删;否则留给废弃数据清理;
- 有盘没有拿到副本时,该版本以规范化的版本 ID(null 版本为
null)排入 MRF heal,由 heal 把这些盘修到已恢复的修订。
未达写 quorum 时的收敛
确认拿到副本的盘少于 W,或者某个结果不确定时,worker 在仍持有 O 时收敛该版本,之后再做有限次数的内存重试,每次重试都重新取 O。只有这个 worker 知道每块盘在提交前持有什么(Sᵢ)、提交在那里写了什么(Pᵢ),以及收敛会写什么(Vᵢ)。每一轮都先读取每块盘上的条目,要求每一条都等于其中之一:
- 出现任何其他内容就停止收敛:两次持锁之间有人改过该版本,例如密钥轮换、标签更新、新的尝试或 heal;不写入任何东西,并记录错误;
- Pᵢ 出现在 W 块或更多盘上,说明提交其实已经成功,例如响应丢失;该版本排入 heal,修复其余的盘;
- 其他情况下,worker 把 Vᵢ 写到每块持有该版本的盘上。Vᵢ 是不带任何恢复字段和内联标记、并使用旧数据目录的修订。此后该版本处于未恢复状态,新的 POST 可以再次恢复它。内容仍在 tier 上。
收敛不删除任何数据目录;不再被引用的目录留给废弃数据清理。重试用尽或某一轮停止时,worker 记录 restore publication did not converge,并附上桶、对象、版本、尝试和数据目录。
多池下的废弃数据清理
在把数据目录移入位置和改名 xl.meta 之间,某块盘上会短暂存在一个尚无元数据引用的目录。单池时,废弃数据清理本来就取同一锁域的对象锁。多池时,它只取各池所属集的锁,而这把锁与 O 并不互斥。现在多池时的废弃数据清理像 heal 一样取池层的对象锁,因此不会删掉提交已经移入位置的目录。每次 PUT 的 rename 也有同样的窗口,同一个改动一并关闭了它。
分段上传回到 main
分段上传完成中的恢复例外、私有 upload 标记、第一版设计的提交锁,以及 null 版本 Object Lock 检查中的恢复例外,都已移除。单池的完成操作重新先取 upload 锁、在提交之前才取对象锁,与 main 相同;多池时先取对象锁。暂存之后完成失败的普通 upload 的行为重新与 main 一致:重新上传分段后可以完成,也可以立即中止。Multipart 提交失败可能让上传卡死中描述的继承来的失败路径保持不变。
能力协议 2
旧服务器不取恢复锁,不在对象锁下核验尝试,提交方式也不同。因此只有集群中每台服务器都明确回答支持协议 2 时,才运行原生恢复:
- 处理 POST 的服务器询问每个对等节点,肯定的回答最多缓存十秒;只要有对等节点没有确认,就返回
503 ServiceUnavailable,不写入任何东西; - 排队中的 worker 在开始前再问一次,因为恢复排队期间某台服务器可能被换成了旧二进制;开关关闭时它清除自己的标记;
- 无法连上的对等节点会阻止原生恢复:无法证明它已经停止,它可能仍在对同一个版本运行旧的恢复。
两阶段恢复的设计文档写的是只有 POST 检查这个开关。代码在 worker 中也会检查,本文以代码为准。
为什么方案充分且必要
充分。 恢复只在提交时、在 O 下、通过上述核验之后才写入该版本,而只有拥有该版本的尝试能通过核验。下载期间,它不持有任何会让其他请求等待的东西。除剩余限制中的排除项外,发布的每种结果都落在三种状态之一:副本达到写 quorum;每块持有该版本的盘都是未恢复修订;或者记录日志后停止,不写入任何东西。
必要。
- 没有恢复锁,对象锁就仍要代表 worker,下载期间无法释放。
- 没有逐盘的合格性跟踪和
CheckParts,tmp 卷被重建的盘可能在缺少分片的情况下参与提交。 - 没有 O 下的核验,被取代的 worker、被替换的版本或已改变的布局都可能被发布。
- 不逐盘调用
RenameData,纠删层的回滚可能删掉被替换版本的新数据,而元数据仍指向它。 - 没有收敛,未达写 quorum 的发布会留下一个可能读不出来的版本。在 12 块盘、8 个数据分片加 4 个校验分片的集上,7 块盘发布成功就构成元数据读 quorum,但分片不足以重建数据。
- 废弃数据清理不取池层的锁,多池清理可能在元数据写入之前删掉已发布的数据目录。
代价与兼容性
- 普通请求。 GET、HEAD、PUT 和分段上传不取恢复锁,不做能力检查,也不多读任何东西。恢复在下载期间不再阻塞该 key 上的其他请求。多池时,scanner 的废弃数据检查改取池层的对象锁,而不是各池所属集的锁。
- 提交。 它在持有 O 期间重新读取版本元数据,并对每块盘调用一次
RenameData。这段时间随版本元数据的大小、分片数和盘数以及磁盘延迟增长,不随下载时间增长。它没有固定的毫秒上限。 - 恢复路径。 提交前对每块合格的盘做一次
CheckParts,恢复锁的刷新,以及在提交之前占用一份编码后副本的 tmp 空间。 - 重复的 POST。 对正在恢复的版本再次发送的 POST,最多等待两秒恢复锁,然后返回
409 RestoreAlreadyInProgress。 - 残留。 收敛或失败的 rename 留下的目录等待废弃数据清理;崩溃的 worker 留下的暂存等待过期 tmp 清理或重启。两者都不会阻塞之后的恢复。
- 格式。 不改变存储格式或公开 API。唯一新增的 RPC 是能力询问。版本元数据中的尝试键是内部键,旧二进制会保留它而不读取。
升级、回滚与降级
- 升级。 旧版本的服务器不会回答支持协议 2,所以在每台服务器都运行本版本之前,POST 返回
503。旧版本留下的、没有尝试 ID 的标记,由下一个拿到恢复锁的 POST 接管。对于各集使用不同主机的单池,锁域修复要求的排空重启仍然适用。 - 降级或回滚之前, 停止发起新的恢复,并等运行中的 worker 结束。旧二进制不认识恢复锁,开关也拦不住已经降级的服务器。
- 检查每台服务器的日志中是否有
restore publication did not converge,降级前逐一修复其中列出的版本。这条日志只是正面证据:进程在重试途中退出时不会留下记录。 - 现有工具无法证明不存在发布分裂。 heal 演练不能作为证明:它在检查可用分片是否足够之前就返回了,所以发现不了"已恢复的盘占多数、分片却不足以重建"的情况。只读的检查工具属于下文协调者崩溃的后续工作。
- 降级之后,残留的进行中标记在旧二进制上会返回
409,与上游相同。
剩余限制
- 协调者在发布途中崩溃。 运行 worker 的服务器在发布途中崩溃,或者收敛重试用尽时,该版本可能留下分裂:部分盘已恢复,部分盘未恢复。没有任何机制会自动收敛它。在人工修复或后续能够检测并收敛这种分裂的修复上线之前,该版本可能读不出来,POST 也可能失败。main 在任何写入途中崩溃时都有同样的风险。
- 迟到完成的存储调用。 调用方已经因超时或错误返回之后,服务端的存储 RPC 仍可能在执行,即使提交仍持有 O 也是如此,它可能在核验或收敛之后才落盘。所有写入路径都有这一限制;为存储 RPC 加 fencing 是单独的事项。少数盘上迟到落盘的副本,只有当未恢复修订达到 heal 对恢复字段要求的 quorum 时,才会被 heal 修回。
- 假定 tier 不可变。 提交相信远端名称和远端版本相同时字节也相同,与所有 tier 读取的前提一样。外部写入者覆盖未开版本控制的 tier 对象,不在保护范围内。
- 暂存丢失。 服务器重启会移走它的 tmp 卷,过期 tmp 清理会删除旧目录。下载期间受影响的盘会被排除在提交之外。只有剩余的合格盘少于写 quorum 时,暂存才会失败,这时可以重新发送 POST。协调服务器重启会结束它的尝试。
- 重启。 收敛和标记重置的重试在内存中运行。重启之后,版本保持进行中状态,直到之后的 POST 接管。
验证
两个问题的回归测试在之前的集成 head 上失败,其中分段上传的测试在 main 上也通过:
TestRestoreDownloadKeepsKeyAvailable:暂停旧版本的下载期间,对该 key 热版本的 GET、HEAD、PutObjectTags和 PUT 都按时完成,覆盖一个池和两个池、单段对象和分段对象;TestMultipartFailedCommitRetriesLikeMain:完成操作暂存元数据后失败,客户端重新上传分段后可以完成,或者中止。
故障注入测试覆盖整个协议:
TestRestorePublicationBelowQuorumReverts和TestRestorePublicationBelowWriteQuorumOnWideSet(8+4 上 12 块盘中 7 块发布成功会回退;把阈值放宽到 W-1 时该测试失败);TestRestorePublicationUncertainCommits(响应丢失)、TestRestoreConvergenceRetries和TestRestoreConvergenceStopsOnOtherWrites;TestRestoreRemovesOldDataDirOnlyWhenUnanimous、TestRestoreDropsStaleInlineMarker和TestRestorePublishedRevisionAcrossExpiry;TestRestoreStagingDropsRecreatedTmp:在 4 块盘的集上,暂存期间 tmp 卷被重建的一块盘被排除,恢复成功;有两块这样的盘时,什么也不发布;TestRestoreSecondPOSTWhileRunning、TestRestoreTargetDeletedDuringDownload和TestRestoreTierNotFoundClearsMarker;TestCheckAbandonedPartsTakesPoolsLock放置一个未被引用的数据目录,确认它在持有对象锁期间保留、之后被删除;没有池层锁时该测试失败;TestRestorePartialCommitQueuesNullVersionHeal:提交漏掉非当前null版本的一块盘时,以null排入 heal,并把它修回。
私有 upload 标记的 13 个测试,以及从客户端完成恢复 upload 的 TestNativeRestoreConcurrentCompletion,随那个设计一起删除。4 个测试按新语义改写,其中锁域的恢复测试现在卡住的是提交,而不是下载。本地完整的 go test -tags kqueue,dev -p 4 ./cmd/... ./internal/... 套件、恢复、分段上传、锁域和废弃数据测试的竞态选集、go vet 和 golangci-lint 都通过。
本地通过记录使用 kqueue,dev 标签。最终提交在 Ubuntu CI 上的无标签命令 CGO_ENABLED=0 MINIO_API_REQUESTS_MAX=10000 go test ./cmd/ -count=1 -timeout 30m 尚待验证;本地带标签的结果不能替代该项验收。
审查记录
| 轮次 | 结果 |
|---|---|
| 维护者审查初稿 | 要求修改:在现有的 tmp 卷而不是新的命名空间中暂存,并把分段上传回退到 main。 |
| 设计第 1 轮 | 要求修改。纠删层的回滚可能删掉被替换版本的新数据;被重建的 tmp 卷可能发布缺失的分片;分裂与纠删布局的核验不够明确;恢复锁只是存活提示;残留和 tier 前提需要写明。全部采纳。 |
| 设计第 2 轮 | 要求修改。多池下废弃数据清理与对象锁不互斥;回滚会删除迟到写入可能引用的目录;WriteMetadata 先截断再写;CheckParts 只是下限检查。发布改为逐盘 RenameData,回滚不再删除目录,清理改取池层的锁。 |
| 设计第 3 轮 | 要求修改。旧数据目录可能被同级版本共享;成功应以写 quorum 判定;已过期的内联恢复保留着它的标记。协调者崩溃后的恢复和迟到完成的存储调用,按决定明确排除,单独跟进。 |
| 设计第 4 轮 | 要求修改。重试可能覆盖更新的元数据写入或后继尝试;现在每一轮收敛都把每块盘与 Sᵢ、Pᵢ、Vᵢ 精确比对,出现其他内容就停止。迟到 RPC 的排除范围扩大到持锁期间落盘的调用。 |
| 设计第 5 轮 | 要求修改。发布一旦开始,任何失败都写 Vᵢ;heal 演练不能用来证明可以降级;少数盘上迟到的副本只有在恢复字段达到 quorum 时才能修回。 |
| 设计第 6 轮 | 通过,附一条小建议,已采纳:发布前的重试清除标记,发布后的重试只做收敛。 |
| 实现第 1、2 轮 | 第 1 轮发现三个问题,各自附测试修复:已发布修订在副本过期前后依赖时钟;tier 404 会让标记残留;废弃数据测试在没有修复时也能通过。第 2 轮通过;补充了一个 12 块盘 8+4 的写 quorum 阈值测试。 |
| 发布审查(2026-11-06) | 发现部分提交以 null 版本原始的空版本 ID 排入 MRF heal,而收敛路径用的是 null;heal 本来就把空 ID 读作 null,现在两条路径都排入 null。还发现能力门的注释仍在描述私有 upload,设计文档也写着只有 POST 检查开关。注释现在描述恢复锁、尝试栅栏和暂存提交,本文对开关的描述以代码为准。 |
参考
- Issue:#284
- 恢复副本:过期的是副本,不是对象(#281)
- 大型单池集群的锁域
- Multipart 提交失败可能让上传卡死:本设计保持与 main 相同的分段上传失败路径
- Heal 演练不能删除数据
- 上游多池恢复锁:minio/minio#20495