太长不看。 Rediacc 的
rdc machine query --storage-health会报告每个仓库的碎片化指标。在某台生产机器上,它把 GitLab 标记为约 19,650 个 extent/GB。直觉是去做碎片整理。我改为实际测了一下。
- 碎片化程度比邻近仓库高 16 倍的仓库,顺序读速度为 149 MB/s,而对比仓库是 143 MB/s;随机 4K 读也更快(719 us 对 957 us)。
- 该设备是闪存。碎片化通过寻道时间损伤旋转磁盘。在 SSD 上,几乎不存在它造成伤害的机制。
- 在这台机器上运行
btrfs filesystem defragment会将约 250 GB 的 reflink fork 和快照解除共享,而存储池只剩 4.4 GB 空间。这才是真正的风险,而基准测试显示没有任何收益可以与之权衡。
存储健康报告的目的是回答一个问题:我的磁盘空间去哪了。它显示每个仓库的大小、与 fork 共享了多少数据,以及碎片化指标。最后这个数字看起来可能很吓人。在我运行的这台机器上,GitLab 的仓库镜像报告在 14.6 GB 里有 268,771 个 extent,约为 19,650 个 extent/GB,工具将其标注为”高”。
随之而来的反应是自动的。碎片化高,那就做碎片整理。我在旋转磁盘上把这个反应写进 shell 脚本已有十五年。在给 Rediacc 加入碎片整理按钮之前,我想知道这个数字在我们实际运行的硬件上到底意味着什么代价。于是我在这台生产机器上做了基准测试。
这个数字实际上在统计什么
Rediacc 的仓库是一个单独的 LUKS 镜像文件,存放在 btrfs 存储池上。碎片化指标来自对该镜像文件运行 filefrag,统计的是加密容器的 extent,而不是应用程序在其中读取的文件。
这一点之所以重要,是因为数据的堆叠方式。从底层往上:物理 SSD,然后是宿主机 ext4 根文件系统,然后是 loop-backed 存储池文件,然后是 loop0,然后是 btrfs 存储池,然后是 LUKS 镜像,然后是 device-mapper 加密设备,最后是容器所见的内层 ext4。btrfs 是写时复制(copy-on-write)的。仓库内的每次随机写入都会在镜像中写入一个新 extent。数据库和容器 overlay 层整天都在随机写,所以镜像按设计会不断积累 extent。
卷内部的文件是另一回事。我检查了 GitLab 仓库:其 gitaly 二进制文件在 10 个 extent 中,一个 git pack 文件在 17 个 extent 中。内层文件系统并不碎片化。19,650/GB 这个数字描述的是写时复制容器本身,这正是你预期它应有的样子,并不能告诉你读取是否慢。
基准测试
我挑选了碎片化程度相差最大的两个仓库,用直接 IO 读取它们(这绕过页面缓存,强制进行物理读取)。
| 仓库 | 平均 extent 大小 | 每 GB extent 数 | 顺序读速度 |
|---|---|---|---|
| GitLab | 54 KB | ~19,650 | 149 MB/s |
| Stack Overflow 演示 | 880 KB | ~1,190 | 143 MB/s |
16 倍的碎片化差距没有产生任何吞吐量损失。碎片化更严重的文件反而略快。然后是人们真正担心的场景(小随机读,也就是数据库流量的形态):
| 仓库 | 随机 4K 延迟 | IOPS |
|---|---|---|
| GitLab(碎片化严重) | 719 us | 1,390 |
| Stack Overflow 演示(碎片化较轻) | 957 us | 1,045 |
同样,碎片化更严重的文件更快。这个小差距源于文件大小和后端缓存,而非 extent 布局。在闪存上,无论周围的 extent 在哪里,一次随机读就是一次查找加一次读取,没有磁头需要移动。
吞吐量以绝对值衡量是适中的,约为 145 MB/s 和 1,000 IOPS,这是因为设备是共享宿主机上的虚拟化磁盘,数据路径很深。这个上限由 btrfs 上下的虚拟化层和加密层决定,对镜像做碎片整理无法突破它。
碎片化唯一真实的代价
诚实要求说出另一面。碎片化在这里确实有一项可测量的代价,但不是读取速度,而是遍历 extent 映射所花的时间:
- GitLab 上的
filefrag(268,771 个 extent):3.19 秒 - Stack Overflow 演示上的
filefrag(152,364 个 extent):0.74 秒
遍历每个 extent 的操作都要付这个代价,包括存储健康扫描本身、备份同步和增量工具。这只是秒级别的时间,与 extent 数量大致成正比,且只涉及后台任务而非你的应用程序。如果 extent 遍历时间真的成为实际瓶颈,那是一个范围窄小的问题,有范围窄小的解决方案,而不是重写实时数据的理由。
Rediacc 为什么不提供碎片整理命令
btrfs filesystem defragment 从大约内核 3.9 起就不再保留 reflink。手册页明确写道:碎片整理会拆散写时复制数据的 reflink,可能导致空间使用量大幅增加。将文件连续重写会将每个共享 extent 复制为私有 extent。
在这台机器上几乎所有数据都是共享的。fork 通过 reflink 共享父仓库的数据,备份定时器还额外添加了也进行共享的只读快照。存储池已 99% 满,只剩 4.4 GB 空间。GitLab 有 97% 是共享的,所以对它做碎片整理会尝试将约 14 GB 复制到 4.4 GB 的空间里,中途就会失败。Stack Overflow 演示仓库有 137 GB,其中只有 26 MB 是唯一数据,对它做碎片整理会尝试实体化 137 GB 实际上并不存在的数据。所有仓库合计约有 250 GB 是通过 reflink 共享的。碎片整理不是一次调优,而是一颗空间炸弹。
即便空间够用,也不会持久。这些镜像在同样的随机写工作负载下几分钟内就会重新碎片化。你会短暂地解除 fork 的共享,却换来一个基准测试已经证明你本来就有的读取速度。
应该关注什么而不是碎片化
在同一份报告里真正值得关注的列是”分歧率”(Divergence)。它是仓库镜像中属于该仓库独有、而非与 fork 和快照共享的数据百分比。一个新鲜 fork 的分歧率接近 0%,因为它几乎共享所有内容。一个自 fork 以来被大量写入的仓库会向 100% 攀升。
分歧率能回答碎片化无法回答的问题:这个仓库实际上占用了多少真实的、可回收的磁盘空间。当存储池空间紧张时,低分歧率的仓库是很差的清理目标,因为它的字节是共享的,删除它几乎不释放空间。字节真正所在的地方是分歧率高的仓库。
结论
碎片化数字是真实存在的,在随机写入下的写时复制镜像上它总是看起来很高。在闪存上,它只是参考信息。我测量了 16 倍的差距,没有发现读取惩罚,碎片化更严重的文件随机读性能反而更好,唯一的小代价是后台扫描时间。那个本来可以”修复”这个数字的工具,反而会将四分之一 TB 的 fork 解除共享,塞进一个根本没有空间容纳它们的存储池。
所以 Rediacc 报告碎片化并加以解释,不提供任何按钮来处理它。诚实的工程答案是对假设进行基准测试,而不是将反射动作自动化。