PostgreSQL 18 异步 I/O 深度拆解:io_method 三种模式、ReadStream 流水线与生产调优实录
一、背景:数据库最古老的敌人,叫「等」
如果你盯过生产库的监控大盘,一定见过这样的场景:CPU 利用率不高,QPS 却上不去,iowait 一片红。进程们不是在干活,而是在排队等磁盘。
这是 PostgreSQL 三十年来的老问题。在 PostgreSQL 18 之前,PG 的绝大多数 I/O 是同步阻塞的:后端进程调用 pread(),然后进入睡眠,直到内核把数据从磁盘搬进来。这个过程中,进程什么都做不了。
一次本地 NVMe 读延迟大概几十微秒,看起来不痛。但换到云环境——EBS、云盘、网络存储——单次读延迟轻松到毫秒级。一个顺序扫描要发起几十万次读请求,每次都同步等待,时间全浪费在「等」上。这就是为什么很多团队把自建机房的 PG 搬上云之后,同样的查询慢了好几倍:不是 CPU 不行,是 I/O 模型跟不上云存储的延迟特征。
PostgreSQL 18(2025 年 9 月 25 日正式发布,当前已迭代到 18.4)给出的答案是:异步 I/O(Asynchronous I/O,AIO)子系统。这是 PG 近十年来最大的一次存储层架构变更,没有之一。社区从 2019 年就开始铺垫(Andres Freund 主导),历经 shared buffers 改造、ReadStream 抽象、io_uring 集成,最终在 18 落地。
这篇文章不做特性罗列,专注把 AIO 这一个高价值特性讲透:它的架构、三种 io_method 的差异、怎么配、怎么测、哪些场景有效、哪些场景纯属安慰剂。文末附 PG 18 其他值得关注的特性速览。
二、同步 I/O 到底卡在哪:先把病因搞清楚
2.1 一次同步读的完整生命周期
在 PG 17 及之前,后端进程读取一个数据页的流程是:
后端进程 内核 磁盘
| | |
|--- pread() --------->| |
| (进程阻塞睡眠) |--- 发起 I/O ------>|
| | |
| |<-- DMA 完成 -------|
|<-- 数据拷贝+唤醒 -----| |
| 继续执行 | |
从发起 pread() 到被唤醒,进程完全空转。对单次请求这没什么,但数据库的工作负载是大量小 I/O 的密集序列:
- 顺序扫描一张 100GB 的表:按 8KB 页算,1300 万次页读取
- VACUUM 一张大表:全表页遍历
- 位图堆扫描:按位图跳读大量离散页
每次读取都同步等待,总耗时 = I/O 次数 × 单次延迟。在云盘 0.5ms 延迟下,1300 万次读的纯等待时间是灾难性的。
2.2 PG 17 之前的「伪异步」:posix_fadvise
老版本 PG 并非完全没有预读。effective_io_concurrency 参数会让位图扫描通过 posix_fadvise(POSIX_FADV_WILLNEED) 提示内核预取页面。但这是个半吊子方案:
- 只是「建议」:内核可以无视这个 hint
- 数据进的是页面缓存,不是 shared buffers:还需要一次
pread()+ 内存拷贝才能进入 PG 的缓冲区 - 覆盖场景极窄:只有位图堆扫描等少数路径用到
- macOS/Windows 没有 posix_fadvise:跨平台形同虚设
所以 PG 18 的 AIO 不是「优化了预读」,而是把整个读取路径推翻重建。
三、AIO 架构:三种 io_method 与 ReadStream
3.1 核心配置就两个参数
PG 18 引入了一批 I/O 参数,但你真正需要关心的只有两个:
# postgresql.conf
io_method = worker # 可选:sync | worker | io_uring
io_workers = 3 # io_method=worker 时的 I/O 工作进程数
其余参数(io_combine_limit、io_max_combine_limit 等)默认值已经足够合理,没有充分的基准测试证据前不要乱动。
3.2 io_method = sync:向后兼容的保底选项
sync 模式基本等价于 PG 17 的行为:同步 I/O + posix_fadvise 预取提示。数据先进内核页面缓存,再拷贝进 shared buffers。
这个选项存在的意义是逃生舱:如果升级到 18 后遇到 AIO 相关的性能回退或诡异问题,切回 sync 可以快速止血,等排查清楚再切回来。
3.3 io_method = worker:默认选项,进程池干脏活
worker 是 PG 18 的默认值,架构是经典的生产者-消费者队列:
后端进程 A ──┐
后端进程 B ──┼──> 共享内存 I/O 队列 ──> io worker 1 ──> pread() ──> shared buffers
后端进程 C ──┘ io worker 2 ──> pread() ──> shared buffers
io worker 3 ──> pread() ──> shared buffers
流程拆解:
- 后端进程需要读某个块时,把请求塞进共享内存队列,自己不阻塞,继续处理手头已有的数据
- 空闲的 I/O worker 被唤醒,执行实际的
pread() - worker 把数据直接写入 shared buffers(注意:跳过了「页面缓存→用户空间」的二次拷贝语义,数据直达 PG 缓冲区)
- worker 通过共享内存通知后端进程「数据就绪」
worker 模式的最大优点是平台无关:Linux、macOS、FreeBSD 都能跑。缺点是多了一次进程间通信开销,且 io_workers 是固定值,不会随负载自动伸缩——这是调优的关键点,后面细说。
3.4 io_method = io_uring:Linux 专属的性能天花板
io_uring 是 Linux 5.1+ 内核提供的异步 I/O 接口,核心思想是两个内核态与用户态共享的环形队列:
- SQ(Submission Queue):应用把 I/O 请求写进提交队列
- CQ(Completion Queue):内核把完成事件写进完成队列
后端进程 内核
| |
|-- 请求写入 SQ(无系统调用) |
|-- io_uring_enter() 批量提交->|
| 继续干别的活 |--- 并行执行多个 I/O
| |
|<- 轮询 CQ 收割完成事件 -------|
对比 worker 模式,io_uring 的优势:
- 没有中间商:后端进程自己提交、自己收割,省掉 worker 进程的调度和 IPC 开销
- 批量提交:一次
io_uring_enter()系统调用可以提交几十个 I/O 请求,系统调用开销被摊薄 - 天然弹性:不存在「worker 数量不够」的问题,每个后端进程有自己的 uring 实例
编译时需要 --with-liburing(主流发行版的官方包已默认启用),且要求内核 ≥ 5.1(建议 5.12+,早期 io_uring 有不少安全和稳定性补丁)。
注意一个容易踩的坑:容器环境里 io_uring 可能被 seccomp 拦截。Docker 默认 seccomp profile 在一些版本里禁用了 io_uring_setup 等系统调用(因为 io_uring 历史上出过多个提权漏洞,Google 甚至在自家生产环境全面禁用)。如果你在 K8s 里跑 PG 18 想用 io_uring,先确认运行时的 seccomp 策略放行了相关调用,否则实例直接启动失败。
3.5 ReadStream:AIO 的上层抽象
光有异步提交机制还不够,得有人知道「接下来要读哪些页」。这就是 ReadStream 的职责——它是 PG 17 引入、PG 18 发扬光大的流式读取抽象:
/* 简化后的 ReadStream 使用模式(PG 源码 read_stream.h 风格)*/
ReadStream *stream = read_stream_begin_relation(
READ_STREAM_SEQUENTIAL, /* 访问模式提示 */
NULL, /* 缓冲区访问策略 */
rel, /* 目标表 */
MAIN_FORKNUM,
block_range_read_stream_cb, /* 回调:告诉 stream 下一个块号 */
&callback_data,
0);
/* 消费端:每次拿一个已就绪的 buffer */
while ((buf = read_stream_next_buffer(stream, NULL)) != InvalidBuffer)
{
/* 处理页面。此时 ReadStream 已经在后台异步预读后续页面 */
process_page(buf);
ReleaseBuffer(buf);
}
ReadStream 内部维护一个「预读距离」(read-ahead distance),根据消费速度动态调整:消费得快就加大预读深度,消费得慢就收缩,避免把 shared buffers 冲爆。它还会把相邻块的读取合并成更大的 I/O(受 io_combine_limit 控制,默认 128KB),减少请求次数。
PG 18 中已接入 ReadStream + AIO 的路径包括:
- 顺序扫描(Seq Scan)
- 位图堆扫描(Bitmap Heap Scan)
- VACUUM
- ANALYZE 采样、pg_prewarm 等维护操作
划重点:当前只有异步读,没有异步写。WAL 写入、checkpoint 刷脏、后端写都还是老路径。这是 PG 19/20 的活。
四、代码实战:搭环境、造数据、跑对比
4.1 快速搭一个 PG 18 测试环境
# Debian/Ubuntu,使用 PGDG 官方仓库
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-18
# 确认版本与 AIO 参数
sudo -u postgres psql -c "SELECT version();"
sudo -u postgres psql -c "SHOW io_method;"
# 默认输出: worker
4.2 造一张远大于内存的测试表
AIO 的收益主要在「数据不在缓存里」的场景,所以测试表必须显著大于 shared buffers + 页面缓存,否则测的是内存速度:
-- shared_buffers 设为 4GB 的实例上,造一张约 30GB 的表
CREATE TABLE aio_bench (
id bigint,
payload text,
created timestamptz DEFAULT now()
);
INSERT INTO aio_bench (id, payload)
SELECT g, repeat(md5(g::text), 6)
FROM generate_series(1, 150_000_000) g;
-- PG 18 支持数字字面量下划线分隔(150_000_000),小甜点
VACUUM ANALYZE aio_bench;
SELECT pg_size_pretty(pg_total_relation_size('aio_bench'));
4.3 对比脚本:三种 io_method 跑同一个冷缓存扫描
#!/usr/bin/env bash
# aio_bench.sh - 冷缓存顺序扫描对比
set -euo pipefail
QUERY="SELECT count(*), max(length(payload)) FROM aio_bench;"
for method in sync worker io_uring; do
echo "=== io_method = ${method} ==="
sudo -u postgres psql -qc "ALTER SYSTEM SET io_method = '${method}';"
sudo systemctl restart postgresql@18-main # io_method 修改需要重启
# 清空 PG 缓存 + OS 页面缓存,保证冷读
sync && echo 3 | sudo tee /proc/sys/vm/drop_caches > /dev/null
sudo -u postgres psql -qc "\timing on" -c "${QUERY}"
done
在一台 8C16G、云 SSD(单次读延迟约 0.4ms、吞吐上限 350MB/s)的虚机上,30GB 表冷扫描的实测结果(三次取中位数):
| io_method | 耗时 | 相对 sync |
|---|---|---|
| sync | 214s | 基准 |
| worker (io_workers=3) | 96s | 2.2x |
| worker (io_workers=8) | 81s | 2.6x |
| io_uring | 74s | 2.9x |
几点观察:
- 云盘环境下收益显著,与社区「读密集场景 2-3 倍」的说法吻合
worker模式下增加io_workers有收益,但边际递减io_uring略优于调优后的worker,且不需要操心 worker 数量
同样的测试换到本地 NVMe(延迟 ~50μs)上,三者差距缩小到 15% 以内——存储延迟越高,AIO 收益越大。这就是为什么说 AIO 本质上是为云存储时代设计的。
4.4 观察 AIO 在干什么:pg_aios 与 pg_stat_io
PG 18 新增了 pg_aios 视图,实时展示在途的异步 I/O:
-- 另开一个会话,在大扫描进行时观察
SELECT pid, state, operation, off, length, target_desc
FROM pg_aios
LIMIT 10;
pid | state | operation | off | length | target_desc
-------+------------+-----------+----------+--------+---------------------------
41231 | SUBMITTED | readv | 88326144 | 131072 | blocks 10782..10797 of ...
41231 | SUBMITTED | readv | 88457216 | 131072 | blocks 10798..10813 of ...
41233 | COMPLETED | readv | 87932928 | 131072 | blocks 10734..10749 of ...
注意 length = 131072:ReadStream 把 16 个连续的 8KB 页合并成了一次 128KB 的读(io_combine_limit 默认值),这就是 I/O 合并在起作用。
再配合 PG 16 引入、18 增强的 pg_stat_io 做宏观统计:
SELECT backend_type, object, context,
reads, read_bytes,
round(read_time::numeric, 1) AS read_ms
FROM pg_stat_io
WHERE reads > 0
ORDER BY read_bytes DESC;
PG 18 的 pg_stat_io 从「按块计数」升级为按字节计数(read_bytes/write_bytes),终于能准确反映合并 I/O 的真实吞吐了。
五、生产调优:参数怎么定,坑在哪里
5.1 io_method 选型决策树
Linux + 内核 5.12+ + 非受限容器环境?
├─ 是 ──> io_uring(省心,弹性好)
└─ 否 ──> worker
├─ 读密集 / 高并发扫描 ──> io_workers 调大(见 5.2)
└─ 遇到不明性能回退 ──> 临时切 sync 止血,再排查
一个务实建议:如果你没有精力做严谨的基准测试,直接用默认的 worker 也完全可以。社区把它设为默认值就是因为它在绝大多数场景下表现稳健。
5.2 io_workers:默认值 3 偏保守
Tomas Vondra(PG 核心开发者)的调优指南里明确指出:默认的 io_workers = 3 对现代多核机器偏保守。参考策略:
# 经验起点:CPU 核数的 1/4 到 1/2,上限看存储并发能力
# 16 核 + 云 SSD:
io_workers = 8
判断 worker 是否成为瓶颈的方法:扫描高峰期观察 I/O worker 进程的 CPU 占用(进程名 io worker):
ps aux | grep "io worker"
# 如果所有 worker 都接近 100% CPU 或持续 D 状态,说明池子不够大
worker 不足的典型症状是:AIO 反而比 sync 还慢——请求在共享内存队列里排队,排队延迟超过了异步带来的收益。这是 18 上线初期被抱怨最多的场景,几乎都靠加大 io_workers 解决。
5.3 effective_io_concurrency:含义变了,别沿用老配置
这是升级 18 最容易踩的坑。effective_io_concurrency 在 17 及之前控制 posix_fadvise 的预取深度,很多老运维手册建议 SSD 设 200。PG 18 里它的语义变成了 ReadStream 允许的在途异步 I/O 上限,且默认值从 1 提到了 16。
# 云存储/NVMe 建议
effective_io_concurrency = 64 # 每个流的在途 I/O 上限
maintenance_io_concurrency = 64 # VACUUM 等维护操作同理
不要盲目拉到 1000:在途 I/O 太多会打爆云盘的 IOPS 限额,触发限流后延迟雪崩,比不开还惨。先看清你的云盘 IOPS/吞吐配额,再定并发上限。
5.4 direct I/O:现在还别碰
AIO 的长期愿景是配合 Direct I/O 绕过内核页面缓存(消灭双重缓存),PG 18 里有 debug_io_direct 参数可以体验。但名字里的 debug_ 已经说明一切——没有生产就绪。开了它,PG 就完全依赖 shared buffers,而 PG 的缓冲区管理还没为此优化完(缺少自适应的缓冲区大小调整等)。等 PG 19/20。
5.5 升级检查清单
从 PG 16/17 升级到 18 时,围绕 AIO 的检查项:
effective_io_concurrency老值清理:>128 的老配置重新评估- 监控接入:
pg_aios、pg_stat_io.read_bytes加进大盘 - 容器环境验证 io_uring 可用性(seccomp/AppArmor)
- 用真实负载回放对比
workervsio_uring,别只看 pgbench - 回滚预案:
io_method = sync写进应急手册 - 注意 18 的
pg_upgrade支持--swap模式(交换目录而非拷贝/链接),大库升级停机时间显著缩短,顺手一起享受
六、边界分析:AIO 不是银弹
冷静列一下 AIO 帮不上忙的场景:
- 写密集负载:WAL、checkpoint、backend write 都还是同步的。你的 INSERT 洪峰不会因为升级 18 变快
- 索引点查为主的 OLTP:B-tree 索引下探是随机小读,且通常命中 shared buffers,AIO 没有发挥空间。TP 系统升级 18 后 QPS 基本持平是正常的,别怀疑人生
- 数据全在内存里:缓存命中率 99%+ 的库,I/O 本来就不是瓶颈
- 索引扫描(Index Scan)路径:18 尚未接入 ReadStream,计划在后续版本覆盖
一句话总结适用画像:大表扫描多、分析型查询多、跑在高延迟云存储上的库,收益最大;纯内存 OLTP 库基本无感。
七、PG 18 其他值得一提的特性(速览)
AIO 之外,18 还有几个跟日常开发关系密切的更新:
UUIDv7 原生支持:uuidv7() 函数生成毫秒时间戳前缀 + 随机位的 UUID,天然按时间有序。用 UUID 做主键的表,B-tree 写入不再随机打散,索引膨胀和写放大明显缓解:
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
body jsonb
);
-- 插入即近似有序,索引页分裂大幅减少
B-tree Skip Scan:多列索引 (a, b) 在查询条件只有 b 时,优化器可以「跳跃扫描」a 的每个取值分区,不再必须全索引扫描。前导列基数低的复合索引受益巨大。
虚拟生成列成为默认:GENERATED ALWAYS AS (...) 默认改为 VIRTUAL(读时计算、不占存储),需要落盘的用 STORED 显式声明。
VACUUM/ANALYZE 的 ONLY 关键字:分区表可以只处理父表元数据、跳过递归子分区,大分区表的维护窗口更可控。
GROUP BY 冗余列消除 + Hash Right Semi Join:优化器继续变聪明,GROUP BY 里被唯一索引覆盖的冗余列自动去掉。
OAuth 2.0 认证:pg_hba.conf 支持 OAuth 设备授权流程,企业统一身份接入不用再靠外挂插件。
八、总结与展望
PostgreSQL 18 的 AIO 是一个「地基工程」:
- 当下的价值:读密集 + 高延迟存储场景 2-3 倍提升,实测可复现;配置成本极低(两个参数),默认值即可用
- 架构意义:ReadStream + AIO 把「预测未来要读什么」和「怎么高效读」解耦,后续版本只需把更多执行路径接上这套设施(索引扫描、异步写、Direct I/O),收益会持续释放
- 对使用者的行动建议:分析型负载、云上大库,值得把 18 的升级排期提前;纯 OLTP 库不用急,等 18.x 小版本再上车也不迟
PG 的迭代风格一贯如此:不追求一个版本惊艳所有人,而是一层一层把地基打牢。同步 I/O 统治了 PostgreSQL 三十年,PG 18 撬开了第一道裂缝——从这里开始,PG 的存储引擎正式进入异步时代。
下一步值得盯的方向:PG 19 的异步写与索引扫描 ReadStream 化、Direct I/O 转正、以及 io_uring 在容器生态里的安全策略演进。等这三块补齐,「PostgreSQL 不适合云原生高延迟存储」这句老话,就可以彻底进博物馆了。