期待 Postgres 18:使用异步 I/O 加速磁盘读取

![](data:image/svg+xml;charset=utf-8,%3Csvg height='180' width='180' xmlns='http://www.w3.org/2000/svg' version='1.1'%3E%3C/svg%3E)Lukas FittlBy Lukas Fittl 2025年5月7日

随着本周 Postgres 18 Beta 1 的发布,一项历时多年的努力以及 Postgres 中的重大架构转变正在成形:异步 I/O (AIO)。这些功能仍在积极开发中,但它们代表了 Postgres 处理 I/O 方式的根本性变化,为性能带来了显著提升的潜力,尤其是在延迟通常是瓶颈的云环境中。

虽然在最终版本发布之前的 Beta 阶段,某些功能可能仍会进行调整或删除,但现在是测试和验证 Postgres 18 在实践中表现的最佳时机。在 Postgres 18 中,AIO 仅限于读取操作;写入仍然是同步的,尽管未来版本可能会扩展支持。

在这篇文章中,我们将解释什么是异步 I/O,它在 Postgres 18 中如何工作,以及它对性能优化意味着什么。

为什么异步 I/O 很重要

Postgres 历来在同步 I/O 模型下运行,这意味着每个读取请求都是一个阻塞系统调用。数据库必须暂停并等待操作系统返回数据才能继续。这种设计引入了不必要的 I/O 等待,尤其是在存储通常是网络连接的云环境中(例如,Amazon EBS),并且 I/O 可能具有超过 1 毫秒的延迟。

在一个简化的模型中,我们可以这样说明差异,忽略 Linux 内核可能进行的任何预取/批处理:

Diagram showing synchronous vs asynchronous I/O model with concurrent requests

你可以把同步 I/O 想象成一位想象中的图书管理员,一次只检索一本书,返回后再获取下一本。随着逻辑操作的物理读取数量增加,这种低效率会加剧。

异步 I/O 通过允许程序并发发出多个读取请求来消除该瓶颈,而无需等待先前的读取返回。在异步程序流程中,I/O 请求被安排读取到内存位置,程序等待这些读取完成,而不是单独发出每个读取。

Postgres 17 的读取流如何铺平道路

在 Postgres 中实现异步 I/O 的工作已经进行了很多年。 Postgres 17 引入了一个重要的内部抽象,随着读取流 API 的引入。这些内部更改标准化了跨不同子系统发出读取操作的方式,并简化了使用 posix_fadvise() 来请求操作系统提前预取数据的方式。

但是,这种咨询机制仅提示内核将数据加载到 OS 页面缓存中,而不是加载到 Postgres 自己的共享缓冲区中。 Postgres 仍然必须为每个读取发出系统调用,并且 OS 预读行为并不总是保持一致。

即将发布的 Postgres 18 版本消除了这种间接性。通过真正的异步读取,数据由数据库本身直接提取到共享缓冲区中,从而绕过了对内核级别启发式的依赖,并实现了更可预测、更高吞吐量的 I/O 行为。

Postgres 18 中的新 io_method 设置

为了控制用于异步 I/O 的机制,Postgres 18 引入了一个新的配置参数:io_method。此设置确定了底层如何分派读取操作,以及它们是以同步方式处理,还是卸载到 I/O 工作进程,或者通过 io_uring 直接提交到内核。

io_method 设置必须在 postgresql.conf 中设置,并且必须重启后才能更改。它控制 Postgres 将使用的 I/O 实现,并且对于在 Postgres 18 中调整 I/O 性能至关重要。io_method 有三个可能的设置,当前的默认设置(截至 Beta 1)是 worker

io_method = sync

Postgres 18 中的 sync 设置镜像了 Postgres 17 中实现的同步行为。读取仍然是同步的和阻塞的,使用 posix_fadvise() 在 Linux 内核中实现预读。

io_method = worker

worker 设置利用在后台运行的专用 I/O 工作进程,这些工作进程独立于查询执行来检索数据。主后端进程将读取请求排队,这些工作进程与 Linux 内核交互以提取数据,然后将其传递到共享缓冲区中,而不会阻塞主进程

可以通过新的 io_workers 设置来配置 I/O 工作进程的数量,默认为 3。这些工作进程始终运行,并且在所有连接和数据库之间共享。

io_method = io_uring

这种特定于 Linux 的方法使用 io_uring,这是一种在内核版本 5.1 中引入的高性能 I/O 接口。异步 I/O 自内核版本 2.5 以来已在 Linux 中可用,但它在很大程度上被认为效率低下且难以使用。 io_uring 在 Postgres 和内核之间建立一个共享环形缓冲区,从而最大限度地减少了系统调用开销。这是最有效的选项,完全消除了对 I/O 工作进程的需求,但仅在较新的 Linux 内核上可用,并且需要与 io_uring 支持兼容的文件系统和配置。

重要说明: 截至 Postgres 18 Beta 1,异步 I/O 支持顺序扫描、位图堆扫描和维护操作(如 VACUUM)。

异步 I/O 实践

异步 I/O 在存储是网络连接的云环境中(例如,Amazon EBS 卷)提供最显着的收益。在这些设置中,单个磁盘读取通常需要多个毫秒,与本地 SSD 相比,这会引入大量的延迟。

使用传统的同步 I/O,每个读取都会阻塞查询执行,直到数据到达,从而导致 CPU 时间闲置和吞吐量降低。相比之下,异步 I/O 允许 Postgres 并行发出多个读取请求,并在等待结果时继续处理。这减少了查询延迟,并可以更有效地利用可用的 I/O 带宽和 CPU 周期。

AWS 上的基准测试:读取性能翻倍,并且从 io_uring 中获得更大的收益

为了评估异步 I/O 的性能影响,我们在 AWS 上对一个具有代表性的工作负载进行了基准测试,比较了使用不同 io_method 设置的 Postgres 17 和 Postgres 18。工作负载在各个版本中保持相同,从而使我们能够隔离新 I/O 基础设施的效果。

我们已经在 AWS c7i.8xlarge 实例(32 个 vCPU,64 GB RAM)上进行了测试,为 Postgres 提供了一个专用的 100GB io2 EBS 卷,并提供了 20,000 个预置 IOPS。测试表的大小为 3.5GB:

CREATE TABLE test(id int);
INSERT INTO test SELECT * FROM generate_series(0, 100000000);
test=# \dt+
                  List of relations
 Schema | Name | Type | Owner  | Persistence | Access method | Size  | Description 
--------+------+-------+----------+-------------+---------------+---------+-------------
 public | test | table | postgres | permanent  | heap     | 3458 MB | 
(1 row)

在测试运行之间,我们清除了 OS 页面缓存 (sync; echo 3 > /proc/sys/vm/drop_caches),并重新启动了 Postgres,以收集冷缓存结果。热缓存结果表示第二次运行查询。我们为每个配置重复了完整的测试运行多次,保留了三次中的最佳结果。

虽然我们还使用并行查询进行了测试,但为了使结果更易于理解,以下所有结果都禁用了并行查询 (max_parallel_workers_per_gather = 0)。

冷缓存结果:

Postgres 17 使用同步 I/O,建立了基线。它显示了持续的读取延迟,但吞吐量受到在发出下一个 I/O 请求之前需要完成每个 I/O 请求的限制:

test=# SELECT COUNT(*) FROM test;
  count  
-----------
 100000001
(1 row)
Time: 15830.880 ms (00:15.831)

Postgres 18 在配置为 io_method = sync 时,执行几乎相同,这证实了在不启用异步 I/O 的情况下,行为保持不变:

test=# SELECT COUNT(*) FROM test;
  count  
-----------
 100000001
(1 row)
Time: 15071.089 ms (00:15.071)

但是,当我们切换到使用 worker 方法时,使用 3 个 I/O 工作进程(默认值)会显示出明显的改进:

test=# SELECT COUNT(*) FROM test;
  count  
-----------
 100000001
(1 row)
Time: 10051.975 ms (00:10.052)

我们观察到通过增加 I/O 工作进程的数量获得了一些收益,但是最大的改进来自于利用 io_uring

test=# SELECT COUNT(*) FROM test;
  count  
-----------
 100000001
(1 row)
Time: 5723.423 ms (00:05.723)

当我们绘制此图时(以毫秒为单位测量运行时,越低越好),很明显 Postgres 18 在冷缓存情况下表现得更好:

Read performance comparison between Postgres 17 and 18 with different io_method settings

对于冷缓存测试,与传统的 sync 方法相比,workerio_uring 都提供了持续的 2-3 倍的改进

虽然 worker 由于其并行性为热缓存测试提供了一些好处,但 io_uring 在冷缓存测试中始终表现更好,并且其较低的系统调用开销和减少的进程协调将使 io_uring 成为在 Postgres 18 中最大限度地提高 I/O 性能的推荐设置

这种磁盘读取的性能变化对基础设施规划具有重要的影响,尤其是在云环境中。通过减少 I/O 等待时间,异步读取可以大大提高查询吞吐量,减少延迟和 CPU 开销。对于读取繁重的工作负载,这可能会转化为更小的实例大小或更好地利用现有资源。

调整 effective_io_concurrency

在 Postgres 18 中,effective_io_concurrency 变得更加有趣,但仅当与异步 io_method(例如 workerio_uring)一起使用时才更有意义。以前,此设置仅建议 OS 使用 posix_fadvise 预取数据。现在,它直接控制 Postgres 在内部发出多少异步预读请求。

预读的块数受到 effective_io_concurrencyio_combine_limit 的影响,遵循一般公式:

maximum read-ahead = effective_io_concurrency × io_combine_limit

这使 DBA 和工程师可以更好地控制 I/O 行为。最佳值需要进行基准测试,因为它取决于你的 I/O 子系统。例如,更高的值可能有益于具有高延迟但又支持高并发的云环境,例如具有高预置 IOPS 的 AWS EBS。

在进行基准测试时,我们还测试了更高的 effective_io_concurrency(介于 16 和 128 之间),但没有看到明显的差异。但是,这可能是由于使用了简单的测试查询。

值得注意的是,Postgres 17 中 effective_io_concurrency 的先前默认值为 1,现在已提高到 16,基于 Postgres 社区所做的基准测试

使用 pg_aios 监控正在进行的 I/O

如前所述,以前版本的 Postgres 使用同步 I/O,可以很容易地发现读取延迟:后端进程会在等待磁盘访问时被阻止,并且 pganalyze 等监控工具可以可靠地将 IO / DataFileRead 作为这些停顿期间的等待事件显示出来。

例如,在这里,我们可以清楚地看到 Postgres 17 同步 I/O 中的等待事件。

Screenshot of pganalyze showing wait events in Postgres 17

使用 Postgres 18 中的异步 I/O,后端等待行为会发生变化。使用 io_method = worker 时,后端进程将读取委托给单独的 I/O 工作进程。因此,后端可能显示为空闲或显示新的 IO / AioIoCompletion 等待事件,而 I/O 工作进程显示实际的 I/O 等待事件:

SELECT backend_type, query, state, wait_event_type, wait_event
 FROM pg_stat_activity
 WHERE backend_type = 'client backend' OR backend_type = 'io worker';
 backend_type | state | wait_event_type |  wait_event  
----------------+--------+-----------------+-----------------
 client backend | active | IO       | AioIoCompletion
 io worker   |    | IO       | DataFileRead
 io worker   |    | IO       | DataFileRead
 io worker   |    | IO       | DataFileRead
(4 rows)

使用 io_method = io_uring 时,读取操作直接提交到内核并异步完成。后端不会阻塞传统的 I/O 系统调用,因此即使 I/O 正在进行,此活动也从 Postgres 端不可见。

为了帮助调试正在进行的 I/O 请求,新的 pg_aios 视图可以显示 Postgres 内部状态,即使在使用 io_uring 时也是如此:

SELECT * FROM pg_aios;
 pid | io_id | io_generation |  state   | operation |  off  | length | target | handle_data_len | raw_result | result |          target_desc          | f_sync | f_localmem | f_buffered 
-------+-------+---------------+--------------+-----------+-----------+--------+--------+-----------------+------------+---------+--------------------------------------------------+--------+------------+------------
 91452 |   1 |     4781 | SUBMITTED  | read   | 996278272 | 131072 | smgr  |       16 |      | UNKNOWN | blocks 383760..383775 in file "base/16384/16389" | f   | f     | t
 91452 |   2 |     4785 | SUBMITTED  | read   | 996147200 | 131072 | smgr  |       16 |      | UNKNOWN | blocks 383744..383759 in file "base/16384/16389" | f   | f     | t
 91452 |   3 |     4796 | SUBMITTED  | read   | 996409344 | 131072 | smgr  |       16 |      | UNKNOWN | blocks 383776..383791 in file "base/16384/16389" | f   | f     | t
 91452 |   4 |     4802 | SUBMITTED  | read   | 996016128 | 131072 | smgr  |       16 |      | UNKNOWN | blocks 383728..383743 in file "base/16384/16389" | f   | f     | t
 91452 |   5 |     3175 | COMPLETED_IO | read   | 995885056 | 131072 | smgr  |       16 |   131072 | UNKNOWN | blocks 383712..383727 in file "base/16384/16389" | f   | f     | t
(5 rows)

在 Postgres 18 中优化 I/O 性能时,了解这些行为变化和异步执行的影响至关重要。

注意:异步 I/O 使 I/O 时间信息难以解释

异步 I/O 引入了执行时间报告方式的转变。当后端不再直接阻塞磁盘读取时(workerio_uring 的情况),执行 I/O 所花费的完整时间可能不会反映在 EXPLAIN ANALYZE 输出中。这可能会使 I/O 绑定的查询看起来比以前需要更少的 I/O 工作。

首先,让我们在 Postgres 17 的冷缓存上在 EXPLAIN ANALYZE 中运行先前的查询:

test=# EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT COUNT(*) FROM test;
                        QUERY PLAN                        
--------------------------------------------------------------------------------------------------------
 Aggregate (cost=1692478.40..1692478.41 rows=1 width=8) (actual rows=1 loops=1)
  Buffers: shared read=442478
  I/O Timings: shared read=14779.316
  -> Seq Scan on test (cost=0.00..1442478.32 rows=100000032 width=0) (actual rows=100000001 loops=1)
     Buffers: shared read=442478
     I/O Timings: shared read=14779.316
 Planning:
  Buffers: shared hit=13 read=6
  I/O Timings: shared read=3.182
 Planning Time: 8.136 ms
 Execution Time: 18006.405 ms
(11 rows)

我们在 14.8 秒内读取了 442,478 个缓冲区。

现在,我们使用默认设置 (io_method = worker) 在 Postgres 18 上重复测试:

test=# EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT COUNT(*) FROM test;
                        QUERY PLAN                         
-----------------------------------------------------------------------------------------------------------
 Aggregate (cost=1692478.40..1692478.41 rows=1 width=8) (actual rows=1.00 loops=1)
  Buffers: shared read=442478
  I/O Timings: shared read=7218.835
  -> Seq Scan on test (cost=0.00..1442478.32 rows=100000032 width=0) (actual rows=100000001.00 loops=1)
     Buffers: shared read=442478
     I/O Timings: shared read=7218.835
 Planning:
  Buffers: shared hit=13 read=6
  I/O Timings: shared read=2.709
 Planning Time: 2.925 ms
 Execution Time: 10480.827 ms
(11 rows)

我们在 7.2 秒内读取了 442,478 个缓冲区。

虽然通过并行查询,我们可以获得所有并行工作进程的 I/O 时间的摘要,但是对于 I/O 工作进程,则不会发生此类摘要。我们看到的是等待 I/O 完成的等待时间,而忽略了可能在幕后发生的任何并行性。

从技术上讲,这不是一个行为改变,因为即使在 Postgres 17 中,报告的时间也是花费在等待 I/O 上的时间,而不是花费在执行 I/O 上的时间,例如,从未考虑用于预读的内核 I/O 时间。

从历史上看,I/O 计时通常等同于 I/O 工作量,而不仅仅是查看共享缓冲区读取计数,以便与 OS 页面缓存命中区分开来。现在,在 Postgres 18 中,解释 I/O 计时需要更加谨慎:异步 I/O 可能会在查询计划中隐藏 I/O 开销。

结论

总而言之,即将发布的 Postgres 18 标志着 I/O 处理方式发生重大演变的开始。虽然目前仅限于读取,但异步 I/O 已经为高延迟云环境中的性能显着提升打开了大门。

但是,其中一些收益伴随着权衡。工程团队将需要调整他们的可观察性实践,学习计时和等待事件的新语义,并且可能需要重新审视先前影响有限的调整参数,例如 effective_io_conurrency

总结

随着 Postgres 开发的继续,未来的版本(19 及更高版本)可能会带来异步写入支持,从而进一步减少现代工作负载中的 I/O 瓶颈,并启用 Direct I/O 的生产使用。

参考