实验 02:RAID 嵌套的取舍 —— 嵌套条带为什么赢不了扁平条带

为什么做这个实验

手里固定 32 块一样的盘,可以拼成一个扁平的 RAID 0(纯条带),也可以拼成"RAID 0 里再套 RAID 0"的多层结构。嵌套看起来更有设计感,厂商宣传里也常出现"多层条带性能更强"的说法。本实验把所有拼法穷举一遍,用仓库自带的成本模型算出每种的顺序读吞吐,看嵌套到底赢不赢。

结论是反直觉的:**嵌套输得很惨**。32 块盘拼扁平 RAID 0 是 3200 MB/s;15 种真正的嵌套方案里最好的 `16 × 2` 只有 1700 MB/s,约为扁平的 53%。原因藏在吞吐公式的 `min` 里——内层阵列辛辛苦苦攒出来的高吞吐,在外层取最小值时会被最慢的裸盘一票否决;内层占掉的所有盘,在外层眼里只占 1 个成员名额。

做完这个实验,你应该能回答:为什么嵌套在纯条带场景下必然劣于扁平?"吞吐只由顶层宽度决定"这条规律是怎么推出来的?以及嵌套到底什么时候才有意义(答案是:当某一层提供了扁平条带给不了的属性,比如 RAID 1/5/6 的容错)。

运行前准备

1. Python 3.10 及以上。
2. 安装依赖(在仓库根目录执行):

```bash
pip install pandas numpy matplotlib jupyter
```

验证方式:

```python
import pandas, numpy, matplotlib
print("环境 OK")
```

3. 必须**从仓库根目录**(其下有 `system/` 与 `notebooks/`)启动 Jupyter,否则下一格的 `from system.storage.RAID.cost_model import ...` 会报 `ModuleNotFoundError`:

```bash
jupyter notebook notebooks/RAID-Nesting-Trade-offs.ipynb
```

4. 本实验不碰真实磁盘,全部结论来自 `system/storage/RAID/cost_model.py` 里的确定性成本模型,因此数字可以精确复现。

import sys, pathlib

# Put the repo root (the directory containing `system/`) on sys.path so
# `from system...` works whether this notebook is opened from its own
# folder or from the repo root.
for _p in (pathlib.Path.cwd(), *pathlib.Path.cwd().parents):
    if (_p / "system").is_dir():
        if str(_p) not in sys.path:
            sys.path.insert(0, str(_p))
        break

一、问题与记号

给定若干块完全相同的设备,把它们排成:

• 一个**扁平 RAID 0**:所有设备都是同一个条带阵列的成员;

• 或者**嵌套 RAID 0**:阵列的某个成员本身又是一个 RAID 0 阵列。

问题是:这两种排法的**顺序读吞吐**一样吗?如果不一样,哪种最好?

回答方式不是挑几个"看起来典型"的方案比一比,而是**穷举**:把设备数 32 分解成若干个不小于 2 的因子,每一种分解对应一种嵌套,逐个构造出真实的嵌套对象,再调用成本模型取吞吐。结论从全集里得出,而不是从样本里猜。

术语约定(后面反复用到):

| 术语 | 含义 |
| --- | --- |
| 因子序列 | 一种嵌套方式,如 `4 × 2 × 4`。顺序有关:`4 × 2 × 4` 与 `4 × 4 × 2` 是不同的排法 |
| 顶层宽度 `a` | 因子序列的第一个因子,即最外层直接挂的裸盘个数 |
| 层数 | 因子序列的长度,即嵌套了几层 |
| 吞吐 | 顺序读吞吐,单位 MB/s |

from typing import Iterator

from system.storage.RAID.cost_model import Device, SubSystem, RAID_0

二、成本模型:一块盘长什么样

下面这个函数给出一块"标准盘":顺序读 100 MB/s、顺序写 100 MB/s、随机读 1000 IOPS、随机写 1000 IOPS。`Device(读, 写, 随机读, 随机写)` 四个参数依次对应这四项。

本实验里所有设备都由 `make_device()` 造出来,**完全一样**。这一点很重要:吞吐公式里的 `min` 只有在成员速度不一致时才会显出威力,而嵌套恰恰能人为造出"不一致"——内层阵列比裸盘快得多。

def make_device() -> Device:
    """A single identical device: 100 MB/s sequential read/write, 1000 IOPS."""
    return Device(100, 100, 1000, 1000)


make_device().get_sequential_read_performance()
100

RAID 0 的顺序读吞吐公式

成本模型(`system/storage/RAID/cost_model.py` 中 `RAID_0.get_sequential_read_performance`)把 RAID 0 的顺序读吞吐定义为

$$\text{read}(\text{RAID 0}) = \min_{m \in \text{members}} \text{read}(m)\; \times\; |\text{members}|$$

符号含义:

| 符号 | 含义 |
| --- | --- |
| $\text{members}$ | 这个阵列的成员集合;成员可以是一块裸盘,也可以是一个子阵列 |
| $\text{read}(m)$ | 成员 $m$ 的顺序读吞吐(MB/s) |
| $\min$ | 在所有成员里取**最慢**的那个 |
| $|\text{members}|$ | 成员的个数 |

也就是说:**半截取最慢成员的吞吐,半截乘成员个数。** 这是一条"木桶效应 + 并行"的复合规则——最慢的成员决定了一条条带要走多久,成员个数决定了同时能走几条。

后面所有的现象都由这一条公式推出,请把它记住。

三、枚举所有嵌套方式

一种嵌套方式由设备数的一个**因子分解**描述,例如 $32 = 4 \cdot 2 \cdot 4$。每个因子对应一层:最外层 RAID 0 由(第一个因子)块裸盘 + 1 个内层阵列组成,内层再按剩下的因子递归展开。

下面这一格定义两个函数,读代码时请注意三点:

1. `factorisations(k)` 用递归生成分解:枚举除数 `divisor`,若能整除且商 $\ge 2$,就把 `[divisor]` 接到商的所有分解前面,同时不要忘了两因子的情形 `[divisor, quotient]`。因为除数从 2 递增、配合递归,每种分解恰好生成一次。
2. **顺序有关**:`[4, 2]` 与 `[2, 4]` 都会出现,它们代表不同的结构。
3. `build_nested_raid_0(config)` 的构造口径是:本层取 `config[0]` 块裸盘,**外加** 1 个由剩余因子递归构造出的子阵列。所以本层的成员数是 `config[0] + 1`,而不是设备总数——这一步正是嵌套"浪费设备"的根源。

def factorisations(k: int) -> Iterator[list[int]]:
    """Yields every factorisation of ``k`` into factors >= 2 (order matters),
    excluding the trivial single-factor ``[k]``."""
    assert isinstance(k, int) and k >= 1
    for divisor in range(2, k):
        quotient, remainder = divmod(k, divisor)
        if remainder != 0 or quotient < 2:
            continue
        for rest in factorisations(quotient):
            yield [divisor] + rest
        yield [divisor, quotient]


def build_nested_raid_0(config: list[int]) -> SubSystem:
    """Builds a (possibly nested) RAID 0 from a factorisation ``config``:
    the level takes ``config[0]`` bare devices plus the array built from the
    remaining factors."""
    assert len(config) >= 1
    if len(config) == 1:
        return RAID_0([make_device() for _ in range(config[0])])
    inner: SubSystem = build_nested_raid_0(config[1:])
    return RAID_0([make_device() for _ in range(config[0])] + [inner])

跑一遍穷举

对 32 块设备,把每一种分解都构造成真实的嵌套对象,取吞吐,装进 `DataFrame` 按吞吐降序排列。

除了吞吐,表里还记了 `levels`(层数)和 `top_width`(顶层宽度 = 第一个因子)——这两个量是用来验证后面那条规律的:吞吐到底跟谁有关。

import pandas as pd

NUMBER_OF_DEVICES = 32

rows: list[dict] = []
for config in factorisations(NUMBER_OF_DEVICES):
    raid = build_nested_raid_0(config)
    rows.append(
        {
            "nesting": " × ".join(map(str, config)),
            "levels": len(config),
            "top_width": config[0],
            "seq_read_MB_s": raid.get_sequential_read_performance(),
        }
    )

nestings = pd.DataFrame(rows).sort_values(
    "seq_read_MB_s", ascending=False, ignore_index=True
)
nestings
              nesting  levels  top_width  seq_read_MB_s
0              16 × 2       2         16           1700
1           8 × 2 × 2       3          8            900
2               8 × 4       2          8            900
3       4 × 2 × 2 × 2       4          4            500
4           4 × 2 × 4       3          4            500
5           4 × 4 × 2       3          4            500
6               4 × 8       2          4            500
7   2 × 2 × 2 × 2 × 2       5          2            300
8       2 × 2 × 2 × 4       4          2            300
9       2 × 2 × 4 × 2       4          2            300
10          2 × 2 × 8       3          2            300
11      2 × 4 × 2 × 2       4          2            300
12          2 × 4 × 4       3          2            300
13          2 × 8 × 2       3          2            300
14             2 × 16       2          2            300

四、结果:两条规律

把所有方案按吞吐分组,本机实测(仓库 `cost_model`,32 块 100 MB/s 的设备):

| 顶层宽度 a | 嵌套方式(全部) | 层数 | 吞吐 (MB/s) |
| --- | --- | --- | --- |
| 16 | `16 × 2` | 2 | **1700** |
| 8 | `8 × 2 × 2`、`8 × 4` | 3、2 | 900 |
| 4 | `4 × 2 × 2 × 2`、`4 × 2 × 4`、`4 × 4 × 2`、`4 × 8` | 4、3、3、2 | 500 |
| 2 | `2 × 2 × 2 × 2 × 2`、`2 × 2 × 2 × 4`、`2 × 2 × 4 × 2`、`2 × 2 × 8`、`2 × 4 × 2 × 2`、`2 × 4 × 4`、`2 × 8 × 2`、`2 × 16` | 5、4、4、3、4、3、3、2 | 300 |

共 **15 种**真正的嵌套方案(不含扁平)。吞吐相同的方案之间的先后次序由枚举顺序决定,不影响数值。

两条规律:

1. **所有嵌套都远低于扁平的 3200 MB/s**($= 32 \times 100$),最好的也只有 1700 MB/s,约占 53%。
2. **吞吐只由顶层宽度 `a` 决定**,与内层怎么摆完全无关:`16 → 1700、8 → 900、4 → 500、2 → 300`,无一例外地等于 $100 \times (a + 1)$。

第二条可以直接从公式推出来。最外层的成员是 `a` 块裸盘(各 100 MB/s)加 1 个内层阵列:

$$\text{read}(\text{顶层}) = \min(\underbrace{100, \ldots, 100}_{a\ \text{块裸盘}},\ \text{read}(\text{内层}))\ \times\ (a + 1)$$

内层至少 2 块盘,$\text{read}(\text{内层}) \ge 200 > 100$,所以 `min` 永远落在裸盘上,取 100;内层攒出多高的吞吐都白费。于是

$$\text{read}(\text{顶层}) = 100 \times (a + 1)$$

**嵌套等于把内层所有设备折叠成 1 个成员。** 参与并行的成员数只有 $a + 1$,永远到不了设备总数 32——这就是嵌套必然输给扁平的全部原因。

与扁平条带正面比一次

下一格做两件事:取出穷举表里最好的那个嵌套,再直接构造一个把 32 块盘全部平铺的 RAID 0,把两个数字并排打印。

预期看到:最好的嵌套 `16 × 2` 是 1700 MB/s,扁平是 3200 MB/s,两者相差约 1.88 倍。

# The best nesting found ...
best = nestings.iloc[0]
# ... versus simply striping all devices in one flat RAID 0.
flat = RAID_0([make_device() for _ in range(NUMBER_OF_DEVICES)])
print(f"best nesting: {best['nesting']:>12}  ->  {best['seq_read_MB_s']} MB/s")
print(
    f"flat RAID 0 of {NUMBER_OF_DEVICES}:      ->  "
    f"{flat.get_sequential_read_performance()} MB/s"
)
best nesting:       16 × 2  ->  1700 MB/s
flat RAID 0 of 32:      ->  3200 MB/s

画图

把最好的 8 个嵌套和扁平条带画在一张柱状图里,扁平那根柱子标成红色。

看图时只看一件事:**红柱是否明显高于所有蓝柱**。如果是,就说明在这个成本模型下,嵌套没有任何一种排法能追上扁平。

import matplotlib.pyplot as plt

top = nestings.head(8)
labels = list(top["nesting"]) + [f"flat ({NUMBER_OF_DEVICES})"]
values = list(top["seq_read_MB_s"]) + [flat.get_sequential_read_performance()]
colors = ["#4C72B0"] * len(top) + ["#C44E52"]

fig, ax = plt.subplots(figsize=(9, 4))
ax.bar(labels, values, color=colors)
ax.set_ylabel("sequential read (MB/s)")
ax.set_title("Nested RAID 0 arrangements vs. one flat stripe (32 devices)")
ax.tick_params(axis="x", rotation=45)
for label in ax.get_xticklabels():
    label.set_ha("right")
fig.tight_layout()
plt.show()
Notebook 已保存的运行结果

五、结论与边界

**对顺序读而言,一个宽的扁平 RAID 0 胜过同规模的任何嵌套排法。** 这不是"碰巧这 15 种都不行",而是可以从公式推出的必然:嵌套把内层设备折叠成单个成员,参与并行的成员数永远小于设备总数。

嵌套真正有意义的场合,是**某一层提供了扁平条带给不了的属性**,典型的是容错:

| 级别 | 容量利用率 | 可容忍同时失效 | 说明 |
| --- | --- | --- | --- |
| RAID 0 | 100% | 0 块 | 纯条带,无冗余 |
| RAID 1 | 50% | 1 块(两块盘互为镜像) | 镜像 |
| RAID 10 | 50% | 每个镜像对各 1 块 | 先镜像再条带,是"有意义的嵌套" |
| RAID 5 | $(N-1)/N$ | 1 块 | 每条纹带 1 块盘的奇偶校验 |
| RAID 6 | $(N-2)/N$ | 2 块 | 双重校验 |

(容量利用率、容错能力为各级别的通行定义,可参考 Red Hat 官方文档《Managing Storage Devices》的 RAID 章节;RAID 的原始论文是 Patterson、Gibson、Katz 发表于 SIGMOD 1988 的《A Case for Redundant Arrays of Inexpensive Disks》。)

RAID 10 就是"嵌套有用"的正面例子:内层是 RAID 1 镜像对,它提供的容错是外层条带给不了的,代价是容量砍半。**纯条带没有任何理由嵌套**——它多加的层数换不来任何新属性,只会浪费并行度。

最后一个提醒:RAID(哪怕 RAID 6)防的是"盘坏了数据还能读",防不了误删除、勒索软件、火灾和阵列控制器故障。业界通行的做法是 3-2-1 规则:3 份数据、2 种介质、1 份异地。

六、思考题与详解

<details>
<summary><b>第 1 题</b>:用 <code>min</code> 语义解释,为什么内层阵列的吞吐(≥ 200 MB/s)在外层永远派不上用场?</summary>

外层公式的 `min` 在**所有成员**里取最小值。裸盘成员都是 100 MB/s,内层阵列无论多快(≥ 200 MB/s),`min` 都落在裸盘上。

内层的高吞吐只换来一个身份:"这个成员不拖后腿",不产生任何加分。`min` 是一票否决制,没有第二名奖励。

</details>

<details>
<summary><b>第 2 题</b>:因子序列 <code>4 × 2 × 4</code> 与 <code>4 × 4 × 2</code> 的吞吐相同吗?设备总数相同吗?分别算出来。</summary>

**吞吐相同**,都是 $100 \times (4 + 1) = 500$ MB/s,因为两者的顶层宽度都是 4。

**设备数**:按本模型的构造口径,每层取该因子块裸盘再加 1 个内层阵列,总设备数是各因子之和(最后一个因子则是那么多块裸盘)。`4 × 2 × 4` 用 $4 + 2 + 4 = 10$ 块,`4 × 4 × 2` 用 $4 + 4 + 2 = 10$ 块——本例恰好相同,但换成分解不同(如 `16 × 2` 用 18 块)就会变,需按构造式逐层累加。

关键差异在内层:`4 × 2 × 4` 的内层是"2 块裸盘 + 1 个 4 盘子阵列",`4 × 4 × 2` 的内层是"4 块裸盘 + 1 个 2 盘子阵列"。可它们在外层眼里都被折叠成 1 个成员,对外完全不可见——这正是"内层怎么摆都无所谓"的含义。

</details>

<details>
<summary><b>第 3 题</b>:把顶层宽度 a 逐步加大(a = 2, 4, 8, 16),吞吐增长是什么形状?为什么把 <code>8 × 4</code> 改成 <code>8 × 2 × 2</code>(加大内层复杂度)完全不改变吞吐?</summary>

吞吐 $= 100 \times (a + 1)$,是关于 `a` 的**一次函数**:`a` 每加 1,吞吐加 100 MB/s。代入:$a = 2, 4, 8, 16 \Rightarrow 300, 500, 900, 1700$ MB/s,与实测表逐点吻合。

加大内层复杂度不改变吞吐,因为内层整体只占外层成员列表里的 **1 个名额**,而且它不是短板(≥ 200 > 100)。短板永远是裸盘,成员总数只由 $a + 1$ 决定。**嵌套的层数是一个"看不见"的参数**——它改变结构,不改变吞吐。

</details>

<details>
<summary><b>第 4 题</b>:32 块 100 MB/s 的盘,若要求能容忍任意 1 块盘失效且有效容量尽量大,你会考虑"RAID 0 嵌套"吗?RAID 5 的顺序读吞吐是多少?</summary>

**不考虑。** RAID 0 的任何嵌套都不提供冗余:内层和外层一荣俱荣、一损俱损,坏 1 块盘就是整个阵列失效。

32 块盘的 RAID 5 顺序读 $= \min \times (N - 1) = 100 \times 31 = 3100$ MB/s(见 `cost_model.py` 的 `RAID_5.get_sequential_read_performance`),容量利用率 $31/32 \approx 97\%$,同时拿到单盘容错。

对比一下:嵌套 RAID 0 用多少设备都换不来"坏一块还能读"这个属性——它是**属性**上的差距,不是数值上的差距。

</details>

<details>
<summary><b>第 5 题</b>:单盘 MTTF 40000 小时,32 块盘的 RAID 0 阵列 MTTF 是多少小时?约合多少天?这提示 RAID 0 适合存什么数据?</summary>

无冗余阵列里任何一块坏则全阵列失效,所以

$$\text{MTTF}(\text{阵列}) = \frac{\text{MTTF}(\text{单盘})}{n} = \frac{40000}{32} = 1250\ \text{小时} \approx 52\ \text{天}$$

也就是说 32 盘的 RAID 0 平均一个半月就会整阵失效一次。(Berkeley 技术报告 CSD-88-479 给出的经典算例是:单盘 MTTF 40000 小时、49 块盘的阵列,MTTF ≈ 816 小时 ≈ 34 天。)

所以 RAID 0 只适合**可重建的临时数据**:视频剪辑的缓存盘、可重新下载的中间结果、可重跑的计算产物。原始数据必须另有副本。

</details>

<details>
<summary><b>第 6 题</b>:RAID 0 的随机读 IOPS 是"成员求和",顺序读吞吐却是"min × 个数"。为什么随机读不受最慢盘的木桶效应限制?</summary>

因为两种负载的**协作方式**不同:

• **随机读**:各次请求互相独立,可以分散到每块盘上并发执行,慢盘只是自己慢,不挡着别人 → 总 IOPS 是各盘 IOPS **相加**(并联)。

• **顺序读一个大文件**:所有成员必须在同一条逻辑流上协同步进,任何一个成员慢了,整条流都要等它 → 取 `min` 再乘成员数(串联)。

一句话:**并联求和,串联取 min。** 判断用哪个公式,看的是请求之间是否需要在同一条流上同步。

</details>

<details>
<summary><b>第 7 题</b>:某厂商宣传"我们把 8 块盘做成 2 × 4 的嵌套 RAID 0,性能更强"。用本实验的公式与数据写一段反驳。</summary>

反驳要点:

1. 按成本模型,`2 × 4` 的顶层宽度是 2,吞吐 $= 100 \times (2 + 1) = 300$ MB/s。
2. 同样 8 块盘的扁平 RAID 0 是 $100 \times 8 = 800$ MB/s,是它的 **2.67 倍**。
3. 嵌套多出来的层数不增加任何并行度,反而把内层 4 块盘折叠成 1 个成员——参与并行的成员只有 3 个。
4. 要求对方给出实测顺序读数字:如果真能跑到 800 MB/s,说明它的实现根本不是本模型描述的这种嵌套结构(可能底层就是扁平条带,只是管理界面上画成两层)。

**可以验证、可复现的数字,比"更有设计感"的结构图更有说服力。**

</details>

<details>
<summary><b>第 8 题</b>:如果 32 块盘里有 1 块只有 50 MB/s,扁平 RAID 0 的吞吐变成多少?这说明了采购与运维上的什么问题?</summary>

$$\min(100, \ldots, 100, 50) = 50 \Rightarrow \text{吞吐} = 50 \times 32 = 1600\ \text{MB/s}$$

恰好腰斩——一块慢盘把 31 块好盘一起拖下水。

两点含义:

• **采购**:阵列吞吐被最慢的盘封顶,应尽量买同型号、同批次、同固件版本的盘。

• **运维**:一块性能退化的"亚健康"盘(坏道重试增多、固件异常降速)会把整个阵列拖慢,发现后应尽快替换重建,而不是继续混用。

把这条和第 5 题放在一起看,就是 RAID 0 的完整画像:**快,但脆,且不耐受异构。**

</details>