编程 PostgreSQL 18 异步 I/O 深度拆解:pgaio 句柄状态机、三种 io_method 的真实代价,以及一份能落地的调优决策树

2026-08-02 00:49:19 +0800 CST views 4

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 SSDT_io ≈ 80100μs,T_cpu(校验和 + memcpy 8KB)≈ 25μs。比值约 20~50。异步收益巨大。
  • 云盘(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 是基于访问模式的——它看到你连续读了几个相邻块,就猜你要顺序扫描,于是往前预读一段。但:

  1. 位图堆扫描(Bitmap Heap Scan) 的访问序列是"跳跃但有序"的,内核的顺序检测器容易判定为随机,直接关掉预读。
  2. 索引扫描的块号序列由索引结构决定,内核完全无法预测。
  3. 即使猜对了,数据只是进了 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_connectionsautovacuum_worker_slotsmax_worker_processesmax_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 用回调 IDPgAioHandleCallbackID)代替函数指针——一个字节存一个回调,查表分发。这个设计副产品还挺香:句柄结构体省了大量内存。

3.3 AIO Target:谁在被读写

每个句柄有且仅有一个 "target",描述 I/O 的对象身份。目前只有 smgr 一种(关系文件),用 RelFileLocator + ForkNumber + BlockNumber 唯一标识。

Target 提供两个能力:

  1. 重新打开文件——worker 模式必需。因为发起 I/O 的是 backend,执行 I/O 的是 worker 进程,worker 手里没有 backend 的 fd,必须能根据 target 信息自己 open。
  2. 描述 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)

两个原因叠加:

  1. 完成回调可能在临界区内执行(见约束一)——临界区里报 ERROR 直接升级成 PANIC,整个实例挂掉。
  2. 完成回调可能被任何 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 读取的总成本包括:

  1. 实际的 I/O 操作
  2. 校验和验证(PG 18 起 initdb 默认开启 data checksums!)
  3. 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/500000
  • C_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_methodworker重启worker / io_uring / sync
io_workers3reloadio_method=worker 时有效
io_max_concurrency-1重启单进程最大在途 I/O 数,-1 自动推导,上限 64
effective_io_concurrency16会话级可改PG 17 是 1,PG 18 提到了 16,范围 1~1000,0 禁用
maintenance_io_concurrency16会话级可改维护操作(VACUUM 等)的对应参数
io_combine_limit128kB会话级可改I/O 合并的最大尺寸
io_max_combine_limit128kB重启上面那个的硬顶,Unix 最大约 1MB,Windows 128kB

重点提醒两个容易搞错的地方:

  1. effective_io_concurrency 的默认值从 PG 17 的 1 变成了 PG 18 的 16。如果你从 17 升级并且复制了旧的 postgresql.conf,你会带着 effective_io_concurrency = 1 进入 18,等于把 AIO 的并发预取阉割了。升级后一定要检查这一项。

  2. 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_IOI/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 = syncpg_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_concurrencysourceconfiguration filesetting1,说明你带了 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_workerseffective_io_concurrency 通常在参数组里开放。io_method 需要重启且涉及编译选项(io_uring 需要 --with-liburing 构建),托管服务大概率锁死在 worker。所以对云托管用户来说,能调的就是 io_workerseffective_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 到底什么时候该用?

三种情况:

  1. 调试——排查怀疑是 AIO 引起的问题时,切 sync 做对照
  2. 需要严格复现 PG 17 行为(比如做 A/B 对比测试)
  3. 极端内存受限环境,连 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 的异步写

升级后的必做三件事:

  1. 检查 effective_io_concurrency 有没有被旧配置带成 1
  2. io_workers 从 3 调到 CPU 核数的 25%(容器里按 CPU limit 算)
  3. pg_aios + pg_stat_io 确认 AIO 真的生效了,别自我感觉良好

别做的两件事:

  1. 别看到"io_uring 最高效"就切过去——顺序扫描场景它是输给 worker 的,而且有 fd 和容器兼容性两个雷
  2. 别指望调个参数就性能翻倍——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 未来几个版本在存储层的动作,大概率都会挂在这套基础设施上。

现在开始理解它,不算早。


参考资料

推荐文章

Vue3中如何实现响应式数据?
2024-11-18 10:15:48 +0800 CST
Manticore Search:高性能的搜索引擎
2024-11-19 03:43:32 +0800 CST
免费常用API接口分享
2024-11-19 09:25:07 +0800 CST
html一些比较人使用的技巧和代码
2024-11-17 05:05:01 +0800 CST
程序员茄子在线接单