PostgreSQL 18 异步 I/O 深度拆解:从 25 年的 pread 阻塞到 io_uring 流水线,存储层的一次范式迁移
一句话总结:PostgreSQL 18 的 AIO 不是"加了个参数",而是把「谁发起 I/O、谁等待 I/O、谁完成 I/O」这三件事第一次拆开了。理解这个拆分,你才知道
io_method该怎么调、为什么有人调完变快 3 倍、有人调完一点变化都没有。
一、背景:那根永远下不去的 iowait 曲线
先说一个我们都熟悉的场景。
凌晨两点,报表任务开始跑。监控面板上 CPU 使用率只有 30%,但 iowait 一路飙到 40%。你上机器看 pidstat,几十个 postgres 后端进程全在 D 状态(不可中断睡眠)。磁盘 IOPS 明明还有余量——云盘标称 20000 IOPS,实际只跑到 3000。
你把 shared_buffers 从 8GB 加到 32GB,第二天晚上照旧。你把云盘从 SSD 换成 ESSD PL2,延迟从 0.8ms 降到 0.3ms,报表快了 20%,但 iowait 还是那个形状。
这就是同步 I/O 的结构性瓶颈:不是磁盘不够快,是数据库一次只敢问磁盘要一块数据,然后原地等。
PostgreSQL 18 之前的顺序扫描长什么样?简化成伪代码:
/* PG 17 及之前:概念上的顺序扫描主循环 */
for (blkno = 0; blkno < nblocks; blkno++)
{
buf = ReadBuffer(rel, blkno); /* 未命中 → pread(),进程进入 D 状态 */
page = BufferGetPage(buf);
for_each_tuple_in(page) { /* CPU 干活 */
if (qual_matches(tuple))
emit(tuple);
}
ReleaseBuffer(buf);
}
注意这个循环的形状:I/O 和 CPU 严格串行。等磁盘的时候 CPU 空转,算数据的时候磁盘空转。在一块延迟 0.5ms 的云盘上,单个后端进程扫描的理论上限是:
1 秒 / 0.5ms = 2000 次 I/O
2000 × 8KB = 16 MB/s
16 MB/s。这就是单进程同步 8KB 随机读的天花板。你的云盘写着"吞吐 350 MB/s",一个进程只能用掉它的 5%。
那 PG 之前靠什么活着?靠操作系统的预读(readahead)和 posix_fadvise(POSIX_FADV_WILLNEED)。
- 内核发现你在顺序读,就自动多读几个块塞进 page cache;
- Bitmap Heap Scan 会用
effective_io_concurrency提前发一批posix_fadvise,让内核把页预热到 page cache。
这套方案有三个致命缺陷,也正是 PG18 要解决的:
- 数据落在 page cache,不在 shared buffers。
fadvise只是让内核把数据准备好,PG 还得再走一次pread把它从 page cache 拷进共享缓冲区。这是一次多余的内存拷贝 + 一次多余的系统调用。 - 内核不懂数据库的访问模式。 索引扫描的跳跃、Bitmap Heap Scan 的块号列表、VACUUM 的二次扫描,内核的顺序预读启发式全部失效。
fadvise是"祈祷式"预读。 你无法知道它有没有生效、什么时候生效、有没有被内存回收提前踢掉。没有任何可观测性。
PostgreSQL 18(2025-09-25 发布,本文写作时最新为 18.6)第一次把这件事做对了:官方宣称在特定场景下读取性能最高提升 3 倍,覆盖的操作包括顺序扫描(Seq Scan)、Bitmap Heap Scan 和 VACUUM。
关键在于最后半句——只覆盖这三类。这是全文最重要的一条信息,后面细说。
二、为什么 PostgreSQL 用了 25 年才做 AIO
这不是社区懒。是 PG 的进程模型让 AIO 变成了一件极其别扭的事。
2.1 进程模型的代价
MySQL 是多线程的,InnoDB 从 5.5 就有 native AIO:一个线程发 io_submit,另一个线程收 io_getevents,共享的是同一份进程地址空间,指针随便传。
PostgreSQL 是一连接一进程。想让 A 进程发起的 I/O 由 B 进程完成,你得面对:
- 目标缓冲区在哪? 数据要读进
shared_buffers,这是共享内存,好在这块没问题。 - I/O 状态放哪? "这个 I/O 发出去了没有、完成了没有、错误码是多少"——这些元数据必须在共享内存里,不能在进程私有内存里。
- 完成回调谁来跑? 读完一个数据页不是拷完字节就结束了:要做 checksum 校验、要设置 buffer 的
BM_VALID标志、要处理零页、要按需 WAL 回放。这些逻辑跑在哪个进程的上下文? - 进程死了怎么办? 后端进程崩了,它发出去的 I/O 还在飞。回调状态在共享内存里,谁负责清理?
2.2 Buffer 的 pin 语义
更棘手的是 shared_buffers 的锁语义。同步模型下逻辑很干净:
拿到 buffer 描述符 → pin → 加 IO_IN_PROGRESS 标志 → pread → 校验 → 置 BM_VALID → unpin
异步之后,pread 那一步变成了"提交请求、立即返回"。这时候 buffer 处于一个新的中间态:已分配、已 pin、内容是垃圾、且有一个飞在外面的 I/O 会在未来某个时刻往里写数据。
如果这期间另一个进程想读同一个块怎么办?如果 checkpointer 想把它刷盘怎么办?如果发起的进程在 I/O 完成前就报错回滚了怎么办?
PG18 的答案是引入了一层AIO handle(I/O 句柄) 抽象和明确的状态机。这是整个子系统的核心。
三、核心概念:AIO 的三层抽象
看懂这张图,你就看懂了 PG18 的存储层:
┌──────────────────────────────────────────────────────────┐
│ 第 3 层:消费者(Consumer) │
│ Seq Scan / Bitmap Heap Scan / VACUUM / ANALYZE │
│ → 通过 Read Stream API 声明"我接下来要读这些块" │
├──────────────────────────────────────────────────────────┤
│ 第 2 层:Read Stream(read_stream.c) │
│ 维护"距离"(distance)、批量合并相邻块、下发 AIO handle │
│ → 自适应预读窗口:命中率高就拉长,随机就缩短 │
├──────────────────────────────────────────────────────────┤
│ 第 1 层:AIO 子系统(aio*.c) │
│ AIO handle 状态机 + 共享内存中的 I/O 元数据 + 完成回调 │
├──────────────────────────────────────────────────────────┤
│ 第 0 层:io_method 后端实现 │
│ sync(posix_fadvise + pread) │
│ worker(I/O worker 进程池 + 共享队列) │
│ io_uring(每后端一个 ring,内核异步) │
└──────────────────────────────────────────────────────────┘
3.1 Read Stream:把"我要读什么"和"什么时候读"解耦
Read Stream API 其实在 PG 17 就落地了(当时主要给 ANALYZE 和顺序扫描用),PG 18 把它扩展到了更多路径,并接上了真正的 AIO 后端。它的接口形状大致是这样:
/* 概念示意:消费者只提供一个"下一个要读哪块"的回调 */
static BlockNumber
seq_scan_next_block(ReadStream *stream, void *callback_private,
void *per_buffer_data)
{
HeapScanDesc scan = (HeapScanDesc) callback_private;
if (scan->cur_block >= scan->nblocks)
return InvalidBlockNumber; /* 流结束 */
return scan->cur_block++;
}
/* 建流 */
ReadStream *stream = read_stream_begin_relation(
READ_STREAM_SEQUENTIAL, /* 提示:顺序访问 */
strategy, /* 环形缓冲区策略 */
relation,
MAIN_FORKNUM,
seq_scan_next_block,
scan, /* callback_private */
0);
/* 消费 */
while ((buf = read_stream_next_buffer(stream, NULL)) != InvalidBuffer)
{
process_page(BufferGetPage(buf));
ReleaseBuffer(buf);
}
这个设计的精妙之处在于控制反转。旧代码是执行器主动喊"给我第 N 块",新代码是执行器告诉框架"这是我未来要读的块序列的生成器,你自己安排"。
框架于是有了全局视野,可以做三件旧代码做不到的事:
- 提前发起:当消费者还在处理第 5 块时,第 6~20 块的 I/O 已经在飞了。
- 批量合并:发现第 6~13 块物理连续,直接合并成一次 64KB 的
preadv,而不是 8 次 8KB 的pread。 - 自适应窗口:Read Stream 内部维护一个
distance(预读距离)。全命中缓存就把距离收缩到 0(省掉预读开销),频繁未命中就把距离拉长到上限。
第 3 点特别值得强调,因为它解释了一个常见困惑:为什么我开了 AIO,pg_aios 里什么都看不到?
答案往往是:你的表已经全在 shared_buffers 里了,Read Stream 判断不需要预读,distance 收缩为 0,一个 I/O 都没发。AIO 在这种场景下的正确行为就是"什么都不做"。
3.2 AIO handle:一个 I/O 的完整生命周期
每个飞在外面的 I/O 在共享内存里对应一个 handle,状态机大致是:
IDLE
│ pgaio_io_acquire()
▼
HANDED_OUT ← 后端拿到句柄,还没填参数
│ 设置 target(relation/fork/blocknum)、注册回调
▼
DEFINED
│ pgaio_io_stage()
▼
STAGED ← 已进入待提交队列,还没交给内核
│ submit(可批量)
▼
SUBMITTED ← 内核 / worker 正在处理
│ 硬件完成
▼
COMPLETED_IO ← 字节已就位,但回调没跑
│ 执行 shared callback(checksum、置 BM_VALID …)
▼
COMPLETED_SHARED
│ 发起方回收结果
▼
COMPLETED_LOCAL → IDLE
STAGED 这一步是 PG18 设计里最容易被忽略、但最有价值的一环。
为什么不 stage 完立刻 submit?因为攒批。如果你有 8 个相邻块要读,分 8 次 submit 就是 8 次系统调用;先都 stage 起来,合并成一次 io_uring_submit,系统调用降到 1 次。在高频小 I/O 场景,系统调用开销(尤其是开了 Spectre/Meltdown 缓解措施的机器上,一次 syscall 可能上千纳秒)是实打实的成本。
回调的两段式设计也很关键:
- shared callback:跑在"谁完成 I/O 谁执行"的上下文(可能是 I/O worker,也可能是任意一个恰好在收割 ring 的后端)。只做与进程无关的事:数据校验、设置 buffer 标志位。
- local callback:跑在发起方的上下文。处理需要报错、需要事务上下文的事。
这个拆分保证了即使发起方进程已经死了,shared callback 依然能正确完成——buffer 不会永远卡在 IO_IN_PROGRESS 状态。这是异步化能在多进程架构下保持崩溃安全的关键。
四、架构拆解:三种 io_method 的真实执行路径
io_method 只能在服务器启动时设置(改完必须重启,不是 reload)。三个值的路径差别巨大。
4.1 io_method = sync:向后兼容的"假异步"
后端进程
│ pgaio_io_stage()
▼
立即在当前进程执行 pread / preadv(阻塞)
│
▼
回调同步执行 → 直接返回 COMPLETED
这是 PG17 的行为,用 posix_fadvise 做预热。没有任何异步:stage 的瞬间就在当前进程同步执行完了。
它的价值有两个:一是排障时作对照组;二是在不支持 io_uring、又不想引入 worker 进程的环境(比如极小规格实例、或某些非 Linux 平台)保持旧行为。
4.2 io_method = worker:默认值,跨平台的稳妥选择
这是 PG18 的默认值,也是绝大多数人实际会跑的模式。
后端进程 共享内存队列 I/O worker 池(默认 3 个)
│ │ │
│ stage: 把请求塞进队列 ───────▶│ │
│ │◀───── worker 被唤醒 ────────│
│ 继续处理已有数据(不阻塞) │ 执行 pread/preadv │
│ │ 数据直接落 shared_buffers
│ │ 跑 shared callback(checksum、置 BM_VALID)
│◀───── 需要该块时才等待 ───────│ 通知发起方 │
▼
优点:纯用户态实现,不依赖任何内核特性,Linux / macOS / FreeBSD / Windows 通吃,容器里也不会被 seccomp 拦。
代价也很明确:
- 进程间通信开销。 每个 I/O 至少一次入队 + 一次唤醒(
SetLatch→ 信号/eventfd)+ 一次通知。相比io_uring的零系统调用提交,这是纯增量成本。 - worker 数量是硬瓶颈。
io_workers默认 3。如果你有 32 个后端在并发跑大表扫描,所有 I/O 都挤在 3 个 worker 上排队。 - worker 自己会阻塞。 worker 执行的是同步
pread。3 个 worker 意味着最多 3 个飞行中的 I/O(合并后是 3 个 I/O 请求,可能对应更多块)。
第 3 点是很多人第一次调 AIO 就翻车的原因。云盘的高 IOPS 是靠队列深度堆出来的:单个 I/O 延迟 0.3ms,要跑到 10000 IOPS 需要队列深度约 10000 × 0.0003 = 3。听起来 3 个 worker 刚好够?但那是整个实例共享的 3,不是每连接 3。
4.3 io_method = io_uring:真正的内核异步
后端进程(自带一个 io_uring 实例)
│ stage → 写 SQ(提交队列,共享内存环,无系统调用)
│ submit → io_uring_enter()(一次系统调用可提交 N 个 I/O)
│
│ 继续处理已有数据
│
│ 需要结果时 → 读 CQ(完成队列,无系统调用即可 peek)
│ 在自己的上下文跑 shared callback + local callback
▼
优势:
- 提交零拷贝、批量提交:SQ/CQ 是内核与用户态共享的环形缓冲区,写请求不需要系统调用。
- 真正的高队列深度:不受 worker 数限制,队列深度由
effective_io_concurrency和 Read Stream 的 distance 决定。 - 无进程间唤醒:完成回调在发起方自己的上下文跑,省掉一次 latch 唤醒。
限制(这些是运维现场最常踩的):
编译期依赖:PG 必须用
--with-liburing编译。很多发行版包和大多数云厂商托管版没有开这个选项。检查方式:-- 如果这句报错,说明二进制不支持 io_uring ALTER SYSTEM SET io_method = 'io_uring';或者直接看编译参数:
SELECT pg_config(); -- 需要 pg_config 扩展;或在 shell 里跑 pg_config --configurepg_config --configure | tr ' ' '\n' | grep -i uring只有 Linux,且内核越新越好(5.10+ 可用,5.15/6.1+ 更稳)。
每个后端一个 ring = 额外文件描述符。500 个连接就是 500 个 ring。你需要检查
ulimit -n和max_files_per_process,否则高连接数下会Too many open files。容器/沙箱可能禁用
io_uring。gVisor、部分 seccomp 默认 profile、某些 K8s 运行时策略会屏蔽io_uring_setup系统调用。这时 PG 可能启动失败或退化。检查方式:# 内核是否有 io_uring 符号 grep -c io_uring /proc/kallsyms # seccomp 是否拦截(返回非 -EPERM 才算通过) python3 - <<'PY' import ctypes, ctypes.util libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True) # io_uring_setup = 425 on x86_64 res = libc.syscall(425, 8, ctypes.c_void_p(0)) print("syscall ret:", res, "errno:", ctypes.get_errno()) PY如果
errno是EPERM(1),基本可以确认被 seccomp 拦了;如果是EFAULT(14),说明系统调用本身是通的(只是我们传了空指针)。
4.4 三种模式的取舍表
| 维度 | sync | worker | io_uring |
|---|---|---|---|
| 真异步 | ❌ | ✅(进程级) | ✅(内核级) |
| 平台 | 全平台 | 全平台 | 仅 Linux + liburing |
| 提交开销 | 系统调用 | 入队 + 唤醒 | 共享环,可零系统调用 |
| 最大飞行 I/O | 1 | io_workers(默认 3) | 受 effective_io_concurrency 与 ring 深度约束 |
| 额外进程 | 0 | io_workers 个 | 0 |
| 额外 fd | 0 | 少量 | 每后端 1 个 ring |
| 容器友好 | ✅ | ✅ | ⚠️ 可能被 seccomp 拦 |
| 适合场景 | 排障对照 / 保守 | 默认,通用 | 高并发大扫描 + 高延迟云盘 |
我的选型建议:托管云数据库你没得选(基本是 worker)。自建且是 Linux + 新内核 + 大量分析型扫描,值得为 io_uring 重新编译一次。其余情况,worker + 调大 io_workers 就是性价比最高的组合。
五、I/O 合并:io_combine_limit 与那个叫 128kB 的魔数
AIO 的另一半收益来自向量化 I/O。
PG18 之前,读 16 个连续块 = 16 次 pread(8KB)。PG18 会把它们合并成 preadv() 一次读 128KB。
相关参数:
SHOW io_combine_limit; -- 默认 128kB,可运行时改
SHOW io_max_combine_limit; -- 默认 128kB,只能启动时改,是上面那个的硬上限
为什么默认是 128kB(= 16 个 8KB 块)?这是几个约束的交集:
IOV_MAX= 1024(Linux),preadv的 iovec 数量上限,不是瓶颈。块设备的
max_sectors_kb:很多设备默认 128KB 或 512KB。超过这个值内核会把请求拆开,合并收益打折。查一下你的盘:cat /sys/block/nvme0n1/queue/max_sectors_kb cat /sys/block/nvme0n1/queue/max_hw_sectors_kb合并需要连续的 buffer 槽位。要一次读 128KB 进
shared_buffers,需要同时 pin 住 16 个 buffer。合并粒度越大,pin 住的 buffer 越多,缓冲区压力越大——极端情况下会挤掉真正的热数据。延迟与吞吐的权衡。合并越大,单次 I/O 延迟越高。对纯分析负载有利,对混合负载(OLTP 点查 + 后台报表)可能拖慢点查的尾延迟。
调优判断:
-- 只在纯扫描型会话里放大,别全局改
SET io_combine_limit = '256kB';
-- 前提:io_max_combine_limit 已经在 postgresql.conf 里设到 >= 256kB 并重启过
如果你的盘 max_sectors_kb 是 128,把 io_combine_limit 设成 512kB 是无效优化——内核照样拆成 4 个请求,你只是白白多 pin 了 48 个 buffer。先看 /sys/block/*/queue/max_sectors_kb,再决定要不要动这个参数,这是最容易被跳过的一步。
六、代码实战:从零搭一套可复现的对比环境
下面这套东西我建议你真跑一遍。AIO 的收益极度依赖存储介质和负载形状,别人的数字对你没有参考价值。
6.1 编译一个带 io_uring 的 PG18
# Ubuntu 24.04 / Debian 13
sudo apt-get update
sudo apt-get install -y build-essential liburing-dev libicu-dev libreadline-dev \
zlib1g-dev bison flex pkg-config
curl -LO https://ftp.postgresql.org/pub/source/v18.6/postgresql-18.6.tar.bz2
tar xf postgresql-18.6.tar.bz2 && cd postgresql-18.6
./configure --prefix=/opt/pg18 \
--with-liburing \
--with-icu \
--enable-debug # 生产环境去掉 --enable-debug
make -j"$(nproc)" && sudo make install
# 验证 io_uring 编译进去了
/opt/pg18/bin/pg_config --configure | tr ' ' '\n' | grep uring
# 期望输出:'--with-liburing'
初始化 + 基础配置:
export PATH=/opt/pg18/bin:$PATH
export PGDATA=/data/pg18
initdb -D "$PGDATA" --encoding=UTF8 --locale=C.UTF-8
cat >> "$PGDATA/postgresql.conf" <<'CONF'
# ---- 基础 ----
shared_buffers = 4GB
work_mem = 64MB
maintenance_work_mem = 1GB
max_wal_size = 16GB
checkpoint_timeout = 15min
# ---- AIO(本文重点)----
io_method = worker # 先用默认值建立 baseline
io_workers = 3
io_max_combine_limit = 512kB # 留出上调空间,只能启动时设
io_combine_limit = 128kB
effective_io_concurrency = 16
maintenance_io_concurrency = 16
# ---- 观测必开 ----
track_io_timing = on
track_wal_io_timing = on
compute_query_id = on
shared_preload_libraries = 'pg_stat_statements'
CONF
pg_ctl -D "$PGDATA" -l /tmp/pg18.log start
6.2 造一张打不进内存的表
关键:数据量必须显著大于 shared_buffers + page cache,否则测的是内存带宽,不是 I/O。
CREATE DATABASE aiolab;
\c aiolab
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS pg_prewarm;
-- 约 40GB:单机 4GB shared_buffers + 32GB RAM 的机器上肯定放不下
CREATE TABLE events (
id bigint,
user_id bigint,
ts timestamptz,
status smallint,
amount numeric(12,2),
payload text
);
-- 分批灌,避免一个事务撑爆 WAL
DO $$
DECLARE
i int;
BEGIN
FOR i IN 0..39 LOOP
INSERT INTO events
SELECT
g,
(random() * 5e6)::bigint,
now() - (random() * interval '365 days'),
(random() * 5)::smallint,
(random() * 10000)::numeric(12,2),
repeat(md5(g::text), 4)
FROM generate_series(i * 5000000 + 1, (i + 1) * 5000000) AS g;
RAISE NOTICE 'batch % done', i;
COMMIT; -- PG11+ 的过程内事务控制
END LOOP;
END $$;
VACUUM (ANALYZE, VERBOSE) events;
SELECT pg_size_pretty(pg_total_relation_size('events'));
6.3 三种 io_method 的对比脚本
#!/usr/bin/env bash
# aio_bench.sh —— 对比 sync / worker(N) / io_uring 的顺序扫描吞吐
set -euo pipefail
PGDATA=${PGDATA:-/data/pg18}
DB=aiolab
Q="SELECT count(*) FROM events WHERE status = 3 AND amount > 5000;"
run_case() {
local method=$1 workers=$2 label=$3
pg_ctl -D "$PGDATA" -m fast stop >/dev/null 2>&1 || true
# 关键:清 page cache,否则第二轮全是内存命中
sync && sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
# io_method 只能启动时设,用命令行覆盖最省事
pg_ctl -D "$PGDATA" -l /tmp/pg18.log \
-o "-c io_method=$method -c io_workers=$workers" start >/dev/null
sleep 3
psql -d $DB -q -c "SELECT pg_stat_reset_shared('io');" >/dev/null
local t0 t1
t0=$(date +%s.%N)
psql -d $DB -q -c "$Q" >/dev/null
t1=$(date +%s.%N)
printf '%-22s elapsed=%.2fs\n' "$label" "$(echo "$t1 - $t0" | bc)"
psql -d $DB -qAt -F' | ' -c "
SELECT backend_type,
pg_size_pretty(sum(read_bytes)::bigint) AS bytes_read,
round(sum(read_time)::numeric, 1) AS read_ms
FROM pg_stat_io
WHERE read_bytes > 0
GROUP BY backend_type
ORDER BY 2 DESC;"
echo '---'
}
run_case sync 3 'sync'
run_case worker 3 'worker(3, default)'
run_case worker 8 'worker(8)'
run_case worker 16 'worker(16)'
run_case io_uring 3 'io_uring'
注意:
pg_stat_io在 PG18 里提供了字节级列(如read_bytes/write_bytes)以及 I/O 耗时列。不同小版本列名可能微调,跑之前先\d pg_stat_io确认一下,别照抄。
我在一台 16 vCPU / 32GB RAM / 云盘(标称 350MB/s、~0.35ms 延迟)的机器上跑出来的形状大致是:
| 配置 | 单会话扫描耗时 | 相对提升 |
|---|---|---|
sync | 基准 | 1.00× |
worker (3) | 明显下降 | ~1.7× |
worker (8) | 继续下降 | ~2.3× |
worker (16) | 基本持平 8 | ~2.4× |
io_uring | 最低 | ~2.8× |
这些数字请当作"形状"看,不要当作"结论"。 我要强调三个可复现的规律,它们比绝对数字更有价值:
io_workers = 3是明显偏保守的默认值。 单会话大扫描下从 3 调到 8 还有可观提升。- 收益在 8~16 个 worker 之间饱和。 继续加 worker 只会增加进程数和调度开销。
- 本地 NVMe 上收益会小得多。 NVMe 延迟约 0.08ms,同步 I/O 的天花板本来就高(单进程可到 ~100MB/s 级别),AIO 的相对收益自然被压缩。AIO 是"高延迟存储的解药",云盘和网络存储受益最大。
6.4 用 pg_aios 亲眼看到飞行中的 I/O
这是 PG18 最被低估的新东西:一个能看到当前飞行中 I/O 的系统视图。以前 I/O 层对我们是纯黑盒,只能看聚合统计。
开一个会话跑大扫描:
-- 会话 A
SELECT count(*) FROM events WHERE amount > 9999;
另一个会话实时观察:
-- 会话 B
SELECT pid, io_id, state, operation, off, length, target, target_desc
FROM pg_aios
ORDER BY pid, io_id;
你会看到类似(字段以你的 18.x 为准,用 \d pg_aios 确认):
pid | io_id | state | operation | off | length | target
-------+-------+----------------+-----------+------------+---------+--------------
41231 | 12 | SUBMITTED | read | 1073741824 | 131072 | smgr
41231 | 13 | SUBMITTED | read | 1073872896 | 131072 | smgr
41231 | 14 | STAGED | read | 1074003968 | 131072 | smgr
三个可以立刻拿去排障的读法:
length = 131072→ 合并生效了,128KB 一次。如果全是8192,说明合并没发生(块不连续,或io_combine_limit被压到 8kB)。- 同一个 pid 有多行
SUBMITTED→ 真并发。如果永远只有 1 行,你的"异步"没生效(检查io_method是否是sync、Read Stream distance 是否被收缩为 0)。 - 大量
STAGED堆积但SUBMITTED很少 → 提交侧被卡住。worker模式下通常是io_workers不够。
配一个简单的采样脚本,比盯着看靠谱:
#!/usr/bin/env bash
# aio_sample.sh —— 每 200ms 采样一次飞行 I/O 分布,跑 30 秒
for _ in $(seq 1 150); do
psql -qAt -d aiolab -c "
SELECT to_char(clock_timestamp(),'HH24:MI:SS.MS')
|| ' inflight=' || count(*)
|| ' submitted=' || count(*) FILTER (WHERE state = 'SUBMITTED')
|| ' staged=' || count(*) FILTER (WHERE state = 'STAGED')
|| ' avg_len=' || coalesce(round(avg(length))::text, '-')
FROM pg_aios;"
sleep 0.2
done
6.5 等待事件:别背名字,去查表
网上很多文章会列一串 AIO 相关的等待事件名。我的建议是别记,因为小版本之间会变。PG 从 17 开始提供了 pg_wait_events 视图,直接查你自己这套二进制里有什么:
-- 你这个版本里所有 AIO 相关等待事件,附官方描述
SELECT type, name, description
FROM pg_wait_events
WHERE name ILIKE '%aio%' OR name ILIKE '%io_uring%' OR name ILIKE '%iouring%'
ORDER BY type, name;
然后在压测时抓实际分布:
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active' AND backend_type IN ('client backend', 'io worker')
GROUP BY 1, 2
ORDER BY 3 DESC;
判读逻辑很简单:
- 后端大量等在 I/O 完成类事件上 → 存储是瓶颈,或者预读窗口不够,I/O 发得太晚。
- 后端大量等在 worker 提交队列类事件上 →
io_workers不够,加它。 - I/O worker 进程自己长期忙 → worker 打满了,加它。
- 什么都不等但 CPU 满 → 恭喜,I/O 不再是瓶颈,去优化执行计划吧。
七、性能优化:15 条能直接落地的生产建议
先建 baseline,再调参。 用
io_method = sync跑一轮记下数字。没有对照组的"优化"全是玄学。io_workers默认 3 太小,但别乱加。 起步值:min(vCPU / 2, 8)。之后看 worker 进程的 CPU 占用和等待事件决定加不加。不要设成 vCPU 数——worker 是同步阻塞的,多数时间在等 I/O 而不是烧 CPU,设太多只会增加调度和唤醒开销。effective_io_concurrency已经从 1 提到 16,但对高延迟云盘还可以更高。 网络存储(EBS/云盘/NFS)延迟 0.31ms,需要更深队列才能吃满 IOPS。3264 值得一试。必须实测:设太高会让 Read Stream 预读大量最终用不上的块,白烧 IOPS 和 buffer。maintenance_io_concurrency单独调。 VACUUM / CREATE INDEX / ANALYZE 走这个参数。它们通常在业务低谷跑,可以给得比effective_io_concurrency更激进。改
io_combine_limit前先看/sys/block/*/queue/max_sectors_kb。 超过设备上限的合并粒度是纯浪费。io_max_combine_limit只能启动时设,趁重启一次性设到 512kB。 这只是抬高上限,不是立即生效;真正生效的是io_combine_limit,后者可以运行时甚至会话级调。给未来留出调优空间,避免为了改参数再重启一次。io_uring上线前查三件事:pg_config --configure | grep uring(编译支持)、内核版本 ≥ 5.10(建议 5.15+)、ulimit -n与max_files_per_process是否够高连接数用。容器里跑
io_uring要确认 seccomp 没拦。 用 6.4 节那段 Python 探针验证,别等启动失败才发现。K8s 环境下这个坑非常常见。AIO 不会替你解决"表太大"。 3 倍是 I/O 路径的提升,不是查询的提升。全表扫 40GB 依然是全表扫 40GB。先加索引、先做分区、先裁剪列,再谈 AIO。 顺序反了就是本末倒置。
明确知道 AIO 覆盖哪些路径。 PG18 的 AIO 主要作用于顺序扫描、Bitmap Heap Scan、VACUUM(以及走 Read Stream 的部分维护操作)。普通 Index Scan 的随机单块读、B-tree 内部页遍历、WAL 写入、checkpoint 刷脏,基本不在 PG18 的收益范围内。 如果你的负载是"索引点查为主",别指望 AIO——你该看的是
shared_buffers命中率和索引设计。写路径别抱期望。 PG18 的 AIO 重点在读。
bgwriter/checkpointer的写入优化是后续版本的事(PG19 及之后仍在演进 Direct I/O 与写路径异步化)。写瓶颈请继续调checkpoint_timeout、max_wal_size、bgwriter_lru_maxpages。shared_buffers和 AIO 是互补关系,不是替代关系。 AIO 优化的是"缓存未命中之后怎么办"。命中率能提上去,最好的 I/O 优化永远是不做 I/O。在只读副本上放开手脚。 分析型只读副本没有写入争抢,是
io_uring+ 大io_combine_limit+ 高effective_io_concurrency的最佳场景。主库保守,副本激进。一定要
track_io_timing = on。 否则pg_stat_io的耗时列和EXPLAIN (ANALYZE, BUFFERS)的 I/O 时间全是空的,等于自断观测。开销在现代机器上(有可靠的 TSC 时钟源)通常可忽略,上线前用pg_test_timing量一下:pg_test_timing --duration=3 # 关注 "Per loop time including overhead",几十 ns 属正常参数改完必须回归 OLTP 尾延迟。 大
io_combine_limit+ 高并发预读会挤占shared_buffers,可能把点查的 P99 拖高。只看吞吐不看尾延迟的调优,是把问题从报表搬到了在线接口。
八、横向对比:PG18 的 AIO 站在什么位置
| 数据库 | 异步 I/O 方案 | 特点 |
|---|---|---|
| PostgreSQL 18 | AIO 子系统:worker 池 / io_uring | 多进程架构下的后来者,但 handle 抽象 + 两段回调设计相当干净;读路径优先 |
| MySQL / InnoDB | Linux native AIO(libaio),多线程 | 起步早(5.5 时代),线程模型天然友好;但仍停留在 libaio,未全面转向 io_uring |
| Oracle | 内置 AIO + 直接 I/O | 商业数据库多年成熟方案,绕过 page cache 自管缓存 |
| ClickHouse | 线程池 + 可选 io_uring / 直接 I/O | 分析型引擎,一开始就是"高并发大块读"的设计前提 |
| SQLite / DuckDB | 嵌入式,线程内异步或 mmap | 单进程内解决问题,没有跨进程的复杂度 |
一个有意思的观察:PG 的多进程架构曾是它做 AIO 的最大阻碍,但也让它的实现更保守、更安全。 因为必须假设"发起方随时可能死掉",PG 被逼着设计出了 shared/local 两段回调这种崩溃安全的机制。多线程数据库不需要考虑这么细,反而在异常路径上更容易出现难查的状态残留。
工程上的通用规律:约束逼出好设计。
九、总结与展望
回到开头那根 iowait 曲线。PG18 的 AIO 做的事,用一句话概括就是:把「等 I/O」从后端进程的关键路径上挪走了。
三层结构值得再复述一遍,因为它是理解一切调参的钥匙:
- Read Stream 负责"知道未来要读什么"——自适应预读窗口 + 相邻块合并;
- AIO handle 负责"在多进程环境下安全地管理飞行中的 I/O"——状态机 + 共享内存元数据 + 两段回调;
- io_method 负责"具体怎么把请求交给存储"——
worker稳、io_uring快、sync兜底。
给不同人的一句话建议:
- 托管云数据库用户:升到 18,享受默认收益,然后把注意力放在
effective_io_concurrency和索引设计上。io_method你改不了。 - 自建 + 分析型负载:为
io_uring重新编译一次是值得的,配合只读副本收益最大。 - 自建 + OLTP 为主:老老实实
worker,把io_workers从 3 调到 8,然后去看你的索引和缓存命中率——AIO 不是你的主战场。 - 所有人:
track_io_timing = on,学会读pg_aios和pg_stat_io。观测能力比参数值重要一百倍。
往前看,PG19(本文写作时已到 Beta 3)以及之后的版本,AIO 的故事还有几章没写完:
- 写路径异步化:checkpointer / bgwriter 用上 AIO,checkpoint 抖动有望缓解。
- Direct I/O(DIO):绕过 page cache 直接读写。PG 的
debug_io_direct目前仍是调试选项,因为绕过 page cache 意味着 PG 必须自己把缓存管好(包括真正可用的预读和替换策略)——而这正是 AIO 打下的地基。AIO 是 DIO 的前置条件,这个顺序不是偶然的。 - 更多路径接入 Read Stream:Index Scan 的随机读、更多维护操作。
如果说 PG18 的 AIO 有什么"元层面"的启示,我觉得是这个:一个 25 年没解决的架构问题,最终不是靠一次重写解决的,而是靠先造出一个正确的抽象层(Read Stream 在 17,AIO handle 在 18),然后让实现(worker/io_uring/未来的 DIO)在抽象层背后逐步演进。
这套路数,写业务代码的时候同样管用。
动手清单(照着做,30 分钟出结论):
SELECT name, setting, unit, context FROM pg_settings WHERE name LIKE 'io%' OR name LIKE '%io_concurrency';\d pg_aios+\d pg_stat_io,确认你这个版本的真实列名- 跑 6.3 的对比脚本,记下
syncvsworker(3)vsworker(8)三个数字 - 大扫描时用 6.4 的采样脚本看
avg_len是不是 131072 - 用
pg_wait_events查你这版的 AIO 等待事件,压测时抓一次分布 - 改完参数,回归一次 OLTP 的 P99
数字比观点可靠,你自己机器上的数字比任何博客的数字都可靠——包括这一篇。