说实话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 的可靠性模型里WALWrite-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 取值核心系统调用说明syncpwrite64fdatasync/fsync经典的同步读写提交时同步等待写入完成再同步等待落盘完成io_uringio_uring_setup/io_uring_register/io_uring_enterLinux 5.1 引入的异步 I/O 框架提交与收割共享内存环形队列libaioio_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 traceio_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 traceio_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 fdatasyncsync模式是 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, {flags0, sq_thread_cpu0, sq_thread_idle0, ...}) 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_submit2返回 2 表示两个请求都成功进入内核队列。这些请求的具体内容文件偏移、缓冲区地址、字节数并不在系统调用参数里而是直接写在共享内存的 SQ ring 里所以strace不会打印它们。第二行调用是等待完成事件。min_complete1表示至少等一个请求完成才返回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 初始化与提交的关键 syscalllibaio模式对应的是 Linux 原生异步 I/O最老也最标准的异步接口。初始化时能看到io_setup(16, [0x7f36c4000000]) 0io_setup创建了一个最大 16 个并发请求的 AIO context地址0x7f36c4000000是这个 context 的用户态句柄后续所有操作都要带这个句柄。运行期间的写入操作长这样io_submit(0x7f36c4000000, 1, [{aio_data..., aio_lio_opcodeIOCB_CMD_PWRITE, aio_fildes7, aio_buf0x7f..., aio_nbytes8192, aio_offset75051008}]) 1 io_getevents(0x7f36c4000000, 1, 1, [{aio_data..., obj..., res8192, res20}], NULL) 1io_submit提交了一个IOCB_CMD_PWRITE写请求参数里直接可见 fd、缓冲区地址、字节数和文件偏移。返回值1表示请求提交成功。随后io_getevents阻塞等待完成事件返回的res8192表示成功写入了 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_methodlibaio而 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 开销上下文切换次数完成事件获取方式syncpwrite64fdatasync固定 2 次每次写批 2 次以上无同步返回io_uringio_uring_enter提交 等待通常 2 次但可攒批每个批次数次读共享内存 CQ ringlibaioio_submitio_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 seccompunconfined仅限测试环境。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、安全模块拦截检查内核/容器配置测试环境可放宽 seccompio_uring_enter报-EINVALWAL 文件 O_DIRECT 对齐问题检查块对齐、WAL 文件打开方式libaio模式性能反而更差WAL 未用 O_DIRECTio_submit 同步执行研究 O_DIRECT 打开路径或改用io_uringWAL 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在你眼里就不再是玄学了。