# ZFS 的原子 I/O 与 PostgreSQL

* 原文链接：[ZFS’s Atomic I/O and PostgreSQL](https://freebsdfoundation.org/wp-content/uploads/2023/02/munro_ZFS.pdf)
* 作者：**THOMAS MUNRO**

PostgreSQL 是一种关系型数据库管理系统，符合 SQL 标准，使用类 BSD 许可证。其前身 POSTGRES 始于 1980 年代中期的伯克利大学。它在 FreeBSD 上很受欢迎，通常部署在 ZFS 存储上。许多有关 PostgreSQL 在 ZFS 上的文章都建议调整 ZFS 的 `recordsize` 设置和 PostgreSQL 的 `full_page_writes` 设置。然而，设置后者对性能和崩溃安全性的真正影响往往没有得到充分解释，可能是因为在大多数流行的文件系统上调整这个设置通常不安全。在本文中，我将简要总结这个神秘机制背后的逻辑和权衡——但在此之前，我们先简单讨论一下块大小。

## 块

几乎所有 PostgreSQL 的磁盘 I/O 都是基于 8KB 的块（或页面）对齐的。虽然可以重新编译 PostgreSQL，使用不同的大小，但这种做法不常见。这个大小最初可能是为了匹配 UFS 的历史默认块大小（但需要注意的是，FreeBSD 的 UFS 现在默认为 32KB）。ZFS 使用术语“记录大小（recordsize）”，其默认为 128KB。与其他文件系统不同，ZFS 可在任何时候轻松更改记录大小，并可以为每个数据集单独配置。

若数据将被随机访问，那么理论上记录大小应与 PostgreSQL 的 8KB 块匹配。否则，随机 I/O 可能会受到两个因素的影响：

* **I/O 放大**：因为每次读取/写入 8KB 块时，还会传输额外的相邻数据。
* **读 - 写顺序**：当存储块当前不在操作系统缓存中，且必须写入 8KB 块时，相邻数据必须先被读取。

如果数据主要是顺序访问的，或者访问频率较低，特别是如果使用较大记录的 ZFS 压缩所带来的好处大于对 I/O 带宽和延迟的顾虑，那么调整记录大小可能是个好主意。

某些出处推荐使用 16KB、32KB 或 128KB 的记录大小，认为这是一个在不产生过多写放大和延迟的情况下，能够实现更好压缩的最佳选择。我的目的并不是做出这样的推荐——我怀疑并不存在一个标准答案——而是要解释发生了什么。有些应用程序对不同类型的数据有混合的需求。表空间可以用来将不同的表存储在不同的 ZFS 数据集里，这些数据集可以具有不同的记录大小、压缩方式或物理介质。表也可以进行分区，例如将旧数据存储在一个表空间中，而当前的活动数据存储在另一个表空间中。

```sql
CREATE TABLESPACE compressed_tablespace
LOCATION '/tank/pgdata/compressed_tablespace';

ALTER TABLE t
SET TABLESPACE compressed_tablespace;
```

与小 ZFS 记录大小相关的问题是碎片化。频繁更新的表可能会导致块散布到各个地方，我们希望它们能够在物理上聚集，以便获得良好的顺序读性能。简单的方法是要求 PostgreSQL 重写包含表和索引的文件，从而在 ZFS 层级上进行碎片整理。这可以通过执行 `VACUUM FULL table_name` 或 `CLUSTER table_name` 来实现，前提是你愿意在重写过程中锁定该表，使查询无法访问该表。重写表也可以使新的记录大小生效，如果它已在数据集级别进行了更改。

## 撕裂写入

PostgreSQL 的默认启用设置 `full_page_writes` ，而 ZFS 用户通常会将其关闭。关闭后，写密集型工作负载的性能变得更快且更一致。例如，在低端云虚拟机上的简单 `pgbench` 测试中，我通过关闭该设置实现了 32% 的每秒事务增加。那么，它到底做了什么呢？这需要一些出乎意料的背景说明。简而言之，PostgreSQL 使用物理日志记录来保证崩溃安全，这意味着写入到单个数据库页面时必须是原子性的，以防断电，否则它可能无法在崩溃后恢复。除非你能保证存储栈具有这种属性，否则 PostgreSQL 必须做一些额外的工作来保护数据。

断电时的原子性是指，如果在断电时某个物理写入正在进行，我们可以期望之后读取到该块的旧版本或新版本，但不是部分修改或撕裂的版本。这与并发读写的原子性不同（见下文）。物理日志记录（简称“物理到页面，逻辑在页面内”）是教科书中对日志策略的分类术语，意思是日志记录通过文件和块编号标识要修改的块，但随后描述如何在该页面内“逻辑”地修改它，而不是仅仅更新物理地址的位。崩溃后，恢复算法可以处理“旧”页面内容或“新”页面内容，应用任何日志记录的更改以使其恢复正常。如果遇到旧数据和新数据的非原子性混合，则无法重放逻辑更改，恢复失败！

一个表面上的问题是，如果启用了 `data_checksums`，PostgreSQL 的页面级校验和检查将无法读取该页面。如果禁用校验和，恢复过程会继续，但类似“在槽位 3 插入元组 (42, Fred)”这样的逻辑更改将无法可靠地重放。为了应用这个示例中的更改，我们需要理解页面上使用的预先存在的元数据表，但它可能已经被损坏。

物理日志记录是数据库行业中广泛使用的一种技术，不同的关系型数据库管理系统（RDBMS）为解决撕裂页面问题提出了不同的解决方案。由于开源系统通常在各种低端系统上开发和使用，这些系统往往没有防止断电的硬件保护，因此故障很常见，必须开发软件解决方案。PostgreSQL 当前的解决方案是切换到页面级物理日志记录或全页面写入，即在每个数据页面的首次修改后，将整个数据页面写入日志，这发生在每个检查点之后。检查点是一个周期性的后台活动，理想情况下，它对前台事务性能的影响应最小。然而，由于“首次接触”规则，待检查点开始，写密集型工作负载可能会突然生成更多的日志数据，因为小的更新突然需要记录多个 8KB 的页面。这个效应通常会逐渐减弱，因为对每个页面的后续修改会恢复为物理日志，直到下一个检查点，有时会导致 I/O 带宽和事务延迟呈锯齿形波动。

另一种流行的开源数据库有不同的解决方案，也涉及将所有数据写入两次，并在两次写入之间使用同步屏障，因为这两份副本不能被撕裂。而 ZFS 不需要任何这些！得益于其自身的写时复制（COW）设计，ZFS 提供了记录级的原子性。ZFS 记录无法看到旧内容和新内容的混合，因为它不会物理地覆盖它们，其 TXG 系统和 ZIL（ZFS 日志）使得写入具有事务性。因此，只要 `recordsize` 至少为 8KB，就可以安全地将 `full_page_writes=off` 设置为关闭。

需要注意的是，在某些情况下 ZFS 也会物理地将数据写入两次。常见的建议是考虑为包含主数据文件的数据集设置 `logbias=throughput`（但可能不适用于包含 PostgreSQL 日志目录 `pg_wal` 的数据集——这个话题在本文中没有探讨）。该选项尝试将块直接写入其最终位置，而不是首先在 ZIL 中记录它们。如果使用 ZFS 默认的 `logbias=latency` 设置并且 PostgreSQL 默认的 `full_page_writes=on`，那么实际上数据可能会被写入四次，因为 PostgreSQL 和 ZFS 都执行额外的工作来创建记录级原子性，而这两个设置最终都只保留一个副本。

不幸的是，在某些特殊场景下，`full_page_writes=on` 仍然是确保行为正确所必需的：在运行 `pg_basebackup` 和 `pg_rewind` 时。这些工具用于备份，或从另一台服务器创建或重新同步流式复制；在 `pg_basebackup` 的情况下，运行命令时会自动启用全页面写入，而在 `pg_rewind` 的情况下，如果没有手动启用该选项，命令将拒绝运行（这是当前版本中的一个令人烦恼的不一致性）。这些工具会制作数据文件的原始文件系统级复制，以及用于崩溃恢复的日志，以解决并发更改引起的一致性问题。在这里，我们遇到了 I/O 原子性的不同含义：读取可能被并发写入的文件。第一个问题是，Linux 和 Windows 上的文件系统（但不是 ZFS，或 FreeBSD 上的任何文件系统，因为使用了范围锁）在存在重叠并发写入时，可能会向读者展示一个随机的旧数据和新数据混合。这会导致数据不一致。此外，目前的 I/O 操作方式未能适当对齐，因此即使在 ZFS 上，撕裂的页面也可能被复制。为了防范这种情况，需要启用 `full_page_writes` 行为。这个问题最终应该在 PostgreSQL 中修复，通过以适当的对齐和锁定方式复制原始数据文件。需要注意的是，如果采取适当的预防措施（主要是确保快照能够原子性地捕获日志和所有数据文件），ZFS 快照可以替代 `pg_basebackup`，从而减少在克隆或备份繁忙系统时的影响。

## 恢复

我们已经看到 `full_page_writes=off` 如何提高写事务的性能，并且 ZFS 使其变得安全。不幸的是，对于复制和崩溃恢复，也可能会有负面的性能影响。这两项活动都涉及恢复，意味着它们需要重放日志。虽然全页面图像在写入时会导致性能下降，但在恢复时重放时它们却起到了优化作用。我们不需要执行一个可能会阻塞恢复串行处理循环的随机同步读取，因为页面的内容已经在我们精心排列的顺序日志中，并且在此之后它已被缓存。

PostgreSQL 15 包含了部分解决方案：它会提前查看日志，找到即将被读取的页面，并发出 `POSIX_FADV_WILLNEED` 提示，以生成可配置程度的 I/O 并发性（这是一种简易的异步 I/O）。截至本文撰写时，FreeBSD 会忽略这一提示，但 OpenZFS 的未来版本希望能将其连接到 FreeBSD 的 VFS（OpenZFS 拉取请求 [#13958](https://github.com/openzfs/zfs/pull/13958)）（**译者注：截止 2025/1/31 仍未合并**）。最终，这应该会被一个真正的异步 I/O 子系统所替代，该子系统正在开发中，并计划用于 PostgreSQL 的未来版本。

一组使用 PostgreSQL 在 illumos 操作系统上的 ZFS 上进行大规模部署的研究人员，研究了 `full_page_writes=off` 对恢复 I/O 停顿的影响。他们开发了一种名为 `pg_prefaulter` 的工具作为解决方法。研究人员发现，由于可预测的 I/O 停顿，他们的流式复制无法跟上主服务器的速度。由于大多数大规模使用 PostgreSQL 的用户甚至无法设置 `full_page_writes=off`，他们可能处于独特的位置来观察到这种效应。如果遇到此问题，`pg_prefaulter` 可能是一个解决方案，直到内置预取功能可用为止。

## 展望未来

块大小对齐可能在未来的 PostgreSQL 版本中成为一个更重要的话题，这些版本可能会包括提议的直接 I/O 支持，目前该支持仅以原型形式存在。这与 OpenZFS 对直接 I/O 支持的开发恰好吻合（拉取请求 #10018），而且可能需要块大小一致性才能有效工作（当前的原型会回退到 ARC；其他一些文件系统则会拒绝不对齐的直接 I/O）。另一个正在开发的 OpenZFS 特性是块克隆（拉取请求 [#13392](https://github.com/openzfs/zfs/pull/13392)）（**译者注：已合并**），以及新的 FreeBSD 系统接口，PostgreSQL 希望能够利用这些接口，快速克隆数据库和数据库对象，且克隆粒度比整个数据集更细。

***

**THOMAS MUNRO** 是一位开源数据库开发者，现任微软 Azure 工作，通常使用 FreeBSD 进行开发。
