坑边闲话:存储系统随时面临扩容的考验。在不重建 RAID 的情况下,使用 ZFS 自带的扩容方案也是可行的。然而 RAID-Z 磁盘组变多之后,占用量不均衡的现象很令人烦恼。此时购买一个全新的阵列,将原有阵列的数据挪过去就变得可行。

1. ZFS 添加阵列的弊端·

在传统认知里,RAID 的重建是很重要的操作,比如我们可以往 RAID-5 里添加硬盘,此时会产生一次重建:RAID 卡会按照一定的原则,将原阵列中的一部分数据和校验值分摊到新的硬盘上,使得新阵列的所有硬盘具有相同的容量占用。在机械硬盘时代,这个过程耗时非常久。尽管 SSD 可以让重建工作变得更简单,但这依旧是个麻烦且会带来服务降级的操作,搞不好还会造成服务质量的严重下降,甚至造成服务的短暂中断。

作为对比,ZFS 支持实时扩容。ZFS 的操作很简单,当目前的存储池占用达到 80% 左右的时候,系统提示告警。当然,80% 只是一个常见值,用户可以自行设定阀值。在收到告警之后,用户需要添加一个一模一样的 RAID-Z 到现有的阵列组里,新老 RAID-Z 以 stripe 组的形式提供单一存储。然而这样的问题很明显:新旧阵列的数据占比不均衡,即老 RAID-Z 已经接近满占用,但是新 RAID-Z 几乎是零占用。此时往存储池里写数据,在极端情况下只有新 RAID-Z 在接收,老 RAID-Z 一点写入量都没有。

可以简单计算一下系统的文件占用:

  • 第一次扩容
    • 扩容后的占用布局:(0.8, 0)
    • 扩容后的占用量为 $\frac{0.8+0}{2}=0.4$
    • 可写入总计 0.8 个阵列可用容量。
    • 在此达到全池占用 80% 后,占用布局为 (1.0, 0.6),写入到 0.4 个阵列可用容量时出现降速。
  • 第二次扩容
    • 扩容后的占用布局:(1.0, 0.6, 0)
    • 扩容后的占用量为 $\frac{1.0+0.6+0}{3} = 0.53$
    • 可写入总计 0.8 个阵列可用容量。
    • 再次达到全池占用 80% 后,占用布局为 (1.0, 1.0, 0.4),到达 80% 占用前,全程只有两个阵列的写入速度,且不降速。
  • 第三次扩容
    • 扩容后的占用布局:(1.0, 1.0, 0.4, 0)
    • 扩容后的占用量为 $\frac{1.0+1.0+0.4+0}{4}=0.6$
    • 可写入总计 0.8 个阵列可用容量。
    • 再次达到全池占用 80% 后,占用布局为 (1.0, 1.0, 0.8, 0.4),到达全池 80% 占用前,全程只有两个阵列的写入速度,且不降速。
  • 第四次扩容
    • 扩容后的占用布局:(1.0, 1.0, 0.8, 0.4, 0)
    • 扩容后的占用量为 $\frac{1.0+1.0+0.8+0.4+0}{5}=0.64$
    • 可写入总计 0.8 个阵列可用容量。
    • 再次达到全池占用 80% 后,占用布局为 (1.0, 1.0, 1.0, 0.7, 0.3),到达全池 80% 占用前,全程先有三个阵列的写入速度,写入 0.6 个阵列可用容量后,降速为双阵列写速。

由此可见,每次扩容后的新可用空间永远不变,但是 pool 的占用比一直在增高,只是后续随着总空间的容量越来越大,分母越来越大,写入的占用比例增速不再明显。至于写入速度则比较难以计算,但总体也是二到三个阵列 stripe 的速率。

下面简单展示每次扩容一个阵列达到阀值时的阵列占用率。

1
2
3
4
5
6
#1 ==> 0.8
#2 ==> 1.0 0.6
#3 ==> 1.0 1.0 0.4
#4 ==> 1.0 1.0 0.8 0.4
#5 ==> 1.0 1.0 1.0 0.7 0.3
#6 ==> 1.0 1.0 1.0 0.97 0.57 0.27

为了进一步澄清这个事实,笔者提供了一段 Python 代码,可以计算出扩容后的布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 单阵列的最高占用比例
alloc_ratio = 0.8

# 扩容次数
iter_time = 30

# 初始化 output 列表
output = [[0.0] * (i + 1) for i in range(iter_time)]

def allocate_capacity(i, start_idx, end_idx, value):
for k in range(start_idx, end_idx):
output[i][k] += value

for i in range(iter_time):
if i == 0:
output[i] = [0.8]
else:
output[i] = output[i - 1] + [0.0]
left = alloc_ratio

for idx, value in enumerate(output[i]):
if value == 1.0:
continue

delta = 1 - value
if delta * (len(output[i]) - idx) >= left:
allocate_capacity(i, idx, len(output[i]), left / (len(output[i]) - idx))
break
else:
allocate_capacity(i, idx, len(output[i]), delta)
left -= delta * (len(output[i]) - idx)

# 输出结果
for row in output:
print(" ".join(f"{value:.2f}" for value in row))

将结果稍微可视化处理,可以得到下图:

图 1. 进行 30 次扩容后的磁盘稍显凌乱,后面新添加的硬盘剩余容量越来越分散。既无法保证数据块的文件级局部性,也无法实现最大的写入效能。

注意

尽管我们可以用公式或者编程的方式计算一下任意次扩容后的满占用布局,但这是没有必要的,因为这种基于 stripe 的扩容方式在阵列数量达到一定值时有风险。

根据以上计算,我们得出两个重要结论:

  • 通过逐个添加同规模同容量的阵列到存储池以实现扩容,可以保证每次扩容后新的可用容量为添加的新阵列的可用容量。
  • 通过上述扩容方式,文件在不重写的情况下,将不会均匀分布在所有磁盘中。这带来的好处是损毁某些阵列,不会造成全部数据的丢失,但弊端是读取性能比较一般。因此 ZFS 建议单阵列的读取性能应该设置为服务的极限读取性能,如果单阵列的性能无法达到服务的要求,则应该在创建阵列的时候考虑增大阵列的可用磁盘数量,即增加条带宽度。

2. 如何解决 ZFS 扩容带来的问题?·

一般有几种常见策略可以解决上述问题:

  1. 阵列的手动平衡。通过检测文件的时间戳,判定出哪些文件在哪个阵列里。随后逐个重新写入,即可完成平衡。要做到精准的话,需要每次添加阵列之前都做一次文件分布记录,比如使用 tree 命令将目录导出。
    • 缺点:繁琐无比,而且需要耗费大量的精力。
    • 优点:可以保证平衡占用。
  2. 寻找一个空余的大空间,将所有数据挪过去,再挪回来。
    • 缺点:成本高,一般人很难找到一个合适的大空间。几百 TiB 的数据就需要几百 TiB 的临时空间,这几乎不可能做到。此外,还有两次完整传输带来的时间成本。
    • 优点:可以做到严格的再平衡。

今天我们的主要思路是方法 2,即重新写入。为了实现重新写入,一般有几个常用的方案:

  1. 使用 rsync 命令按文件进行搬迁。
  2. 使用 zfs sendzfs recv 管道传输整个文件系统。

从性能上看,方案 2 更加合适,它可以实现 full stream,最大化地使用到磁盘的极限性能。rysnc 的速率也不慢,但是很难摸清楚它的块读取策略。

2.1 最大化 rsync 的性能·

rsync 用来做文件迁移是非常好用的。

2.2 理解 zfs send / zfs recv 的问题·

我们设想有如下存储场景:

  • 单个 RAID-Z 的条带宽度为 4,即 4 盘 RAID-Z;
  • RAID-Z 磁盘组的数量为 3,即该存储池经历过两次扩容。
  • 现在每个磁盘的容量是 18TB,需要将该存储池的数据迁移到一个所有盘均为 22TB 的新存储池中。

假如 zfs send 的时候,先从一号 RAID-Z 里读取三个块,然后平均写入到新存储池的三个 RAID-Z 中,则效果非常理想,因为老 RAID-Z 里逻辑上相邻的的块来自于老的文件,写入时将这些块平均分散到所有的 RAID-Z 里,无疑提升了新存储池的并行度,此后读取这个文件时,将不再是只从一个 RAID-Z 里读取,而是从三个 RAID-Z 里同时读取。如此完成数据迁移,将带来平均的占用以及更高的单文件读取性能。

然而,如果 zfs send 并行地从老存储池的三个 RAID-Z 里读取块,然后分别写入到三个新的 RAID-Z 里,则到了后期,只剩老 RAID-Z(原先占用量最高的组)没有搬迁完毕。此时剩余部分将会平均分配到三个新 RAID-Z 里。这样的结果依旧不够明确,因为我们不知道搬迁的前期,新旧 RAID-Z 的读取和写入是否有一一对应的关系,如果答案是肯定的,那么将保持原有的布局,不会提升性能;反之,ZFS 可能会采取一种调度策略,尽量保证从一个老 RAID-Z 里读出的块序列,可以随机分配到新的三个 RAID-Z 里。那么答案是什么呢?需要做实验才清楚。

总结·

这篇博客讨论了创建 RAID-Z 时需要注意的问题,同时对比了迁移存储的一些方法。