> For the complete documentation index, see [llms.txt](https://freebsd-journal-cn.bsdcn.org/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://freebsd-journal-cn.bsdcn.org/20230102-gou-jian-freebsd-web-fu-wu-qi/zfs.md).

# 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 许可证。其 SQL 之前的前身 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 使用生理日志（physiological logging）来保证崩溃安全，这意味着写入到单个数据库页面时必须在断电时保持原子性，否则它可能无法在崩溃后恢复。除非你能保证存储栈具有这种属性，否则 PostgreSQL 必须做一些额外的工作来保护数据。

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

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

生理日志是数据库行业中广泛使用的技术，不同的关系型数据库管理系统（RDBMS）为解决撕裂页面问题提出了不同的解决方案。由于开源系统通常在各种低端系统上开发和使用，这些系统往往没有防止断电的硬件保护，因此故障很常见，必须开发软件解决方案。PostgreSQL 当前的解决方案是切换到页面级物理日志（physical-only logging）或全页面写入，即在每个检查点之后，对每个数据页面的首次修改，会将整个数据页面转储到日志中。检查点是周期性的后台活动，理想情况下，它对前台事务性能的影响应最小。然而，由于“首次接触”规则，一旦检查点开始，写密集型工作负载可能会突然生成更多日志数据，因为小的更新突然需要记录多个 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
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## 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, and the optional `goal` query parameter:

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

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

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.
