软 RAID 里 0.90 版元数据算是最古老、也最容易被跳过的一块知识。很多人第一次真正去算“0.90 元数据到底占多少空间、放在哪”,都是在阵列容量和预期对不上的时候:几块同型号盘,按 RAID5 的公式心算一遍,结果mdadm报出来的数字总是差那么一截,差得不多,几十兆到几百兆,但就是不整齐。这个差值不是算错了,而是 0.90 元数据存放区域的布局决定的——它固定侵占每块成员设备末尾的一小块区域,且这块区域的大小和你设备的实际字节数之间存在取整关系。
这篇内容讲的就是这件事:0.90 元数据(也就是日常说的 v0.9 老式超级块)的存放位置怎么定位、保留区到底多大、sb->size字段是怎么从设备字节数推出来的、以及在各种 RAID 级别下最终能拿到多少可用容量。适合三类人看:还在维护老阵列、需要精确核算容量的运维;做数据恢复、需要手工定位超级块并读出字段的工程师;以及正在评估“要不要从 0.90 迁到 1.2”的规划者。所有结论都可以用blockdev、dd、xxd现场验证,不需要信任任何二手说法。
1. 0.90 元数据的落点:钉在设备末尾的那 64KiB
1.1 为什么超级块被放在尾部而不是开头
0.90 这套格式成型的时候,软 RAID 的典型用法是直接拿整块盘或者整块分区当成员设备,而磁盘开头那一段是有历史包袱的:主引导记录、分区表、各种引导代码都挤在前面的扇区里。如果超级块放在设备开头,一来会和引导区抢地盘,二来早期不少工具默认“设备开头就是用户数据”,一写就覆盖。放到末尾是最省事的选择——末尾那一小段本来也不会有正常文件系统去碰,只要约定好“最后 64KiB 归元数据”,谁都不会踩到谁。
代价是这个位置是硬编码的死位置,没有商量的余地。后来的 1.x 元数据之所以要引入data_offset这个概念,就是为了解决这个死板的问题:1.0 放尾部、1.1 放开头、1.2 放开头 4KiB 处,位置可以通过偏移量描述,甚至可以整体挪到别的地方去。0.90 没有这个字段,位置是格式本身的一部分,超级块永远在设备末尾往前 64KiB 处开始的那一页上。这个差异在迁移和恢复时影响非常大,后面会单独讲。
我先说结论:0.90 超级块位于设备末尾 64KiB 保留区的最前面,实际写入的只有 4KiB,剩下 60KiB 名义上留给别的用途。记住这三个数字,后面所有计算都是从这里长出来的。
1.2 4KiB 超级块页、64KiB 保留区与那 60KiB 的去向
内核头文件里跟这件事直接相关的常量有三个,值都不大,但决定了整个容量账:
| 常量 | 值 | 含义 |
|---|---|---|
MD_SB_BYTES | 4096 | 单个超级块页的大小,即真正写入 0.90 元数据的那 4KiB |
MD_RESERVED_BYTES | 65536 | 每块成员设备末尾保留的总字节数,即 64KiB |
MD_RESERVED_SECTORS | 128 | 上面那个值的扇区表示,按 512 字节扇区算 |
超级块占保留区的第一个 4KiB,这个位置是固定的。剩下那 60KiB 并不是空白浪费——0.90 的写意图位图(write-intent bitmap)就存在这块区域里,紧跟在超级块页后面。这一点经常被忽略:很多人在创建阵列时随手把--bitmap-chunk调得很小,想把重建时的位图粒度做细,结果 0.90 直接报错或者干脆不给建,原因就是 60KiB 装不下他要求的位图。位图大小和阵列容量、chunk 大小成正比,chunk 越小位图越大,这个账在 0.90 上是被那 60KiB 卡死的。
一个容易混淆的点:这 64KiB 是每块成员设备各自保留的,不是整个阵列共享一份。4 块盘组 RAID5,就有 4 份 64KiB 的保留区,分别躺在这 4 块盘的末尾。所以你不能简单地认为“阵列总量 = 各盘之和 − 64KiB”,而应该是“每块盘先各自扣掉 64KiB,再参与阵列容量的折算”。当然对 RAID5 这种按(N-1) × 单盘可用折算的级别,最后算出来的数字看起来一样,但推导过程不能混。
1.3 把超级块从末尾直接读出来验证
与其背结论,不如自己读一次。所有操作都可以只读进行,用blockdev拿到字节数,算出保留区起点,再用dd按 4KiB 块读出来:
# 1. 拿到设备总字节数 B=$(blockdev --getsize64 /dev/sdX) echo "total bytes: $B" # 2. 保留区起点 = 总字节数 - 64KiB OFF=$(( B - 65536 )) echo "superblock offset: $OFF" # 3. 只读取出保留区的第一页 dd if=/dev/sdX of=/tmp/sb0.bin bs=4096 skip=$(( OFF / 4096 )) count=1 status=none # 4. 看前 16 字节 xxd -l 16 /tmp/sb0.bin正常情况下你会看到开头四个字节是fc 4e 2b a9。这是0xA92B4EFC的小端存储,也就是 0.90 格式的魔数。如果你在这块盘的末尾读不到这个魔数,那要么它不是 0.90 阵列的成员,要么超级块已经被写坏,要么设备根本不是你以为的那块。
拿到这 4KiB 之后,可以直接按字段偏移解出关键信息。0.90 超级块的结构体是定长的,字段顺序固定,前几十个字节的排布大致是这样:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0x00 | 4 | md_magic | 固定 0xA92B4EFC |
| 0x04 | 4 | major_version | 0.90 格式为 0 |
| 0x08 | 4 | minor_version | 0.90 格式为 0 |
| 0x1C | 4 | level | RAID 级别,负数用补码表示 |
| 0x20 | 4 | size | 本设备贡献的可用容量,单位 KiB |
| 0x24 | 4 | nr_disks | 阵列中已记录设备数 |
| 0x28 | 4 | raid_disks | 阵列中设备总数 |
用一条python3就能把size字段读成十进制,省得自己数字节:
python3 - <<'EOF' import struct d = open('/tmp/sb0.bin','rb').read(64) magic, major, minor = struct.unpack('<III', d[0:12]) level, = struct.unpack('<i', d[0x1c:0x20]) size, = struct.unpack('<I', d[0x20:0x24]) nr, = struct.unpack('<I', d[0x24:0x28]) rd, = struct.unpack('<I', d[0x28:0x2c]) print(f"magic=0x{magic:08x} ver={major}.{minor}") print(f"level={level} size={size}KiB ({size/1024:.2f}MiB)") print(f"nr_disks={nr} raid_disks={rd}") EOF这个脚本的好处是它可以对任意一块成员设备单独跑,不需要阵列处于组装状态。做数据恢复的时候,阵列散架了、mdadm --assemble不认,但你手里只要有一块盘,把这个 4KiB 读出来,size、level、raid_disks就全有了,重建参数基本能推出来。这是我个人觉得 0.90 比 1.x 更“好救”的地方——布局简单到可以用眼睛和十六进制编辑器处理。
2. 从设备字节数推导可用容量:一条公式与它的适用边界
2.1 公式本体与逐步推导
现在把账面算清楚。设设备总字节数为 B,0.90 模式下这块盘能贡献给阵列的数据区大小(字节)就是:
usable = B - 65536就这么简单,没有别的扣减项。因为数据区是从设备开头 0 偏移一直铺到保留区起点,中间不插任何东西。超级块在末尾,位图也在末尾,前面全是数据。这一点和 1.x 有本质区别:1.x 里数据区起点由data_offset决定,开头会空出一段,而 0.90 的开头是实打实的 0 偏移。
超级块里的size字段单位是 KiB,所以:
sb->size = (B - 65536) / 1024由于 65536 是 1024 的整数倍,只要 B 是 1024 的倍数(现代块设备基本都满足),这个除法天然整除,不会出现小数截断。举个例子,设备总字节 1073741824(正好 1GiB),减去 65536 得 1073676288 字节,除以 1024 得 1048512 KiB。所以size字段读出来应该是1048512。你拿mdadm --examine去看,输出里那一行Used Dev Size显示的就是这个数换算之后的结果。
这里有个细节值得单独说:size记录的是这块设备被使用的容量,不是设备本身的容量。如果你在创建时用--size手动限定了每块盘只用一部分,size字段记的就是你限定的值,而不是B - 65536。所以当你发现size比预期小得多的时候,先别怀疑计算,先回想一下创建时是不是加过--size。这个坑我见过不止一次,尤其是早期做小容量测试阵列、想着“先给每块盘限 100GB 玩玩”的人,后来扩容时发现怎么都长不上去。
2.2 设备容量不是 64KiB 整数倍时,差的那点到底是什么
上面那条公式有一个隐含前提:设备总字节数是 64KiB(65536 字节)的整数倍。现代分区基本都按 1MiB 对齐,1MiB 是 64KiB 的 16 倍,所以绝大多数场景天然满足。但一旦不满足,就会出现两种算法分歧:
| 设备总字节 | 是否 64KiB 整数倍 | 直接减 64KiB | 先按 64KiB 向下取整再减 | 差值 |
|---|---|---|---|---|
| 1073741824 | 是 | 1073676288 | 1073676288 | 0 |
| 1073742336 | 否(多 512 字节) | 1073676800 | 1073676288 | 512 字节 |
内核在组装 0.90 阵列时,实际采用的可用扇区数是dev_sectors - 128,也就是“直接减”。而老版本 md 代码里有一个MD_NEW_SIZE_SECTORS(x)宏,做法是先把扇区数按 128 扇区(64KiB)向下取整,再减掉 128 扇区,也就是“先对齐再减”。两者在设备容量是 64KiB 整数倍时完全一致,在不是的情况下差不到 64KiB。
这点差异在实际运维里几乎无感,但有两种情况会咬人。第一种是手工做过分区、起始扇区不是 2048 的整数倍、结尾又贴着磁盘末端的老分区,容量天然不是 64KiB 的倍数,这时候你按哪条公式算都会有一点出入,必须用mdadm --examine读实际值。第二种是从别的存储系统迁过来的 LUN,容量经常是奇数的扇区数,同样要现场核对。我的做法是:规划阶段用直接减法估算,落地之后一律以--examine的输出为准,不要拿估算值去写文档,否则后面扩容时账目永远对不上。
顺带说一个实用技巧。如果你就是想让每块盘贡献的容量是个漂亮数字,创建时直接用--size指定,单位 KiB。比如想让每块盘贡献 100GiB,就算好104857600KiB 填进去,size字段就精确记这个值,比事后纠结取整规则省心得多。
2.3 各 RAID 级别的容量折算与条带对齐的隐性损耗
单盘可用容量确定了,接下来是阵列层面的折算。设单盘可用为 D(KiB),成员数为 N,0.90 下各常见级别的理论容量是:
| 级别 | 理论容量 | 8 块 1TiB 盘的例子 |
|---|---|---|
| RAID0 | N × D | 约 8TiB 减 512KiB |
| RAID1 | D | 约 1TiB 减 64KiB |
| RAID5 | (N−1) × D | 约 7TiB 减 448KiB |
| RAID6 | (N−2) × D | 约 6TiB 减 384KiB |
| RAID10 | (N/2) × D | 约 4TiB 减 256KiB |
表里那个“减”的部分就是保留区的总量:每块盘各扣 64KiB,N 块盘就扣 N×64KiB。8 块盘组 RAID5,保留区一共吃掉 512KiB,但因为 RAID5 有一个盘的容量用于校验,实际体现在可用空间上的是 7 份,所以是 448KiB。这就是开头说的“差一截”的来源。
除了保留区,RAID5/RAID6 还有一层条带对齐损耗。阵列容量必须是 chunk 大小与数据盘数量的乘积的整数倍,如果单盘可用容量除不尽,最后会剩下一点用不上的尾巴。0.90 在这方面比很多人想的要“聪明”一点:它在创建时会自动把每块盘的使用量往下取整到条带边界,而不是硬塞。所以你在--detail里看到的Array Size往往比(N−1) × Used Dev Size还要小一点点,这个差值是正常的,不是哪里算错了。要验证的话,把Used Dev Size、chunk 大小、数据盘数三个数乘一乘,就能对上。
我个人的经验是:做容量规划时,先按理论公式算,再统一预留 1% 的余量,基本不会翻车。0.90 的损耗比 1.x 小得多(1.x 每个设备至少吃 1MiB 以上的元数据区,某些配置下更多),但老格式的容量上限又低,两头都难受,具体取舍后面第 5 章再展开。
3. 让算出来的数对不上的几个硬边界
3.1 2TiB 天花板:字段位宽和工具链的双重夹击
0.90 最出名的限制就是单块成员设备不能超过 2TiB。这个限制的来源不是某一处,而是两处叠加。
第一处是size字段本身。它是 32 位无符号整数,单位 KiB,理论上能表示到 4TiB。单看字段,2TiB 并不是硬墙。但第二处约束在工具链和内核的实现路径上:从mdadm到内核 md 层,处理块地址时还有其他的位宽假设在其中起作用,最终落到实际可用容量上的就是 2TiB 这条线。所以你会看到一种很奇怪的现象——用一块 3TiB 的盘建 0.90 阵列,命令能跑通,但报出来的容量只有 2TiB 出头,剩下的空间既不报错也不使用,静静地躺在那儿。
遇到这种情况,mdadm在创建阶段通常会给提示,告诉你设备对 0.90 来说太大了、容量会被截断,但提示不一定显眼,尤其在批量脚本里跑的时候很容易被日志淹掉。所以我的习惯是:只要单盘容量可能接近或超过 2TiB,就不要用 0.90,直接用 1.2,别在这上面省事。真要用 0.90,就老老实实给每块盘划一个小于 2TiB 的分区当成员,把剩余空间留给别的用途,这样账目是清楚的。
顺带一提,这个限制只针对单块成员设备的容量,不针对阵列总容量。理论上 27 块 2TiB 的盘组 RAID5 能到 52TiB 级别——前提是你真的愿意用 0.90 管这么多盘,实际上没人这么干。
3.2 位图和那 60KiB 保留区的争夺
前面提过,0.90 的写意图位图存在超级块页后面的 60KiB 里。这块区域是固定大小的,位图需求超过它就直接失败。
位图大小的估算逻辑是:位图要覆盖阵列的所有条带,每个条带对应一个比特(实际上有额外的头信息和粒度对齐),所以位图字节数大致正比于阵列容量 / bitmap-chunk。chunk 越大,位图越小;chunk 越小,位图越大。60KiB 的预算下,如果你把--bitmap-chunk设成 4KiB 这种极小值,阵列稍微大一点就会撑爆。
实际做的时候,我给的经验值是:0.90 阵列上--bitmap-chunk不要低于 64MiB,容量大的话用 128MiB 甚至 256MiB。位图 chunk 的粗细影响的是重建时重同步的粒度——chunk 越细,中断后恢复时扫过的已完成区域越精确,但代价是位图本身占空间、每次写都要更新位图页。对我们这种以归档、备份为主的场景,64MiB 的粒度完全够用,重建时多扫一点无所谓。
还有一个更隐蔽的坑:有些场景下你想加位图,加不上,报错信息又很含糊,指向的是一堆看不懂的偏移。这时候别怀疑别的,先算一下阵列容量 / chunk / 8有没有超过 60KiB 的一半(要留头信息和页对齐的余量)。按这个思路去排查,往往一次就能命中。