PostgreSQL 19 的异步 IO:数据库开始吃满 NVMe 之后

先说一个可能让不少人意外的结论:你在 PG 19 宣传里看到的异步 IO,主干代码在 PostgreSQL 18 就已经落地了。PG 18 的 release notes 原话是「An asynchronous I/O (AIO) subsystem that can improve performance of sequential scans, bitmap heap scans, vacuums, and other operations」。PG 19 干的不是"从无到有",而是把"谁来执行这些异步请求"做成自适应(新增 io_min_workers、io_max_workers、io_worker_idle_timeout、io_worker_launch_interval),再让 streaming reads 铺到更多路径上(hash 索引批量删除、GIN vacuum、bloom、pgstattuple)。

所以如果你的升级决策表上写着"等 PG 19 解锁 NVMe",这张表的前提就已经错了:你该评估的是 PG 18 的能力边界,PG 19 只是在边界内把调度做得更细。

一、先把版本状态钉死:截至现在它还不是 GA

这一节必须写准,因为网上已经有一堆"PG 19 重磅发布"的文章在用 beta 的截图讲生产收益。

里程碑 状态 依据
PG 19 Beta 1 至 Beta 4 已结束 postgresql.org 已发布 Beta 1、Beta 4 公告
PG 19 RC 1 2026-10-15 发布 官方新闻页 PostgreSQL 19 RC 1 Released
PG 19 RC 2 计划 2026-11-12 与 11 月例行更新版同日发布
PG 19 GA 计划 2026-11-19 同上新闻页给出的 planned date

官方 RC 1 公告里有一段很关键的说明:11 月 12 日的例行更新会带大量修复,release team 决定把这些修复也并进 PG 19,因此 RC 2 会和 11 月更新同日发布,GA 计划到 11 月 19 日。另外 postgresql.org 的 roadmap 页面上仍写着 planned for October 2026,和新闻页的 11-19 口径不一致——看日期以最新新闻页为准,roadmap 页更新滞后。

结论:本文讨论的所有行为,均以 PG 19 RC 1 的文档与 release notes 为准,不是 GA 行为。生产上要上,等 GA 之后再看一个小版本更稳妥。

二、同步 IO 在 NVMe 上究竟浪费了什么

不是"延迟高",恰恰相反,是延迟太低而无法被叠加。

用 Little’s Law 算一下就很清楚:并发请求数 = 吞吐 × 单请求延迟。假设一次 8kB 页读取的设备侧延迟约 100µs(企业级 NVMe 随机读的典型量级),那么单线程一次一个地读:

队列深度 理论 IOPS 折算带宽(8kB 页)
1(同步阻塞) 约 1 万 约 80MB/s
32 约 32 万 约 2.5GB/s
64 约 64 万 约 5GB/s

这是算术推演不是实测,但量级关系是对的:同步模型下,一块能跑几十万 IOPS、几 GB/s 顺序读的设备,被一个队列深度钉死在 1% 的利用率上。设备没吃饱,后端进程却在睡眠。

再看 PostgreSQL 的进程模型。一个连接一个 backend 进程,读页的路径是 ReadBuffer → smgrread → pread,读不到就睡,等 IO 完成再被唤醒。这个模型简单、可预测、可观测,是 PG 几十年来的优势,但它的代价就是:一次只能有一个在飞行中的请求。PG 17 及以前所谓的"异步",其实只是 posix_fadvise WILLNEED 给内核提建议(effective_io_concurrency 控制的预读距离),真正读页时仍然是同步等待。

Linux 侧的答案是 io_uring:一对共享内存环形队列(提交队列 + 完成队列),一次系统调用批量提交多个请求,可以配 SQPOLL 内核线程轮询、可以注册固定缓冲区,从而真正表达"深度 N 的并发"。PG 19 的 io_method = io_uring 就是走它,但需要 --with-liburing 编译。

那为什么默认值不是它,而是 worker?我的理解是取舍而不是能力问题:worker 模式复用了 PG 已有的进程与共享内存体系,可观测、可移植(Windows 也能跑),出了问题是一群能 ps 出来的进程;io_uring 则对内核版本、构建依赖、容器 seccomp 策略都更挑剔。默认 worker 是"先让所有人能跑"的选择,不是"io_uring 更快所以默认它"。

三、三个层级的参数必须一起调,否则等于没开

很多人只改一个 effective_io_concurrency 就期待起飞,这是最典型的无效操作。下面这段配置和检查 SQL 是我现在给任何 PG 18/19 实例做基线时都会跑的。

1
2
3
4
5
6
7
8
9
# postgresql.conf —— 异步 IO 相关,注意 io_method 需要重启
io_method = worker # worker | io_uring | sync,只能启动时设置
io_min_workers = 4 # 仅 worker 模式生效,默认 2
io_max_workers = 16 # 仅 worker 模式生效,默认 8
io_worker_idle_timeout = 60s # 默认 1min
io_worker_launch_interval = 100ms # 默认 100ms
effective_io_concurrency = 32 # 1..1000,0 表示禁用异步请求,默认 16
maintenance_io_concurrency = 16 # 维护类操作的并发度
io_combine_limit = 128kB # 请求合并上限,默认 128kB
1
2
3
4
5
6
7
8
9
10
11
-- 1. 核对自己实例上的真实取值
SELECT name, setting, unit, context
FROM pg_settings
WHERE name IN ('io_method', 'io_min_workers', 'io_max_workers',
'io_worker_idle_timeout', 'io_worker_launch_interval',
'effective_io_concurrency', 'maintenance_io_concurrency',
'io_combine_limit', 'io_max_concurrency')
ORDER BY name;

-- 2. 看当前在飞行中的 I/O 句柄(PG 18 起提供)
SELECT * FROM pg_aios LIMIT 20;
1
2
3
-- 3. 用 IO 选项看预取队列与请求规模,IO 必须与 ANALYZE 同用
EXPLAIN (ANALYZE, BUFFERS, IO)
SELECT count(*), sum(amount) FROM orders WHERE created_at >= '2026-01-01';

这段代码在说明什么:第一段是"容量",io_min_workers 与 io_max_workers 决定 worker 池能有多少个执行者,effective_io_concurrency 决定每个会话愿意保持多少个在飞行中的请求,io_combine_limit 决定相邻请求能不能合并成大 IO(默认 128kB 正好 16 个 8kB 块);第二段是"事实",pg_aios 是 PG 18 起才有的视图,列出当前使用的异步 I/O 句柄,升级后监控脚本在低版本实例上查它会直接报错;第三段是"证据",EXPLAIN (ANALYZE, IO) 会输出预取队列的平均与最大距离、队列容量,以及已发请求总数、平均请求大小、I/O 等待次数、平均并发请求数——判断"异步到底生效没有",看的就是平均并发请求数是否大于 1,以及队列是否被打满。

四、同步阻塞与异步队列的模型对比

这张图想让你看见什么:两条路径的差别不在"读得快不快",而在同一个后端进程在被唤醒之前,能有多少个请求在设备里排队。左路真正的成本是"睡眠—唤醒"的次数,右路的成本转移到了队列管理与 worker 调度上——这也解释了为什么异步不是免费的:请求数越多,worker 越忙,上下文切换与共享内存竞争就越明显。

五、一次顺序扫描在异步模式下长什么样

这张图想让你看见什么:执行器的循环结构没有变(还是取行、过滤、返回),变的是"取下一批页"这个动作从阻塞变成了"提交 + 稍后收割"。所以真正的收益点落在"有大量连续页要读、且这些页不在 shared_buffers 里"的场景,一旦命中缓存,这张图的下半部分根本不会发生。

六、应用层能看到什么:受益与几乎无感的清单

场景 是否受益 原因
大表顺序扫描,表远大于 shared_buffers 受益最大 连续页读取,队列容易打满
Bitmap Heap Scan 受益(PG 18 起) 位图阶段就知道要读哪些页,可提前排队
VACUUM / autovacuum 受益 维护类 IO,受 maintenance_io_concurrency 控制
GIN vacuum、hash 索引批量删除、bloom、pgstattuple PG 19 起受益 release notes 明确改为 streaming reads
Index Scan 单页点查 几乎无感 一个请求没有可重叠的对象
数据全在 shared_buffers 里 无感甚至更差 多了队列与 worker 开销
CPU 密集的聚合、排序、大连接 无感 瓶颈在 CPU,不在 IO 等待
高并发短事务 OLTP 无感 瓶颈在行锁、网络往返、提交延迟

七、我的判断:什么情况下这次升级才成立

我的判断是,PG 18/19 的异步 IO 值得升级的条件可以压成三条硬门槛,缺一条就先别升:

  1. 瓶颈确实在 IO 等待上。看 pg_stat_io 与 pg_stat_database 里 blk_read_time 的占比,或者用 EXPLAIN (ANALYZE, IO) 看 I/O 等待次数与平均并发数。如果 CPU 常年 90%、IO 等待不到 10%,升级之后你只会觉得"重启了一次"。
  2. 数据量大于 shared_buffers,且有稳定的扫描型负载。库能全塞进内存的实例,异步 IO 没有任何发挥空间。
  3. 存储的并发能力大于你当前的并发度。NVMe 收益最大;SATA SSD 队列深度有限,收益打折;网络块存储要看带宽是否已经封顶。

这里我不太同意"没有 NVMe 就没必要升级"这个说法。异步 IO 的收益来自"把等待重叠起来",而延迟越高的存储,单请求等待越长、可重叠的收益反而越大——远端块存储、云盘上确实能看到改善。但反过来也不成立:当带宽先撞墙时,加大队列深度只会把 p99 拖长,因为请求开始在设备侧排队,而不是在 CPU 侧。

反例也要说清楚,异步 IO 从来不是银弹:

  • CPU 瓶颈。顺序扫描的每行开销(tuple deform、表达式求值)是纯 CPU。PG 19 虽然优化了行变形,但如果扫描节点的 CPU 时间是 IO 等待的 5 倍,IO 再快也没用。
  • 行锁与事务。热点行更新,瓶颈在锁等待与 WAL 刷盘,异步 IO 完全不参与。
  • 连接数放大。effective_io_concurrency 是每会话的乘数。1000 个活跃会话各配 32,理论上就是 3.2 万个在飞行中的请求,全部砸在同一块设备上,结果不是更快而是更抖。这是我见过最常见的反向优化。
  • 单页点查。没有可重叠对象,队列深度永远是 1。

八、我踩过的坑

我踩过的坑里,最浪费时间的一个是:io_method 改了 ALTER SYSTEM SET 之后执行 pg_reload_conf 然后盯着 EXPLAIN 的输出怀疑人生。它在文档里写得很清楚——只能在服务器启动时设置,也就是必须重启 postmaster,reload 不生效。同理 io_max_workers 这类参数属于配置级,不是会话级,改完不重启的系统永远停在默认的 worker 池上。

第二个坑是"以为开了 io_uring 就更快"。io_uring 需要 --with-liburing 构建,发行版二进制包不一定带这个能力,容器里还可能被 seccomp 拦掉。顺序应该是:先 SHOW io_method 确认自己到底跑在哪种模式,再看平均并发数,别拿内核版本猜。

第三个坑是监控。pg_aios 是 PG 18 起才有的视图,一套采集脚本同时跑在 14/16/18/19 上时,查询会直接报错;这类新视图必须按版本分流,或者用 to_regclass 做探测。

九、如果你下周就要做决定,按这个顺序来

  1. 先用 EXPLAIN (ANALYZE, BUFFERS, IO) 在一条最具代表性的慢查询上取基线,记录平均并发请求数、I/O 等待次数、队列容量三个数。
  2. 确认存储类型:NVMe 才谈"吃满",SATA SSD 与网络盘先确认带宽是否已封顶,别用队列深度掩盖带宽瓶颈。
  3. 在测试实例上只改三件事:io_method(先留 worker)、io_max_workers、effective_io_concurrency,其余保持默认,做 A/B 对比。
  4. 按会话数算一次并发预算:活跃会话数乘 effective_io_concurrency 不要超过设备能舒服承受的队列深度,超了就要压会话数或压并发度。
  5. 生产升级时间点放在 PG 19 GA(计划 2026-11-19)之后,再留一个例行更新周期观察 pg_stat_io 与 p99。
  6. 把监控里的 pg_aios 与 EXPLAIN 的 IO 采集按版本分流,别让新视图把老实例的采集打挂。

异步 IO 这件事,PG 18 已经把地基打完,PG 19 在做的是让地基上的调度更聪明。真正决定你能不能吃满 NVMe 的,还是"你的瓶颈是不是 IO 等待"这一个问题——这个问题答错了,版本号升到 20 也没用。