1. 先说结论:io_method 到底管什么
PostgreSQL 18 开发周期里最让我这类做运维的人眼前一亮的,不是某个 SQL 语法糖,而是这个看起来有点"黑话"的 GUC:io_method。简单说,它决定 PostgreSQL 能不能以及用哪种方式把 I/O 交给内核异步执行。在 PG18 之前,阻塞式 pread/pwrite 是绝对主流;从 PG18 开始,执行器里的大部分预读、顺序扫描、位图堆扫描相关 I/O 都可以换成异步接口。这套机制在社区里讨论了挺久,落到参数上,我手上这个版本里主要有三个值:默认的 worker、Linux 上直通内核的 io_uring,以及什么都不做的 none。
我第一次看到这个参数时的反应跟大多数人一样:又一个玄学调优项?文档就一两句话,算法细节藏在源码深处。但好在 Linux 上有一个永远诚实的观察手段——strace。把系统调用摊开看,I/O 到底是同步阻塞、worker 模拟异步,还是真正进了 io_uring,一眼就能分清楚,比任何监控工具都直观。
这篇文章不打算讲"调了它性能提升百分之多少"这种结论,而是带你把 io_method 在 strace 下的各类行为看明白。看完你至少能回答三个问题:io_method 到底生效没有;不同取值下后端进程在系统调用层面分别做了什么;生产环境里要怎么验证、怎么决策。适合对 PostgreSQL 内部机制好奇的 DBA、刚接手 PG18 实例的运维,以及想排查慢查询却搞不清楚 I/O 子系统的人。
1.1 为什么第一眼就觉得很"玄学"
原因很简单:io_method 不像 shared_buffers 或 max_connections 那样,调大调小有非常直观的效果。它是一个"开关型"参数,打开之后性能未必立刻变好,数据量小的时候甚至完全看不出差别。它改变的是内核态和用户态的协作方式:选了 io_uring,后端进程不再自己调用 pread 去傻等,而是把一批读取请求塞进内核的提交队列,等数据准备好了再从完成队列里取结果。整个过程对 DBA 来说几乎是黑盒,很难根据 pg_stat 里的数字判断它到底干活没有。
再加上它在旧内核、容器、受限安全策略下还可能静默失效,或者只在某些 I/O 路径上生效。如果不顺着进程一级一级往下看,光靠参数值和性能监控曲线,确实只能靠猜。这也是我坚持用 strace 的原因——系统调用是用户态能看到的最后一层,所有优化和回退最终都会诚实地反映在这一层上,做不了假。
1.2 同步与异步最朴素的区别
先补一点背景,免得后面看代码发懵。同步 I/O 的模型是:进程发起一次 read,内核把数据放到用户缓冲区之后这次调用才返回。中间进程要么阻塞在不可中断睡眠状态,要么反复重试,总之这个线程在这段时间里没法干别的事。异步 I/O 的模型则是:进程把"我要读这些块"这个请求交给内核,内核立即返回,进程继续做自己的事;等数据准备好了,内核再通过某种机制通知进程来取结果。异步不是为了把单次 I/O 变快,而是为了让 CPU 不再干等磁盘。
PostgreSQL 过去对同步 I/O 做了大量工程优化,比如通过位图扫描一次取一堆 tid、按物理顺序预读、把多个请求尽量合并。但底层仍然是一个后端进程发一堆阻塞读,发一个等一个。io_method 就是想在这一层改变交互方式。用生活化的例子说:同步 I/O 是你去柜台办业务,一个一个窗口排队,办完一个再排下一个;异步 I/O 是你把一摞材料一次性交进去,拿个号去旁边坐着玩手机,叫到你再过去拿结果。代价是你要多管理一个"号牌",收益是过程中你的时间没有被柜台占死。
1.3 几个取值分别意味着什么
| 取值 | 原理 | 适用场景 |
|---|---|---|
| none | 完全不启用异步 I/O,回到并发预读但同步等待的老路 | 调试对照、最小化依赖、不确定环境因素时兜底 |
| worker | 后端进程把 I/O 请求打包交给辅助 worker 进程,由 worker 去执行阻塞读,再通过跨进程通信传回结果 | 兼容性优先,内核版本低,或容器权限受限 |
| io_uring | 直接使用 Linux io_uring 这套内核异步接口,请求进提交队列、完成进完成队列 | Linux 新内核、NVMe/SSD、高并发预读场景 |
需要提醒的是,不同发行版、不同编译参数下,GUC 的取值名称和默认值可能略有差异。有的编译版本默认值是 worker,有的是 none。你拿到实例后先执行SHOW io_method;确认,再去查对应版本的参数说明。别把别人的默认值当成自己的默认值,这是个很常见的认知坑。
2. 实验环境:怎么把一个带 io_uring 的 PG18 编译跑起来
要做这种内核级实验,最稳的不是依赖某个现成发行版,而是自己编译一个带 liburing 的 PostgreSQL 18,这样才能确认 io_method 的选项真的存在,而不是被编译期开关剪掉了。下面是我实际准备流程,每一条都可以直接照抄。
2.1 先检查宿主机到底支持不支持
io_uring 对内核版本有要求。Linux 5.1 开始有基本能力,5.10 之后进入比较可用的状态,越新越省心。我建议至少 5.10 以上,6.x 更好。检查方法很简单:
uname -r grep io_uring /proc/filesystems如果/proc/filesystems里没有 io_uring 这一行,说明当前内核编译时根本没带这个模块,后面就不用白折腾了。如果你在容器里跑,先别急着怪 PostgreSQL,先看:
grep Seccomp /proc/self/statusSeccomp 为 2 的容器可能把某些新系统调用拦住。这个环境因素在后面的排查清单里还会碰到,先把底摸清,免得实验做到一半被误导。
2.2 编译 PG18 时指定 --with-liburing
编译时最关键的是--with-liburing。这个开关会去检查系统的 liburing 开发包,把 io_uring 的封装库链接进 PostgreSQL。没有它,即使内核支持,PG 也不会暴露 io_uring 路径。我在源码目录里执行的 configure 大概是这样的:
./configure --prefix=/opt/pg18 \ --with-liburing \ --with-icu \ --with-pgport=5432 \ --enable-debugconfigure 结束前留意这一段输出:出现checking for liburing.h... yes才算真正找到。如果显示 no,装开发包:Debian/Ubuntu 是liburing-dev,RHEL 系是liburing-devel。没装对的话,后续 make 不会报致命错,只是io_method=io_uring不可选,等你想切的时候才发现就晚了。
编译安装完后,用pg_config或者直接起实例执行:
SHOW io_method; SHOW data_directory;再对应看ldd输出是否包含 liburing。到这一步,实验材料就齐了。
2.3 准备一张能产生大量 I/O 的表
为了能明显看出异步与同步的差异,测试表不能太小,最好几百 MB 以上。我用一条 SQL 生成大约两千万行:
CREATE TABLE big_io AS SELECT generate_series(1,20000000) AS id, md5(random()::text) AS payload;然后给表加一个普通 btree 索引,用于后面的位图堆扫描观察:
CREATE INDEX big_io_idx ON big_io(id);统计信息更新完,确认执行计划能走到位图扫描路径。位图堆扫描是最容易触发批量预读的场景之一,PostgreSQL 会一次性取一批 tid,然后按块顺序读取,非常适合观察异步 I/O 是否真的冒出来了。为了稳定执行计划,我在每个实验里都会带上:
SET effective_io_concurrency = 8; SET enable_indexscan = off; SET enable_bitmapscan = on;这个组合的意思是:预读并发度固定为 8,走位图路径。前面那个参数管的是预读量,io_method 管的是预读通过什么系统调用发出去,两者是配合关系,后面会反复强调。
3. 对照实验:同步 I/O 在 strace 下到底是什么样
实验之前必须先跑一次基线。基线就是 io_method=none,也就是完全关闭异步 I/O。这一步不是浪费时间,后面判断 io_uring 是否生效时,没有基线的对比很容易被零散的调用刷昏头。
3.1 怎么把 strace 挂到 postmaster 和所有后端上
PostgreSQL 是多进程架构,跟踪单个 SQL 时至少要跟踪对应的 backend 进程。最简单的办法是直接跟踪 postmaster,并且带上 -f 让它跟随 fork 出来的所有子进程:
mkdir -p /tmp/aio rm -f /tmp/aio/trace* strace -f -tt -T -yy \ -e trace=pread64,preadv2,pwrite64,pwritev2,read,write,io_uring_setup,io_uring_enter,io_uring_register \ -o /tmp/aio/trace \ -p $(head -n1 $PGDATA/postmaster.pid)参数说明:-f 表示跟踪子进程;-tt 输出高精度时间戳;-T 显示每个系统调用耗时;-yy 在文件描述符后面标出对应文件路径;-e trace 只保留和 I/O 相关的系统调用,避免把 accept 和 epoll 刷爆日志。如果只想看 io_uring,新版本 strace 还支持%io_uring这个分类符,可以直接用。
跑起来之后在另一个终端执行:
SET effective_io_concurrency = 8; SET enable_indexscan = off; SET enable_bitmapscan = on; SELECT count(*) FROM big_io WHERE id BETWEEN 1 AND 10000000;统计结束后,按 Ctrl+C 停掉 strace,然后埋头翻日志。注意 strace 在高负载下会放大开销,实验别跑太久,能观察到几千次调用的模式就够了。
3.2 同步模式下的特征:一请求一等待
打开 /tmp/aio/trace 文件,看到的典型行是:
21034 13:15:23.123456 pread64(166</opt/pgdata/base/16386/16412>, "...", 8192, 4096000) = 8192 <0.000083> 21034 13:15:23.123600 pread64(166</opt/pgdata/base/16386/16412>, "...", 8192, 4096001) = 8192 <0.000062> 21034 13:15:23.123720 pread64(166</opt/pgdata/base/16386/16412>, "...", 8192, 4096002) = 8192 <0.000090>这个日志非常"老实":每一条记录都对应一次完整等待。第二条和第三条的时间差几乎等于磁盘返回的时间;线程在这个过程中没有其他系统调用插进来。日志条数和预计要读的块数基本一一对应。也就是说,这个模式下预读逻辑确实发出了多个请求,但每个请求仍然是阻塞式等结果,发得再快也逃不过"发了就卡住"的本质。
从调度器角度看,后端进程会出现明显的不可中断睡眠状态,CPU 占用不高,进程却好像被什么东西拖住了。遇到这种曲线,经验不足的 DBA 会先怀疑锁等待,其实锁在系统调用层根本藏不住,一次性ps看进程状态加strace看调用栈,几秒钟就能定位方向。
3.3 用 -c 做摘要统计,建立数字参照
单独看流水刷屏看不出比例,我再跑一次,这次用 strace 的统计模式:
timeout 30 strace -f -c -p $(head -n1 $PGDATA/postmaster.pid) \ -e trace=pread64,preadv2 2> /tmp/aio/sync_summary.txt结束后,摘要文件里会有一张表,列出 pread64 的总调用次数、单次耗时分布以及错误返回值。这张表就是基线的核心数字。我在同一台机器上测的典型结果是:调用次数等于扫描涉及的数据块数,平均每次耗时基本由磁盘响应延迟决定,调用之间几乎没有机会去执行别的系统调用。
这些数字先记下来,下一步切 io_uring 后直接对比调用类型和调用次数,效果差异会非常直观。如果没有这张基线表,光看 io_uring 的日志会觉得它多了一堆奇奇怪怪的调用,反而分不清好坏。
4. 切到 io_method=io_uring:内核接口终于露出了尾巴
基线记录完成,现在进入正题:把 io_method 改成 io_uring,然后重启让配置生效。
4.1 修改配置并确认确实切换成功
配置修改方式很常规:
ALTER SYSTEM SET io_method = io_uring; pg_ctl -D $PGDATA restart -m fast重启后先看日志里有没有回退或报错。如果 PostgreSQL 检测到当前环境不支持 io_uring,有的发行版会直接让实例起不来,有的会打一条 WARNING 后继续用兼容模式。这一步是很多坑的源头,所以我的习惯是重启后立刻执行:
SHOW io_method;如果显示的是 io_uring,说明 GUC 层面已经生效。但这距离"真的在内核里跑 io_uring"还有一步,必须靠 strace 验证。GUC 显示值只是配置值,不代表运行时选择值,很多人在这一步栽过。
4.2 strace 里看到了 io_uring_setup 和 io_uring_enter
用跟上一节一模一样的 strace 参数重新跑一轮查询,打开日志后会发现画风突变。pread64 大量消失,出现的是另一批系统调用。比较典型的三段输出如下:
[pid 10722] io_uring_setup(8, {flags=0, sq_thread_cpu=0, sq_thread_idle=0, ...}) = 3 [pid 10722] io_uring_register(3, IORING_REGISTER_FILES, [166, 167, ...], 16) = 16 [pid 10722] io_uring_enter(3, 64, 0, 0, NULL, 0) = 64第一行是创建 io_uring 实例,它输出了队列深度 8。第二行是尝试把一批文件描述符注册进内核,省得每次提交请求都传 fd,这是 io_uring 的优化手段之一。第三行最关键:io_uring_enter的第一个参数是 io_uring 的 fd,第二个参数是这次提交的请求数,第三个参数是最少等待完成的请求数。当第三个参数为 0 而第二个参数大于 0 时,代表这次调用只是"提交",不等待结果。
后面还会看到这种:
[pid 10722] io_uring_enter(3, 0, 16, IORING_ENTER_GETEVENTS, NULL, 0) = 16这一次第二个参数是 0,第三个参数为 16,含义是"我等至少 16 个请求完成"。这两个动作合起来就是异步 I/O 的标准姿势:先一把塞进去一堆请求,然后统一收割。strace 里的时间戳会告诉你,这两次 enter 之间往往隔着一段 CPU 计算时间,说明后端进程确实没有趴在磁盘上干等。
4.3 和同步模式的差异可以量化到调用次数上
对比最直观的是 strace -c 的统计。同样一次位图扫描,同步模式产生数万次 pread64,io_uring 模式下产生的是几十次 io_uring_setup/register,外加几百次 io_uring_enter,每次 enter 的第二个参数往往大于 1。这说明单次系统调用已经从"读一个块"变成了"批量提交一批块"。
| 观察项 | io_method=none | io_method=io_uring |
|---|---|---|
| 主要读调用 | pread64 | io_uring_enter |
| 单次调用请求数 | 通常 1 | 8、16、64 不等 |
| 后端 D 状态频率 | 高 | 明显降低 |
| 内核返回粒度 | 单次完成 | 批量完成 |
| 日志中调用条数 | 高 | 低 |
如果你在日志里依然看到满屏 pread64,但 io_uring_enter 也出现了,说明只是部分 I/O 走了异步,另一部分仍然同步。这种情况在上节提过,属于正常现象,但比例需要重点观察。如果 pread64 占比还高,那切换的意义就打折扣了。
4.4 为什么批量提交反而更靠谱
这里解释一下背后的逻辑。现代 NVMe 设备的队列深度很高,一次只发一个请求等于让设备一直处于吃不饱的状态。io_method=io_uring 的价值在于把多个预读请求压进同一个提交批次,内核和设备可以按最优顺序调度,减少中断和上下文切换的次数。同步模式虽然一次只发一个,但同样可以利用预读并发,只是每发一个请求都会牵扯到进程调度和直返,CPU 在等结果期间其实是被浪费的。
更关键的是,批量提交让 PostgreSQL 不必把"读块"这个动作做成顺序化。进程可以一次性规划出后续要读的几十个块,把请求全部丢给内核,然后趁内核搬运数据的时候去算位图、做过滤、准备下一批任务。对于 CPU 快、磁盘慢的典型分析型查询,这一进一出节省的是大量等待时间。当然,如果你的查询本身只读几个块,比如走主键索引,那 io_uring 几乎没有存在感,因为它根本凑不齐一个像样的批次。
5. io_method=worker:没有 io_uring 的机器也不必放弃异步
不是所有环境都允许 io_uring。容器里 Seccomp 过滤、云盘驱动不支持、内核版本太老,都可能让你无法使用 io_uring 路径。PG18 为此留了 worker 这个选项:用进程模拟异步。前面说过,我手上这个版本默认值就是 worker,所以它在兼容性上其实扮演了更重要的角色。
5.1 worker 模式的工作原理
worker 模式的基本思路是:后端进程把 I/O 请求打包,发送给一个专门的辅助进程。这个辅助进程承担阻塞式 read,把数据读好后再通过共享内存或者管道还给后端进程。后端进程自身在整个过程中不用趴在磁盘上等待,可以继续去做解析、计算、生成下一批请求等事情。
这种做法等价于把同步阻塞从"主执行路径"挪到了"辅助进程路径"上。它比纯同步好在后端进程不会因为一次磁盘等待就完全停摆;坏处也很明显,多了一次跨进程通信,而且辅助进程数量有限,遇到海量小 I/O 时可能排队。但至少在没有 io_uring 的环境里,它提供了一条相对平滑的异步路径,不是非黑即白。
5.2 strace 里 worker 模式长什么样
再次切到 worker 模式,相同查询,strace 日志里的调用主体会发生迁移。你会发现大量的 pread64 不再出现在原来的 backend 进程 pid 下,而转移到辅助 worker 的 pid 下。后端 pid 下频繁出现的反而是read/write管道、epoll_wait、poll这类用于处理完成通知的系统调用。
典型的日志片段长这样:
[pid 10888] pread64(12</srv/pgdata/base/16386/16412>, "...", 8192, 8192000) = 8192 [pid 10888] pread64(12</srv/pgdata/base/16386/16412>, "...", 8192, 8192001) = 8192 [pid 10833] poll([{fd=4, events=POLLIN}], 1, 30000) = 1 [pid 10833] read(4, "...", 8) = 8前面两条是 worker 在老老实实读盘,后面两条是主后端收到"完成通知"。如果你用ps -ef同时看,会发现多出一个名字里带 aio 或者 worker 的 PostgreSQL 子进程。用 strace 的 -f 把进程树展开,能看到它和主后端之间通过管道或事件通知通信。
5.3 worker 和 io_uring 到底怎么选
我个人的经验是:优先能用 io_uring 就用 io_uring,尤其在高并发、小查询、延迟敏感的场景。但 worker 是兜底选项,它能让你在不具备 io_uring 的环境里也享受到一部分异步收益,代价是额外的进程间通信开销。如果实例本身查询并发不高、单查询 I/O 压力不大,worker 和 io_uring 的差距基本感知不到。
选型没有一刀切的答案,关键要看 strace 展示的瓶颈。日志里如果看到主后端长期被 pread64 占据,说明异步路径根本没生效;如果看到辅助 worker 排队严重,而 io_uring 又没法启用,那不如退回到 none,老老实实靠调整effective_io_concurrency优化预读策略。工具是用来做判断的,不是用来制造信仰的。没有数据支撑的"新特性一定更好",十个有九个会翻车。
6. 实操中经常翻车的排查清单
做这种内核层实验,坑一定比想象多。下面这些是我真实遇到,或者看群友折腾后整理出来的现场事故,复制出来给后来人少走弯路。
6.1 io_method 显示 io_uring,却看不到 io_uring_setup
先别急着怀疑 strace 写错。最常见的两个原因:一是跟踪目标不对,二是编译时没带 liburing。确认编译期开关,可以看pg_config --configure或者ldd里有没有 liburing;确认跟踪目标,用pgrep -P postmaster_pid看实际后端进程,然后单独 attach。
还有一个容易忽略的点:如果 PostgreSQL 检测到 io_uring 不可用,有的版本会直接回退并使用 worker 或 none,但 GUC 的显示值可能仍然是配置值。所以光看SHOW io_method不够,必须同时看日志和 strace。日志里一般会有io_method ... not supported, falling back这类字眼,没有的话多半是它还没走到初始化路径。
6.2 为什么 pread64 和 io_uring_enter 会同时出现
这是正常现象。io_uring 只对异步 I/O 子系统管理的请求生效,PostgreSQL 里有些路径仍然保留同步读,比如某些临时文件、控制文件读取、小尺寸的单块读取。遇到这种情况不要认为"io_uring 没生效",而要看比例。我之前跑过一个测试,日志里 pread64 占了七成,后面一查是查询走了索引扫描而不是位图扫描,单块读取根本没进异步通道。
所以复现实验时一定要控制执行计划。像我这样故意关掉 indexscan、强制 bitmapscan,就是为了让预读路径最大化。如果你要观察其他场景,请先确认执行计划是什么,再谈 I/O 调用模式。执行计划一变,I/O 模式天翻地覆,拿两套不同计划的日志对比只会得出错误结论。
6.3 容器环境和文件系统对 io_uring 的限制
容器里的 Seccomp 策略、某些云盘的块设备驱动、文件系统挂载参数,都可能让 io_uring 在某些路径上不可用或回退。不要在这个问题上跟内核较劲,先用最朴素的本地磁盘、普通 ext4/xfs 跑通实验,再回头调容器策略。否则你会发现换个挂载点,同一套配置结论就变了。
排查时顺手看下:
dmesg | grep -i io_uring重点还是回到 strace。如果 strace 能看到 io_uring_setup 成功并返回一个 fd,说明内核允许;如果返回 EPERM 或 ENOSYS,那基本就是权限或版本问题,和 PostgreSQL 配置没有关系。
6.4 并发度和 I/O 方法不是一回事
最后强调一次:effective_io_concurrency决定 PG 预读多少块、是否预取;io_method决定底层通过什么机制去发起这些预读。两者互相配合,但不能互相替代。一个常见错误是发现 io_uring 没收益,就来回切 io_method,却忽略了effective_io_concurrency可能只有 1,预读根本没启动。这种时候调 io_method 等于白调。
反过来,如果effective_io_concurrency已经是 32,但同步 I/O 已经成为瓶颈,继续提高并发度只会让磁盘队列变深,延迟反而上升。正确路径是先把基线验明白,再用 strace 判断瓶颈到底在预读数量上还是预读方式上,然后对症下药。
7. 留在最后的操作心得
实验做完之后,我给自己定了几条规矩,这可能比任何结论都值钱。第一,生产上遇到读多写少、单查询延迟敏感的负载,先跑一轮基线 strace,确认瓶颈到底在不在系统调用层。很多慢查询的根因是执行计划烂或者内存命中率低,跟 io_method 一点关系都没有,上来就改参数纯粹是给自己找麻烦。
第二,改 io_method 之前,把实例的编译选项、内核版本、容器权限、文件系统类型全部记在案。这四个因素任何一个不满足,实验的结论就可能完全不一样。第三,不要只盯着SHOW io_method,日志、strace、执行计划三个信息源必须能相互印证。我最开始就是只看 GUC 显示值就以为 io_uring 已经生效,最后被 strace 打脸,后来才养成了这个习惯。
最后分享一个特别实用的技巧:用 strace 观察这类 I/O 行为时,把-o指向一个空目录,并在文件名上直接写前缀,比如-o /tmp/aio/trace,因为-f模式下每个被跟踪进程会独立生成 trace.pid 文件,方便你按 pid 快速定位是哪个后端在发请求。再配合-yy,你甚至能在日志里直接看到读的是哪个表文件,排查对账时特别有用。这套组合拳一旦用熟,io_method 在你眼里就不再是玄学,而是一个可以看到、可以验证、可以安心做生产决策的系统行为了。