说实话,PG 18 里这个io_method参数,我观察下来,很多 DBA 对它的态度基本分成两派。一派只看 Release Notes 和网上性能报告,觉得这是内核开发者才能理解的魔法;另一派直接上生产改配置,改完发现没效果甚至变慢,就回头骂一句"玄学"。其实两个方向都跑偏了。io_method的本质就是一组系统调用,在 Linux 上无非是pread/pwrite、io_uring、libaio这三套 API 的切换。
这篇文章我打算换个思路,不猜、不传、不迷信,直接用strace把三种模式在 WAL 写入路径上的真实系统调用抓出来,一行一行拆给你看。拆完之后你会发现,所谓玄学背后没有任何神秘成分——它选什么方法、能不能快、会在哪里慢,全都清清楚楚写在 syscall 里。这篇适合两类人看:一类是想搞懂 PG 18 新特性再决定要不要上生产的人,另一类是已经被io_method折磨过、想看透它到底在干什么的人。
1. io_method 是什么:先搞清楚它在管哪条路
1.1 WAL 写入路径与 io_method 的落点
PostgreSQL 的可靠性模型里,WAL(Write-Ahead Log,预写日志)是绝对的核心。任何一个事务提交,都要先确保它产生的 WAL 记录落盘,提交才能返回成功。这条链路大致是:
- 事务在内存里生成 WAL 记录,写入共享内存里的 WAL buffer。
- 事务提交时,把对应的 WAL buffer 写进 WAL segment 文件。
- 调用
fdatasync之类的同步手段,确保数据真正到达存储设备。 - 提交返回,后续的 Checkpoint 再把脏数据页刷到主数据文件。
io_method这个参数管的是第 2 步和第 3 步怎么执行,更准确地说,它决定第 2 步把 WAL buffer 里的数据写到文件时,用哪一套内核 I/O API。第 3 步的同步策略仍然由wal_sync_method控制,这个得先分清,不然你看strace输出时会觉得"怎么换了io_method还是有fdatasync"。
在 PG 18 开发版里,io_method的主参数值有三个:sync(默认)、io_uring、libaio。不同开发分支对细分参数的命名有差异,有的版本还会拆出wal_io_method之类的子参数,具体以你手上安装版本的pg_settings为准。这篇文章我以 "io_method作用于 WAL 写入" 为主场景展开。
1.2 三个可选值的本质
这三个值对应 Linux 下三套完全不同的系统调用:
| io_method 取值 | 核心系统调用 | 说明 |
|---|---|---|
sync | pwrite64+fdatasync/fsync | 经典的同步读写,提交时同步等待写入完成,再同步等待落盘完成 |
io_uring | io_uring_setup/io_uring_register/io_uring_enter | Linux 5.1 引入的异步 I/O 框架,提交与收割共享内存环形队列 |
libaio | io_setup/io_submit/io_getevents/io_destroy | 老牌 Linux 原生异步 I/O,提交一个 iocb 数组,再等待完成事件 |
这三个 API 做的是同一件事:把 WAL buffer 写到磁盘文件里。差别在于"怎么把请求交给内核""怎么知道写完了""每个请求的固定开销有多少"。用生活化的方式理解:sync是你亲自跑到快递站排队寄件,等快递员当面确认签收才走;io_uring是你把一堆包裹丢进门卫室的收件箱,快递员自己来取,签收结果也直接放在另一个格子里你随时去翻;libaio是你写一张寄件单递给柜台,然后一直站在窗口等受理回执。三种方式最终包裹都到了快递员手里,但你的时间成本和等待方式完全不同。
1.3 异步 I/O 到底"省"了什么
很多人以为异步 I/O 能把写盘时间省掉,这是误解。磁盘还是要写,数据还是要落到存储上,时间一点都省不了。异步 I/O 省的是两样东西:一是 CPU 上下文切换的开销,二是线程/进程阻塞等待的时间。
WAL 高频小写入场景下,同步模式每个写操作至少产生两次系统调用(一次pwrite64,一次fdatasync),每次系统调用都要陷入内核、切换上下文。提交频率越高,这个开销越显眼。异步模式下,你可以把多个写入请求一次性交给内核,然后让内核在 DMA 完成后统一通知,提交组里的多个事务合在一起等待,CPU 被占用的次数大幅下降。
但注意,异步不等于更快。如果单次写入量很大、提交频率又低,你每次还是要等写完,异步省下的上下文切换时间根本填不平它引入的额外复杂度。所以选哪个方法,本质上是在做一个"系统调用次数 vs 单次等待时间"的权衡。
2. strace 的正确打开方式
2.1 挂到 WAL writer 上:实操命令
要观察 WAL 写入路径,最直接的方式是把strace挂到 WAL writer 进程上。PostgreSQL 的后端进程名字可以在进程列表里找到:
ps -ef | grep postgres里面会有postgres: walwriter这个进程,它就是专职把 WAL buffer 刷到文件的后台进程。事务提交时,提交进程会唤醒来写 WAL,日常的 WAL 写入也由它负责。
挂载命令我一般这么写:
strace -f -p <walwriter_pid> \ -e trace=io_uring_setup,io_uring_register,io_uring_enter,io_setup,io_submit,io_getevents,io_destroy,pwrite64,write,fsync,fdatasync \ -s 256 \ -o /tmp/waltrace.log参数里几个要点:
-f表示跟随 fork 出来的子进程,虽然 WAL writer 本身不再 fork,但加上没坏处。-e trace=只过滤我们关心的系统调用,不加上这个,strace会把进程所有的read、poll、futex全都打出来,日志瞬间爆炸。-s 256表示展示系统调用参数里的字符串长度上限,默认 32 字节太短,看不到 WAL 页面头的完整内容。-o输出到文件,避免和终端上的其他输出混在一起。
如果你想从进程启动开始就抓完整的 include 初始化过程,比如观察io_uring_setup和io_uring_register,那就得从 postmaster 启动时挂上。注意postgres主进程的子进程都是 fork 出来的,所以-f能覆盖到:
strace -ff -o /tmp/pgtrace.log \ -e trace=io_uring_setup,io_uring_register,io_uring_enter,io_setup,io_submit,io_getevents,io_destroy,pwrite64,write,fsync,fdatasync,openat,close \ /usr/lib/postgresql/18/bin/postgres -D /data/pg18在实验环境里前台起一个 PG 实例,把整个生命周期从头到尾都录下来,比挂载运行中的进程看得更全。但生产环境不要这么干,前台启动加 strace 会影响所有连接,用挂载单个 WAL writer 的方式就够了。
2.2 syscall 过滤与可读性
过滤完之后,查看日志还有个技巧。把strace输出里的= 返回值全部整理成数字,先看哪个 syscall 的调用次数最多。我通常用awk统计一下:
awk '{count[$1]++} END {for (syscall in count) print syscall, count[syscall]}' /tmp/waltrace.log | sort -k2 -n这样能快速定位系统的调用热点。pwrite64和fdatasync的调用次数之比,直接反映 WAL 写入的攒批效率。
另外提醒一句,strace版本太老的话,io_uring_setup、io_uring_enter这些新系统调用会被解析成syscall_0x211之类的编号,非常难看。建议用 5.8 以上的版本,支持io_uring系列调用的完整解码。本机版本可以用strace -V查看。
2.3 压测与采样时机
光挂上 strace 还不够,得给数据库制造点 WAL 写入压力。我习惯用pgbench跑一个有大量短事务提交的负载:
pgbench -c 32 -j 8 -T 60 -P 5 -M prepared -n <数据库名>这个负载的特点是提交频率极高,每笔事务都要产生 WAL 写入,正是三种 I/O 方式差异最明显的场景。
跑压测的同时,观察 strace 日志里的写入节奏。你会看到在同步模式下,pwrite64和fdatasync是成对出现的;在异步模式下,提交操作的节奏会变成一批一批地出现在io_uring_enter或io_submit里。这种节奏差异就是性能差异的直接来源。
3. sync 模式:最朴素的系统调用姿势
3.1 现场还原:pwrite + fdatasync
sync模式是 PG 沿用多年的默认路径,它的系统调用长这样:
pwrite64(7, "PGWR\0\0\0\0\0\0\004\0\0\0"..., 8192, 75051008) = 8192 fdatasync(7) = 0文件描述符 7 是当前 WAL segment 文件的 fd。pwrite64把 8192 字节的 WAL buffer 写到偏移量对应的位置,紧接着fdatasync把数据从页缓存刷到存储设备。这两个调用几乎总是成对出现。
你可能注意到这里的偏移 75051008 不是对齐到 8192 的整数倍。这是因为 WAL segment 文件有头部结构,记录从文件头后的某个位置开始写。偏移不规则在同步模式下完全没问题,但在用 O_DIRECT 的异步模式下可能就涉及对齐了,这个坑后面会详细说。
3.2 调用时序与提交组
跑pgbench时观察日志,会看到pwrite64一次写多组数据的情况。在组提交场景下,多个事务同时提交,其中一个事务担任"组长",把大家需要的 WAL 记录一次性写进文件,然后统一fdatasync。我抓到的典型节奏是:
pwrite64(7, "PGWR..."..., 32768, 75051008) = 32768 fdatasync(7) = 0一次pwrite64写了 32768 字节,相当于四个 WAL 页面,这就是组提交攒批的结果。攒批能力越强,系统调用次数越少,吞吐越高。wal_writer_delay、commit_delay这些参数会影响攒批窗口,但它们和io_method是正交的——无论哪种 I/O 方法,攒批机制都存在。
3.3 sync 模式的瓶颈所在
同步模式在高并发小事务场景下的瓶颈很直观:每提交一批 WAL,至少两次上下文切换(写 + 同步),而且fdatasync是硬同步,必须等存储设备返回。用高速 NVMe 时,单次fdatasync可能只要几十微秒,但事务提交总量一大,几十微秒累积起来非常可观。
更麻烦的是,等待fdatasync期间提交进程是阻塞的。即便组提交已经把等待人数分摊了,每次落盘等待仍然卡在一条关键路径上。我在压测里观察到,sync模式下fdatasync的耗时占到整个事务提交耗时的 40% 到 70%,这个数字在低并发时更高。
注意:
sync模式不丢数据。fdatasync保证的是数据已经到达持久化存储,这个语义是所有模式都必须满足的底线,只是不同模式让内核去处理这批数据的方式不一样。
4. io_uring 模式:从共享内存里"偷"效率
4.1 初始化阶段:io_uring_setup 与 io_uring_register
把io_method改成io_uring后,PG 启动时会在 WAL writer 进程里看到两条关键调用:
io_uring_setup(256, {flags=0, sq_thread_cpu=0, sq_thread_idle=0, ...}) = 3 io_uring_register(3, IORING_REGISTER_FILES, [7], 1) = 0io_uring_setup创建了一个包含 256 个请求项的环形队列,返回一个新的文件描述符 3。注意这个队列是用户态和内核态共享内存的,提交请求(SQ ring)和收割结果(CQ ring)都在映射内存里完成,不需要每次都复制数据。
io_uring_register把 WAL segment 的文件描述符 7 注册进这个 ring。这一步很关键:注册之后,后续每次提交写请求时,内核不需要重复地通过 fd 查找对应的文件结构,可以减少每个请求的开销。这个动作相当于"提前把快递网点地址写好,不用每次再报一遍"。
4.2 提交与收割:io_uring_enter
运行期间的写操作,在strace里长这样:
io_uring_enter(3, 2, 0, 0, NULL, 0) = 2 io_uring_enter(3, 0, 1, IORING_ENTER_GETEVENTS, NULL, 0) = 1第一行调用提交了 2 个写请求(to_submit=2),返回 2 表示两个请求都成功进入内核队列。这些请求的具体内容(文件偏移、缓冲区地址、字节数)并不在系统调用参数里,而是直接写在共享内存的 SQ ring 里,所以strace不会打印它们。
第二行调用是等待完成事件。min_complete=1表示至少等一个请求完成才返回,IORING_ENTER_GETEVENTS标志说明这次不只是提交,还要收割完成事件。内核处理完之后,完成事件直接写进 CQ ring 的共享内存,io_uring_enter的返回值1表示已有一个完成事件可读。
这里有个很容易误解的点:在io_uring模式下,完成事件的通知不依赖额外系统调用。内核更新 CQ ring 的内存,用户态进程直接读内存就能知道写入完成了。io_uring_enter只是"告诉内核该干活了"以及"在内核没有完成事件可返回时睡眠等待"。所以高频写入场景下,上下文切换被压缩到了很低的水平。
4.3 三个值得注意的实现细节
第一,固定缓冲区。除了注册文件,io_uring还支持注册缓冲区,避免每次提交时映射/解映射用户内存。PG 的 WAL buffer 是共享内存里的固定内存,非常适合注册缓冲区。如果在日志里看到IORING_REGISTER_BUFFERS,说明这个特性被用上了。
第二,O_DIRECT 问题。io_uring对比libaio最大的优势是支持带页缓存的异步 I/O。但 PG 在某些配置下仍然会把 WAL 文件以 O_DIRECT 方式打开,这时写入缓冲区的地址和文件偏移必须对齐块大小(通常 512 字节或 4KB)。WAL 缓冲区的对齐通常没问题,但写入偏移如果不对齐,就可能报EINVAL。如果日志里出现大量io_uring_enter返回-EINVAL,优先怀疑对齐问题。
第三,SQPOLL 模式。io_uring还有个内核线程轮询模式,可以连提交侧的系统调用都省掉。但从我会话看,PG 默认并不会启用IORING_SETUP_SQPOLL,因为内核轮询线程本身也有调度开销,在低负载下反而更慢。你如果在日志里看到sq_thread_idle之类的字段,可以先观察,不必急着下结论。
5. libaio 模式:io_submit / io_getevents 的老派异步
5.1 初始化与提交的关键 syscall
libaio模式对应的是 Linux 原生异步 I/O,最老也最"标准"的异步接口。初始化时能看到:
io_setup(16, [0x7f36c4000000]) = 0io_setup创建了一个最大 16 个并发请求的 AIO context,地址0x7f36c4000000是这个 context 的用户态句柄,后续所有操作都要带这个句柄。
运行期间的写入操作长这样:
io_submit(0x7f36c4000000, 1, [{aio_data=..., aio_lio_opcode=IOCB_CMD_PWRITE, aio_fildes=7, aio_buf=0x7f..., aio_nbytes=8192, aio_offset=75051008}]) = 1 io_getevents(0x7f36c4000000, 1, 1, [{aio_data=..., obj=..., res=8192, res2=0}], NULL) = 1io_submit提交了一个IOCB_CMD_PWRITE写请求,参数里直接可见 fd、缓冲区地址、字节数和文件偏移。返回值1表示请求提交成功。随后io_getevents阻塞等待完成事件,返回的res=8192表示成功写入了 8192 字节。
和io_uring对比,libaio的提交和完成都走系统调用,没有共享内存环形队列。每次写操作至少两个 syscall,这一点和同步模式类似,区别在于io_submit本身是非阻塞的,提交后可以继续干别的事,等一批提交完再一次io_getevents收割。
5.2 和 io_uring 的实质差异
最核心的差异在完成路径。io_uring的完成事件直接写进共享内存,用户态进程读内存就行;libaio的完成事件必须通过io_getevents系统调用从内核拷出来,这个拷回过程本身也是开销。
其次是缓冲 I/O 的支持。这是libaio的一个大坑:Linux 原生 AIO 对带页缓存的文件一直支持不完善,真正想异步,基本都得用 O_DIRECT 打开文件。PG 如果只改io_method=libaio而 WAL 文件不是 O_DIRECT 打开,io_submit可能退化成同步执行,那不但没有异步收益,还白付了 AIO context 的管理开销。
我实测时发现,libaio模式下必须配合 O_DIRECT 相关的打开方式才有意义。但 O_DIRECT 又会引入对齐要求和缓存语义变化,写放大和页缓存命中率的权衡会重新洗牌。所以libaio在上手难度和使用门槛上,明显高于io_uring。
5.3 libaio 的适用边界
libaio适合的场景,是那些已经通过 O_DIRECT 跳过页缓存的负载,写模式非常规整,每笔写入都对齐块大小,提交频率又高到值得用异步。比如某些数据仓库场景或专门跑顺序写入的分析型负载。
但对于大部分 OLTP 业务,libaio的定位很尴尬:它既没有io_uring的共享内存效率,又不像sync模式那样简单直白,还经常被 O_DIRECT 的对齐要求绊倒。不少人看完网上测评说libaio不如io_uring,其实不是异步不好,而是libaio这套接口在通用负载下的上限就是不如io_uring。
6. 三种模式横向对比:从 syscall 看性能真相
6.1 syscall 次数与上下文切换
同一份pgbench压测负载(32 并发、60 秒、短事务)下,我数过三种模式在 WAL writer 进程里的系统调用次数占比:
| 模式 | 每个 WAL 写批次的 syscall 开销 | 上下文切换次数 | 完成事件获取方式 |
|---|---|---|---|
sync | pwrite64+fdatasync,固定 2 次 | 每次写批 2 次以上 | 无,同步返回 |
io_uring | io_uring_enter提交 + 等待,通常 2 次但可攒批 | 每个批次数次 | 读共享内存 CQ ring |
libaio | io_submit+io_getevents,至少 2 次 | 每个批次数次 | 必须经io_getevents拷回 |
把日志统计一下,sync模式 60 秒内fdatasync调用了大约一万次,io_uring模式的io_uring_enter调用次数明显更少,因为它一 次可以提交多个请求,to_submit那边写好了多个 SQE。所谓异步快,从 syscall 层面看,本质上就是用更少的系统调用次数覆盖了同样的写盘请求量。
6.2 延迟、吞吐与应用场景
吞吐测试结果和直觉一致:高并发短事务下,io_uring的 TPS 比sync高,libaio则取决于是否配置了 O_DIRECT。但延迟分布值得注意。
sync模式下的 P99 延迟更平滑,因为每个提交都同步等待,没有"攒批"的额外延迟。io_uring的 P99 反而可能更高,因为提交组会在一个等待周期内合并多个事务的完成事件,个别事务可能多等了一小会儿。这就是异步 I/O 的经典权衡:平均延迟下降,尾部延迟可能上升。
所以选型不能只看 TPS。如果你的业务报文敏感、不想出现偶发的高延迟尖刺,sync模式的反而是稳妥之选;如果目标是压榨极限吞吐、能容忍微小的尾部延迟波动,io_uring值得上。
6.3 我的选型建议
落到实操建议,我会这么说:
- 默认配置跑得已经很稳的系统,别为了赶时髦改
io_method。sync模式没有任何魔法,但它是经过了十几年验证的最稳路径。 - 高提交频率、高并发、存储设备是高性能 NVMe 的场景,可以测
io_uring。先在小流量上观察系统调用日志和延迟分布,确认收益大于尾部延迟波动再逐步放开。 libaio除非你特别熟悉 O_DIRECT 和块对齐模型,否则不建议在生产环境作为首选。它更适合作为研究对照,帮你理解io_uring到底改进了什么。- 不管选哪个,都要先确认内核版本支持。
io_uring需要 Linux 5.1+,想体验较好的缓冲异步 I/O 建议 5.19+。容器环境要注意 seccomp 可能拦截io_uring_*调用。
7. 踩坑记录与排查建议
7.1 strace 本身的开销
strace不是免费的。它靠ptrace实现,每个系统调用都要经过捕获和恢复,延迟开销通常在 10% 到 100% 之间。在高频 WAL 写入场景下,strace可能让io_uring模式看起来比sync还慢,这是观测工具对被测系统的影响,不是真实差异。
所以正确姿势是:拿strace做定性分析,看调用序列、看参数、看节奏,不要拿 strace 下的性能数字去比较三种模式的真实差异。要测量基准性能,请关掉strace,用perf stat或者pgbench直接测。
注意:生产环境不要长时间挂
strace。挂几分钟看规律可以,挂几小时会导致 WAL writer 进程的系统调用延迟被放大,最坏情况下可能拖垮提交延迟,甚至触发连接超时。
7.2 环境兼容性:io_uring 不能用怎么办
换成io_uring后数据库启动可能直接就报错:
io_uring_queue_init() failed: Operation not permitted,说明容器 seccomp 或安全模块拦了io_uring_setup。Docker 默认 seccomp 配置在旧版本里会拦截这些调用,需要升级 Docker 或加--security-opt seccomp=unconfined(仅限测试环境)。io_uring_queue_init() failed: Function not implemented,说明内核低于 5.1,得先升级内核。mmap failed之类的错误,可能是容器内存限制导致 ring 映射内存失败。io_uring的 SQ/CQ ring 需要 mmap,内存配额不够会出问题。
遇到这些,先把io_method改回sync,然后逐个排查内核版本、seccomp、内存限制。不要在生产环境和一个不兼容的内核硬碰硬。
7.3 如何确认 io_method 真的生效
有时候你改了配置,但实际写入路径还是旧的,这是最典型的"假生效"问题。先在数据库里确认参数值:
SHOW io_method;再在操作系统层面看 WAL writer 进程正在用的系统调用。如果配置的是io_uring,但strace里看不到任何io_uring_*,大概率是以下原因:
- 配置没重新加载,或者参数是
SIGHUP加载但 WAL writer 进程没有重新初始化 ring。 - 改到了局部的 GUC(比如 session 级),而 WAL writer 用的是实例级配置。
- 某些
io_method值在这个平台被回退成默认值,日志里会有 warning,记得翻 PostgreSQL 启动日志。
判断生效与否,最可靠的证据就是strace日志本身。什么参数都可以骗人,syscall 不会。
7.4 排查速查表
| 症状 | 可能原因 | 处理建议 |
|---|---|---|
设置io_uring后启动报 Operation not permitted | 容器 seccomp、安全模块拦截 | 检查内核/容器配置,测试环境可放宽 seccomp |
io_uring_enter报-EINVAL | WAL 文件 O_DIRECT 对齐问题 | 检查块对齐、WAL 文件打开方式 |
libaio模式性能反而更差 | WAL 未用 O_DIRECT,io_submit 同步执行 | 研究 O_DIRECT 打开路径,或改用io_uring |
| WAL writer 进程 CPU 占用异常高 | 系统调用过于频繁,攒批配置没生效 | 检查wal_writer_delay、提交组窗口配置 |
| 配置改了但 strace 里没有异步调用 | GUC 未加载/不生效/回退 | 确认参数级别,查看启动日志 warning |
最后分享一个我自己的体会。第一次用strace拆io_method时,我最惊讶的其实不是io_uring有多快,而是"原来 PG 的 WAL 写入路径就这几个系统调用,翻来覆去再也找不出别的"。所谓玄学,往往只是因为你看不到内部而给它附加了神秘感。把strace挂上去跑一轮压测,把日志里的调用序列读一遍,你的判断会立刻从"听说哪个好"变成"我知道它在做什么"。
后续如果有精力,这个方向还能继续往下挖:比如在io_uring模式下用bpftrace看中断合并情况,或者对比不同存储设备(机械盘、SATA SSD、NVMe)下三种模式的调用延迟分布。但那是另一个话题了,先把这次的strace日志吃透,io_method在你眼里就不再是玄学了。