io_uring、并把「Streaming I/O」推广到更多代码路径。我们用 gdb 把「一次顺序扫描的读」拆成后端分期和 io worker 执行两端,看清楚 PG 到底是怎么把磁盘读从 backend 身上卸下来的。
0. 先说结论(给赶时间的你)
PostgreSQL 传统上每个 backend 自己调 pread
/preadv
做同步读,读盘的毫秒级延迟直接卡住 backend。PG18 引入 AIO 子系统:
- backend 只负责「分期」
(stage)一次读:填好 PgAioHandle
,入队,立刻返回; - 专门的 io worker 进程
负责「执行」:真正发 pg_preadv
系统调用; 后端用 read_stream
(Streaming I/O 基础设施)做预读和合并,把一堆小读合并成大块readv
。
我们用 gdb 在 bigt
(600 万行)冷读时,同时抓住了这两端。
1. 场景:顺序扫描的读,以前卡在谁身上?
sql -- 冷启动后第一次扫全表(shared_buffers 为空)
SELECT count(*) FROM bigt; -- bigt: 6,000,000 行
在 PG17 及以前,这条语句的每个 backend 走到 heapgettup_pagemode
取下一页时,如果页不在 shared_buffers
,就得自己阻塞着等磁盘。并发一高,backend 全堵在 I/O 上,CPU 白白空闲。
PG18 之后,这个读被拆开了。我们用相同的调试实例(端口 5618,--enable-debug --enable-cassert
,PG18.4)实测。
2. gdb 实测之一:后端只做「分期」
断点:pgaio_io_start_readv
+ StartReadBuffers
,cold read bigt
。
===== pgaio_io_start_readv (backend stages an async READ) =====
ioh->state=1 <-- HANDED_OUT:只拿到句柄,尚未执行
#0 pgaio_io_start_readv (ioh=0x7fffedee8990, fd=29, iovcnt=1, offset=8192) at aio_io.c:81
#1 FileStartReadV (ioh=..., file=22, iovcnt=1, offset=8192, ...) at fd.c:2241
#2 mdstartreadv (ioh=..., reln=0x13748e8, forknum=MAIN_FORKNUM, blocknum=1, ...) at md.c:1030
#3 smgrstartreadv (ioh=..., reln=0x13748e8, forknum=MAIN_FORKNUM, ...) at smgr.c:758
#4 AsyncReadBuffers (operation=0x7fffffffbc90, nblocks_progress=...) at bufmgr.c:1954
#5 StartReadBuffersImpl (operation=..., blockNum=1, nblocks=..., flags=8, ...) at bufmgr.c:1428
同时抓到的「发起者」调用栈,正是 Streaming I/O 路径:
===== StartReadBuffers (multi-block read initiator) =====
#0 StartReadBuffers (operation=0x12ff948, blockNum=0, nblocks=..., flags=0) at bufmgr.c:1495
#1 read_stream_start_pending_read (stream=0x12ff738) at read_stream.c:356
#2 read_stream_look_ahead (stream=0x12ff738) at read_stream.c:514
#3 read_stream_next_buffer (stream=0x12ff738, ...) at read_stream.c:889
#4 heap_fetch_next_buffer (scan=0x12ff328, ...) at heapam.c:678
#5 heapgettup_pagemode (scan=0x12ff328, ...) at heapam.c:1040
关键事实:
后端一路走到 pgaio_io_start_readv
(aio_io.c:81),它做的事只是pgaio_io_stage(ioh, PGAIO_OP_READV)
——把读请求登记到句柄上(ioh->state=1
=PGAIO_HS_HANDED_OUT
),并不真正读盘。真正的「发起者」是 heapgettup_pagemode → read_stream_* → StartReadBuffers
。read_stream.c
(Streaming I/O 基础设施)在 PG18 源码里就已存在(1092 行),说明预读/合并这条能力在 18 就落地了。
3. gdb 实测之二:io worker 才做「执行」
pgaio_io_start_readv
只是分期。真正发 pg_preadv
的是 io worker 进程。我们对一个 io worker(PID 9985)挂 gdb,断 pgaio_io_perform_synchronously
:
===== WORKER: pgaio_io_perform_synchronously (pg_preadv) =====
ioh->op=1 <-- PGAIO_OP_READV
#0 pgaio_io_perform_synchronously (ioh=0x7fffedfcbd00) at aio_io.c:118
#1 IoWorkerMain (startup_data=0x0, startup_data_len=0) at method_worker.c:565
#2 postmaster_child_launch (child_type=B_IO_WORKER, ...) at launch_backend.c:290
#3 StartChildProcess (type=B_IO_WORKER) at postmaster.c:3962
#4 maybe_adjust_io_workers () at postmaster.c:4393
#5 PostmasterMain (argc=3, argv=0x12c1570) at postmaster.c:1383
#6 main (argc=3, argv=0x12c1570) at main.c:227
铁证:这是一个由 postmaster 以 B_IO_WORKER
类型专门启动的辅助进程,它的主循环 IoWorkerMain
反复调用 pgaio_io_perform_synchronously
(aio_io.c:118),而该函数里就是:
c case PGAIO_OP_READV:
result = pg_preadv(ioh->op_data.read.fd, iov,
ioh->op_data.read.iov_length,
ioh->op_data.read.offset); aio_io.c:128
真正阻塞在磁盘 I/O 上的,是 io worker,不是你的业务 backend。backend 把读分期入队后就去干别的(或等 latch 被唤醒),io worker 并发地把一批读刷到磁盘。
4. 源码结构:AIO 子系统怎么组织的
源码目录 src/backend/storage/aio/
:
aio.c | |
aio_io.c | pgaio_io_start_readvpgaio_io_perform_synchronously(执行,内含 pg_preadv) |
method_worker.c | io_method=workerIoWorkerMain是 worker 主循环 |
method_sync.c | io_method=sync |
method_io_uring.c | io_method=io_uring |
read_stream.c |
调用分工(对照 gdb):
后端(backend):
heapgettup_pagemode
→ read_stream_next_buffer read_stream_look_ahead (read_stream.c)
→ StartReadBuffers → StartReadBuffersImpl → AsyncReadBuffers
→ smgrstartreadv → mdstartreadv → FileStartReadV
→ pgaio_io_start_readv ★ 仅分期 (state=HANDED_OUT),入队
io worker 进程:
IoWorkerMain
→ pgaio_io_perform_synchronously ★ 真正执行
→ pg_preadv (系统调用,阻塞在这里)
pgaio_worker_submit_internal
(method_worker.c:244)的逻辑很典型:把分期好的 IO 插进 AioWorkerSubmissionQueue
,选一个空闲 worker 用 SetLatch
唤醒;队列满时剩下的退化为 pgaio_io_perform_synchronously
同步执行(保并发)。
5. 实例里的异步 I/O 长什么样
调试实例(PG18.4)实际配置:
sql SELECT name, setting, unit FROM pg_settings
WHERE name IN ('io_method','io_workers','io_combine_limit','io_max_concurrency');
-- io_method | worker
-- io_workers | 3 <-- PG18 叫 io_workers,PG19 改名 io_max_workers
-- io_combine_limit | 16 <-- 16 个块 = 128kB(合并上限)
-- io_max_concurrency | 64
进程视角(ps
),确实拉起了 3 个专用进程:
postgres: io worker 0
postgres: io worker 1
postgres: io worker 2
pg_stat_activity.backend_type = 'io worker'
也能查到这 3 个进程——它们就是 IoWorkerMain
的实体。
6. PG19 视角:把 AIO 做厚
PG19 在 PG18 的 AIO 骨架上加了一批东西(已对照 PG19 官方文档核实):
io_workers | io_max_workers(本机 PG19 beta 实测默认 4;最终 GA 默认值以官方文档为准) | |
io_min_workers(默认 2) | ||
io_max_concurrency | ||
workersync | io_uring( worker/ io_uring/ sync) | |
io_combine_limit | io_max_combine_limit(默认 16 块=128kB,作为前者的硬上限静默截断) | |
io_worker_idle_timeout(默认 1min=60000ms)、 io_worker_launch_interval(默认 100ms) | ||
read_stream |
几个要点:
io_workers
→io_max_workers:PG19 把它从「固定数量」变成「最大数量」,并配合 io_min_workers
让 postmaster 按需启动/回收 worker(对应源码maybe_adjust_io_workers
)。io_uring
方法:PG19 实验性支持 Linux io_uring
,比 worker 进程模型更轻量,省去进程间队列和上下文切换。生产环境是否上,看内核版本与压测。io_max_concurrency
默认 64(PG18/PG19 一致):控制单个 io worker 能并发发起的 I/O 数——大多数场景不用手调。 - Streaming I/O 是贯穿 PG18→19 的主线
:PG18 已把 read_stream.c
写进源码,PG19 把它用得更广。理解 AIO,先理解read_stream
。
资深视角:AIO 改的是 I/O 调度范型,且与 effective_io_concurrency
衔接。PG18 的 AIO 首版主要覆盖关系页读取(顺序扫描、bitmap heap scan、索引扫描的预取);PG19 进一步把 checkpoint 写、部分 WAL 写也纳入 AIO 调度。无论哪种 io_method
,上层都是 read_stream
在做预读与合并;而 read_stream
向前看多远,由 effective_io_concurrency
(本机 PG18 实测默认 16)界定——它现在不再是老式的 posix_fadvise
软建议,而是直接决定交给 io worker 的预取并发度。所以有人问"开了 AIO 还要不要管 effective_io_concurrency
",答案是要:它仍是预取窗口的大小,调大能让 io worker 更饱和地并行读。
注意:PG18 的 io_method
只支持 worker
和 sync
;io_uring
是 PG19 才出现的选项。调试实例(PG18.4)跑的是 worker
。
7. 小结
一次「冷的顺序扫描读」被拆成后端分期( pgaio_io_start_readv
,只登记句柄)和 io worker 执行(pgaio_io_perform_synchronously
→pg_preadv
)两端。阻塞在磁盘 I/O 上的是 io worker,不是业务 backend——这是 PG18 AIO 的核心收益。 read_stream(Streaming I/O)负责预读与合并,是 AIO 的上层驱动器,PG18 已存在。 PG19 把 worker 数量改成 io_max_workers
(本机 beta 实测默认 4,GA 以官方文档为准)+io_min_workers
(默认 2),新增io_max_concurrency
、合并上限与 worker 生命周期 GUC,并实验性支持io_uring
。
8. 复现附录(gdb 配方)
调试环境:PG18.4(--enable-debug --enable-cassert
),端口 5618,用户 postgres18
,数据目录 /pgdata/postgres18/data
。
关键坑(踩过):
gdb 断点命令里的 printf
用%p
会报 *Wrong number of arguments* 并 detach——用%d
/%x
或纯文本。后端侧断 pgaio_io_start_readv
只在冷读(shared_buffers 为空)时触发;暖读走缓存不进 AIO 路径。要抓 worker 侧执行,必须 gdb -p <io worker 的 PID>
,且需handle SIGUSR1 nostop noprint pass
(worker 间用 SIGUSR1 通信,否则 gdb 会被信号打断)。多 worker 时把 gdb 用 timeout
后台化会被 ssh 会话回收导致输出丢失;用前台直跑 +setsid
启动读更稳。
后端侧:
# 冷读(重启实例清空 shared_buffers 后)
SELECT pg_sleep(25); SELECT count(*) FROM bigt;
# gdb 断点
break pgaio_io_start_readv
break StartReadBuffers
调用链:heapgettup_pagemode → read_stream_next_buffer → read_stream_look_ahead → read_stream_start_pending_read → StartReadBuffers → StartReadBuffersImpl → AsyncReadBuffers → smgrstartreadv → mdstartreadv → FileStartReadV → pgaio_io_start_readv
。
worker 侧:
# 取 worker PID
SELECT pid FROM pg_stat_activity WHERE backend_type='io worker';
# gdb(前台)
handle SIGUSR1 nostop noprint pass
break pgaio_io_perform_synchronously
continue
调用链:IoWorkerMain → pgaio_io_perform_synchronously → pg_preadv
。
9. 官方文档核对表
io_methodworker,可选 worker/ io_uring/ sync,仅启动期可设 | ||
io_min_workersworker方法生效 | ||
io_max_workersio_workers=3,GA 默认以官方文档为准) | SHOW | |
io_max_concurrency | ||
io_combine_limitio_max_combine_limit默认 16 块/128kB 作为硬上限 | SHOW实测 | |
pgaio_*、 IoWorkerMain、 read_stream)存在于 PG18 源码 | ||
pg_preadv的分工 |




