PostgreSQL 18 异步 I/O 深度解剖:从 io_uring 内核到 3 倍读性能背后的工程真相
数据库世界里最沉默的瓶颈,往往不是 CPU,不是锁,而是那个你以为早就被操作系统页缓存"藏起来"的东西——磁盘 I/O。PostgreSQL 用了近三十年的同步 I/O 模型,直到 18 版本才第一次把"异步"这两个字写进内核。这篇文章不聊营销话术,我们从第一性原理把 PG18 的 AIO 子系统拆开,看看那个"最高 3 倍读性能提升"到底是怎么来的,以及你在生产环境(尤其是容器里)会踩到哪些坑。
一、背景:为什么 PostgreSQL 憋了 30 年才做异步 I/O
要理解 PG18 这次改动的分量,得先明白它之前有多"原始"。
1.1 同步 I/O 的老问题
在 PostgreSQL 18 之前,当一个 backend 进程需要从磁盘读一个 8KB 的数据块时,流程大致是这样的:
backend 进程
│
├─ 发现目标 page 不在 shared_buffers
├─ 调用 pread() 系统调用
├─ ★ 进程在这里彻底阻塞,交出 CPU
├─ 内核去磁盘/文件系统取数据
├─ 数据回来,进程被唤醒
└─ 继续处理
关键就在那个星号。一次 pread() 阻塞期间,这个 backend 什么都干不了。哪怕它明明知道自己接下来还要读第 2、3、4 个块,它也得老老实实一个一个来:读完 1 才能发起 2。
对于一次全表扫描(Seq Scan)、位图堆扫描(Bitmap Heap Scan)或者 VACUUM 这种要连续吃掉成千上万个数据块的操作,这意味着 I/O 延迟无法被隐藏,全部串行累加。假设单块读延迟是 100 微秒,读一万个块就是纯粹的 1 秒钟,而这 1 秒里 CPU 大部分时间在睡觉。
1.2 PG 过去的"打补丁"方案
PostgreSQL 社区当然不是没想过办法。历史上它用过两个"曲线救国"的手段:
posix_fadvise + effective_io_concurrency:从 8.4 版本起,PG 用 posix_fadvise(POSIX_FADV_WILLNEED) 给内核发"预读提示"。它本质上是告诉内核"我待会儿要这些块,你先帮我准备下",然后依赖内核的预读机制。但这只是提示,不是真正的异步,控制力很弱,而且只对 bitmap heap scan 这类场景生效。
操作系统预读(readahead):顺序扫描时依赖文件系统层面的预读。问题是数据库比文件系统更懂自己的访问模式,把决策权交给 OS 本身就是次优解。
这些方案的共同问题:PostgreSQL 无法主动、批量、精确地控制"多个 I/O 同时在飞"这件事。
1.3 PG18 的答案:一个真正的 AIO 子系统
PostgreSQL 18(2025 年 9 月正式发布)第一次引入了一个统一的异步 I/O 子系统(AIO subsystem)。官方给出的数据是:在从存储读取数据的工作负载上,最高可获得约 3 倍的性能提升。
它的核心思路一句话概括:让 backend 进程可以一次性发起多个 I/O 请求,然后继续干别的活,等数据真正需要用到时再去"收货"。这就是异步的本质——发起(submit)和完成(completion)解耦。
下面我们把这套系统一层层拆开。
二、核心概念:AIO 子系统的三种 io_method
PG18 的 AIO 不是单一实现,而是通过 io_method 这个 GUC 参数提供了三种后端:
| io_method | 平台 | 原理 | 适用场景 |
|---|---|---|---|
sync | 全平台 | 退化为传统同步 I/O(伪异步) | 兼容/排障基线 |
worker | 全平台 | 由专门的 I/O worker 进程池代为执行 | 默认值,通用安全 |
io_uring | 仅 Linux | 直接使用内核 io_uring 接口 | 高性能、追求极致 |
2.1 io_method = sync
这是最保守的模式,本质上关掉了异步。它保留了新的 AIO 代码路径(read stream API 等),但在真正提交 I/O 时立即同步执行。它存在的意义主要是给你一个对照基线——当你怀疑异步 I/O 引起问题时,切回 sync 就能快速验证。
2.2 io_method = worker(默认值)
这是 PG18 的默认模式,也是社区精心选择的"最大公约数"。
它的工作原理是:PostgreSQL 启动一批专门的 I/O worker 进程(数量由 io_workers 控制,默认 3 个)。当某个 backend 想发起异步读时,它把 I/O 请求放进一个共享内存队列,I/O worker 进程从队列里取出请求,用普通的 pread() 去执行,完成后通知发起方。
backend A ──┐
backend B ──┼──> [共享内存 I/O 队列] ──> I/O worker 1 ─┐
backend C ──┘ I/O worker 2 ─┼─> 磁盘
I/O worker 3 ─┘
好处:跨平台,不依赖任何特定内核特性,Windows、macOS、各种 Linux 发行版都能跑。坏处:请求进队列、worker 出队列、跨进程通知,这中间有额外的调度和上下文切换开销。
为什么默认选 worker 而不是更快的 io_uring?因为 io_uring 只在 Linux 上有,而且需要编译期支持 + 内核版本 + 权限配置,社区选择了"到处都能安全跑"的方案作为默认。
2.3 io_method = io_uring(性能天花板)
这是这次改动真正的性能王牌,但它有前提条件:
- 仅限 Linux,且内核需支持 io_uring(5.1+,实际建议 5.10+ 甚至更高以规避早期 bug);
- PostgreSQL 编译时必须带
--with-liburing(依赖 liburing 库); - 运行环境要允许 io_uring 相关系统调用(容器里这点是大坑,后面详说)。
io_uring 是 Linux 在 5.1 引入的下一代异步 I/O 接口,它的精妙之处在于用两个共享环形缓冲区(提交队列 SQ、完成队列 CQ)在用户态和内核态之间传递 I/O,极大减少了系统调用次数。
用户态 (PostgreSQL backend) 内核态
┌─────────────────────┐
│ Submission Queue │ ──写入──> 内核读取 SQ,执行 I/O
│ (SQ 环形缓冲) │
├─────────────────────┤
│ Completion Queue │ <──写入── 内核写入完成事件
│ (CQ 环形缓冲) │
└─────────────────────┘
↑
一次 io_uring_enter() 可提交/收割一批 I/O
对比 worker 模式,io_uring 少了跨进程队列和进程间通知这一层,backend 直接和内核对话,延迟更低、吞吐更高。这就是"3 倍"数据主要的来源场景。
三、架构分析:一次异步读到底发生了什么
光知道三种模式还不够,我们需要理解 PG18 内部是怎么把"异步"这个抽象落地的。核心是三个东西:AIO handle、read stream、staged/submitted/completed 生命周期。
3.1 pgaio:统一的 AIO 抽象层
PG18 在 src/backend/storage/aio/ 下新增了一整套代码,核心是 pgaio 模块。它对上层屏蔽了 sync/worker/io_uring 的差异,对外提供统一的 AIO handle 抽象。
一个 I/O 的生命周期被拆成几个明确的阶段:
[空闲]
│ pgaio_io_acquire() 申请一个 handle
▼
[Staged] ── 定义好这次要读哪个文件的哪些块
│ pgaio_io_stage()
▼
[Submitted] ── 真正丢给 io_method 后端(worker/io_uring/sync)
│
▼
[Completed] ── I/O 完成,回调触发(校验、错误处理)
│ pgaio_io_release()
▼
[回收]
这个状态机的价值在于:发起和完成被彻底解耦。一个 backend 可以连续 stage + submit 好几个 I/O,然后去处理已经到手的数据,等真正需要下一块时再去"收割"(reap)完成事件。
3.2 Read Stream API:让上层算子"无痛"用上异步
如果只提供裸的 AIO handle,那每个扫描算子都要自己管理一堆异步状态,代码会非常难写。PG17 引入、PG18 大幅铺开的 Read Stream API(read_stream.h)就是那层"甜味剂"。
它的设计哲学是回调驱动的预取流:算子只需要提供一个回调函数,告诉 read stream"我下一个想读的块是哪个",read stream 内部就会自动帮你维护一个"正在飞行的 I/O 窗口",提前把后面的块读进来。
伪代码感受一下这个 API 的用法:
/* 1. 定义"下一个块是谁"的回调 */
static BlockNumber
my_next_block(ReadStream *stream, void *priv,
void *per_buffer_data)
{
MyScanState *state = priv;
if (state->current >= state->nblocks)
return InvalidBlockNumber; /* 流结束 */
return state->current++;
}
/* 2. 创建 read stream */
ReadStream *stream = read_stream_begin_relation(
READ_STREAM_SEQUENTIAL, /* 访问模式提示 */
strategy, /* buffer 访问策略 */
relation,
MAIN_FORKNUM,
my_next_block,
scan_state,
0);
/* 3. 循环取 buffer——异步预取在背后自动发生 */
Buffer buf;
while ((buf = read_stream_next_buffer(stream, NULL)) != InvalidBuffer)
{
Page page = BufferGetPage(buf);
/* ... 处理这个 page ... */
ReleaseBuffer(buf);
}
read_stream_end(stream);
关键点在于第 3 步:当你调用 read_stream_next_buffer() 拿第 1 个 buffer 时,read stream 内部已经悄悄把第 2、3、4……个块的异步读发出去了。等你处理完第 1 块回来拿第 2 块,它很可能已经在内存里了——I/O 延迟被你处理数据的时间"藏"掉了。
在 PG18 里,越来越多的核心算子被改造成使用 read stream:顺序扫描、位图堆扫描、VACUUM、ANALYZE、pg_prewarm 等。这也是为什么"读密集"负载受益最明显。
3.3 访问模式提示:SEQUENTIAL vs 随机
read stream 创建时可以传入访问模式提示(如 READ_STREAM_SEQUENTIAL),这会影响预取的激进程度和 I/O 合并策略。顺序扫描可以放心大胆地大批量预取并合并相邻块;而随机访问(如索引回表)则要更克制,避免读进一堆用不上的块浪费带宽。
四、代码实战:配置、监控与调优
理论讲完,进入实操。这一节全是能直接用在生产上的东西。
4.1 查看和设置 io_method
先看当前用的是哪种模式:
SHOW io_method;
-- io_method
-- -----------
-- worker
切到 io_uring(需要重启,且编译/内核/权限都满足):
-- postgresql.conf 里设置
ALTER SYSTEM SET io_method = 'io_uring';
-- io_method 变更需要重启实例
# 重启后确认
psql -c "SHOW io_method;"
如果你设了 io_uring 但环境不支持,PostgreSQL 启动时会报错或(视配置)回退——这里要格外小心,别以为设了就一定生效了,一定要用 SHOW 复核。
4.2 调节并发度:effective_io_concurrency
这是 PG18 里语义被"翻新"的关键参数。它控制单个会话可以同时发起多少个异步读 I/O。
一个重要变化:PG18 把 effective_io_concurrency 的默认值从 1 提高到了 16。这个默认值的跳变本身就说明了社区对异步 I/O 的信心。
-- 查看
SHOW effective_io_concurrency; -- PG18 默认 16
-- 针对 SSD/NVMe 可以调更高
ALTER SYSTEM SET effective_io_concurrency = 32;
SELECT pg_reload_conf();
还有一个配套参数 maintenance_io_concurrency,专门管 VACUUM、CREATE INDEX 这类维护操作的并发度,PG18 默认也提高到了较高的值(通常 16)。维护操作往往可以比常规查询用更激进的 I/O 并发,因为它们不那么在意打扰前台事务。
调优心法:
- 传统机械盘:
effective_io_concurrency别设太高(2~4),并发寻道反而更慢; - SATA SSD:16~32 是合理区间;
- NVMe/云盘高 IOPS:可以往 32~64 甚至更高试,配合压测找拐点。
4.3 I/O 合并:io_combine_limit 与 io_max_combine_limit
PG18 的另一个大杀器是把多个相邻块的读合并成一次大 I/O。这就是 io_combine_limit:
SHOW io_combine_limit; -- 默认 128kB(= 16 个 8KB 块)
SHOW io_max_combine_limit; -- 默认 128kB,重启才能改的硬上限
原理:顺序扫描时,与其发 16 次 8KB 的读,不如发 1 次 128KB 的读。系统调用次数少了,磁盘也更喜欢大块顺序 I/O。
# postgresql.conf
io_combine_limit = 256kB # 运行时可改(reload)
io_max_combine_limit = 256kB # 硬上限,改这个要重启
注意两者的关系:io_combine_limit 是运行时可调的"当前值",但它不能超过 io_max_combine_limit 这个"重启才能变的天花板"。想把合并粒度拉到 256KB 以上,必须先把 io_max_combine_limit 提上去并重启。
大块合并对大表全表扫描、VACUUM、备份类顺序读收益巨大;但对高度随机的 OLTP 点查意义不大,别指望它救随机读。
4.4 监控利器:pg_aios 视图
PG18 新增了一个专门观察在途异步 I/O 的系统视图 pg_aios。这是排障时的"透视镜":
SELECT pid, io_id, io_generation, state,
operation, off, length, target_desc
FROM pg_aios
ORDER BY pid;
它能告诉你:此刻有哪些 I/O 正在飞、处于什么状态(staged/submitted/completed)、读的是哪个文件的哪段偏移、大小多少。当你怀疑某个查询卡在 I/O 上时,配合 pg_stat_activity 一起看,能快速定位是不是异步 I/O 队列堆积了。
4.5 结合 pg_stat_io 看全局 I/O 画像
PG16 引入的 pg_stat_io 在 PG18 里更有用了,它把 I/O 按对象类型、上下文(normal/vacuum/bulkread 等)、操作(read/write/extend)分类统计:
SELECT backend_type, object, context,
reads, read_time, writes, write_time
FROM pg_stat_io
WHERE reads > 0
ORDER BY reads DESC;
异步 I/O 上线后,观察 read_time 相对 reads 的比值变化,是判断"延迟是否真的被隐藏掉"的好指标。
五、性能优化实战:让 3 倍真正落到你的库上
"最高 3 倍"是理想场景数字,现实中能拿到多少取决于你怎么调。下面给一套可落地的优化路径。
5.1 一个可复现的对照实验
准备一张足够大、无法完全放进内存的表:
-- 造一张约几 GB 的表
CREATE TABLE bench AS
SELECT g AS id,
md5(g::text) AS payload,
repeat('x', 200) AS filler
FROM generate_series(1, 50000000) g;
-- 清掉页缓存影响(重启或用 pg_prewarm 反向操作)
分别在三种 io_method 下跑冷缓存全表扫描:
-- 每次测试前清 shared_buffers(重启实例最干净)
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT count(*) FROM bench WHERE filler LIKE '%zzz%';
对比 EXPLAIN ANALYZE 里的执行时间和 BUFFERS 里的 read 数。典型结论:在 NVMe + io_uring + 合理并发下,冷读全表扫描相比 PG17(同步)能有数倍提升;而在页缓存全热的情况下,AIO 帮不上忙(因为根本没真去读盘)。
这条结论很重要:AIO 优化的是"真正打到存储的读",不是内存命中的读。 如果你的库工作集完全在内存里,别指望 AIO 有肉眼可见的变化。
5.2 三个参数的协同调优
异步 I/O 的收益来自三个参数的乘法效应:
有效吞吐 ≈ 并发度 (effective_io_concurrency)
× 单 I/O 大小 (io_combine_limit)
× 后端效率 (io_method: io_uring > worker > sync)
调优顺序建议:
- 先定 io_method:Linux 生产且环境允许 → io_uring;否则保持 worker。
- 再调 io_combine_limit:顺序读为主的分析库,拉到 256KB 甚至更高(记得同步抬 io_max_combine_limit 并重启)。
- 最后调并发度:从默认 16 开始,用真实负载压测,观察 IOPS/延迟拐点,逐步上探。
5.3 worker 模式下别忘了 io_workers
如果你出于兼容性用 worker 模式,但发现高并发下 I/O worker 成了瓶颈(队列堆积、pg_aios 里大量 submitted 状态),可以适当增加 worker 数:
ALTER SYSTEM SET io_workers = 6; -- 默认 3
-- 需要重启
经验值:不要盲目拉高,通常设成 vCPU 数的一个较小比例(如 25%~50%),太多 worker 会带来调度开销和内存占用,反而得不偿失。
5.4 VACUUM 与维护任务的隐性提速
一个容易被忽略的收益点:VACUUM 变快了。因为 vacuum 需要顺序扫过大量堆页和索引页,read stream 化之后,这些读可以异步批量预取。对于动辄跑几小时的大表 autovacuum,这意味着更短的清理窗口、更少的膨胀累积。配合 maintenance_io_concurrency 调高,效果更明显。
六、容器化与生产环境踩坑指南
这一节是最"实战"的部分,因为 io_uring 在容器里的坑真的能坑死人。
6.1 io_uring 在容器里为什么会静默失效
io_uring 需要一系列系统调用(io_uring_setup、io_uring_enter、io_uring_register)。而现代容器运行时(Docker、containerd)出于安全考虑,默认的 seccomp profile 往往会屏蔽 io_uring 相关系统调用——因为 io_uring 历史上出过不少内核安全漏洞,云厂商和运行时对它相当警惕。
后果是:你在容器里设了 io_method = io_uring,但实际根本调不动 io_uring 系统调用,轻则启动报错,重则静默回退到低效路径,你以为开了异步,其实没有。
6.2 排查与解法
第一步,确认是否真的在用 io_uring:
# 进容器看进程系统调用
strace -f -e trace=io_uring_setup,io_uring_enter -p <backend_pid>
# 如果完全没有 io_uring_* 调用,说明没生效
第二步,放开 seccomp / 授予能力(仅在你信任的环境这么做):
# Docker:授予 io_uring 相关能力
docker run --cap-add=SYS_IOURING \
-e PGIO_METHOD=io_uring \
postgres:18
Kubernetes 里对应地在 securityContext 里放开:
securityContext:
capabilities:
add: ["SYS_IOURING"]
# 或使用自定义 seccompProfile 放开 io_uring 系统调用
seccompProfile:
type: Localhost
localhostProfile: profiles/allow-io-uring.json
第三步,实在搞不定 io_uring,就大方用 worker:
ALTER SYSTEM SET io_method = 'worker';
ALTER SYSTEM SET io_workers = 8; -- 补偿性地多给几个 worker
worker 模式虽然比 io_uring 慢一点,但它不依赖任何特权,在受限容器里稳定可预期。对绝大多数业务来说,从 sync 到 worker 的提升已经很香了,不必非追 io_uring 不可。
6.3 五个最常见的错误配置
- 设了 io_uring 但没重启:io_method 变更必须重启,
pg_reload_conf()不生效。 - 设了 io_uring 但没检查是否真生效:一定要
SHOW+ strace 双重确认。 - effective_io_concurrency 在机械盘上拉太高:并发随机寻道让 HDD 更慢。
- io_combine_limit 想超过 io_max_combine_limit:后者是重启才能改的硬上限,先抬它。
- 容器 seccomp 没放开就硬上 io_uring:静默失效,性能反而不如预期。
6.4 监控告警建议
上线异步 I/O 后,建议把这几个指标纳入监控:
pg_aios中长期处于submitted状态的 I/O 数量(堆积信号);pg_stat_io中read_time / reads的平均单次读延迟趋势;- I/O worker 进程的 CPU 占用(worker 模式下判断是否需要加 worker);
- 内核层面 io_uring 的错误计数(dmesg 里的相关告警)。
七、PG18 其他值得一提的配套改进
异步 I/O 是主角,但 PG18 还有几个和"读性能/开发体验"高度相关的改动,顺带一提:
7.1 Skip Scan:多列索引的救赎
PG18 支持 B-tree 的 skip scan。过去如果你有 (a, b) 复合索引,但查询只带了 b 的条件(没带 a),索引基本用不上。现在优化器可以在 a 的不同取值之间"跳跃"扫描,让这类查询也能吃到索引。这对那些"前导列基数很低"的复合索引场景是实打实的福音。
7.2 UUIDv7:可排序的主键
PG18 内置了 uuidv7() 函数。UUIDv4 完全随机,用作主键会让 B-tree 索引插入到处乱跳、页分裂严重;UUIDv7 把毫秒级时间戳放在高位,生成的 UUID 天然按时间近似有序,插入局部性好得多,对分布式系统和时序数据尤其友好。
SELECT uuidv7();
-- 高位是时间戳,连续生成的值近似递增
7.3 虚拟生成列(Virtual Generated Columns)
PG12 有存储型(stored)生成列,PG18 加入了虚拟(virtual)生成列——值不落盘,查询时才计算。省存储、省写放大,适合那种"经常读、由其他列算出来、但不想占空间"的派生字段,现在还成了默认行为。
7.4 更平滑的大版本升级
PG18 优化了 pg_upgrade,升级时间更短,而且升级后能更快达到预期性能(比如保留优化器统计信息、减少升级后重新 analyze 的痛苦)。对运维来说,这降低了从旧版本迁移的心理门槛。
八、总结与展望
把这次 PG18 的异步 I/O 放到 PostgreSQL 的演进史里看,它的意义可能被很多人低估了。
它补齐的是一块地基级的短板。 过去 PG 在查询优化器、并行查询、分区、逻辑复制上都做得很出色,唯独 I/O 层还停留在"一次一个块阻塞读"的原始状态。AIO 子系统的引入,让 PostgreSQL 第一次能像现代高性能系统那样,把 I/O 延迟藏在计算背后。
几个可以带走的核心结论:
- AIO 优化的是真正打到存储的读,不是内存命中。 工作集全在内存里的库感受不明显,冷读/大扫描/VACUUM 场景收益最大。
- 默认 worker 模式已经够用且安全,io_uring 是性能天花板但有环境门槛,尤其在容器里要过 seccomp 这关。
- 三个旋钮协同调优:io_method 定后端、io_combine_limit 定单次 I/O 大小、effective_io_concurrency 定并发度,是乘法关系。
- 一定要用
SHOW+pg_aios+ strace 验证异步是否真生效,别被"设了就以为开了"坑到。
展望未来,PG18 的 AIO 目前主要覆盖读路径,写路径的异步化(WAL 写、checkpoint 刷脏、buffer 回写)是社区下一步的重头戏。可以预见 PG19、PG20 会把这套 AIO 框架继续往写侧推进,届时 PostgreSQL 在高并发写入和大批量导入场景的表现还会再上一个台阶。
对我们这些天天和数据库打交道的工程师来说,最实际的建议是:如果你有读密集型的大库,PG18 值得认真评估一次升级。先在测试环境用 worker 模式跑起来,压测对比,确认收益后再决定要不要挑战 io_uring。把 I/O 这块最沉默的瓶颈打开,往往比你反复调那几条慢 SQL 的收益还要大。
数据库性能优化的世界里,最大的进步常常来自最底层的地基。PG18 这次,就是在给地基换钢筋。