ZFS 的原子 I/O 与 PostgreSQL

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 数据集里,这些数据集可以具有不同的记录大小、压缩方式或物理介质。表也可以进行分区,例如将旧数据存储在一个表空间中,而当前的活动数据存储在另一个表空间中。

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

ALTER TABLE t
SET TABLESPACE compressed_tablespace;

与小 ZFS 记录大小相关的问题是碎片化。频繁更新的表可能会导致块散布到各个地方,我们希望它们能够在物理上聚集,以便获得良好的顺序读性能。简单的方法是要求 PostgreSQL 重写包含表和索引的文件,从而在 ZFS 层级上进行碎片整理。这可以通过执行 VACUUM FULL table_nameCLUSTER 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_basebackuppg_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)(译者注:截止 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)(译者注:已合并),以及新的 FreeBSD 系统接口,PostgreSQL 希望能够利用这些接口,快速克隆数据库和数据库对象,且克隆粒度比整个数据集更细。


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

最后更新于

这有帮助吗?