# 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 进行开发。


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://freebsd-journal-cn.bsdcn.org/20230102-gou-jian-freebsd-web-fu-wu-qi/zfs.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
