PostgreSQL 18 异步 I/O 深度拆解:pgaio 句柄状态机、三种 io_method 的真实代价,以及一份能落地的调优决策树
一句话结论先放这儿:PostgreSQL 18 的 AIO 不是"加了个开关性能就翻倍"的特性,它是把过去 25 年外包给操作系统的 I/O 调度权收回到数据库自己手里的一次架构手术。手术做完了,但只缝了一半——只有读,没有写;只有顺序扫描、位图堆扫描和 VACUUM,索引扫描还在原地。理解这"一半"的边界在哪,比背下三个参数的默认值重要得多。
一、先讲清楚一件事:PG 为什么忍了 25 年同步 I/O
1.1 老架构的隐含假设
翻开 PG 18 之前的源码,你会发现一个很有意思的事实:整个存储层几乎找不到"异步"这个概念。md.c 里读一个数据块,最终就是一个老老实实的 pread()。进程发出系统调用,内核让它睡觉,磁盘转完了内核把它叫醒,数据从 page cache memcpy 到 shared_buffers,进程继续跑。
这个设计在 2000 年代是完全合理的,因为它建立在一个隐含假设上:
操作系统的 page cache + 预读(readahead)足够聪明,能把同步 I/O 的延迟藏起来。
在机械硬盘时代这个假设成立得很好。磁盘寻道 10ms,内核的顺序预读一次拉 128KB 进来,PG 后面连读 16 个 8KB 块全部命中 page cache,摊薄下来每块的等待时间接近于零。PG 甚至不需要知道预读这回事。
官方的 src/backend/storage/aio/README.md 开篇就承认了这一点:
Until the introduction of asynchronous IO postgres relied on the operating system to hide the cost of synchronous IO from postgres. While this worked surprisingly well in a lot of workloads, it does not do as good a job on prefetching and controlled writeback as we would like.
关键词是 "surprisingly well" 和 "not as good a job"。前半句是过去,后半句是现在。
1.2 假设崩塌于云盘
假设崩在哪?崩在延迟结构变了。
我们做个第一性原理的数学推演。假设一次数据块读取的完整耗时为 T = T_io + T_cpu,其中 T_cpu 包括校验和验证、memcpy 到共享缓冲区等固定开销。同步模型下,处理 N 个块的总耗时是:
T_sync = N × (T_io + T_cpu)
异步模型下,如果能同时压 K 个 I/O 在途,总耗时理论下界是:
T_async ≈ N × T_cpu + (N / K) × T_io (当 T_io 能被充分重叠)
加速比 T_sync / T_async 的大小,完全取决于 T_io / T_cpu 这个比值。
- 本地 NVMe SSD:
T_io≈ 80100μs,5μs。比值约 20~50。异步收益巨大。T_cpu(校验和 + memcpy 8KB)≈ 2 - 云盘(EBS gp3 / 云硬盘):
T_io≈ 500μs ~ 2ms(网络存储,多一跳),T_cpu不变。比值飙到 100~400。异步收益极其巨大。 - 机械盘 + 内核预读命中:
T_io摊薄后 ≈ 1μs(page cache 命中),比值小于 1。异步基本没收益,甚至因为调度开销变慢。
看明白了吧:PG 18 引入 AIO 的直接动因,是云上部署成为主流之后,T_io / T_cpu 这个比值暴涨了一到两个数量级。 内核预读在随机 I/O 面前本来就不灵,而云盘把每一次"不灵"的代价放大了 10 倍。
1.3 内核预读为什么不够用
还有个更本质的问题:内核不知道 PG 要干什么。
内核的 readahead 是基于访问模式猜的——它看到你连续读了几个相邻块,就猜你要顺序扫描,于是往前预读一段。但:
- 位图堆扫描(Bitmap Heap Scan) 的访问序列是"跳跃但有序"的,内核的顺序检测器容易判定为随机,直接关掉预读。
- 索引扫描的块号序列由索引结构决定,内核完全无法预测。
- 即使猜对了,数据只是进了 page cache,PG 还要再做一次
read()把它 memcpy 到 shared_buffers。这就是经典的双缓冲问题——同一份数据在内存里躺了两份,CPU 还白干了一次拷贝。
而 PG 自己是知道下一步要读哪些块的:顺序扫描知道自己扫到第几页,位图扫描手里握着完整的块号位图,VACUUM 知道 visibility map 里哪些页要清理。这个信息优势,就是 AIO 存在的全部理由。
二、PG 17 的伏笔:Read Stream
很多人以为 AIO 是 PG 18 突然冒出来的,其实 PG 17 已经埋好了地基——Read Stream 抽象层(src/include/storage/read_stream.h)。
Read Stream 解决的是一个纯粹的接口问题:如何让上层代码用一种统一的方式表达"我接下来要按这个顺序读这一串块"。
它的心智模型很简单:调用方提供一个回调函数,每次被调用时返回"下一个要读的块号";Read Stream 负责在后台把这些块提前捞进来。上层只管一个个 read_stream_next_buffer() 拿缓冲区,完全不关心底层是同步 pread 还是 io_uring 提交。
/* 伪代码:Read Stream 的典型使用形态 */
ReadStream *stream = read_stream_begin_relation(
READ_STREAM_SEQUENTIAL, /* 提示:这是顺序访问模式 */
strategy, /* 缓冲区访问策略(如 VACUUM 的环形策略) */
rel,
MAIN_FORKNUM,
seq_scan_next_block_cb, /* 回调:告诉 stream 下一个块号 */
scan_state, /* 回调私有数据 */
0);
Buffer buf;
while ((buf = read_stream_next_buffer(stream, NULL)) != InvalidBuffer)
{
/* 处理这一页 —— 此时后续若干页的 I/O 已经在途 */
process_page(BufferGetPage(buf));
ReleaseBuffer(buf);
}
read_stream_end(stream);
PG 17 时代,这个 stream 底下走的还是 posix_fadvise(POSIX_FADV_WILLNEED)——建议性预读,数据落到 page cache,不落到 shared_buffers。PG 18 做的事情,就是把这个抽象层底下的实现换成了真正的异步 I/O,上层调用方代码几乎不用改。
这是非常漂亮的工程决策:先立接口,再换实现。所以 PG 18 的 AIO 能一次性覆盖顺序扫描、位图堆扫描、VACUUM 这几个大头,就是因为它们在 PG 17 已经完成了 Read Stream 化改造。
推论也很直白:谁没做 Read Stream 化改造,谁就享受不到 AIO。 这就是为什么 PG 18 里索引扫描(Index Scan / Index Only Scan)的 I/O 依然是同步的——它的 Read Stream 改造(索引预取)还在邮件列表里吵,没进 18。
三、pgaio 子系统架构:五个核心抽象
现在进入真正的硬核部分。PG 的 AIO 子系统由五个抽象组成,理解它们你就理解了整个设计。
3.1 AIO Handle:一切的中心
PgAioHandle 是 I/O 操作的句柄。生命周期是这样的:
pgaio_io_acquire() → 获取句柄(必须成功,不允许失败)
↓
pgaio_io_register_callbacks() → 注册完成回调
↓
pgaio_io_set_handle_data_32() → 绑定业务数据(如 buffer 编号)
↓
smgrstartreadv() → 逐层下沉,最终由 fd.c 层的 pgaio_io_start_*() 定义操作
↓
(提交、执行、完成)
↓
句柄立即被回收复用
这里有两个设计约束需要重点讲,因为它们解释了后面所有的奇怪之处:
约束一:pgaio_io_acquire() 必须永远成功(除非 PANIC)。
为什么?因为 AIO 要能在**临界区(critical section)**内启动。比如 WAL flush 场景:backend 先发起一批 shared buffer 的写,然后进入临界区刷 WAL——此时如果因为句柄耗尽而失败,就没有任何合法的错误处理路径。
约束二:一个 backend 在"未定义"状态下只能持有一个句柄。
README 说得很明白:
only a single AIO Handle may be acquired (i.e. returned by
pgaio_io_acquire()) without causing the IO to have been defined ... Otherwise a backend could trivially self-deadlock by using up all AIO Handles without the ability to wait for some of the IOs to complete.
翻译成人话:如果允许一个 backend 攥着一堆没定义的句柄,它可以把全局句柄池耗光,然后自己等自己——死锁。所以规则是:拿一个,定义一个,才能拿下一个。
句柄总数由 io_max_concurrency 控制,默认 -1 表示根据 shared_buffers 和最大进程数(max_connections、autovacuum_worker_slots、max_worker_processes、max_wal_senders)推导,但上限封死在 64。
3.2 AIO Callbacks:分层解耦的必然产物
一次 buffer 读取完成后,有三层代码需要作出反应:
md.c:I/O 是不是彻底失败了?是不是短读(partial read)?bufmgr.c:页面校验和对不对?页头结构合法吗?bufmgr.c:更新BufferDesc状态,让其他 backend 能用这个 buffer。
问题在于:上层不该知道下层细节(bufmgr 不能假设 I/O 一定经过 md.c),下层也不该知道上层语义(md.c 不知道什么是校验和,因为 smgr API 也可能绕过 shared buffers 使用)。
解法是多回调注册:每个句柄可以挂多个完成回调,各层挂各层的。
但这里有个 PG 特有的坑:共享内存里不能存函数指针。因为在 EXEC_BACKEND 构建(Windows 以及带 ASLR 的场景)下,同一个函数在不同进程里的地址不一样。所以 PG 用回调 ID(PgAioHandleCallbackID)代替函数指针——一个字节存一个回调,查表分发。这个设计副产品还挺香:句柄结构体省了大量内存。
3.3 AIO Target:谁在被读写
每个句柄有且仅有一个 "target",描述 I/O 的对象身份。目前只有 smgr 一种(关系文件),用 RelFileLocator + ForkNumber + BlockNumber 唯一标识。
Target 提供两个能力:
- 重新打开文件——worker 模式必需。因为发起 I/O 的是 backend,执行 I/O 的是 worker 进程,worker 手里没有 backend 的 fd,必须能根据 target 信息自己 open。
- 描述 I/O——用于错误信息和调试日志。
README 明确说了:如果两种用途能用同样的方式描述文件身份,就该共用 target;WAL 文件用 TimeLineID + XLogRecPtr 描述,跟 smgr 完全不同,所以将来 WAL 走 AIO 时会是一个独立的 target。这句话是 PG 19/20 的路线图预告。
3.4 AIO Wait Reference:句柄复用带来的问题
句柄完成后立刻被回收复用,那你怎么"等待某个 I/O"?拿句柄指针等是不行的——你等的那个句柄可能早就被别人拿去干别的了。
解法是 PgAioWaitRef:它不只记录句柄 ID,还记录句柄的 generation(世代号)。句柄每复用一次,generation 自增。等待时对比 generation,就能判断"我要等的那个 I/O 是不是已经完成了"。
这个 wref 可以放在本地内存也可以放共享内存,任意进程都能等待任意 I/O。这一点至关重要,下一节讲死锁时会用到。
3.5 AIO Result:为什么不能在回调里报错
这是整个设计里最反直觉的一点:完成回调里绝对不能 ereport(ERROR)。
两个原因叠加:
- 完成回调可能在临界区内执行(见约束一)——临界区里报 ERROR 直接升级成 PANIC,整个实例挂掉。
- 完成回调可能被任何 backend 执行——A 发起的 I/O,B 帮它完成。如果在 B 的上下文里报错,B 的查询就被 A 的 I/O 故障搞崩了,这完全不可接受。
所以设计成:完成回调只做状态更新,把错误编码进 PgAioResult;发起方在 backend 本地内存里放一个 PgAioReturn,句柄复用前把结果填进去。发起方拿到结果后,自己在安全的上下文里 pgaio_result_report(..., ERROR)。
还有个细节值得玩味——为什么不直接存一个 ErrorData?README 的回答是内存:
With AIO a large number of concurrently issued AIOs might fail. To avoid the need for preallocating a potentially large amount of memory (in shared memory no less!), completion callbacks instead have to encode errors in a more compact format.
elog.c 之所以敢保证"记日志不会 OOM",是因为同时在途的日志数量极少。AIO 不一样,可能几百个 I/O 同时失败。所以只能用紧凑编码。
3.6 一段完整的 AIO 使用代码
把上面五个抽象串起来,就是 README 里的这段范例(我加了中文注释):
/* 操作结果,只能在本 backend 内访问 */
PgAioReturn ioret;
/* 1. 获取句柄,ioret 用于接收完成结果。
* 注意:ioret 必须活到 IO 完成,或 CurrentResourceOwner 被释放为止。 */
PgAioHandle *ioh = pgaio_io_acquire(CurrentResourceOwner, &ioret);
/* 2. 拿一个等待引用(含 generation),任意进程可用它等待本 IO */
PgAioWaitRef iow;
pgaio_io_get_wref(ioh, &iow);
/* 3. 注册共享缓冲区完成回调:更新 BufferDesc,让其他 backend 能访问 */
pgaio_io_register_callbacks(ioh, PGAIO_HCB_SHARED_BUFFER_READV, 0);
/* 4. 绑定业务数据:AIO 子系统不认识 buffer,得显式关联 */
pgaio_io_set_handle_data_32(ioh, (uint32 *) &buffer, 1);
/* 5. 下沉到存储层定义并启动 IO。
* 句柄交出去之后就不能再用了 —— IO 可能在函数返回前就已完成并被复用 */
void *page = BufferGetBlock(buffer);
smgrstartreadv(ioh, operation->smgr, forknum, blkno, &page, 1);
/* 6. 关键:这里去干别的活。不干别的活就等于同步 IO,AIO 白搞了 */
perform_other_work();
/* 7. 等待完成 */
pgaio_wref_wait(&iow);
/* 8. 检查结果。注意 IO 失败时只会有 LOG,不会有 ERROR,
* 因为等待这个 IO 的其他 backend 不该看到 ERROR */
if (ioret.result.status == PGAIO_RS_ERROR)
pgaio_result_report(ioret.result, &ioret.target_data, ERROR);
/* 9. 还要处理"部分完成"和"带警告成功"(如 zero_damaged_pages 触发) */
if (ioret.result.status != PGAIO_RS_OK)
pgaio_result_report(ioret.result, &ioret.target_data, ERROR);
第 6 步那行注释是整段代码的灵魂:AIO 的收益 100% 来自于"发起后到等待前"这段时间你干了多少活。如果发起完立刻等待,那就是穿着异步马甲的同步 I/O,纯亏调度开销。
Read Stream 的价值就在这——它替你自动管理"提前发起 N 个 I/O,然后一个个消费"的流水线,你不用手写第 6 步。
批量发起多个 I/O 时还有 pgaio_enter_batchmode() / pgaio_exit_batchmode()。但 README 特意警告了一个隐蔽的死锁:批处理期间有未提交的 I/O,而另一个 backend 恰好要等这个未提交的 I/O,如果本 backend 又去等对方——无法检测的死锁。这个坑普通用户碰不到,但写扩展的要小心。
四、死锁与饥饿:一条决定了整个架构的约束
这一节单独拎出来讲,因为它直接决定了为什么 io_method 只有那三个选项。
4.1 问题场景
想象一个 backend 在做顺序扫描,用 Read Stream 提前发起了 16 个块的读。发起之后,它执行到某个算子上阻塞了——比如等一个锁,或者仅仅是 CPU 密集的排序跑了很久。
此时那 16 个 I/O 完成了,但没人去处理完成事件。这 16 个 buffer 一直停留在"I/O 进行中"状态,其他想访问这些页的 backend 全部卡死。如果这个链条成环,就是死锁。
4.2 PG 的解法
README 给出的约束是:
This AIO implementation solves this problem by requiring that AIO methods either allow AIO completions to be processed by any backend in the system (e.g. io_uring), or to guarantee that AIO processing will happen even when the issuing backend is blocked (e.g. worker mode).
也就是说,任何一种 io_method 实现,必须满足以下二者之一:
- (A) 任意 backend 都能处理任意 I/O 的完成 —— io_uring 走这条路。io_uring 实例创建在 postmaster 里,所有 backend 都能访问,B 可以帮 A 收割完成事件。
- (B) 保证发起方阻塞时 I/O 仍会被处理 —— worker 走这条路。执行和完成处理都在独立的 worker 进程里,跟发起方的死活无关。
这就是为什么不能简单地"用 libaio 包一层"或者"每个 backend 自己开个线程" ——那些方案都不满足这两个条件之一,会引入死锁。
而 sync 模式为什么安全?因为它压根不异步,发起即完成,不存在"在途 I/O 无人处理"的窗口。
理解了这条约束,你再看 io_uring 需要"为每个可能的子进程创建一个 fd"这件事,就不觉得是设计缺陷了——那是满足条件 (A) 的必要成本。
五、三种 io_method 的真实代价
5.1 执行路径对比
io_method = sync
backend: posix_fadvise(WILLNEED) → 数据进 page cache(不进 shared_buffers)
backend: pread() → 阻塞,数据 memcpy 到 shared_buffers
本质是 PG 17 的行为,但依然要走一遍 AIO 基础设施。所以它不是"零开销回退",Vondra 明确说了:即使你想模拟 PG 17,用 sync 也不保证不掉性能。
io_method = worker(默认)
backend: 请求入队(共享内存队列)
backend: 发信号唤醒某个 I/O worker
worker: pread() → 校验和验证 → memcpy 到 shared_buffers
worker: 发信号通知 backend 完成
backend: 继续
io_method = io_uring
backend: SQE 入环,io_uring_enter() 提交
(内核异步执行)
backend: 收割 CQE → 校验和验证 → memcpy 到 shared_buffers
5.2 带宽:worker 反而更快的原因
直觉上 io_uring 应该完胜——它没有进程间通信,上下文切换更少。但 Tomas Vondra 的基准测试给出了反直觉的结果:顺序扫描场景下 worker 明显快于 io_uring。
原因藏在"I/O 完成后还要干什么"里。一次 buffer 读取的总成本包括:
- 实际的 I/O 操作
- 校验和验证(PG 18 起
initdb默认开启 data checksums!) - memcpy 到共享缓冲区
io_uring 模式下,这三件事全部在发起方 backend 进程内完成。I/O 那部分确实高效,但校验和 + memcpy 是纯 CPU 活,它们成了单进程瓶颈。
worker 模式下,第 2、3 步分摊到了多个 worker 进程。1 个 backend 配 3 个 worker,CPU 处理上限直接 ×3。
Vondra 的原话:
我相信这种将开销分散到多个进程的能力,是 worker 在顺序扫描上优于 io_uring 的原因。在本次基准测试中,约 20% 的差异对于校验和内存复制来说似乎是合理的。
但反过来也成立:16 个并发连接时,io_uring 有 16 个进程在算校验和,worker 只有 io_workers 个。这就是 io_workers=3 在高并发下会成为全局瓶颈的根本原因。
一个粗略的心智模型:
io_uring 的 CPU 处理上限 ≈ 活跃 backend 数 × 单进程处理能力
worker 的 CPU 处理上限 ≈ io_workers 数 × 单进程处理能力
低并发大扫描 → worker 赢(并行度被 io_workers 放大)
高并发小查询 → io_uring 赢(并行度自然跟随连接数)
5.3 信号开销:worker 模式的物理天花板
worker 模式的另一个隐藏成本是 UNIX 信号。最坏情况下,每读一个 8KB 块要一次双向信号往返。
Vondra 写了个信号往返基准测试,实测在他的机器上是 每秒 25 万 ~ 50 万次往返。换算一下:
8 KB × 250,000 /s = 2 GB/s
8 KB × 500,000 /s = 4 GB/s
而同一台机器上,单进程从 page cache 拷数据能跑到 10~20 GB/s。也就是说,在最坏情况下,信号开销让 worker 模式的带宽只有理论值的 1/4 ~ 1/5。
好消息是"最坏情况"很少发生:
- 大量 buffer 在 shared_buffers 里直接命中,根本不产生 I/O
- 由于 I/O 合并(
io_combine_limit,默认 128kB = 16 个块),一次信号往返摊到了 16 个块上,开销除以 16
但这也提醒了一件事:io_combine_limit 在 worker 模式下不只是省 IOPS,它直接决定了信号开销的摊薄倍数。 如果你的工作负载 I/O 合并率很低(比如高度随机的位图扫描),worker 模式的信号成本会浮上水面。
5.4 io_combine_limit 的数学:一个被低估的参数
既然信号开销靠 I/O 合并摊薄,那 io_combine_limit 到底该设多大?这个可以算。
设:
S= 合并后的单次 I/O 尺寸(字节)B= 8192(块大小)n = S / B= 一次 I/O 携带的块数C_sig= 一次信号往返成本(秒),实测约1/250000 ~ 1/500000C_io(S)= 存储完成尺寸为 S 的一次 I/O 的耗时
worker 模式下,读取 N 个块的总成本近似为:
T = (N/n) × C_sig + (N/n) × C_io(n × B) + N × C_cpu
└─ 信号开销 ─┘ └─── 存储开销 ───┘ └ CPU 开销 ┘
信号项随 n 增大而线性下降,这是调大 io_combine_limit 的收益。
代价在哪?两处:
代价一:读放大。 合并要求块号连续。如果实际需要的块是 [100, 101, 105, 106],用 128kB(16 块)合并会把 102/103/104 也一起读进来——读了不需要的数据,浪费带宽和 shared_buffers 空间。合并窗口越大,读放大的期望值越高。
代价二:延迟毛刺。 一次 1MB 的 I/O 比一次 8KB 的 I/O 完成得慢。对延迟敏感的短查询,大合并窗口会拉高 P99。
所以决策逻辑是:
访问模式高度连续(纯顺序扫描 / VACUUM 全表)
→ 读放大接近 0,放心调大到 256kB ~ 1MB
访问模式跳跃(位图扫描、选择率 1%~20% 的过滤)
→ 读放大明显,保持 128kB 默认,或视压测结果小幅上调
延迟敏感的 OLTP 短查询为主
→ 不要动,128kB 已经够了
怎么量化读放大? 对比逻辑读和物理读:
-- 跑目标查询前后各采一次
SELECT object, context,
reads,
read_bytes / 1024 / 1024 AS read_mb,
round(read_bytes::numeric / NULLIF(reads,0) / 1024, 1) AS avg_io_kb
FROM pg_stat_io
WHERE backend_type = 'client backend' AND object = 'relation';
-- 同时看 EXPLAIN 报的逻辑块数
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT count(*) FROM aio_test WHERE id % 7 = 0;
-- 关注 "Buffers: shared read=NNN"
如果 pg_stat_io 统计的物理读字节数 明显大于 EXPLAIN 报的 shared read × 8KB,差额就是合并带来的读放大。放大超过 30% 就该考虑调小 io_combine_limit 了。
5.5 worker 池的调度:为什么"宁多勿少"
worker 模式的调度逻辑很朴素:backend 把请求塞进共享内存队列,然后发信号唤醒一个 worker。
这里有个值得注意的细节:唤醒的是"一个",不是"广播"。PG 没有走惊群(thundering herd)路线——如果给所有 worker 发信号让它们抢,在 worker 多的时候会产生大量无效唤醒和上下文切换。
这个设计带来的后果是:worker 数量偏多的代价,比你想的要小。
- 空闲 worker 就是几个睡在信号上的进程,占的是内存(每个进程几 MB 的私有内存 + 共享内存映射)和一个进程槽位
- 它们不会因为存在就消耗 CPU
- 也不会因为数量多而增加单次 I/O 的调度成本
Vondra 的原话:
好的一面是,虽然 I/O 工作进程不是免费的,但它们的开销也不算高。因此,即便工作进程数量偏多,通常也比数量不足要好。
反过来,worker 数量不足的代价是灾难性的:所有 backend 的 I/O 请求排在同一个队列里,被 3 个 worker 串行消费。这不是"慢一点",这是把并发 I/O 退化成了 3 路串行。位图扫描场景下 io_workers=3 比 PG 17 同步模式还慢,就是这么来的。
未来 PG 计划让 worker 池变成自适应的——按需启停,始终保持最优数量。补丁已经在 commitfest 里了(编号 5913),但没赶上 18。在那之前,手动设置就是唯一选择,而且要往大了设。
有个实用的观测方法,判断 worker 是不是不够:
# 找出所有 I/O worker 进程
ps -eo pid,pcpu,pmem,etime,comm,args | grep 'io worker' | grep -v grep
# 或者用 top 盯住它们的 CPU
top -p $(pgrep -d, -f 'io worker')
判据:压测期间所有 I/O worker 的 CPU 都接近满载,而 backend 大量卡在 I/O 等待 → worker 不够,翻倍再测。
5.6 io_uring 的文件描述符地狱
io_uring 不用 IPC,避开了信号问题,但它有自己的税:文件描述符。
pgsql-hackers 上的原始描述是这样的:
问题在于,使用 io_uring 时,我们需要为每个可能的子进程创建一个 FD,以便一个后端进程可以等待由另一个后端进程发起的 I/O 完成。这些 io_uring 实例需要在主进程中创建,以便所有后端进程都能访问。显然,如果 max_connections 设置得较高,这有助于更快地达到未调整的软性 RLIMIT_NOFILE 限制。
注意这个 FD 是 postmaster 在启动时按"可能的最大进程数"预创建的,不是按需分配的。所以:
需要的额外 FD ≈ max_connections + autovacuum_worker_slots
+ max_worker_processes + max_wal_senders + 若干
max_connections = 1000 的实例,光 io_uring 就要吃掉 1000+ 个 fd。而很多发行版的默认软限制是 1024。服务直接起不来。
用 io_uring 之前先改:
# systemd 环境
sudo mkdir -p /etc/systemd/system/postgresql.service.d
cat <<'EOF' | sudo tee /etc/systemd/system/postgresql.service.d/limits.conf
[Service]
LimitNOFILE=65536
EOF
sudo systemctl daemon-reload
sudo systemctl restart postgresql
# 验证
cat /proc/$(pgrep -o postgres)/limits | grep 'open files'
顺带说个相关的坑:每个 backend 还会维护最多 max_files_per_process(默认 1000)个打开的 fd 缓存。在分区表或者按租户分 schema 的场景下,文件数量爆炸,很容易触发频繁且昂贵的 open/close。这跟 io_uring 是两个独立问题,但它们会叠加。
5.7 容器环境:io_uring 可能被静默禁用
这是生产上最坑的一条。多个容器运行时(如 containerd)出于安全考虑默认禁用了 io_uring 相关系统调用。
原因不难理解:io_uring 让用户态程序能更直接地驱动内核,历史上出过一批 CVE,也被证明可以用来构造绕过传统 hook 检测的 rootkit(因为它的操作深度嵌入内核正常的异步 I/O 流程,基于行为模式或静态特征的检测很难发现)。所以默认 seccomp profile 把它关了。
后果是:你在 postgresql.conf 里写了 io_method = io_uring,实例照常启动,你以为在用异步,实际上被内核挡回来了。
诊断方法:
-- 1. 确认当前生效值
SHOW io_method;
-- 2. 确认二进制编译时带了 liburing
-- io_uring 需要 --with-liburing / -Dliburing 构建
SELECT * FROM pg_config WHERE name = 'CONFIGURE';
# 3. 从系统层确认 io_uring 是否可用
grep -i io_uring /proc/kallsyms | head # 内核是否有符号
cat /proc/sys/kernel/io_uring_disabled 2>/dev/null # 0=允许 1=受限 2=禁用
# 4. 容器里直接用 strace 抓
strace -f -e trace=io_uring_setup,io_uring_enter -p <backend_pid> 2>&1 | head -20
# 一个 seq scan 期间完全没有 io_uring_* 调用 = 没生效
如果确实无法启用(K8s 环境、托管服务、合规要求),老老实实用 worker 模式,把 io_workers 调上去,这是更稳妥的选择。
六、参数全解与调优决策树
6.1 PG 18 相关参数速查
| 参数 | 默认值 | 生效方式 | 说明 |
|---|---|---|---|
io_method | worker | 重启 | worker / io_uring / sync |
io_workers | 3 | reload | 仅 io_method=worker 时有效 |
io_max_concurrency | -1 | 重启 | 单进程最大在途 I/O 数,-1 自动推导,上限 64 |
effective_io_concurrency | 16 | 会话级可改 | PG 17 是 1,PG 18 提到了 16,范围 1~1000,0 禁用 |
maintenance_io_concurrency | 16 | 会话级可改 | 维护操作(VACUUM 等)的对应参数 |
io_combine_limit | 128kB | 会话级可改 | I/O 合并的最大尺寸 |
io_max_combine_limit | 128kB | 重启 | 上面那个的硬顶,Unix 最大约 1MB,Windows 128kB |
重点提醒两个容易搞错的地方:
effective_io_concurrency的默认值从 PG 17 的1变成了 PG 18 的16。如果你从 17 升级并且复制了旧的 postgresql.conf,你会带着effective_io_concurrency = 1进入 18,等于把 AIO 的并发预取阉割了。升级后一定要检查这一项。io_combine_limit想调大必须同时调io_max_combine_limit,否则会被静默截断。而io_max_combine_limit需要重启。
6.2 调优决策树
┌─ 你在 Linux 且能确认 io_uring 真的可用?
│
否 ───────┴──────── 是
│ │
io_method = worker 你的负载画像是?
(唯一选择) │
│ ┌───────┴────────┐
│ 高并发 OLTP 低并发大扫描
│ (100+ 活跃连接) (数仓 / 报表 / ETL)
│ │ │
│ 可以试 io_uring 坚持 worker
│ (并行度跟随连接) (worker 数放大 CPU 并行)
│ 记得先改 ulimit -n
│
↓
调 io_workers:
起步 = CPU 核数 × 25%
I/O 密集可以拉到 = CPU 核数 × 100%
(worker 不免费,但开销不高;宁多勿少)
Vondra 的核心建议原文:
- 保留
io_method = worker的默认值:除非通过基准测试证明 io_uring 对您的工作负载更优,否则不建议切换。- 根据 CPU 核心数调整
io_workers:建议从核心数的 25% 开始配置,在 I/O 密集场景下可尝试提高至 100%。
注意 io_workers = 3 这个默认值有多离谱:Vondra 的测试里,位图扫描场景下 io_workers=3 在所有测试点上都是最慢的配置——比 PG 17 的同步模式还慢。换成 12 个 worker 后反超所有其他方法。
也就是说:如果你升级到 PG 18 什么都不改,位图扫描重的负载可能会变慢。 这不是理论推测,是官方开发者自己跑出来的数据。
6.3 一份可直接抄的生产配置
以一台 32 核 / 128GB RAM / NVMe SSD 的机器为例:
# ============ AIO 核心 ============
io_method = worker
io_workers = 8 # 32 核 × 25%,I/O 重可以上到 16~32
# ============ I/O 并发深度 ============
effective_io_concurrency = 64 # NVMe 队列深,可以压
maintenance_io_concurrency = 64 # VACUUM / 索引构建同样受益
# ============ I/O 合并 ============
io_max_combine_limit = 256kB # 需要重启
io_combine_limit = 256kB # 摊薄 worker 模式的信号开销
# ============ 相关基础参数 ============
shared_buffers = 32GB # 25% RAM
max_wal_size = 16GB
maintenance_work_mem = 2GB
云盘(EBS / 云硬盘)环境要更激进,因为 T_io 更大:
io_workers = 16 # 云盘延迟高,需要更多在途 I/O
effective_io_concurrency = 200 # 云盘 IOPS 靠并发堆
maintenance_io_concurrency = 200
但请务必自己压测。 上面这些是起点不是终点。下一节给你压测方法。
七、实战:怎么验证 AIO 真的在干活
7.1 用 pg_aios 直接看在途 I/O
PG 18 新增了 pg_aios 系统视图,列出所有正在使用的 AIO 句柄。这是最直接的证据。
-- 需要 superuser 或 pg_read_all_stats 权限
SELECT pid, io_id, state, operation, length,
target, handle_data_len, result, f_sync, f_buffered
FROM pg_aios
ORDER BY pid, io_id;
state 字段就是句柄状态机,一共七个状态:
| 状态 | 含义 |
|---|---|
HANDED_OUT | 已分配给代码,但还没定义操作 |
DEFINED | 执行所需信息已知 |
STAGED | 准备就绪,待提交 |
SUBMITTED | 已提交执行 |
COMPLETED_IO | I/O 完成,结果尚未处理 |
COMPLETED_SHARED | 共享完成处理已做完 |
COMPLETED_LOCAL | 后端本地完成处理已做完 |
result 字段的四个值也值得记:
OK—— 成功PARTIAL—— 无错但没读完,调用方需要为剩余部分重发 I/O(AIO 子系统不做透明重试!)WARNING—— 成功但有告警,比如开了zero_damaged_pages遇到坏页ERROR—— 失败
实战验证脚本——开两个会话:
-- 会话 A:跑一个大表顺序扫描,制造持续 I/O
DROP TABLE IF EXISTS aio_test;
CREATE TABLE aio_test AS
SELECT i AS id, md5(i::text) AS h, repeat('x', 200) AS pad
FROM generate_series(1, 20000000) i;
-- 清空缓存影响(生产别做,测试机随意)
-- Linux: sudo sync && echo 3 | sudo tee /proc/sys/vm/drop_caches
-- 然后重启 PG 清空 shared_buffers
SET max_parallel_workers_per_gather = 0; -- 关并行,看单进程行为
EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM aio_test WHERE pad LIKE '%zzz%';
-- 会话 B:扫描期间高频采样
SELECT state, operation, count(*), sum(length)/1024/1024 AS mb
FROM pg_aios
GROUP BY 1, 2;
如果 io_method = worker 生效了,你会在会话 B 看到一批 SUBMITTED / COMPLETED_IO 状态的 readv 操作,pid 是 I/O worker 的进程号。如果 io_method = sync,pg_aios 基本是空的或者只有瞬时的一两行——因为同步 I/O 发起即完成,抓不到在途状态。
7.2 pg_stat_io:看聚合视角
pg_aios 是瞬时快照,pg_stat_io(PG 16 引入,18 增强)是累计统计:
SELECT backend_type, object, context,
reads, read_bytes, read_time,
writes, write_bytes, write_time,
evictions, hits
FROM pg_stat_io
WHERE reads > 0 OR writes > 0
ORDER BY reads DESC;
调 io_workers 前后各跑一次,对比 read_time / reads(平均单次读延迟)和 read_bytes / reads(平均 I/O 尺寸)。
平均 I/O 尺寸是判断 I/O 合并有没有生效的关键指标:
- 接近 8KB → 完全没合并,检查
io_combine_limit和访问模式 - 接近
io_combine_limit→ 合并良好,信号开销已被摊薄
7.3 等待事件:找瓶颈
-- 采样等待事件分布
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active' AND backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;
重点关注 IO 类型下的等待事件。如果你在 worker 模式下看到大量 backend 卡在等 I/O 完成,而 I/O worker 进程的 CPU 又都跑满了 —— 教科书级的 io_workers 不足信号,直接加。
反过来,如果 I/O worker 大量空闲而 backend 还在等,那瓶颈就不在 worker 数量上,去查存储本身或者 effective_io_concurrency。
7.4 一个完整的 A/B 压测脚本
#!/usr/bin/env bash
# aio_bench.sh —— 对比不同 io_workers 配置下的顺序扫描性能
set -euo pipefail
PGDB="${PGDB:-postgres}"
QUERY="SET max_parallel_workers_per_gather=0;
SELECT count(*) FROM aio_test WHERE pad LIKE '%zzz%';"
run_case () {
local method="$1" workers="$2"
echo "=== io_method=$method io_workers=$workers ==="
psql -d "$PGDB" -qc "ALTER SYSTEM SET io_method = '$method';"
psql -d "$PGDB" -qc "ALTER SYSTEM SET io_workers = $workers;"
# io_method 改动需要重启
pg_ctl restart -D "$PGDATA" -m fast -w > /dev/null
# 冷缓存:重启已清空 shared_buffers,再清 page cache
sync; echo 3 | sudo tee /proc/sys/vm/drop_caches > /dev/null
# 跑 3 次取中位数
for i in 1 2 3; do
/usr/bin/time -f " run$i: %e s" \
psql -d "$PGDB" -qc "$QUERY" > /dev/null
done
psql -d "$PGDB" -qtc \
"SELECT ' avg_io_size=' || round(read_bytes::numeric/NULLIF(reads,0)/1024, 1) || ' KB'
FROM pg_stat_io WHERE object='relation' AND context='normal'
AND backend_type='client backend';"
}
run_case sync 3
run_case worker 3
run_case worker 8
run_case worker 16
run_case io_uring 3 # 若不支持会启动失败,注意兜底
跑之前记得:这个脚本会重启数据库并清 page cache,只在测试机上用。
八、六个必须知道的边界与坑
坑 1:只有读,没有写
这是最大的认知误区。PG 18 的 AIO 只实现了异步读。所有写路径——checkpoint 的脏页刷盘、WAL 写入、后台写进程——全部还是同步的。
所以如果你的瓶颈是写(大量 UPDATE/INSERT、checkpoint 尖刺、WAL 刷盘延迟),PG 18 的 AIO 对你一点帮助都没有。别抱期待,也别为此升级。
README 里明确写了 WAL 走 AIO 的收益预期(能提前发起 WAL 写、多个 flush 并行、O_DIRECT + O_DSYNC 单次 FUA 写省一个 round trip),但那是未来版本的事。
坑 2:索引扫描完全没走 AIO
Index Scan / Index Only Scan 的所有 I/O 仍是同步的。Vondra 的基准测试里,索引扫描在四种 io_method 下耗时完全一致——因为它们走的是同一条同步路径。
索引预取(index prefetching)的补丁还在 commitfest 里排队。所以:点查为主的 OLTP 负载,从 AIO 上几乎拿不到收益。
坑 3:升级时带过来的旧配置会阉割 AIO
再强调一遍,因为这个坑太容易踩:
-- 从 PG 17 升级后,第一件事
SELECT name, setting, boot_val, source, sourcefile
FROM pg_settings
WHERE name IN ('effective_io_concurrency', 'maintenance_io_concurrency',
'io_method', 'io_workers', 'io_combine_limit');
看 source 列。如果 effective_io_concurrency 的 source 是 configuration file 而 setting 是 1,说明你带了 PG 17 的旧值过来。boot_val 会告诉你 PG 18 的原生默认是 16。
坑 4:io_max_concurrency 的 64 上限
io_max_concurrency 默认 -1 自动推导,但上限硬编码为 64。这意味着单个进程最多 64 个在途 I/O。
如果你把 effective_io_concurrency 设到 200 甚至 1000,指望单个顺序扫描能压 200 个并发 I/O —— 不可能,会被 io_max_concurrency 卡在 64。
effective_io_concurrency 更多是影响预取距离和调度策略,不等于实际在途上限。这两个参数的关系是很多调优文章讲错的地方。
坑 5:worker 模式下的 CPU 记账变了
I/O worker 是独立进程,它们消耗的 CPU(尤其是校验和验证和 memcpy)不算在发起查询的 backend 头上。
后果:
EXPLAIN (ANALYZE, BUFFERS)里的时间可能比实际系统消耗低- 基于单进程 CPU 的监控告警可能失真
- 容器环境下,
io_workers消耗的 CPU 要计入 cgroup 配额 ——io_workers开太多可能触发 CPU throttling,反而变慢
在 K8s 里跑 PG 18,io_workers 的设置要参考 CPU limit 而不是宿主机核数。
坑 6:PG 18 默认开了 data checksums
PG 18 的 initdb 默认启用数据校验和。这本身是好事(能发现静默数据损坏),但它给 I/O 路径加了固定 CPU 开销。
这个开销放大了前面说的"带宽由 CPU 决定"效应。如果你的负载对吞吐极度敏感,且存储层已经有端到端校验(比如 ZFS、企业存储阵列),可以评估:
# 检查是否启用
pg_controldata $PGDATA | grep checksum
# PG 18 可以在线关闭(需要短暂的集群级操作,谨慎)
# 或者 initdb 时用 --no-data-checksums
但默认建议是保持开启。 校验和救过太多人的命,那点 CPU 值得。
九、常见疑问快答
Q: 我用的是 RDS / 云托管 PostgreSQL 18,能调这些参数吗?
io_workers 和 effective_io_concurrency 通常在参数组里开放。io_method 需要重启且涉及编译选项(io_uring 需要 --with-liburing 构建),托管服务大概率锁死在 worker。所以对云托管用户来说,能调的就是 io_workers 和 effective_io_concurrency 这两个——好消息是这俩恰好是收益最大的。
Q: 主从复制中,备库能享受 AIO 吗?
备库上的查询(Hot Standby 读)走的是同样的 Read Stream 路径,能享受 AIO。但 WAL 重放(recovery) 目前还是同步 I/O 路径。所以备库延迟大的问题,AIO 帮不上忙。
Q: io_workers 会不会和 max_parallel_workers 抢资源?
不会。I/O worker 是独立的进程类型,不占 max_worker_processes 的配额,不和并行查询 worker 共享池子。但它们确实抢 CPU——所以在 CPU 受限环境(容器)要通盘考虑总进程数。
Q: 并行顺序扫描(Parallel Seq Scan)和 AIO 是什么关系?
互补,不冲突。并行扫描是把块范围分给多个 backend,每个 backend 独立跑自己的 Read Stream,各自发起 AIO。所以 4 个并行 worker × 各自的预取深度 = 更高的总在途 I/O 数。
但要注意:这也意味着并行度高时 io_workers 的压力更大。8 个并行 worker 全在压 I/O,而你只有 3 个 io worker 在消费队列——瓶颈非常明显。
Q: 怎么快速判断我的负载能不能从 AIO 受益?
升级前,在 PG 17 上跑这个:
-- 看 I/O 等待占比
SELECT wait_event_type, wait_event, count(*) AS samples
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY 1,2 ORDER BY 3 DESC;
多采样几次(或者上 pg_wait_sampling 扩展)。如果 IO 类型的等待占活跃时间 20% 以上,且主要来自顺序扫描 / 位图扫描 / VACUUM,那 AIO 对你有实打实的价值。如果 I/O 等待只占 5%,别折腾了,瓶颈在别处。
Q: io_method = sync 到底什么时候该用?
三种情况:
- 调试——排查怀疑是 AIO 引起的问题时,切 sync 做对照
- 需要严格复现 PG 17 行为(比如做 A/B 对比测试)
- 极端内存受限环境,连 3 个 worker 进程都不想要
但记住 Vondra 的提醒:sync 模式依然要走一遍 AIO 基础设施,它不等于 PG 17,也不保证不掉性能。它是"兼容选项"而不是"零开销回退"。
十、AIO 之外:PG 18 值得同步关注的几个改动
顺手把几个和 AIO 有协同效应的特性串一下:
1. 虚拟生成列(Virtual Generated Columns)
PG 18 的生成列默认变成虚拟的——查询时计算,不占存储。
CREATE TABLE metrics (
id bigint PRIMARY KEY,
bytes_in bigint,
bytes_out bigint,
total bigint GENERATED ALWAYS AS (bytes_in + bytes_out) VIRTUAL
);
这和 AIO 是正向协同:表更小 → 顺序扫描要读的块更少 → I/O 压力下降。存储层的每一个字节节省,在 AIO 时代都直接换算成扫描速度。
2. uuidv7()
SELECT uuidv7();
-- 时间戳前缀 + 随机后缀,单调递增
对比 uuidv4:v4 完全随机 → B-tree 插入点随机分布 → 页分裂频繁 → 索引膨胀 → 随机 I/O 暴增。v7 带时间戳前缀 → 插入集中在右端 → 局部性好 → 随机 I/O 变顺序 I/O。
这一条对 AIO 的意义特别大:AIO 对顺序扫描的加速远大于随机读,v7 相当于把一部分随机访问模式转化成了 AIO 擅长的形态。
3. 升级体验改进
pg_upgrade 现在可以保留优化器统计信息,不用升级完再跑一遍全库 ANALYZE。对大库来说这是从"停机数小时"到"停机数分钟"的差别。
顺带说,PG 19 Beta 已经出来了,Postgres 19 计划把 TOAST 默认压缩算法从 pglz 换成 LZ4(压缩速度快约 8 倍,滑动窗口从 4kB 提到 64kB)。又是一个"减少物理 I/O 量"的改进——和 AIO 是同一个方向上的两条腿。
十一、总结:怎么理性看待 PG 18 的 AIO
它到底解决了什么
一句话:把 I/O 调度权从内核手里拿回来,让数据库用自己的语义知识做预取。
收益的大小完全取决于 T_io / T_cpu 这个比值:
| 场景 | 预期收益 |
|---|---|
| 云盘 + 大表顺序扫描 | ⭐⭐⭐⭐⭐ 最大受益者 |
| 云盘 + VACUUM / 位图扫描 | ⭐⭐⭐⭐ 明显收益 |
| 本地 NVMe + 顺序扫描 | ⭐⭐⭐ 有收益 |
| 内存充足、命中率 99% 的 OLTP | ⭐ 基本无感 |
| 写密集 / checkpoint 瓶颈 | ⭕ 零收益(异步写没做) |
| 点查 / 索引扫描为主 | ⭕ 零收益(索引没 Read Stream 化) |
决策清单
该升级 PG 18 吗?
- 数仓 / 报表 / ETL 跑在云盘上 → 强烈建议,这是为你做的特性
- OLTP 但有大量全表扫描或大 VACUUM → 建议
- 纯点查 OLTP,内存够大 → 为别的特性升(uuidv7、虚拟列、升级体验),AIO 别抱期待
- 写瓶颈型负载 → 等 PG 19/20 的异步写
升级后的必做三件事:
- 检查
effective_io_concurrency有没有被旧配置带成1 - 把
io_workers从 3 调到 CPU 核数的 25%(容器里按 CPU limit 算) - 用
pg_aios+pg_stat_io确认 AIO 真的生效了,别自我感觉良好
别做的两件事:
- 别看到"io_uring 最高效"就切过去——顺序扫描场景它是输给 worker 的,而且有 fd 和容器兼容性两个雷
- 别指望调个参数就性能翻倍——AIO 的收益高度依赖工作负载画像,压测是唯一的真理
更深一层的东西
我觉得 PG 18 AIO 这件事,比性能数字更值得琢磨的是它的设计约束是怎么倒推出实现方案的:
- 因为要在临界区启动 I/O →
pgaio_io_acquire()必须永不失败 - 因为必须永不失败 + 句柄数有限 → 一次只能持有一个未定义句柄
- 因为共享内存不能存函数指针 → 回调用 ID 而非指针
- 因为句柄要立即复用 → 等待必须靠带 generation 的 wref
- 因为完成回调可能在临界区/其他进程执行 → 错误不能直接抛,得编码进 result
- 因为要防死锁 → io_method 必须满足"任意进程可完成"或"发起方阻塞也能完成"
每一条实现细节都不是拍脑袋来的,都是从一条硬约束推出来的。 这种"约束驱动设计"的思路,比记住 io_workers 该设多少有价值得多。
顺便说一句:Read Stream 这个抽象的先见之明也值得学。PG 17 先立接口、PG 18 换实现,上层调用方几乎零改动就吃到了异步 I/O 的红利。先做抽象再做优化,这个顺序在任何系统里都成立。
AIO 的故事才写了开头。异步写、Direct I/O、WAL 的 O_DIRECT + O_DSYNC、索引预取——README 里全都留了钩子。PG 未来几个版本在存储层的动作,大概率都会挂在这套基础设施上。
现在开始理解它,不算早。
参考资料
- PostgreSQL 18 官方文档 · 19.4. Resource Consumption
- PostgreSQL 18 官方文档 · 53.2. pg_aios
- PostgreSQL 源码 ·
src/backend/storage/aio/README.md - Tomas Vondra · Tuning AIO in PostgreSQL 18
- pgsql-hackers 邮件列表 · io_method 默认值讨论线程
- PostgreSQL 18 Release Announcement