多池覆盖亲和:为何最终没有采纳 #245
2026 年 10 月 24 日决定:不合并本次测试的多池覆盖亲和补丁(PR #245)。 该方案解决了跨池覆盖遗留旧副本的空间占用问题,但无论完整策略还是仅限普通无版本覆盖的收窄策略,都未能在性能测试中达到可接受的权衡。两个实验分支(codex/healthy-pool-affinity 与 codex/unversioned-pool-affinity)已保留归档,服务端 main 分支保持原状。
Denis Babchuk 提出的思路是:覆盖已有对象时,优先写回已存放该对象的池。代码评审发现并修复了请求级写 quorum 判断缺陷,收紧了健康池判定规则,并通过了功能验证。但随后的性能实测改变了结论:完整策略使深版本历史的写入成本翻倍;第二版排除版本桶后,普通小对象覆盖写入仍有明显退化。
后续的多池删除修复(#257)已于 10 月 24 日独立合并:在保留按容量加权分配的前提下,先清理非权威池中的旧副本,再删除决定当前读取结果的权威副本,防止删除中途报错导致旧值暴露。
问题背景:跨池覆盖会在旧池留下过时副本
假设无版本对象 reports/latest.json 位于池 A,下一次 PUT 将新内容写入池 B。后续读取仍会选出池 B 中最新的逻辑对象,但池 A 中的旧物理副本会继续占用磁盘空间——普通覆盖写成功后,并不会跨池清理其他池里的旧副本。
基线代码在查找到可读的既有对象后,仍会再次进入按剩余容量加权的选池流程。视各池使用率不同,反复覆盖同一个 key 最终可能在两个池中都留下池级记录(xl.meta)。
这一行为源于如下历史演进:
- 上游 MinIO issue #21087 报告单池上传可能绕过最小剩余空间阈值;2025 年 7 月 19 日合入的 PR #21115(
83b2ad418b)为可读旧对象的覆盖写也强制执行了容量选池,从而丢掉了原有的原池亲和性。 - SILO issue #199 与 PR #207 修复了跨池当前逻辑对象的条件写校验,同时保留了按容量加权的分配策略。
- 池迁移通过独立机制防止旧内容覆盖新内容及删除标记丢失,详见多池对象一致性。
覆盖亲和的空间收益主要集中在多池部署下对同一批无版本 key 的高频覆盖。单池部署不存在跨池重复;新 key 仍需按容量跨池分配;版本桶则必须保留所有历史版本,集中到单个池并不会减少需保存的数据量。
原始 PR 的设计方案
提案复用了现有的对象查找和锁边界:若最新可读对象已存在,则在满足写 quorum 和硬空间阈值的前提下优先选择其所在池;新 key 继续按容量加权分配;当原对象所在的纠删码组无法接受写入时,先排除该池再从其余池中分配。
生产代码修改集中在 cmd/erasure-server-pool.go(新增 73 行、删除 3 行,附带 331 行放置测试),作用于进入共享选池路径的 PUT 和分片上传初始化。
选池过程涉及两条容量规则:
- 软分配预留(15% reserve):存在可用替代池时,分配器会过滤掉使用率超过约 85% 的池,优先填充较空的池。
- 硬空间检查(
hasSpaceFor):结合写入大小、单盘容量及diskFillFraction(0.99)执行硬性门槛检查。
当原池健康且有足够空间时,亲和策略会绕过软分配预留,同时保留硬空间检查。代价是在集群扩容后,既有热点 key 会继续留在较满的旧池中更新,而不会随覆盖写自动迁移到新池。
如果只是简单删掉“已有对象重新选池”的分支,就会丢失 83b2ad418b 引入的容量检查与故障回退。仅检查剩余空间同样不够:当某个纠删码组仍有足够在线盘读取旧对象、但在线盘数已不足以写入新对象时,选池必须提前识别;由于选池发生在消费上传流之前,一旦不可重放的请求体被消费,写入失败后无法无代价地换池重试。
正确性评审与实现改进
写 quorum 必须按本次请求计算
原始 helper 对所有写入一律按 STANDARD 存储类推算写 quorum。双池受控测试暴露了使用 REDUCED_REDUNDANCY(RRS)覆盖时的回归:
| 条件 | 测试设置 |
|---|---|
| 存储策略 | optimize=capacity;STANDARD=EC:4,REDUCED_REDUNDANCY=EC:2 |
| 已有对象 | 使用普通 STANDARD 奇偶校验创建 |
| 原对象所在组 | 16 盘中 13 盘在线,已用空间 90% |
| 另一池 | 16 盘全部在线,空间充足;RRS 探针写入成功 |
| 新请求 | 通过 PUT 或新分片上传初始化发起 RRS 覆盖写 |
由于校验片越少、数据片越多,RRS(EC:2)实际需要 14 块在线盘才能写入,而原始预检查按 EC:4 只要求 12 块写盘,误判原池可写,最终在写入阶段因盘数不足返回 InsufficientWriteQuorum(映射为 HTTP 503)。基线代码通过 85% reserve 过滤掉已用 90% 的原池,路由到健康的第二池并全部成功;原始 PR 在两个原池位置 × 两种入口的 4 个场景(重复 3 轮共 12 次尝试)中全部失败。
PUT 的内部 MaxParity 选项还会触发反方向的误判:实际允许使用 EC:8 的写入被按 EC:4 评估,导致不必要的跨池迁移。
仅优先选择完全健康的原池,并明确回退规则
改进后的实现将请求的存储类和 PUT MaxParity 一并传入选池逻辑,严格对齐写入路径的 availability / capacity 校验片计算规则(包括数据片与校验片相等时额外要求的写盘数),并保持分片初始化的既有 parity 语义:
| 原对象所在的目标纠删码组 | 处理方式 |
|---|---|
| 完全健康、满足本次请求的写要求且空间充足 | 优先留在原池 |
| 已降级,但仍可写且空间充足 | 走原有容量分配器,原池仍作为候选池参与筛选 |
| 已知不可写或空间不足 | 在 reserve 过滤前排除原池,再从其余池中选择 |
| 其他池均无空间,原组有空间但不足写 quorum | 交由原组返回真实的 InsufficientWriteQuorum,避免误报 StorageFull |
| 所有池均无可用空间 | 保留 StorageFull 错误 |
健康度与容量检查共享同一次组级 DiskInfo 快照:只有在线、查询成功且总容量为正的磁盘才计入可用写盘;DiskInfo 调用失败的磁盘一律按不健康处理。
回归测试同步覆盖了请求级 parity、已知坏盘、降级组、扫描次数、异构组大小、最新对象选取以及既有分片上传会话,并为分片上传回退测试配置了独立的初始对象(避免前置 PUT 先一步把最新副本迁走而削弱测试前置条件)。
第二版设计:仅对普通无版本覆盖启用
第一轮性能测试暴露出深版本历史的元数据重写放大后,第二分支(codex/unversioned-pool-affinity)将亲和范围收窄至普通无版本覆盖写:
| 请求或桶状态 | 收窄版行为 |
|---|---|
替换既有可读对象的普通无版本 PUT |
启用上述健康原池优先规则 |
| 覆盖普通无版本对象的新分片上传 | 仅在初始化进入选池时启用;保留已有上传绑定 |
| 已启用或已暂停版本控制 | 保持基线分配策略 |
指定显式版本 ID(含 null) |
不启用亲和 |
| 复制、复制锁协调或数据迁移 | 不启用亲和 |
| 内部元数据桶 | 不启用亲和 |
| 新 key 或单池部署 | 保持原有路径与容量行为 |
| 既有分片会话、普通 COPY 路由、最新删除标记、读 quorum 错误路由 | 保持各自原有路径 |
资格判定同时检查桶的版本控制配置与请求选项:启用了版本控制但配置了排除前缀的桶,或处于版本暂停(Suspended)状态、带有混合历史版本的桶,均继续使用基线分配器。
各阶段功能验证
| 阶段 | 验证结果 | 覆盖范围 |
|---|---|---|
| 原始 PR 初审 | base/head 对照复现正常亲和、STANDARD 回退、RRS 写入失败及 MaxParity 偏差 |
macOS 对象层单元测试 |
| 改进后的完整实现 | Go 全量测试:52 个包通过,1,823 个顶层测试(含子测试共 8,233 个)通过,12 个跳过;定向 -race:35 个顶层测试(含子测试 201 个)通过 |
隔离 APFS 测试卷上的完整亲和分支 |
完整版 mcli 验证 |
使用 mcli 20261018 验证普通覆盖读回、4 个不同内容的历史版本读取、分片覆盖及校验和 |
单机本地目录部署 |
| 收窄版实现 | Linux 定向测试:37 个顶层测试(含子测试 209 个)通过,覆盖放置、parity、健康/容量回退、版本状态排除、PUT/MPU 及条件写 |
放置与覆盖定向测试集 |
| 构建与静态检查 | Linux amd64 编译、make verifiers、lint(0 告警)与空白字符检查全部通过 |
仓库静态检查 |
定向回归测试筛选正则为:
性能测试环境与指标定义
两轮基准测试均在 m3 主机上执行(AMD Ryzen AI MAX+ 395,32 逻辑 CPU,61 GiB 内存,Linux 7.0.0-38,单块 CT2000P310SSD8 NVMe SSD,XFS 文件系统)。两个存储池各使用该 XFS 文件系统上的 16 个目录(共 32 个目录,单池单纠删码组,默认 EC 12+4)。
基线与候选分支均使用 Go 1.27.3(linux/amd64,CGO_ENABLED=0)编译。服务端绑定 CPU 4–11(GOMAXPROCS=8),客户端绑定 CPU 12–15(GOMAXPROCS=4),通过 minio-go v7.4.1 走本机回环 HTTP 压测。
- 吞吐(operations/s):每秒完成的对象操作数(分片上传每次操作含初始化、2 个分片上传及完成请求)。
- P50 / P99:取各轮运行 50 / 99 分位延迟的中位数。
- CPU 成本:每完成一次操作消耗的服务端 CPU 时间。
- 块 I/O:取自
/proc/PID/io的进程级读写字节数。 - 活动桶空间:对象数据文件、元数据与目录占用之和(不含临时目录)。
第一轮结果:完整策略导致深版本历史写入显著退化
第一轮对比完整候选分支(0aa870115d7d)与基线(789a2327ba8c),共执行 54 次测量运行(完成 243,718 次操作,0 失败)。基础矩阵每项运行 3 对 15 秒测试,新 key 与 4 KiB 覆盖追加 2 对 60 秒长测,单对象版本测试固定写入 4,000 个新版本,另在存量双副本数据上执行 4 轮 ABBA 二进制切换测试:
| 场景 | 基线 → 完整版,次/秒 | 变化 | P50,ms | P99,ms | 样本 |
|---|---|---|---|---|---|
| 双池 4 KiB 覆盖,256 key、16 并发 | 236.9 → 174.5 | −26.3% | 30.4 → 74.4 | 277.9 → 307.4 | 每版 2 × 60 秒 |
| 双池 1 MiB 覆盖,64 key、8 并发 | 140.9 → 132.2 | −6.2% | 34.2 → 41.3 | 169.3 → 176.2 | 3 × 15 秒 |
| 双池 16 MiB 分片覆盖,16 key、4 并发 | 25.8 → 26.4 | +2.1% | 144.0 → 141.7 | 265.3 → 272.7 | 3 × 15 秒 |
| 双池单个 4 KiB key 覆盖,1 并发 | 135.2 → 136.4 | +1.0% | 6.9 → 6.8 | 12.0 → 10.6 | 3 × 15 秒 |
| 双池 4 KiB 新 key,16 并发 | 290.7 → 296.8 | +2.1% | 46.9 → 48.7 | 170.2 → 165.9 | 2 × 60 秒 |
| 单池 4 KiB 覆盖,16 并发 | 166.2 → 159.2 | −4.2% | 83.9 → 89.4 | 296.8 → 293.2 | 3 × 15 秒 |
| 双池单个 4 KiB key,新增 4,000 版 | 76.6 → 53.0 | −30.8% | 11.4 → 17.8 | 47.7 → 59.5 | 3 次固定操作数 |
| 已有双份副本,切换程序 ABBA | 198.6 → 202.0 | +1.7% | — | 291.2 → 290.9 | 2 × 20 秒 |
普通 4 KiB 覆盖的两对 60 秒长测分别下降 30.7% 和 21.8%,此前三对 15 秒短测也全部下降;但每次操作的 CPU 时间反而从 5.85 ms 降至 5.33 ms,单次块写入保持在约 128 KiB。
单对象 4,000 版本测试下降 30.8% 的原因则非常直接:池内 xl.meta 保存了该池内所有版本的元数据(以及小对象的内联数据),每次新增版本都要重新序列化并整文件重写 xl.meta。基线按容量分配将 4,000 个版本大致均分到两个池(每池约 2,000 个版本),而亲和策略将全部 4,001 个版本集中写入同一个池,导致每次 PUT 重写的 xl.meta 体积翻倍:
- 单次写入 CPU 耗时:26.61 ms → 39.29 ms
- 单次写入块读取:12.20 MiB → 24.42 MiB
- 单次写入块写入:12.27 MiB → 24.49 MiB(4,000 次写入上传正文共约 15.6 MiB,累计块写入从 47.9 GiB 增至 95.7 GiB)
- 4,001 版本最终活动数据量:两版均为约 48.90 MiB
- 最后 1,000 次写入平均延迟:18.4 ms → 28.5 ms
集中历史版本既不能节省空间,又让更新成本随版本深度成倍放大,因此第二版直接排除了版本桶写入。
第二轮结果:排除了版本放大,但无版本小对象覆盖依然退化
第二轮对比收窄版(codex/unversioned-pool-affinity)与同一基线(789a2327ba8c)。测试前设定的准入线为:普通覆盖吞吐损失不超过 5%,P99 延迟增长不超过 10%,且 P50 与排除范围场景无实质退化。
四个场景每版各运行 5 次(AB/BA 交替,常规场景预热 5 秒、测量 60 秒,版本对照场景固定写入 4,000 次,每轮重建服务与数据目录,关闭性能分析器插桩):
| 场景 | 基线 → 收窄版,次/秒 | 变化 | P50,ms | P99,ms |
|---|---|---|---|---|
| 新建数据集,4 KiB 覆盖,256 key、16 并发 | 167.9 → 158.2 | −5.7% | 66.2 → 75.8 | 470.2 → 428.7 |
| 新建数据集,1 MiB 覆盖,64 key、8 并发 | 79.45 → 79.58 | +0.2% | 94.0 → 95.6 | 521.5 → 483.2 |
| 单个 4 KiB key 新增 4,000 版,对照项 | 48.5 → 59.6 | +22.8% | 12.7 → 12.4 | 180.9 → 135.4 |
| 4 KiB 新 key、16 并发,对照项 | 289.8 → 243.1 | −16.1% | 40.4 → 49.2 | 314.9 → 170.0 |
在核心的 4 KiB 普通覆盖场景中,五对运行的吞吐变化分别为 +11.9%、−14.9%、−1.4%、−10.3%、−12.8%(配对中位数变化 −10.3%,两版中位数比值 −5.7%,P50 上升 14.4%)。基线自身在 141.4–202.6 次/秒之间波动(变异系数 13.0%),收窄版为 146.3–176.6 次/秒,未能满足“吞吐损失 ≤ 5%”的准入线。
收窄范围确实消除了版本历史重写放大:单版本块写入为 12.272 → 12.268 MiB,块读取为 12.201 → 12.197 MiB,CPU 为 26.98 → 27.26 ms(版本对照项中五对均为后运行的二进制更快,而未改逻辑的新 key 对照项逐对波动达 −44.3% 至 +15.3%,反映出共享单盘测试环境的顺序波动)。
固定初始数据布局的恢复对照测试
新建数据集测试中,两种策略会随运行逐步写出不同的磁盘目录布局。为排除布局演化差异,追加的 8 轮夹具测试在每轮运行前恢复完全相同的初始数据快照——“每 key 单池副本(共 256 条记录)”或“每 key 双池副本(共 512 条记录)”——每次恢复后静置 30 秒,再预热 5 秒、测量 60 秒:
| 初态,256 个 key | 基线 → 收窄版,次/秒 | 变化 | P50,ms | P99,ms | 结束时池内记录数,基线 → 收窄版 |
|---|---|---|---|---|---|
| 每个 key 一份副本 | 188.0 → 160.1 | −14.9% | 51.9 → 68.0 | 521.0 → 466.4 | 512 → 256 |
| 每个 key 两份副本 | 185.7 → 140.1 | −24.5% | 37.6 → 79.7 | 460.0 → 629.9 | 512 → 512 |
单份副本初态的两对运行分别为 −0.9% 和 −23.5%;而双份副本初态的两对运行分别下降 −22.6% 和 −26.5%,P50 暴涨 +111.9%,P99 上升 +36.9%,且磁盘上原有的 512 份记录一份也不会减少。对于已经积累了跨池旧副本的存量集群,这意味承担显著的写入性能退化却无法立即回收任何空间。
根因定位与 I/O 路径实验
阶段计时显示,新增的健康检查每次仅耗时 0.014–0.016 ms,远小于对象查找(毫秒级)和纠删码写入(数十毫秒级)。第二轮 4 KiB 覆盖的单次 CPU 时间从 5.75 ms 降至 5.36 ms,吞吐却反而下降,说明瓶颈来自存储路径上的 I/O 与调度等待。
Go 执行跟踪(execution trace)显示系统调用等待集中在 RenameData → writeAllInternal → write 以及读取和 goroutine 调度等待。
随后我们做了一项局部 I/O 实验:将小元数据写入(约 791 字节,小于 4 KiB 对齐粒度,CopyAligned 会自动关闭 O_DIRECT)从 Linux O_DSYNC 路径改走 writeAllDirect(实际等价于缓冲写加独立 fdatasync)。“收窄亲和 + I/O 修改”五轮正式运行均值达到 179.4 次/秒(对比基线 167.9 次/秒,+6.9%,P99 −1.5%,逐对波动 −19.2% 至 +35.9%)。但在两对同时给基线和候选分支都加上该 I/O 修改的对照实验中,“基线 + I/O 修改”达到 212.5 次/秒,“收窄亲和 + I/O 修改”为 185.2 次/秒(中位数差值 −12.8%,逐对 +37.7% 与 −49.1%)。由于修改小元数据同步写路径会影响全局存储引擎,且开启后亲和分支依然落后于同条件基线,该构建覆盖补丁未被采纳。
收益与代价汇总
在双池新建数据集测试中,256 个普通 4 KiB key 结束时的池内记录数从 512 份降至 256 份,活动数据逻辑体积从 6.18 MiB 降至 3.09 MiB;1 MiB 场景记录数从 128 份降至 64 份,体积从 171.56 MiB 降至 85.78 MiB。
从集群稳态看,若逻辑数据中有比例为 $f$ 的对象会在双池间产生第二份副本,消除额外副本可节省的总体空间比例为 $f / (1 + f)$(例如当 $f = 10%$ 时约节省 9.1%)。单次请求的写入量则不会减半:两版在 4 KiB 覆盖下的单次进程块写入均为 128 KiB 左右。
| 维度 | 收益 | 代价或局限 |
|---|---|---|
| 容量空间 | 避免符合条件的无版本覆盖写产生新的跨池旧副本 | 不清理存量旧副本;故障回退时仍会产生跨池副本 |
| 写入性能 | 原池健康时可省去一次全池容量筛选 | 改变落盘分布与 I/O 争用;4 KiB 小对象覆盖吞吐退化(新建数据 −5.7%,双副本初态 −24.5%) |
| 扩容与热点 | 既有对象池位置固定 | 扩容后旧池继续承接热点 key 更新,新池分担的覆盖写流量减少 |
| 协议与格式兼容 | 不改 S3 协议、配置项、依赖或磁盘格式 | 池间容量分布与原有运维预期不同 |
| 可用性与正确性 | 请求级写 quorum 与坏盘检查避免误选不可写原池 | 预检查与实际写入之间仍存在状态变化窗口 |
| 维护复杂度 | 收窄至无版本覆盖后避免了版本 xl.meta 重写放大 |
需持续同步 parity、桶版本状态、内部写、reserve 规则及 PUT/MPU 选池入口 |
| 版本回滚 | 退回基线二进制无需转换磁盘格式 | 已产生的布局变化与跨池旧副本不会自动消除 |
最终决定与后续重启条件
- 完整亲和方案(
codex/healthy-pool-affinity):因集中版本历史导致xl.meta重写 I/O 翻倍、4,000 版本写入吞吐下降 30.8% 且无空间收益而否决。 - 无版本收窄方案(
codex/unversioned-pool-affinity):虽消除了版本历史放大,但在普通 4 KiB 覆盖上仍存在 −5.7%(配对中位数 −10.3%)的新数据吞吐退化,且在存量双副本恢复测试中出现 −24.5% 的吞吐退化(P50 +111.9%)而否决。
因此,我们决定保持主线分配器行为不变,归档两轮基准测试证据(第一轮 54 次运行 / 243,718 次操作;第二轮 53 次运行 / 495,745 次操作)。后续若重新评估该问题,应先采集生产环境的真实旧副本空间占比并在多块独立物理盘拓扑上压测,再权衡是采用后台旧副本清理还是显式可选的放置策略。
源码与证据索引
公开参考资料:PR #245、上游容量修复 PR #21115、多池对象一致性,以及基线存储实现 cmd/erasure-server-pool.go、cmd/xl-storage-format-v2.go 与 cmd/xl-storage.go。
| 制品 | 精确标识 |
|---|---|
| 初次独立评审的 base / PR head | 856f8f4c5580d1c5adacbd8ef2aedf3a4fbc507c / 3a9cdc731528e31bc4f9e61256025a1baae863e8 |
| 撰文时观察到的 rebase 后 PR head | 0a6cad318409451b52dfcef18efb476c3927b6f8 |
| 两轮 m3 共同基线 | 789a2327ba8c2283cda7192eb65f8374a7f919ba |
| 完整本地分支 | codex/healthy-pool-affinity(0aa870115d7d0904791104b25ad66f3430579de4) |
| 收窄本地分支 | codex/unversioned-pool-affinity(76db3626eb9068b29e4565e78ad22f7720012e65) |
| 收窄版性能二进制对应的生产语义 | bb4b987825985481e5047f94c60f560be85b9c19 |
第二轮 results/analysis.json SHA-256 |
68eec465749b625c691abcace90fc71f103b5826ccab4c40a120f39e29ded368 |
| 完整候选补丁 SHA-256 | af833ec4b7bbbc22fca8539a6da40f4fe5fa93d61febabbd5b08a63ae1ecbe1c |