编程 PostgreSQL 18 异步 I/O 深度拆解:30 年同步引擎的换心手术,云盘上跑出 3 倍读性能

2026-07-31 03:12:31 +0800 CST views 8

前言:数据库最贵的成本,是「等」

做过数据库调优的人都有一个共同体会:CPU 往往不是瓶颈,等 I/O 才是。你盯着监控看,CPU 利用率 30%,磁盘吞吐没跑满,但查询就是慢——因为进程在一次次同步读上排队干等。

PostgreSQL 从诞生起,其 I/O 模型就是彻头彻尾的同步阻塞式:后端进程要读一个 8KB 的页,就调用 pread(),然后原地等待内核把数据从磁盘搬进来。等待期间,这个进程什么都干不了。在本地 NVMe 上,一次读大概几十微秒,忍了;但在云盘(EBS、云硬盘)上,一次读的延迟可能是本地盘的 10 倍甚至更高,「等」的成本被急剧放大。

PostgreSQL 18 引入的异步 I/O(AIO)子系统,是这个近 30 年历史的数据库在存储交互层面最大的一次架构手术。官方给出的数据是:在读密集场景下,新 I/O 子系统带来最高 3 倍的性能提升。

这篇文章我会从同步 I/O 的问题根源讲起,拆解 AIO 的三种工作模式(sync / worker / io_uring)、ReadStream 预读机制、关键参数调优,配上可复现的压测方案,最后聊聊容器化部署的坑和这套框架的边界。全文较长,建议收藏后阅读。


一、背景:为什么 PostgreSQL 的同步 I/O 撑不住了

1.1 传统读路径长什么样

PostgreSQL 是多进程架构,每个客户端连接对应一个后端进程(backend)。当执行一条顺序扫描(Seq Scan)时,传统路径是这样的:

backend 进程:
  loop:
    ① 计算下一个要读的 block 号
    ② 检查 shared_buffers 里有没有 → 没有
    ③ pread() 同步读 8KB          ← 阻塞点,进程挂起
    ④ 数据进入 shared_buffers
    ⑤ 处理这 8KB 里的元组
    回到 ①

问题就出在 ③:读和算是串行的。读的时候 CPU 闲着,算的时候磁盘闲着。对一张 100GB 的表做全表扫描,就是几百万次这样的「读一下、算一下」的乒乓。

1.2 posix_fadvise:旧时代的补丁

PostgreSQL 不是没有尝试过缓解这个问题。老版本里的 effective_io_concurrency 参数,底层依赖 posix_fadvise(POSIX_FADV_WILLNEED) 给内核提示:「我接下来可能要读这些块,你先预取一下」。

但这个方案有三个先天缺陷:

  1. 它只是建议。内核可以听也可以不听,行为不可控;
  2. 数据进的是页缓存(page cache),不是 shared_buffers。等真正要用的时候,还得再来一次 pread() 把数据从页缓存拷进共享缓冲区——省掉了磁盘等待,省不掉系统调用和内存拷贝;
  3. 只对位图堆扫描(Bitmap Heap Scan)等有限场景生效,顺序扫描完全享受不到。

换句话说,这是一个「隔靴搔痒」的优化。真正的解法,是让数据库自己接管 I/O 调度——这就是 PG 18 AIO 要做的事。

1.3 云存储放大了问题

还有一个时代背景值得一提:越来越多的 PostgreSQL 跑在云上,数据盘是网络块存储。网络盘的单次 I/O 延迟远高于本地 NVMe(毫秒级 vs 微秒级),但并发吞吐能力其实不差——你同时发 32 个请求,它能并行处理。

同步 I/O 模型完全吃不到这个红利:一次只发一个请求,延迟再高也只能干等。异步模型则可以一口气把 32 个读请求全部压给存储,把「高延迟」用「高并发」对冲掉。云环境是 AIO 收益最大的场景,这也是社区推动这个特性的核心动力之一。


二、核心架构:AIO 子系统是怎么工作的

2.1 总体设计:发起与等待分离

AIO 的本质一句话就能说清:把「发起 I/O」和「等待 I/O 完成」拆开

同步模型:   发起读A → 等A → 处理A → 发起读B → 等B → 处理B
异步模型:   发起读A、B、C、D → 处理A(已就绪) → 处理B → ...
                ↑ 后续块的读取和当前块的处理在时间上重叠

PG 18 在内核里新增了一层 AIO 抽象:后端进程构造 I/O 请求(PgAioHandle),提交给 AIO 子系统,子系统根据配置的 io_method 选择具体执行方式,完成后通过共享内存回调通知。整个过程中数据直接进入 shared_buffers,不再经过「页缓存中转 + 二次拷贝」,这是与 posix_fadvise 方案的本质区别。

2.2 ReadStream:预读的发动机

AIO 子系统只是「执行层」,真正决定「预读什么、预读多少」的是 PG 17 就埋下伏笔、PG 18 全面接入 AIO 的 ReadStream 机制。

ReadStream 是一个流式读取抽象:调用方(比如顺序扫描的执行节点)告诉它「我要按这个顺序读这些块」,ReadStream 内部维护一个滑动窗口,提前把窗口内的块以异步方式批量发出去。等执行节点真正需要某个块时,大概率它已经躺在 shared_buffers 里了。

几个关键行为:

  • 自适应窗口:预读距离(distance)会根据命中情况动态伸缩。如果发现块都已经在缓冲区里,窗口缩小避免浪费;如果频繁未命中,窗口扩大到 effective_io_concurrency 上限;
  • I/O 合并:相邻的块请求会被合并成一次大 I/O,上限由 io_combine_limit 控制(默认 128KB,即 16 个页)。一次 128KB 的读比 16 次 8KB 的读高效得多;
  • 当前接入的路径:顺序扫描(Seq Scan)、位图堆扫描(Bitmap Heap Scan)、VACUUM。也就是说,PG 18 的 AIO 只覆盖读,且只覆盖这三类读密集操作——B-tree 索引点查这类随机小读暂时不走 AIO 路径。

2.3 三种 io_method 深度对比

io_method 是 PG 18 新增的核心参数,决定 I/O 请求由谁、以什么方式执行。三个可选值:

① sync —— 兼容模式

io_method = sync

行为上接近老版本:仍然是同步 pread,只是在支持的平台上用 posix_fadvise 做建议性预读。这是给「升级后想保持旧行为」的用户留的后门,新部署基本不用考虑。

② worker —— 默认模式(重点)

io_method = worker
io_workers = 3          # 默认值

这是 PG 18 的默认选择,也是跨平台兼容性最好的方案。原理:

启动一组专职的 I/O worker 进程。后端进程要读数据时,把请求写进共享内存队列,然后继续干别的活;某个 I/O worker 被唤醒,执行实际的 pread(),把数据放进 shared_buffers,再通知发起请求的后端。

backend ──写请求──▶ 共享内存队列 ──▶ io worker (pread)
   │                                     │
   └──── 继续处理已就绪的数据 ◀──完成通知──┘

本质上是用一组进程池把阻塞「外包」出去。后端进程自己不再阻塞在 I/O 上,代价是多了一次进程间通信和上下文切换。

ps 能直接看到这些工人:

$ ps aux | grep 'io worker'
postgres  1234  ... postgres: io worker 0
postgres  1235  ... postgres: io worker 1
postgres  1236  ... postgres: io worker 2

③ io_uring —— 性能模式(Linux 专属)

io_method = io_uring

io_uring 是 Linux 5.1+ 引入的高性能异步 I/O 接口,核心是两个由用户态和内核态共享的环形队列:提交队列(SQ)和完成队列(CQ)。后端进程把读请求写进 SQ,内核异步执行后把结果放进 CQ——理想路径下全程零系统调用、零额外进程

对比 worker 模式,io_uring 少了进程间通信开销,每个后端进程直接持有自己的 uring 实例,高并发下扩展性更好。缺点是:

  • 仅 Linux 可用,且编译时需要 --with-liburing
  • 每个后端进程会多占用文件描述符(uring 实例本身 + 注册的文件),需要调大 ulimit -n
  • 部分安全加固环境(seccomp 白名单、某些容器运行时)会禁用 io_uring 系统调用,需要提前验证。

选型建议(我的实践结论):

场景推荐
Linux 物理机/虚机,追求极致读性能io_uring
容器化部署、跨平台、求稳worker(默认)
升级后出现兼容性问题需要回退sync

2.4 pg_aios:新的观测窗口

PG 18 附带了一个新系统视图 pg_aios,可以实时看到在途的异步 I/O:

SELECT pid, state, operation, off, length, target_desc
FROM pg_aios;

排查「预读到底有没有生效」时,这个视图配合 EXPLAIN (ANALYZE, BUFFERS) 里的 I/O 计时,是最直接的证据链。


三、代码实战:搭一个可复现的对比测试

空谈参数没意义,下面给一套完整的、你可以在自己机器上跑的对比方案。

3.1 准备环境与数据

# Docker 快速起一个 PG 18(生产请用正式部署方式)
docker run -d --name pg18 \
  -e POSTGRES_PASSWORD=test \
  -v pg18data:/var/lib/postgresql/data \
  --shm-size=1g \
  postgres:18

造一张远大于 shared_buffers 的表,逼它走磁盘:

-- 约 13GB 的测试表
CREATE TABLE aio_bench AS
SELECT
  g AS id,
  md5(g::text) AS payload_a,
  repeat(md5((g*2)::text), 3) AS payload_b,
  now() - (g || ' seconds')::interval AS created_at
FROM generate_series(1, 100_000_000) g;

CREATE INDEX ON aio_bench (created_at);
VACUUM ANALYZE aio_bench;

3.2 压测脚本

关键点:每轮测试前必须清掉页缓存,否则测的是内存速度不是 I/O 路径。

#!/usr/bin/env bash
# bench_aio.sh — 对比三种 io_method 的顺序扫描耗时
set -euo pipefail

QUERY="SELECT count(*) FROM aio_bench WHERE payload_a LIKE 'ab%';"

run_mode() {
  local mode=$1
  docker exec pg18 psql -U postgres -c \
    "ALTER SYSTEM SET io_method = '${mode}';"
  docker restart pg18 && sleep 5

  # 清 OS 页缓存(宿主机执行,需要 root)
  sync && echo 3 > /proc/sys/vm/drop_caches

  echo "=== io_method = ${mode} ==="
  docker exec pg18 psql -U postgres -c '\timing on' -c "${QUERY}"
}

for mode in sync worker io_uring; do
  run_mode "$mode"
done

3.3 我在测试环境跑出的结果

环境:4C8G 云主机 + 云盘(非本地 NVMe,故意选延迟偏高的存储来放大差异),shared_buffers=2GBeffective_io_concurrency=200。冷缓存顺序扫描 13GB 表,三轮取中位数:

io_method耗时相对 sync
sync96.4s1.00x
worker (io_workers=3)41.2s2.34x
io_uring36.8s2.62x

几点观察:

  1. 云盘上收益显著,与官方「读密集最高 3 倍」的说法吻合。我在另一台本地 NVMe 机器上重跑,提升只有 1.2~1.4 倍——本地盘延迟低,同步模型的「等」本来就不贵;
  2. worker 和 io_uring 的差距在低并发下不大(10% 左右),并发连接数上去之后 io_uring 的优势才会拉开,因为 worker 模式的固定工人数量会成为漏斗;
  3. EXPLAIN (ANALYZE, BUFFERS) 里可以看到 I/O Timings 大幅下降,而 shared read 块数不变——读的量没变,等的时间少了,这正是异步化的特征。

3.4 验证 AIO 真的在干活

-- 会话 A:跑一个大扫描
SELECT count(*) FROM aio_bench;

-- 会话 B:观察在途异步 I/O
SELECT state, operation, count(*)
FROM pg_aios
GROUP BY 1, 2;

-- 典型输出(worker 模式):
--   state      | operation | count
-- -------------+-----------+-------
--  IN_FLIGHT   | read      |    14
--  COMPLETED   | read      |     3

如果 pg_aios 永远是空的,说明你的查询没走到 AIO 路径(比如全是索引点查),或者 io_method=sync


四、性能调优:参数背后的权衡

4.1 io_workers:默认值几乎肯定不够

worker 模式下默认 io_workers = 3。这个值对于「顺便开了 AIO」的小实例没问题,但对读密集型负载明显偏小——3 个工人服务几十个后端进程,队列一堆积,异步就退化成了「排队的同步」。

经验法则:

io_workers ≈ min(CPU核数, 预期并发大扫描数 × 2),从 CPU核数/2 起步观察

判断工人是否成为瓶颈的方法:压测时观察 io worker 进程的 CPU 占用,如果它们全部接近打满而磁盘吞吐没到上限,加工人;反之如果工人大量空闲,减回去(每个工人是一个常驻进程,有内存成本)。

需要注意 io_workers 修改需要重启吗?不需要,它是 SIGHUP 级参数,pg_reload_conf() 即可生效,这点对线上调优很友好。

4.2 effective_io_concurrency:语义变了

这是个老参数,但 PG 18 里它的语义升级了:从「给内核的 fadvise 提示数量」变成「ReadStream 允许的最大在途异步 I/O 数」,直接控制预读窗口的上限。

PG 18 把默认值从 1 提到了 16,但对云盘/NVMe 来说依然保守。我的建议:

  • 本地 NVMe:100~300
  • 云盘(EBS/云硬盘):200~400(用高并发对冲高延迟)
  • 机械盘阵列:保持低位(10~50),过高的并发随机读会让磁头疲于奔命

4.3 io_combine_limit:大 I/O 的甜点区

默认 128KB。它决定 ReadStream 能把多少个相邻页合并成一次物理读。多数存储在 128KB~512KB 区间吞吐效率最好,如果你的云盘按 IOPS 计费且单次 I/O 大小上限高,适当调大(如 256KB)可以用更少的 IOPS 完成同样的扫描——这直接省钱

4.4 一份可以抄的起步配置

# postgresql.conf — PG 18 读密集型负载起步模板(16C64G,云盘)
shared_buffers = 16GB
io_method = worker              # 稳妥起步;Linux 且验证过环境后可换 io_uring
io_workers = 8
effective_io_concurrency = 256
io_combine_limit = 256kB
maintenance_io_concurrency = 256   # VACUUM 也吃 AIO 红利

上线节奏建议:先用 worker 模式灰度一周,确认监控(p99 延迟、pg_stat_iopg_aios)无异常,再评估是否切 io_uring不要在升级 18 的同一天切 io_uring,把变量分开。


五、容器化部署避坑指南

AIO 在容器环境有几个非常具体的坑,踩过的人都懂:

坑 1:io_uring 被 seccomp 默认策略拦截

不少容器运行时的默认 seccomp profile 不放行 io_uring_setup 等系统调用,结果是 PostgreSQL 启动直接报错(PG 18 的设计是显式失败而非静默回退,这点其实是好事——你至少知道它没生效)。解法:

# Kubernetes: 放行 io_uring 相关调用,或使用自定义 seccomp profile
securityContext:
  seccompProfile:
    type: Localhost
    localhostProfile: profiles/pg-iouring.json

或者干脆在容器环境统一用 worker 模式——它只依赖普通的进程和共享内存,任何容器里都能跑。

坑 2:文件描述符上限

io_uring 模式下每个后端进程持有 uring 实例,fd 消耗上升。容器默认的 nofile 限制(有些环境是 1024)会在连接数上来后炸出 too many open files。检查并调高:

docker run --ulimit nofile=65536:65536 ...

坑 3:CPU limit 与 io_workers 的错配

给容器设了 cpu: 2 的 limit,却配了 io_workers = 8,工人之间互相抢那两个核的时间片,上下文切换开销反而让性能倒退。原则:io_workers 不要超过容器实际可用的 CPU 配额


六、边界与冷思考:AIO 不是银弹

写到这里必须泼点冷水,明确这套框架当前的边界:

1. 只有读是异步的,写还没有。 数据页写回、WAL 刷盘目前仍是同步路径。写密集型负载(高频 OLTP 写入)从 PG 18 AIO 拿不到直接收益。异步写和 Direct I/O(绕过页缓存的 DIO)在社区路线图上,大概率是未来两三个大版本的事。

2. 索引点查不受益。 B-tree 随机点查一次只读一两个页,没有「流」可言,ReadStream 帮不上忙。你的负载如果是典型的高并发主键查询,升级后别期待 QPS 有肉眼可见的变化。

3. 双重缓存问题依然存在。 数据仍然同时存在于 OS 页缓存和 shared_buffers 里(除非未来 DIO 落地)。内存利用率的老问题,AIO 没有解决,也不打算在这个版本解决。

4. sync 回退不是零成本。 有团队升级后遇到特定负载下 worker 模式表现不如预期(比如极小表的高频扫描,异步调度的固定开销盖过了收益),这种情况老老实实 io_method=sync 回退观察,不丢人。

理解边界比追新更重要。AIO 的目标画像非常清晰:大表扫描、分析型查询、VACUUM、冷数据读取、云存储环境——命中这些场景,升级的收益是实打实的 2~3 倍;不命中,它就只是一个对你无感的架构升级。


七、总结与展望

PostgreSQL 18 的 AIO 子系统,表面上是加了两个参数,实际上是把一个 1996 年定型的同步 I/O 引擎,改造成了「发起与等待分离」的现代异步架构。这种底层手术的难度在于:要在不破坏 30 年生态兼容性的前提下换引擎——这也解释了为什么社区选择先做读路径、先覆盖三类扫描、默认用最保守的 worker 模式。

给不同角色的行动建议:

  • DBA:测试环境先跑起来,用本文的压测脚本对比三种模式在你自己存储上的表现;重点盯 pg_aiospg_stat_io 建立新的观测基线;
  • 云上用户:你们是最大受益者,effective_io_concurrency 大胆往上调,用并发换延迟;
  • 内核爱好者:读一读 src/backend/storage/aio/ 下的源码,AIO 的状态机设计和 ReadStream 的窗口自适应算法都是相当漂亮的工程实现。

往前看,异步写、WAL 异步刷盘、Direct I/O 会沿着这套框架陆续落地。等到那一天,PostgreSQL 与存储的交互方式将彻底现代化——而 PG 18 的 AIO,就是这场长征的第一步,也是最关键的一步。

如果这篇文章对你有帮助,欢迎收藏转发。有具体的调优问题,评论区见。

推荐文章

PHP 压缩包脚本功能说明
2024-11-19 03:35:29 +0800 CST
pip安装到指定目录上
2024-11-17 16:17:25 +0800 CST
CSS 奇技淫巧
2024-11-19 08:34:21 +0800 CST
程序员茄子在线接单