OpenZFS 中的 Zstandard 压缩

ZFS 是一款高度先进的文件系统,具有集成的卷管理器,于 2007 年加入 FreeBSD,并成为 FreeBSD 的重要组成部分。ZFS 内置透明且可调节的压缩功能,能在存储数据之前无缝地对其进行压缩,并在返回给应用程序使用之前进行解压。由于压缩是由 ZFS 管理的,应用程序无需关心压缩过程。文件系统压缩不仅节省空间,而且在许多情况下,甚至可以通过减少需要存储或检索的数据总量来降低读写延迟。

最初,ZFS 支持的压缩算法较少:LZJB(一种由 Jeff Bonwick(ZFS 的共同创建者之一)创建的改进型 Lempel–Ziv 变体,速度适中,但压缩比率较低)、ZLE(零长度编码,仅压缩零的连续序列)以及九个级别的 gzip(熟悉的、较慢但压缩比适中的算法)。用户因此可以选择不压缩、快速但压缩比适中的方法,或者慢速但压缩比更高的方法。不出所料,这些用户常常会竭尽全力将应压缩的数据与已经压缩的数据分开,以避免 ZFS 尝试重新压缩已压缩的数据,避免浪费时间却没有效果。由于各种历史原因,压缩在新创建的 ZFS 存储池中仍默认“关闭”。

在 2013 年,ZFS 新增了一压缩算法 LZ4,它比 LZJB 提供了更高的速度和更好的压缩比。2015 年,当用户启用压缩而未指定算法时,LZ4 取代了 LZJB 成为默认算法。通过这一新的高速压缩器,加上一个名为“早期中止”的现有功能,全局启用压缩变得可行,因为无法压缩的数据会被快速检测并跳过,从而避免影响性能。早期中止功能通过限制压缩算法输出缓冲区的大小,使其比输入缓冲区小八分之一。如果压缩算法无法将输出数据放入更小的缓冲区,它将失败并返回错误。因此,如果压缩算法无法提供足够的收益,它可以预先中止,在这种情况下,数据将以未压缩的方式存储,以避免解压一个几乎没有压缩的块的开销。事实上,在所有数据上启用 LZ4 压缩的影响非常小,以至于这种“设置并忘记”的配置非常常见,甚至在 FreeNAS 中已经是多年的默认设置。

项目开始

该项目始于 2016 年秋季,当时作者因与 EuroBSDCon 的日程冲突错过了 OpenZFS 开发者峰会。项目的目标是将最近宣布的一个新压缩算法集成到 OpenZFS 中。Zstandard(简称 Zstd)由 LZ4 的原始作者 Yann Collet 创建。新算法的目的是提供类似于 gzip 的压缩比(并且具有更大的灵活性,提供比 gzip 的九个级别更多的二十多个级别!),但速度与 LZ4 相当。

项目开始时,我们立刻遇到了堆栈大小的问题,因为 Zstd 是作为用户空间程序编写的,并且倾向于使用较大的堆栈变量。这对将 Zstd 集成到内核中造成了问题,因为内核的堆栈限制为 16 KB,并且必须支持文件系统集成前后的其他操作系统层。我们通过暂时增加开发内核中的堆栈大小来规避这个问题,经过几周的工作,成功实现了带有 Zstd 压缩的第一个版本的 ZFS。然后,我们着手修改 Zstd,改为使用内核 malloc 框架返回的堆内存来减少堆栈使用。这很困难,因为函数中通常有多个退出路径,需要释放已分配的内存。经过有限的成功后,项目被暂时搁置了,知道重新开始时,它可能会更糟,因为所有本地补丁需要重新基于更新版本的 Zstd。

幸运的是,当我们重新开始项目时,Zstd 1.3 版本已经发布,大大减少了堆栈使用,同时还允许调用者管理自己的内存分配。凭借这些令人欣喜的改进,Zstd 不再需要大量修改来进行内核集成。最终,只需要一些表面上的改动,Zstd 就可以在不修改的情况下使用。

到 2017 年秋季和下一次 OpenZFS 开发者峰会时,我们已经有了一个工作原型,可以在会议上进行演示。峰会提供了一个宝贵的机会,与专家和经验丰富的开发者讨论剩余的挑战。其中一个挑战是如何让用户提供压缩类型(Zstd)和级别(1-19),以避免用户稍后更改压缩类型为 gzip 时导致致命混乱,比如“19”级别可能无效。这个问题在会议中提到,之后 Robert Mustacchi 提出了一个非常优雅的解决方案:仅向用户公开不同级别的 Zstd 压缩类型,但在 ZFS 内部将它们作为单独的值存储。尽管这个对话只花了他不到两分钟的时间,但它节省了我们几周的工作时间。在休息期间,我们还与一些人讨论了他们可能有的想法来解决其他问题,以及他们可能用到 Zstd 的用途。

我们在 BSDCan 2018 上展示了我们的进展,受到了相当大的关注。尽管在提交之前还有很多工作要做,但原型展示了 Zstd 对 ZFS 和 FreeBSD 带来的巨大好处。

超越原型

在实现了初步功能之后,接下来需要解决更大的集成问题。这一切将如何集成到 ZFS 中?在 ZFS 的磁盘格式中,压缩类型存储在每个块指针的 8 位字段中。最高位已经被借用来表示嵌入式块指针,用于块压缩非常好(112 字节)的情况,这样它可以直接存储在块指针中,代替磁盘地址和校验和,因此不需要在磁盘上分配自己的空间。这意味着最多只能支持 127 种压缩算法,未来可能需要借用另一个位来扩展。已经使用了若干槽位:值 0 实际上并不意味着没有压缩,而是表示压缩继承自父对象。对于开启、关闭、LZJB、空块(完全由零组成的块)、gzip 1 至 9、ZLE 和 LZ4,前 15 个值已被使用。最终,这个 Zstd 补丁引入了 41 个额外的压缩级别(1-19,“快速”1-9,“快速”10-100,以 10 为增量,“fast-500”和“fast-1000”),这可能会导致磁盘格式中的压缩字段所剩可用的选项非常少。经过检查发现,块指针中的压缩字段只需要将压缩设置映射到正确的解压函数,而对于所有 Zstd 级别,解压函数都是相同的。那时看来,似乎没有必要存储压缩块时使用的 Zstd 具体级别。

然而,经过进一步工作后发现,有时我们确实需要知道一个块使用了哪个级别进行压缩。具体来说,在压缩 ARC 功能被禁用的(大概不常见的)情况下,L2ARC 会因校验和错误而始终失败。L2ARC 是一个二级缓存,用于复制那些可能被主 ARC 驱逐的数据。根据设计,L2ARC 避免了保存每个块的校验和副本的开销,而是引用原始块指针中的校验和。这意味着每个块必须在写入 L2ARC 之前以完全相同的设置重新压缩。当从 L2ARC 读取时,块会被校验和,并与磁盘上的块以及原始校验和进行比较。对于以前的压缩算法,没有需要额外考虑的参数,但使用 Zstd 时,使用默认级别重新压缩最有可能生成不同的输出,从而导致校验和不匹配。

为了解决这个问题,我们扩展了在 LZ4 中使用的现有概念,其中磁盘上压缩块的前 4 字节用于存储块的压缩长度。由于磁盘上的分配总是整个扇区,因此这能避免 LZ4 读取并尝试解压缩压缩数据和扇区末尾之间的空闲空间中的随机数据。Zstd 压缩块使用了更大的头部,除了大小外,还存储了 Zstd 的版本和压缩级别。我们决定存储 Zstd 的版本,以便在将来更容易升级 Zstd 的版本,使我们能够包括多个版本的 Zstd 压缩函数,从而在需要时始终能够重新创建一个块。这对于 “NOPwrite” 功能最为实用:当一个块需要被覆盖时,ZFS 可以比较新块的校验和,如果它与旧块相同,则无需重新写入数据。这种操作在 Oracle 数据库中非常常见,某些类型的备份软件也可能发生。如果原始块使用旧版本的 Zstd 压缩,但现在使用新版本重新压缩,这可能会导致此优化的丧失。如果 ZFS 能够检测到这种情况,并尝试使用旧版本的 Zstd 进行压缩,它就能够避免 Oracle 数据库快照意外增长的问题。

Zstd 的优势

Zstandard 提供了多种压缩级别,允许存储管理员在性能和压缩比之间进行相对精细的控制。Zstd 的主要优势之一是解压速度与压缩级别无关。对于那些写入一次但多次读取的数据,Zstd 允许使用最高的压缩级别而不会产生性能损失。在写入大量数据时,ZFS 会单独压缩每个记录,因此能够充分利用现代系统上多个处理器核心的优势。即使数据更新频繁,启用压缩往往也能带来性能提升。最大的优势之一来自于压缩 ARC 功能——这本身是 ZFS 的一项最新改进。ZFS 的自适应替换缓存(ARC)现在会将数据的压缩版本缓存到 RAM 中,并在每次请求时进行解压。这使得相同数量的缓存能够存储更多(通常是更多)的逻辑数据和元数据,从而增加缓存命中率,并提高访问频繁或最近访问的数据的性能。如果从 LZ4 升级到 Zstd 增加了磁盘上的压缩比,这些增益会直接放大压缩 ARC 中每个字节的效能。

在下图中,我们比较了在 ZFS 上使用不同压缩算法和级别存储大型未压缩的 FreeBSD 源代码 tar 包的情况。测试系统使用了四个条带化的 SATA SSD,未压缩时的读取速度受限于底层存储设备的可用吞吐量,约为 1.5 GB/s。然而,随着数据的压缩比提高,读取速度通常也会增加,因为限制因素仍然是压缩数据从底层存储读取的速度。与 gzip 相比,Zstd 的解压速度要快得多,并且由于它通常不需要更多的 CPU 时间进行解压,因此这些增益几乎没有浪费。

解压速度 vs 压缩比(128k 记录,固态硬盘)

有趣的是,使用更大的 ZFS “记录大小”可以实现更高的压缩比。原因在于 ZFS 独立地压缩每个记录,因此记录大小对压缩增益有很大影响;记录越大,压缩字典就越优化。gzip9 的压缩比从 4.3 倍提升至 4.7 倍,性能仅增加了 8%,而 Zstd-9 的压缩比从 4.9 倍提升至 5.5 倍,性能提高了 28%,达到了硬件最大吞吐量的四倍以上。

解压速度与压缩比(1024k 记录,固态硬盘)

需要注意的一点是,如果压缩带来的节省不足以节省至少一个磁盘扇区,ZFS 将不会存储压缩后的块。例如,在典型的数据库文件系统中,如果记录大小为 16 KB,且压缩比为 1.32x,最终块的大小为 12.1 KB,那么仍然需要存储四个 4 KB 的扇区,因此直接存储未压缩的数据反而会更有效。然而,如果压缩比为 1.34x,所需的存储空间为 11.9 KB,这可以通过三个 4 KB 的扇区来实现,因此 ZFS 会使用压缩版本。数据集的 compressionratio 属性返回所有记录的平均值。

接下来会怎样?

Zstd 在 ZFS 中的集成才刚刚开始,后续无疑会有许多改进。我们已经有了一些相关的想法。例如,我们预计使用高级的 Zstd API 来提供更多关于输入数据最大大小的提示,这可以减少内存使用并提高 Zstd 利用“early abort”(我们在文章早期提到的特性)的能力。还有许多机会可以优化 ZFS 设置和拆卸 Zstd 压缩上下文的方式,并通过 Zstd 重置 API 增加这些上下文的复用,这有望显著提高小块数据的压缩性能。

除了继续优化 Zstd 以适应 ZFS,下一步显而易见的进化是去除用户需要决定哪个 Zstd 等级最合适的需求(毕竟有 40 种选项可供选择)。相反,我们设想用户只需设置 compress=zstd-auto,ZFS 会以某种合理的方式动态调整。当使用 Zstd 在命令行上压缩传输的网络数据流时,用户可以指定 —adapt=min=3,max=10,Zstd 会根据网络缓冲区的清空速度动态调整压缩级别。这可以确保压缩不会成为瓶颈:如果网络带宽充足,压缩级别会降低;反之,如果网络跟不上当前的压缩级别,压缩时的等待时间会增加。

在 ZFS 中,这可能会基于“脏数据”(等待压缩并写入磁盘的数据)的量来建模。当新数据写入 ZFS 时,会使用最高压缩级别进行压缩。如果写入数据的速度过快,ZFS 无法跟上请求的压缩级别,导致脏数据量不断增加,则压缩级别会逐渐降低,理想情况下会稳定在不会限制吞吐量的最高级别。像往常一样,ZFS 的理念是合理利用系统资源,同时最大限度地减少用户调整和微调的需要。

结论

Zstd 支持已经作为 OpenZFS 2.0 的一部分发布,并且可以通过包 sysutils/openzfs 作为 FreeBSD 12.2 中 ZFS 的替代品使用,已集成到 FreeBSD 13.0 开发分支中。

我要特别感谢 FreeBSD 基金会的所有工作人员,感谢他们提供的资助,使得这个长期项目得以顺利完成并及时合并到 OpenZFS 2.0 中。同时感谢 Sebastian Gottschall、Kjeld Schouten-Lebbing 和 Michael Niewöhner,他们完成了 Linux 移植工作,包括额外的 kmem 兼容性代码,并创建了最终补丁中大部分的测试。我还要感谢为将 FreeBSD 支持集成到 OpenZFS 上游仓库中的团队,以及 OpenZFS 项目中的所有人。最后,我还要感谢所有测试和审阅多年来的多个版本补丁的人,直到最终提交。


ALLAN JUDE 是 Klara Inc. 的工程副总裁,这是一家全球领先的 FreeBSD 专业服务和支持公司。他还是每周 BSD 播客 BSDNow.tv 的主持人,并曾于 2016 至 2020 年期间担任 FreeBSD 核心团队成员。他与 Michael W. Lucas 共同著有《FreeBSD Mastery: ZFS》和《FreeBSD Mastery: Advanced ZFS》。

最后更新于

这有帮助吗?