PostgreSQL 18 异步 I/O 深度拆解:io_uring 如何终结 Postgres 二十年的同步阻塞
一、先说结论:这不是性能补丁,是地基重浇
先把观点抛出来,省得你读到第三节才知道我要说什么。
PostgreSQL 18 的异步 I/O(AIO)子系统,在 release note 里只是若干条不起眼的条目:新增 io_method 参数、新增 pg_aios 视图、effective_io_concurrency 默认值从 1 改成 16。看起来像是"又调了几个旋钮"。
但如果你翻过 src/backend/storage/buffer/bufmgr.c 的代码,你会知道这件事的分量:Postgres 从 1996 年开源到 2025 年,读一个数据页的路径本质上一直是 pread(fd, buf, 8192, offset) 这一行同步系统调用。 一个 backend 进程发起读,然后进入内核态睡觉,等磁盘把 8KB 搬回来,醒了继续跑。整整二十多年,无论底下是 5400 转的机械盘、SAS SSD、还是能吃 100 万 IOPS 的 NVMe,Postgres 看到的世界都是"我发一个请求,我等一个结果"。
这个模型在机械盘时代是合理的——反正单盘 IOPS 就那么点,队列深度给再高也没用,寻道时间是绝对瓶颈。但在 NVMe 时代它是荒谬的:一块普通企业级 NVMe 盘要跑满带宽,队列深度至少要 32~128。而单个同步 Postgres backend 提供的队列深度永远是 1。 你花几万块买的盘,被一个 pread 死死掐在单通道上。
PG18 做的事,是在 buffer manager 和 storage manager 之间插入了一整层 AIO 抽象:请求的"发起"和"等待"被彻底解耦,并且提供了三种可切换的后端实现(同步、worker 进程池、Linux io_uring)。这不是优化某个算子,这是把地基重新浇了一遍——所以它的收益范围、它的坑、它的调参方式,都和你熟悉的 Postgres 调优经验不太一样。
这篇文章我会按这个顺序拆:旧路径为什么烂 → 新抽象长什么样 → 三种 io_method 的架构差异 → 怎么开、怎么观测、怎么压测 → 怎么调参 → 哪些场景根本没收益。 代码和配置都能直接抄,但请在你自己的机器上验证数字,因为 I/O 这件事,换个云盘型号结论就能翻转。
二、背景:Postgres 的 I/O 到底"同步"在哪
2.1 一次读页的完整旧路径
假设你执行 SELECT * FROM orders WHERE id = 12345,走索引,需要读一个堆页。旧路径(PG17 及之前)简化成伪代码是这样:
/* 上层调用 */
Buffer buf = ReadBuffer(relation, blocknum);
/* ReadBuffer 内部(极度简化) */
Buffer ReadBuffer(Relation rel, BlockNumber blocknum)
{
/* 1. 在 shared buffers 的哈希表里查这个页在不在 */
BufferDesc *bufHdr = BufTableLookup(&tag);
if (bufHdr != NULL)
return BufferDescriptorGetBuffer(bufHdr); /* 命中,直接返回 */
/* 2. 未命中:找一个可复用的 buffer slot(可能触发脏页回写) */
bufHdr = StrategyGetBuffer(...);
if (BufferIsDirty(bufHdr))
FlushBuffer(bufHdr); /* 同步写! */
/* 3. 关键:同步读磁盘,进程在这里睡死 */
smgrread(rel->rd_smgr, MAIN_FORKNUM, blocknum, bufBlock);
/* └─> FileRead() └─> pread(fd, buf, BLCKSZ, offset) */
/* 4. 校验 checksum,标记有效,返回 */
return BufferDescriptorGetBuffer(bufHdr);
}
第 3 步是全部问题的根源。pread 返回之前,这个 backend 进程什么都干不了。它不能去预读下一个页,不能去算表达式,不能去处理别的元组。它就是一个等待 I/O 的空壳。
2.2 一次 I/O 等待到底浪费了多少
给个成本模型,你就知道为什么这事值得动地基:
| 存储介质 | 单次 8KB 随机读延迟 | 折算 CPU 周期(按 3GHz) |
|---|---|---|
| shared_buffers 命中(内存) | ~100 ns | ~300 |
| OS page cache 命中 | ||
| 本地 NVMe | ||
| 云盘(网络块存储) | ~300 μs ~ 2 ms | |
| 机械盘随机读 |
一次云盘读,等于白扔掉近百万个 CPU 周期。而一个顺序扫描 10GB 的表要读 128 万个页——如果全部冷读、全部同步、每次 500μs,光 I/O 等待就是 640 秒。这不是理论:任何做过大表冷扫描的人都见过那种"CPU 5%、磁盘 20MB/s、查询跑十分钟"的诡异画面。磁盘不忙,CPU 不忙,但查询就是慢,因为瓶颈是"往返次数 × 单次延迟",跟带宽和算力都没关系。
2.3 老 hack:posix_fadvise 和 effective_io_concurrency
Postgres 社区当然知道这个问题。PG 8.4 引入了一个补丁式方案:effective_io_concurrency + posix_fadvise(POSIX_FADV_WILLNEED)。
思路是:在真正 pread 之前,先告诉内核"我马上要读这几个块,你先帮我预热到 page cache"。这确实能制造并发,但它有三个硬伤:
- 只在极少数算子里用了。 主要是 Bitmap Heap Scan。普通顺序扫描、索引扫描的堆访问、VACUUM,大多没吃到。
- fadvise 是"建议",不是契约。 内核可以完全忽略。而且它只对走 page cache 的 buffered I/O 有效,对 Direct I/O 毫无意义。
- 它做的是双份工作。 先 fadvise 把数据搬进 page cache,再 pread 从 page cache 拷进 shared buffers。数据在内存里被搬了两次,还占了双份内存。
PG17 往前走了一步,引入了 Read Stream API(read_stream.c)和 io_combine_limit:把连续的块合并成一次 preadv 向量读,把"128 次 8KB 读"变成"1 次 1MB 读"。这个改动收益很实在,但它解决的是"请求太碎",不是"请求太串行"。
PG18 才是终局:请求本身变成异步的。
三、核心概念:三层新抽象
理解 PG18 AIO,抓住三个东西就够了:PgAioHandle、io_method、ReadStream。它们分别对应"一次 I/O 的载体"、"谁去执行 I/O"、"上层怎么用"。
3.1 PgAioHandle:一次 I/O 的生命周期
新代码在 src/backend/storage/aio/ 下。核心数据结构是 PgAioHandle,你可以理解成"一张 I/O 工单"。它有一个明确的状态机:
IDLE ─(acquire)→ HANDED_OUT ─(定义好 target/op)→ DEFINED
│
(staged)
↓
STAGED
│
(submit)
↓
SUBMITTED
│
(I/O 完成,被 reap)
↓
COMPLETED_IO
│
(跑 shared callbacks)
↓
COMPLETED_SHARED
│
(发起者跑 local callbacks)
↓
COMPLETED_LOCAL → IDLE
关键在于 COMPLETED_SHARED 和 COMPLETED_LOCAL 的分裂。这是整个设计里最见功力的地方,值得单独讲。
3.2 为什么回调必须分成 shared 和 local
问题:在 worker 模式下,I/O 是由另一个进程(io worker)执行完成的。谁来做"校验 checksum、标记 buffer 有效"这些收尾工作?
如果让发起者做:那 io worker 完成后必须唤醒发起者,发起者才能收尾。可万一发起者在忙别的事呢?这个 buffer 就一直处于"数据到了但没标记有效"的中间态,别的 backend 想读这个 buffer 就得干等。
Postgres 的选择是:允许任何进程完成收尾工作。 谁先发现 I/O 完成了,谁就顺手把 shared 部分的收尾做掉——包括 checksum 验证、把 buffer 标记为 BM_VALID、唤醒等待者。这样即使发起者去泡咖啡了,其他 backend 也能"帮它"把 buffer 变成可用状态。
这带来一个非常硬的约束:shared callback 不能依赖任何进程本地状态。
- 不能捕获闭包(C 里本来也没有,但不能塞指向本地内存的指针)
- 不能引用 backend-local 的 relation cache
- 不能在里面
ereport(ERROR)——因为你在替别人干活,把自己的事务搞崩了算谁的?
所以回调不是普通的函数指针,而是注册在全局表里的 ID:
/* src/include/storage/aio.h 的思路(简化示意) */
typedef enum PgAioHandleCallbackID
{
PGAIO_HCB_INVALID = 0,
PGAIO_HCB_MD_READV,
PGAIO_HCB_SHARED_BUFFER_READV,
PGAIO_HCB_LOCAL_BUFFER_READV,
/* ... */
} PgAioHandleCallbackID;
typedef struct PgAioHandleCallbacks
{
/* I/O 完成后,任何进程都可能执行:做共享状态的收尾 */
PgAioHandleCallbackComplete complete_shared;
/* 只有发起者执行:可以报错、可以碰本地状态 */
PgAioHandleCallbackComplete complete_local;
/* 生成错误信息用 */
PgAioHandleCallbackReport report;
} PgAioHandleCallbacks;
PgAioHandle 里存的是 callback ID 数组(而不是指针),因为进程间共享内存里存函数指针在有 ASLR 的系统上根本不可靠。ID → 函数指针的映射由每个进程在启动时各自建立。
错误处理同样精妙:I/O 失败(比如 EIO)时,shared callback 只把错误码记录到 handle 里(raw_result),绝不抛异常。真正的 ereport(ERROR, "could not read block ...") 留给发起者在 complete_local 阶段抛。这样错误永远归属正确的事务。
这个设计我认为是整个 patch 最值得学的部分——任何做异步化改造的系统都会遇到"完成回调该在谁的上下文里跑"这个问题,Postgres 给出的答案是"按副作用范围切成两半",而不是简单地"谁发起谁负责"。
3.3 io_method:三种执行后端
io_method 决定 STAGED → SUBMITTED 之后,实际由谁来跑系统调用。三个值:
| 值 | 机制 | 平台 | 真异步? |
|---|---|---|---|
sync | 提交即在本进程内 preadv 同步完成 | 全平台 | 否,兼容/回退用 |
worker | 交给独立的 io worker 进程池执行 | 全平台 | 是(进程级并发) |
io_uring | 提交到内核 io_uring 环,内核异步完成 | Linux(需编译时链接 liburing) | 是(内核级) |
PG18 的默认值是 worker。 这个选择很务实:io_uring 需要编译期依赖 + 较新内核 + 容器 seccomp 放行,做不了默认;sync 等于什么都没变。worker 是唯一能开箱即用地提供真并发的选项。
io_method 是 postmaster 级参数,改了必须重启。这一点在生产上很重要——你不能靠 reload 来 A/B 测试。
3.4 ReadStream:上层真正的入口
AIO 子系统再牛,也得有人调用它。PG18 的做法不是让每个算子直接操作 PgAioHandle(那太底层、太容易写错),而是让它们统一走 Read Stream。
Read Stream 的心智模型是"给我一个块号生成器,我负责流水线地把页喂给你":
/* 使用 Read Stream 的典型骨架(顺序扫描的思路) */
static BlockNumber
seq_next_block(ReadStream *stream, void *cb_private, void *per_buffer_data)
{
MyScanState *st = cb_private;
if (st->cur >= st->last)
return InvalidBlockNumber; /* 流结束 */
return st->cur++;
}
/* 建流 */
ReadStream *stream = read_stream_begin_relation(
READ_STREAM_SEQUENTIAL, /* 访问模式提示 */
strategy, /* buffer access strategy,如 BAS_BULKREAD */
relation,
MAIN_FORKNUM,
seq_next_block, /* 块号回调 */
scanstate, /* 回调私有数据 */
0); /* per-buffer data 大小 */
/* 消费 */
Buffer buf;
while ((buf = read_stream_next_buffer(stream, NULL)) != InvalidBuffer)
{
process_page(BufferGetPage(buf));
ReleaseBuffer(buf);
}
read_stream_end(stream);
Read Stream 内部干了三件你不想自己写的事:
- 预读窗口管理:根据
effective_io_concurrency提前把后面 N 个块的读请求发出去(distance 是自适应的,命中率高就缩、缺页多就扩)。 - 请求合并:连续块合并成一次
preadv,上限由io_combine_limit控制。 - 命中短路:如果块已经在 shared buffers 里,直接返回,不产生 I/O。
这就是为什么 PG18 的收益分布很不均匀:只有已经改造成走 Read Stream 的代码路径才吃得到异步 I/O。PG17 已经改造了顺序扫描、ANALYZE、部分 VACUUM 阶段;PG18 继续扩到了 Bitmap Heap Scan 等更多路径。但普通索引扫描的堆页随机访问,仍然是逐个页取——因为在拿到当前元组之前,你根本不知道下一个要读哪个页,这是逻辑上的依赖,不是实现问题。
记住这条判断准则:能提前知道块号的路径 → 能异步;必须读完上一个才知道下一个 → 无法异步。 后面调参章节全靠这条。
四、架构分析:worker 与 io_uring 的真实差异
4.1 worker 模式:共享内存队列 + 进程池
worker 模式的结构:
backend A ──┐
backend B ──┼──→ [共享内存提交队列] ──→ io worker 0 ──→ preadv()
backend C ──┘ ├──→ io worker 1 ──→ preadv()
└──→ io worker 2 ──→ preadv()
↑ │
└────────── 完成通知 / 状态置位 ←─────────┘
- io worker 是真正的独立进程(postmaster 的子进程),你能在
ps里看到,在pg_stat_activity里backend_type = 'io worker'。 - 数量由
io_workers控制,默认 3,范围 1~32,可以 reload 生效(不像io_method要重启)。 - backend 把工单挂到共享队列,io worker 摘下来执行
preadv,完成后跑 shared callback。
优点:全平台可用,不依赖内核特性,实现相对好审计。
代价也很明确:
- 上下文切换和进程间通知开销。每个 I/O 都要走一遍"挂队列 → 唤醒 worker → worker 执行 → 置位/唤醒发起者"。对小 I/O 来说,这个固定开销占比不低。
- worker 数量是全局资源。3 个 worker 面对 200 个 backend 的突发冷读,队列一样会堵。这是一个新的、以前不存在的瓶颈点。
- worker 自己是同步阻塞的。每个 worker 同一时刻只能有少量 in-flight I/O(它执行的是
preadv)。所以整个实例的有效队列深度大致是io_workers × 每 worker 并发,而不是无限。
所以 worker 模式的心智模型应该是:把"每个 backend 队列深度 1"改成了"全局有一个可调深度的 I/O 线程池"。这是巨大的进步,但它离硬件极限还有距离。
4.2 io_uring 模式:把队列深度交给内核
io_uring 模式下,每个 backend 自己持有一个 io_uring 实例(提交队列 SQ + 完成队列 CQ 的一对环形缓冲区):
backend A ── SQ/CQ ring ──→ 内核 io_uring ──→ block layer ──→ NVMe
backend B ── SQ/CQ ring ──→ 内核 io_uring ──→ ...
提交是批量的、零系统调用开销的(写 SQ 环 + 一次 io_uring_enter,甚至可以配 SQPOLL 完全免除系统调用)。完成是内核直接写 CQ 环,backend 轮询或等待即可。
优势:
- 单 backend 就能打出很高队列深度。一个顺序扫描的 backend 可以同时挂 32、64 个 in-flight 读,直接把 NVMe 喂饱。
- 没有额外进程间跳转。提交和完成都在本进程上下文,少一次唤醒往返。
- 天然适配未来的 Direct I/O。io_uring + DIO 才是绕过 page cache 双拷贝的完整答案。
代价和坑(这部分是生产上真会踩的):
编译期依赖。二进制必须
./configure --with-liburing(meson 下-Dliburing=enabled)。发行版官方包不一定开了这个开关——先查,别猜:-- 如果这句报错说 invalid value,说明你的二进制没编 io_uring SET io_method = 'io_uring';或者在 shell 里直接看:
pg_config --configure | tr ' ' '\n' | grep -i uring ldd $(which postgres) | grep -i uring文件描述符消耗。每个 backend 一个 ring,每个 ring 至少吃一个 fd。
max_connections = 1000就意味着上千个额外 fd。必须同步抬高ulimit -n/ systemd 的LimitNOFILE,否则表现是"连接数上去就报 too many open files",排查起来很折磨。# /etc/systemd/system/postgresql.service.d/override.conf [Service] LimitNOFILE=1048576 LimitMEMLOCK=infinity容器里可能被 seccomp 拦死。Docker 的默认 seccomp profile 在较新版本里对
io_uring_setup等系统调用是拒绝的(历史上 io_uring 出过一串内核提权漏洞,容器运行时对它态度保守)。症状是 Postgres 启动时报无法初始化 io_uring。解决要么放行 syscall,要么老老实实用 worker:# 仅在你明确接受安全代价时使用 docker run --security-opt seccomp=unconfined ...我的建议很直接:在 K8s / 托管容器环境里,默认就用
worker。 io_uring 的收益不值得你去动集群的 seccomp 基线。内核版本。io_uring 的行为和性能在内核 5.x 到 6.x 之间差异巨大。老内核(尤其是各种"企业发行版长期支持内核"回合过来的 5.4/5.10)上开 io_uring,可能既不快也不稳。生产建议 6.1+,能上 6.6+ 更好。
4.3 写路径还没异步
必须泼一盆冷水:PG18 的异步 I/O 目前主要覆盖读路径。 WAL 写入、checkpoint 的脏页刷盘,仍然走原来的同步路径。
这意味着:
- 你的 OLTP 写入延迟不会因为开了 AIO 而降低。想优化写,还是老三样:
wal_buffers、commit_delay/组提交、full_page_writes与存储的原子写能力、以及最重要的——WAL 盘别和数据盘抢 IOPS。 - checkpoint 风暴不会消失。
checkpoint_completion_target、bgwriter_*那套调参逻辑完全照旧。
所以如果有人跟你说"升 PG18,写性能翻倍",你可以直接判定他没读过代码。PG18 的 AIO 是给读密集、大扫描、冷数据分析型负载准备的礼物,写路径要等后续版本。
五、代码实战:开启、观测、压测
5.1 配置:一份可直接抄的 postgresql.conf 片段
# ============ PG18 异步 I/O ============
# 执行后端:sync | worker | io_uring (改动需重启)
io_method = 'worker'
# worker 模式下的 I/O 进程数(可 reload)
# 经验起点:min(CPU 核数 / 2, 8);读密集大实例可上探到 12~16
io_workers = 6
# 普通查询的 I/O 并发度(PG18 默认已从 1 提到 16)
# 本地 NVMe:32~64;网络云盘:16~32;机械盘:2~4
effective_io_concurrency = 32
# 维护操作(VACUUM / ANALYZE / 索引构建)的 I/O 并发度
maintenance_io_concurrency = 32
# 单次合并读的上限(默认 128kB)
io_combine_limit = '256kB'
# 上面这个值的硬顶,需重启才能改
io_max_combine_limit = '512kB'
# 想看到真实 I/O 耗时,必须打开(有少量开销,但值得)
track_io_timing = on
# 顺带:AIO 让大扫描更容易吃满内存带宽,把这两个一起看
shared_buffers = '16GB'
huge_pages = try
改完之后验证:
SELECT name, setting, unit, context, source
FROM pg_settings
WHERE name IN ('io_method','io_workers','effective_io_concurrency',
'maintenance_io_concurrency','io_combine_limit',
'io_max_combine_limit','track_io_timing')
ORDER BY name;
注意 context 列:postmaster 表示要重启,sighup 表示 reload 即可,user 表示单会话可改。effective_io_concurrency 是 user 级的——这点非常有用,你可以只给分析型会话开高并发,而不影响 OLTP:
-- 只在这个跑报表的会话里激进一点
SET effective_io_concurrency = 64;
SET io_combine_limit = '512kB';
SET work_mem = '512MB';
5.2 观测:pg_aios 视图
PG18 新增的 pg_aios 视图能让你看到当前正在飞的 I/O。这是以前完全没有的能力。
SELECT pid, io_id, state, operation,
pg_size_pretty(length::bigint) AS len,
target, target_desc, f_sync, f_localmem, f_buffered
FROM pg_aios
ORDER BY pid, io_id;
典型输出(大表冷扫描进行中):
pid | io_id | state | operation | len | target | target_desc | f_sync | f_localmem | f_buffered
-------+-------+-----------+-----------+--------+--------+----------------------------+--------+------------+------------
41230 | 12 | SUBMITTED | readv | 256 kB | smgr | blocks 81920..81951 of ... | f | f | t
41230 | 13 | SUBMITTED | readv | 256 kB | smgr | blocks 81952..81983 of ... | f | f | t
41230 | 14 | STAGED | readv | 256 kB | smgr | blocks 81984..82015 of ... | f | f | t
怎么读这张表,直接给你判断规则:
pg_aios长期空表 + 查询慢:说明你的慢查询根本没走 Read Stream 路径(大概率是索引随机访问),AIO 帮不上忙,别再调effective_io_concurrency了,去优化索引和数据布局。- 同一 pid 只有 1~2 条 in-flight:预读窗口没打开。要么
effective_io_concurrency太小,要么访问模式被判定为随机。 - 大量
SUBMITTED堆积、length都很小(8kB):请求没合并起来,检查io_combine_limit,也可能是表碎片严重导致块号不连续。 operation = readv且len接近io_combine_limit:这是理想状态,合并和并发都生效了。
配合一个实时监控脚本,压测时开着看:
#!/usr/bin/env bash
# aio-watch.sh — 每秒采样一次在途 I/O 分布
while true; do
psql -qtAX -d mydb <<'SQL'
SELECT to_char(now(),'HH24:MI:SS')
|| ' inflight=' || count(*)
|| ' submitted=' || count(*) FILTER (WHERE state='SUBMITTED')
|| ' staged=' || count(*) FILTER (WHERE state='STAGED')
|| ' avg_len=' || coalesce(round(avg(length)/1024)::text,'0') || 'kB'
|| ' backends=' || count(DISTINCT pid)
FROM pg_aios;
SQL
sleep 1
done
5.3 pg_stat_io:算清 I/O 的总账
pg_aios 看瞬时,pg_stat_io 看累计。PG18 给它补了字节级统计(read_bytes / write_bytes / extend_bytes),终于不用自己拿"次数 × 8192"去估了:
SELECT backend_type, object, context,
reads,
pg_size_pretty(read_bytes) AS read_bytes,
round(read_time::numeric, 1) AS read_ms,
CASE WHEN reads > 0
THEN round((read_time / reads)::numeric, 3)
END AS ms_per_read,
CASE WHEN reads > 0
THEN pg_size_pretty((read_bytes / reads)::bigint)
END AS avg_read_size
FROM pg_stat_io
WHERE reads > 0
ORDER BY read_time DESC NULLS LAST
LIMIT 15;
avg_read_size 是我最看重的一列。 它直接告诉你请求合并有没有生效:
- 接近 8kB → 完全没合并,纯随机小 I/O
- 32~128kB → 合并生效了
- 接近
io_combine_limit→ 合并跑满
而 ms_per_read 告诉你存储的真实延迟。这两个数一乘,就是你的有效吞吐。
顺便注意 backend_type 里会出现 io worker:
SELECT backend_type, sum(reads) AS reads, pg_size_pretty(sum(read_bytes)) AS bytes
FROM pg_stat_io
GROUP BY backend_type
ORDER BY sum(reads) DESC;
如果 io worker 的读占比很低,而 client backend 很高,说明大部分 I/O 还是同步走掉了——AIO 名义上开了,实际没用上。这是最容易自欺欺人的场景,一定要查这一行。
5.4 一个可复现的冷读压测
要测 AIO,关键是保证冷缓存。否则你测的是内存速度。
#!/usr/bin/env bash
# pg18-aio-bench.sh
# 用法: ./pg18-aio-bench.sh <io_method> <eic> <io_workers>
set -euo pipefail
METHOD=${1:-worker}
EIC=${2:-16}
WORKERS=${3:-3}
DB=aiobench
PGDATA=${PGDATA:-/var/lib/postgresql/18/main}
echo ">>> 配置: io_method=$METHOD eic=$EIC io_workers=$WORKERS"
# 1. 写配置并重启(io_method 需要重启)
psql -d postgres -c "ALTER SYSTEM SET io_method = '$METHOD';"
psql -d postgres -c "ALTER SYSTEM SET io_workers = $WORKERS;"
psql -d postgres -c "ALTER SYSTEM SET effective_io_concurrency = $EIC;"
psql -d postgres -c "ALTER SYSTEM SET maintenance_io_concurrency = $EIC;"
psql -d postgres -c "ALTER SYSTEM SET track_io_timing = on;"
pg_ctl -D "$PGDATA" restart -m fast -w
# 2. 准备数据(约 12GB,确保远大于 shared_buffers)
psql -d postgres -c "SELECT 1 FROM pg_database WHERE datname='$DB'" | grep -q 1 || {
createdb "$DB"
psql -d "$DB" <<'SQL'
CREATE TABLE big AS
SELECT g AS id,
(random()*1e6)::int AS v,
md5(g::text) AS h,
repeat('x', 180) AS pad
FROM generate_series(1, 40000000) g;
ALTER TABLE big SET (autovacuum_enabled = off);
VACUUM ANALYZE big;
SQL
}
# 3. 清缓存:重启 PG + 丢弃 OS page cache(需要 root)
pg_ctl -D "$PGDATA" restart -m fast -w
sync
if [ "$(id -u)" = "0" ]; then echo 3 > /proc/sys/vm/drop_caches; fi
pg_ctl -D "$PGDATA" restart -m fast -w
# 4. 冷顺序扫描
echo ">>> 冷顺序扫描:"
psql -d "$DB" -c "\timing on" \
-c "EXPLAIN (ANALYZE, BUFFERS, TIMING) SELECT count(*) FROM big WHERE v < 500;"
# 5. 冷 Bitmap Heap Scan(PG18 也走 Read Stream)
psql -d "$DB" -c "CREATE INDEX IF NOT EXISTS big_v_idx ON big(v);" >/dev/null
sync; [ "$(id -u)" = "0" ] && echo 3 > /proc/sys/vm/drop_caches
pg_ctl -D "$PGDATA" restart -m fast -w
echo ">>> 冷 Bitmap Heap Scan:"
psql -d "$DB" \
-c "SET enable_seqscan = off;" \
-c "EXPLAIN (ANALYZE, BUFFERS) SELECT count(h) FROM big WHERE v BETWEEN 1000 AND 30000;"
# 6. I/O 总账
psql -d "$DB" <<'SQL'
SELECT backend_type, object, context, reads,
pg_size_pretty(read_bytes) AS bytes,
round(read_time::numeric,1) AS read_ms,
CASE WHEN reads>0 THEN pg_size_pretty((read_bytes/reads)::bigint) END AS avg_sz
FROM pg_stat_io WHERE reads > 0 ORDER BY read_time DESC NULLS LAST;
SQL
跑三轮(sync 16 3 / worker 16 6 / io_uring 64 0),对比 EXPLAIN ANALYZE 里的 I/O Timings: shared read=... 和总执行时间。
看 EXPLAIN 输出时重点盯这一段:
Seq Scan on big (cost=... rows=... width=...) (actual time=0.412..38221.7 rows=... loops=1)
Buffers: shared read=1523810
I/O Timings: shared read=35102.441
I/O Timings / actual time 的比值就是"I/O 占比"。同步模式下这个比值经常高到 85%~95%;异步生效后,同样的 shared read 次数,I/O Timings 应该显著下降——因为等待被重叠掉了。如果 shared read 次数没变而 I/O Timings 掉了一半,恭喜,AIO 起作用了。
5.5 用 Python 做多并发混合压测
单会话测不出 worker 池的瓶颈。加一个并发脚本:
#!/usr/bin/env python3
"""pg18_aio_concurrent.py — 多并发冷扫描,观察 io_workers 是否成为瓶颈"""
import argparse, statistics, threading, time
import psycopg # pip install "psycopg[binary]"
DSN = "dbname=aiobench"
QUERY = """
SELECT count(*) FROM big
WHERE id BETWEEN %s AND %s AND v < 500
"""
def worker(idx, span, eic, results, barrier):
with psycopg.connect(DSN, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute(f"SET effective_io_concurrency = {eic}")
cur.execute("SET max_parallel_workers_per_gather = 0") # 排除并行查询干扰
lo = idx * span + 1
hi = lo + span - 1
barrier.wait() # 所有线程同时起跑
t0 = time.perf_counter()
cur.execute(QUERY, (lo, hi))
cur.fetchone()
results[idx] = time.perf_counter() - t0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--conc", type=int, default=8)
ap.add_argument("--span", type=int, default=4_000_000)
ap.add_argument("--eic", type=int, default=32)
args = ap.parse_args()
results = [0.0] * args.conc
barrier = threading.Barrier(args.conc)
threads = [threading.Thread(target=worker,
args=(i, args.span, args.eic, results, barrier))
for i in range(args.conc)]
wall0 = time.perf_counter()
for t in threads: t.start()
for t in threads: t.join()
wall = time.perf_counter() - wall0
print(f"并发数 : {args.conc}")
print(f"eic : {args.eic}")
print(f"总墙钟时间 : {wall:.2f}s")
print(f"单查询 p50 : {statistics.median(results):.2f}s")
print(f"单查询 max : {max(results):.2f}s")
print(f"有效并行度 : {sum(results)/wall:.2f}x")
if __name__ == "__main__":
main()
"有效并行度"这个指标是关键。 理想情况下 8 个并发查询的有效并行度接近 8。如果你在 worker 模式下测出来只有 3.2,而 io_workers = 3——不用猜了,就是 worker 池满了。把 io_workers 调到 8 再测,这个数应该往上走。这是我见过最快定位 io_workers 是否够用的方法,比看任何监控图都直接。
5.6 Go 版本:持续负载下的延迟分布
如果你想看长尾延迟(p99),Go 写起来更顺手:
// pg18_aio_load.go — go run pg18_aio_load.go -conc 16 -dur 60s
package main
import (
"context"
"flag"
"fmt"
"math/rand"
"sort"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
conc := flag.Int("conc", 16, "并发数")
dur := flag.Duration("dur", 60*time.Second, "压测时长")
eic := flag.Int("eic", 32, "effective_io_concurrency")
flag.Parse()
cfg, err := pgxpool.ParseConfig("dbname=aiobench pool_max_conns=64")
if err != nil {
panic(err)
}
// 每条新连接都设好会话级参数
cfg.AfterConnect = func(ctx context.Context, c *pgx.Conn) error {
_, err := c.Exec(ctx, fmt.Sprintf(
"SET effective_io_concurrency=%d; SET io_combine_limit='256kB'", *eic))
return err
}
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
if err != nil {
panic(err)
}
defer pool.Close()
var mu sync.Mutex
var lat []time.Duration
deadline := time.Now().Add(*dur)
var wg sync.WaitGroup
for i := 0; i < *conc; i++ {
wg.Add(1)
go func(seed int64) {
defer wg.Done()
rng := rand.New(rand.NewSource(seed))
for time.Now().Before(deadline) {
lo := rng.Int63n(39_000_000)
t0 := time.Now()
var n int64
err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM big WHERE id BETWEEN $1 AND $2 AND v < 500`,
lo, lo+200_000).Scan(&n)
if err != nil {
continue
}
d := time.Since(t0)
mu.Lock()
lat = append(lat, d)
mu.Unlock()
}
}(int64(i) + 1)
}
wg.Wait()
sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
pct := func(p float64) time.Duration {
if len(lat) == 0 {
return 0
}
return lat[int(float64(len(lat)-1)*p)]
}
fmt.Printf("样本数 : %d\n", len(lat))
fmt.Printf("QPS : %.1f\n", float64(len(lat))/dur.Seconds())
fmt.Printf("p50 : %v\np95 : %v\np99 : %v\nmax : %v\n",
pct(0.50), pct(0.95), pct(0.99), lat[len(lat)-1])
}
长尾延迟是 worker 模式最容易翻车的地方。 我的经验判断:如果 p99 / p50 的比值在同步模式下是 3 倍,切到 worker 模式后变成 8 倍,那基本是 io worker 队列排队造成的——降低并发或加 worker。io_uring 模式因为没有进程间排队,长尾通常更平。
六、性能优化:调参的取舍与顺序
调参这件事,最怕"把所有旋钮都往大扭"。给一个有优先级的方法论。
6.1 第一步:先确认你的负载吃不吃 AIO
别急着调参,先做判定。 用这个查询把你 Top 慢 SQL 的 I/O 特征打出来:
-- 需要 pg_stat_statements
SELECT
left(query, 70) AS q,
calls,
round(total_exec_time::numeric/1000, 1) AS total_s,
round((shared_blk_read_time + local_blk_read_time)::numeric/1000, 1) AS io_read_s,
round(100.0 * (shared_blk_read_time + local_blk_read_time)
/ nullif(total_exec_time, 0), 1) AS io_pct,
shared_blks_read,
round(shared_blks_read::numeric / nullif(calls,0), 0) AS blks_per_call
FROM pg_stat_statements
WHERE shared_blks_read > 10000
ORDER BY (shared_blk_read_time + local_blk_read_time) DESC
LIMIT 20;
判定规则:
| 特征 | 结论 |
|---|---|
io_pct > 50% 且 blks_per_call 很大(万级以上) | AIO 主战场,认真调,收益能到 2~5 倍 |
io_pct > 50% 但 blks_per_call 很小(几十) | 随机小点查,AIO 帮不上,去查索引/数据布局 |
io_pct < 20% | 你是 CPU 或锁瓶颈,调 AIO 是浪费时间 |
shared_blks_read 本来就小 | 缓存命中良好,别动,加内存的钱省下来 |
我见过太多人在第三、四种情况下疯狂调 effective_io_concurrency,然后得出"PG18 的 AIO 是营销噱头"的结论。不是它没用,是你的瓶颈不在那儿。
6.2 effective_io_concurrency:怎么找到那个数
这个参数在 PG18 里的语义比以前干净多了:它就是"Read Stream 允许的在途 I/O 上限"。
调参方法不是猜,是二分:
for eic in 1 4 16 32 64 128; do
echo "=== eic=$eic"
# 清缓存
sync; echo 3 | sudo tee /proc/sys/vm/drop_caches >/dev/null
sudo -u postgres pg_ctl -D "$PGDATA" restart -m fast -w
psql -d aiobench -c "SET effective_io_concurrency=$eic;" \
-c "EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM big WHERE v < 500;" \
| grep -E 'I/O Timings|Execution Time'
done
典型曲线长这样(本地 NVMe,12GB 表,仅供理解形状):
| eic | 执行时间 | I/O Timings | 边际收益 |
|---|---|---|---|
| 1 | 基准 100% | 92% | — |
| 4 | ~42% | 34% | 巨大 |
| 16 | ~24% | 15% | 明显 |
| 32 | ~19% | 9% | 还有 |
| 64 | ~18% | 8% | 基本饱和 |
| 128 | ~19% | 9% | 反而变差 |
注意最后一行。 超过存储的最优队列深度后,继续加并发只会增加排队延迟,不增加吞吐,而且会挤占别的会话。这就是典型的"过载即退化"曲线。
存储类型对应的起点建议:
- 本地 NVMe(单盘):32~64
- NVMe RAID / 多盘:64~128
- 云盘(网络块存储):16~32(延迟高但队列深,能吃并发;但注意云厂商的 IOPS 配额)
- 机械盘 / RAID with HDD:2~8(队列深了只会加剧寻道)
- 全内存命中的实例:随便,反正不产生 I/O
6.3 io_workers:worker 模式的隐藏瓶颈
默认 3 太保守了。判断方法就是 5.5 节那个"有效并行度":
有效并行度 ≈ io_workers × 单 worker 并发 (在 I/O bound 时)
调参步骤:
- 用 8~16 并发跑冷读压测,记录有效并行度。
ALTER SYSTEM SET io_workers = 8;+pg_reload_conf()(不用重启,这点很友好)。- 重测。如果有效并行度上升 → 继续加;持平 → 已经不是 worker 瓶颈了,停手。
-- 在线调整并确认
ALTER SYSTEM SET io_workers = 8;
SELECT pg_reload_conf();
SELECT pid, backend_type, backend_start
FROM pg_stat_activity WHERE backend_type = 'io worker' ORDER BY pid;
上限提醒:io_workers 是真实进程,每个都吃内存和调度资源。在 8 核机器上开 32 个 io worker 是自残。我的经验区间是 CPU 核数 / 4 到 CPU 核数 / 2。
6.4 io_combine_limit:合并读的甜点
io_combine_limit 默认 128kB(16 个 8KB 块)。要往上调,必须先抬 io_max_combine_limit(需重启)。
io_max_combine_limit = '1MB' # 硬顶,重启生效
io_combine_limit = '256kB' # 实际值,可 reload / 会话级设置
什么时候值得调大:
- 大表全表扫描、CTAS、
COPY TO、逻辑备份 → 调大有收益 - 数据文件物理连续度好(新导入、刚
VACUUM FULL/CLUSTER过)→ 调大有收益
什么时候没用甚至有害:
- 表碎片严重、块号不连续 → 合不起来,白搭
- 随机点查为主 → 每次就读 1 个块,
io_combine_limit完全不参与 - 内存紧张 → 更大的合并读需要更多临时 buffer
验证是否生效,回到 pg_stat_io 的 avg_read_size:调大之后这个数应该跟着涨。涨不上去就说明瓶颈不在这儿,把值改回去。
6.5 别忘了 maintenance_io_concurrency
VACUUM 和 ANALYZE 也走 Read Stream。大表 VACUUM 慢的实例,这个参数收益经常比 effective_io_concurrency 更明显——因为 VACUUM 是纯顺序扫描,最吃预读。
-- 给一次性的大维护操作单独开高
SET maintenance_io_concurrency = 64;
SET maintenance_work_mem = '2GB';
VACUUM (VERBOSE, ANALYZE) big;
顺带一个实用技巧:在业务低峰期跑维护时,把这两个值临时拉高,做完改回来。写成脚本:
psql -d mydb <<'SQL'
SET maintenance_io_concurrency = 64;
SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 4;
REINDEX INDEX CONCURRENTLY big_v_idx;
SQL
6.6 一张调参优先级表
按投入产出比排序,从上往下做:
| 优先级 | 动作 | 预期收益 | 风险 |
|---|---|---|---|
| 1 | 确认负载是否 I/O bound(6.1 节) | 避免白干 | 无 |
| 2 | track_io_timing = on | 拿到可观测性 | 极小开销 |
| 3 | effective_io_concurrency 二分调优 | 大 | 过大会挤占资源 |
| 4 | io_workers 从 3 提到 6~8 | 中~大(高并发时) | 多几个进程 |
| 5 | io_combine_limit 128kB → 256kB | 中(大扫描) | 内存占用 |
| 6 | maintenance_io_concurrency | 中(维护窗口) | 无 |
| 7 | 切 io_uring | 中(延迟长尾) | 编译/内核/容器依赖 |
| 8 | Direct I/O | 未来 | 目前仍是开发者选项 |
第 8 项特别说明:debug_io_direct 这个参数存在,但名字里的 debug_ 就是社区的态度——它不是给生产用的。绕过 page cache 的完整收益需要 AIO + DIO + 更聪明的 Postgres 内部缓存管理三者齐备,那是后续版本的事。现在开它,你只会失去 OS page cache 这层免费缓存,然后性能暴跌。
七、给不同角色的行动清单
如果你是 DBA / SRE
- 升级不等于开启。 PG18 默认
io_method = worker,io_workers = 3。这是"能用但没调"的状态。 - 升级后第一件事:
track_io_timing = on,然后跑 6.1 节的查询摸清 I/O 画像。 effective_io_concurrency默认从 1 变 16 是行为变更。在机械盘或 IOPS 有配额的云盘上,这可能让某些查询变慢或者打爆配额。升级前务必在预生产验证,别信"默认值都是安全的"。- 把
pg_aios加进你的监控采集:in-flight 数量、平均 length、涉及的 backend 数。这三个指标能让你第一次真正"看见"数据库的 I/O 行为。 - 容器环境默认用
worker,别碰 seccomp。
如果你是应用/后端开发
- 你不需要改任何代码。但你可以在会话级精细控制:报表连接池设高
effective_io_concurrency,OLTP 连接池保持默认。 EXPLAIN (ANALYZE, BUFFERS)里的I/O Timings现在是你最该看的一行。它把"慢在等磁盘"和"慢在算数据"区分开了。- AIO 不会救你的 N+1 查询。逻辑上串行的访问永远无法异步化——1000 次单行查询依然是 1000 个往返。这个问题只能在应用层用批量查询解决。
如果你是内核/存储方向的工程师
那个 shared / local callback 的切分,值得单独读一遍源码。它是"异步化改造中如何处理完成回调的副作用归属"这个通用难题的一个高质量工业答案:按副作用可见范围切分,共享状态谁都能收尾,私有状态和错误报告严格归属发起者。 这个模式可以直接搬到任何有共享资源池的异步系统里。
八、总结与展望
把这篇文章压缩成几句能带走的话:
PG18 的 AIO 是地基级改动,不是旋钮级优化。 它把"发起 I/O"和"等待 I/O"解耦,第一次让单个 Postgres backend 能打出大于 1 的队列深度。
收益高度集中在"能提前知道块号"的读路径:顺序扫描、Bitmap Heap Scan、VACUUM、ANALYZE。索引随机访问和写路径目前基本不受益。
三种 io_method 各有定位:
sync是回退,worker是全平台默认(有进程间开销和池容量上限),io_uring是性能上限(但有编译、内核、容器 seccomp 三重门槛)。生产上先用 worker 调好io_workers,把 io_uring 当进阶选项。pg_aios是这一代最被低估的新特性。 以前 Postgres 的 I/O 行为是黑盒,现在你能实时看到每一个在飞的请求。光这一点就值得升级。调参有顺序:先判定负载是否 I/O bound,再二分
effective_io_concurrency,再看io_workers是否成为瓶颈,最后才动io_combine_limit。跳过第一步的所有调优都是撞运气。
往前看,这条路线的后续拼图已经很清晰:WAL 与脏页刷盘的异步化(写路径),真正生产可用的 Direct I/O(消除 page cache 双拷贝和双份内存),以及更激进的、基于 AIO 的预读策略(比如让 planner 把访问模式信息传给 Read Stream)。这三块拼齐,Postgres 才算真正把现代存储硬件的性能吃干榨净。
而现在这一版,是二十年来最关键的一次转身。它自己不一定让你的库快多少,但它让"快"这件事第一次成为可能。
最后一句实在话:升级 PG18 之后,请务必在你自己的硬件上跑一遍第五节那套压测。I/O 这个领域没有普适的最佳参数——你的盘、你的内核、你的数据分布,共同决定那个最优值是 16 还是 64。别抄我的数字,抄我的方法。