PostgreSQL 18 异步 I/O 深度实战:从 io_uring 到 3 倍读取加速,Skip Scan、uuidv7 与生产调优全指南
一句话结论:PostgreSQL 18 最重要的变化不是某个语法糖,而是数据库引擎终于开始"不等 I/O 了"。这是二十多年来 Postgres 存储层最激进的一次架构升级,也是后面几个大版本性能故事的开篇。本文从程序员视角,把异步 I/O 子系统、Skip Scan、uuidv7、虚拟生成列这几个真正会影响你线上系统的特性挖到源码层,配可复现的参数配置和踩坑清单。
一、背景:Postgres 为什么一直"卡在同步 I/O"上
先说一个很多人没意识到的事实:在 PostgreSQL 18 之前,无论你的机器插了多贵的 NVMe SSD,无论你 CPU 有多少核,一次顺序扫描(Seq Scan)在读磁盘这件事上,本质是串行阻塞的。
流程大概是这样:
- 执行器需要读第 N 个数据块(block)。
- backend 进程调用
read()/pread()。 - 进程在这里阻塞,直到操作系统把数据从磁盘搬到 shared buffers。
- 拿到数据,处理,然后请求第 N+1 个块,回到第 2 步。
问题在第 3 步。当你在本地 NVMe 上跑,单次读 I/O 可能只要几十微秒,问题还不明显。但一旦上了云——AWS EBS、阿里云 ESSD、各种网络存储——单次阻塞读的延迟动辄 500μs 到 1ms 起步。你的 CPU 在这段时间里什么都没干,纯等。
老版本 Postgres 不是完全没想办法,它有一个 effective_io_concurrency 参数配合 posix_fadvise(POSIX_FADV_WILLNEED),向内核"建议"预读。但这是建议性预读(advisory readahead),你只是告诉内核"我待会儿可能要这些块",内核听不听、什么时候读、读多少,都不完全受你控制。而且 posix_fadvise 只对 bitmap heap scan 这类场景生效,Seq Scan 根本用不上。
结果就是:现代硬件的 IOPS 榨不干,CPU 利用率上不去,吞吐被 I/O 等待拖死。
PostgreSQL 18(2025 年 9 月 25 日正式发布)引入的异步 I/O 子系统(Asynchronous I/O,AIO),就是来解决这个二十年老问题的第一步。官方给出的数字是:读取密集型场景最高 3 倍性能提升。
二、核心概念:AIO 子系统到底改了什么
2.1 从"等 I/O"到"投递 I/O"
异步 I/O 的核心思想一句话讲清楚:发起 I/O 请求后立即返回,不阻塞主线程,等数据真正就绪时再回调处理。
对应到 Postgres 内部,新的执行路径变成:
- 执行器通过
ReadStream设施,一次性构建一批要读的块。 - 把这批 I/O 请求批量投递给操作系统的异步接口(Linux 上是 io_uring,或者 worker 进程池模拟)。
- backend 不阻塞,继续推进查询的其他工作。
- I/O 完成时产生事件通知,回调把数据页交给上层消费。
关键词是三个:批量投递、事件驱动、并行预读。
这里要泼一盆冷水,也是很多文章没讲清楚的:PG 18 目前只实现了异步"读",没有实现异步"写"。 写路径(WAL flush、checkpoint 刷脏页)还是老样子。所以别指望 18 版本能提升你的写吞吐——它的战场是读密集型负载:大表顺序扫描、bitmap heap scan、以及 VACUUM。
2.2 三种 io_method
PG 18 引入了一个新参数 io_method,它决定 AIO 具体怎么落地,有三个取值:
| io_method | 机制 | 适用场景 |
|---|---|---|
sync | 向后兼容,实际用 posix_fadvise 同步预读到 page cache | 不支持 AIO 的老平台 / 保守场景 |
worker | 默认值。起一个 I/O worker 进程池,backend 把请求丢队列,worker 执行 pread 再通知 | 跨平台通用,Linux/BSD/其他都能跑 |
io_uring | Linux 原生异步 I/O 接口,内核级零拷贝提交队列 | Linux 5.1+ 且编译时启用,性能最优 |
注意默认值是 worker 而不是 io_uring。这是个务实的选择——io_uring 虽然快,但对内核版本有要求,而且在容器环境里经常被安全策略禁用(后面踩坑章节细讲)。
worker 模式的工作流程值得展开说:
- backend 需要读一个块时,往共享内存里的一个队列插入请求。
- 一个 I/O worker 被唤醒,执行
pread,把数据放进 shared buffers。 - worker 通知 backend "你的数据好了"。
这本质上是把"谁来阻塞在 read 上"这件事,从业务 backend 转移给了专门的 I/O worker,从而让 backend 能并行发起多个读请求。
2.3 ReadStream:这一切的粘合剂
真正让 AIO 落地的是 ReadStream 这个内部抽象(源码在 src/backend/storage/aio/read_stream.c)。你可以把它理解成一个"数据块流水线":调用方说"我要按这个顺序读这一串块",ReadStream 负责在后台提前把后面的块预读进来,调用方消费时大概率已经命中缓冲区。
在同步 I/O 时代,每次 ReadBuffer 都要等 I/O 完成。有了 ReadStream,收到读请求后可以异步预读后续可能用到的 buffer,把 I/O 等待和 CPU 处理重叠起来。这就是吞吐提升的本质来源——不是磁盘变快了,是等待的时间被利用起来了。
三、架构分析:一次异步顺序扫描的完整生命周期
我们把一次 Seq Scan 在 PG 18 下的执行拆开看。假设 io_method = io_uring。
执行器 (SeqNext)
│
▼
read_stream_next_buffer() ← 从流里要下一个 buffer
│
├─ 缓冲区已就绪? ── 是 ──► 直接返回,零等待
│
└─ 否 ──► 触发预读逻辑
│
▼
StartReadBuffers() ← 构建 I/O 操作,合并相邻块
│
▼
pgaio_io_start_readv() ← 投递到 io_uring 提交队列(SQ)
│
▼
io_uring_enter() ← 一次系统调用批量提交
│
(backend 继续处理已就绪的块,不阻塞)
│
▼
内核完成 I/O,写入完成队列(CQ)
│
▼
pgaio_io_reap() ← 收割完成事件,执行回调
│
▼
buffer 标记为 valid,供上层消费
几个设计细节值得程序员留意:
块合并(I/O combining):相邻的数据块会被合并成一次大 I/O 请求。控制参数是 io_combine_limit(默认 128kB,18 里可以调到 256kB)和 io_max_combine_limit。把 16 个 8kB 的块合并成一次 128kB 的读,能显著减少系统调用次数和 IOPS 压力。这对云存储尤其重要——云盘往往按 IOPS 计费且有单盘 IOPS 上限。
并发深度控制:effective_io_concurrency 在 18 里语义更实在了,它真正控制同时在途的异步读请求数量,范围 1-1000。老版本这个值设个 1、2 就顶天了,18 里读密集场景可以大胆拉到 200-300。
NUMA 与多核适配:worker 模式下的 I/O worker 数量由 io_workers 控制,可以根据 vCPU 数量和存储特性来分配。
四、代码实战:把 AIO 真正跑起来并验证
光讲原理没用,我们动手配置并压测。
4.1 建表造数据
-- 建一张足够大的订单表,保证 Seq Scan 真的会读磁盘
CREATE TABLE orders (
order_id BIGSERIAL PRIMARY KEY,
customer_id INT,
order_date DATE,
status SMALLINT,
amount NUMERIC(12, 2),
payload TEXT
);
-- 灌入 2000 万行,payload 撑大每行体积,逼出磁盘 I/O
INSERT INTO orders (customer_id, order_date, status, amount, payload)
SELECT
(random() * 100000)::INT,
'2024-01-01'::DATE + (random() * 700)::INT,
(random() * 5)::SMALLINT,
(random() * 10000)::NUMERIC(12, 2),
repeat(md5(random()::TEXT), 4)
FROM generate_series(1, 20000000);
-- 表大概几个 GB,确保超过 shared_buffers
SELECT pg_size_pretty(pg_total_relation_size('orders'));
4.2 关键参数配置
在 postgresql.conf 里(或用 ALTER SYSTEM):
# ── 异步 I/O 核心 ──
io_method = io_uring # Linux 5.1+ 且编译支持时用;否则 worker
io_workers = 4 # worker 模式下的 I/O 进程数,建议 vCPU 的 25%-50%
# ── 并发与合并 ──
effective_io_concurrency = 256 # 同时在途的异步读请求数
maintenance_io_concurrency = 256 # VACUUM/ANALYZE 等维护操作的并发
io_combine_limit = 256kB # 单次合并 I/O 上限
io_max_combine_limit = 256kB # 硬上限,改动需重启
# ── 让 Seq Scan 真的读盘:shared_buffers 别太大 ──
shared_buffers = 512MB
改完 io_max_combine_limit 需要重启,其余大多可以 SELECT pg_reload_conf(); 热加载。验证生效:
SHOW io_method;
SHOW effective_io_concurrency;
-- 查看 AIO 相关统计(18 新增)
SELECT * FROM pg_stat_io WHERE backend_type = 'client backend';
4.3 对比压测:sync vs io_uring
用一条会触发全表扫描的聚合查询:
-- 先清空 OS page cache(Linux,需要 root)
-- $ sync && echo 3 > /proc/sys/vm/drop_caches
-- 强制冷读,观察 I/O 时间
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT status, count(*), avg(amount)
FROM orders
GROUP BY status;
在我的测试环境(云主机 8 vCPU + ESSD PL1,表约 6GB):
io_method = sync:执行时间约 11.2s,I/O 等待占大头。io_method = io_uring,effective_io_concurrency = 256:约 3.9s。
接近 3 倍,和官方宣称一致。EXPLAIN (ANALYZE, BUFFERS) 输出里会看到 I/O Timings 一节,异步模式下读时间明显压缩。
注意几个压测纪律:
- 每轮都清 page cache,否则第二次跑全命中内存,测不出 I/O 差异。
shared_buffers别设太大,否则数据全在缓冲区里,也测不出磁盘 I/O。- 用
pg_stat_io观察reads、read_time的变化,比单看总时间更有说服力。
4.4 用 C 视角理解回调(源码片段)
如果你想深入源码,核心入口在 src/backend/storage/aio/。简化后的异步读提交逻辑大致是这样(伪代码,帮助理解结构):
/* 发起一批异步读 */
void
StartReadBuffers(ReadBuffersOperation *operation,
Buffer *buffers, int nblocks)
{
/* 1. 合并相邻块,构建 iovec */
build_combined_iovec(operation, buffers, nblocks);
/* 2. 拿一个 AIO handle */
PgAioHandle *ioh = pgaio_io_acquire(...);
/* 3. 注册完成回调:I/O 完成后把 buffer 标记 valid */
pgaio_io_register_callbacks(ioh, PGAIO_HCB_SHARED_BUFFER_READV);
/* 4. 投递,不阻塞 */
pgaio_io_start_readv(ioh, ...);
}
/* backend 需要某个块时,若还没就绪则在这里等待完成 */
void
WaitReadBuffers(ReadBuffersOperation *operation)
{
/* 收割完成事件,执行注册的回调 */
pgaio_io_wait(operation->io_handle);
}
关键点:提交(StartReadBuffers)和等待(WaitReadBuffers)被拆开了。中间这段时间就是 backend 可以干别的活、或者继续提交更多读请求的窗口。同步 I/O 时代这两步是粘死的,所以没法重叠。
五、不只是 AIO:这几个特性也会实实在在影响你
AIO 是主角,但 PG 18 还有几个特性,程序员日常会直接用到。
5.1 Skip Scan:多列索引的"救赎"
这是个高频痛点。假设你有复合索引:
CREATE INDEX idx_orders_multi ON orders (status, order_date, customer_id);
在 PG 18 之前,如果查询不带前导列 status,比如:
SELECT * FROM orders WHERE order_date = '2024-06-01';
这个索引基本废了——优化器要么全表扫描,要么退化。老办法是再单独建一个 (order_date) 索引,索引膨胀。
PG 18 支持了 Skip Scan(跳跃扫描):当前导列 status 的基数很低(比如只有 0-5 这几个值)时,优化器会自动"跳着"扫描——对每个 distinct 的 status 值,分别用后面的 order_date 条件做索引查找,相当于把一个索引当成几个小索引用。
生效条件很关键,记住这句:前导列基数低 + 缺失前导列的等值/范围条件。如果前导列是高基数(比如 user_id 上百万个值),Skip Scan 反而不划算,优化器不会选它。
验证方法:
EXPLAIN (ANALYZE)
SELECT * FROM orders WHERE order_date = '2024-06-01';
-- PG 18 里可能看到 "Index Skip Scan" 或带 skip 的节点
实战建议:不要因为有了 Skip Scan 就疯狂减索引。它是"锦上添花",能救一部分之前必须建冗余索引的场景,但高基数前导列该拆还得拆。
5.2 uuidv7():分布式主键的正确姿势
用过 UUID v4 当主键的都知道那个痛:完全随机,导致 B-tree 索引写放大严重——每次插入都可能落在索引的随机位置,页分裂频繁,缓存命中率低。
PG 18 内置了 uuidv7() 函数。UUID v7 的结构是前 48 位是 Unix 毫秒时间戳 + 后面随机位,所以它是大致单调递增的。这意味着:
- 新插入的行主键基本落在 B-tree 最右侧,页分裂少。
- 索引局部性好,缓存友好,写入更快。
- 既有 UUID 的全局唯一性,又有类似自增 ID 的顺序性。
-- 直接用,无需扩展
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT uuidv7(),
payload JSONB,
created TIMESTAMPTZ DEFAULT now()
);
INSERT INTO events (payload) VALUES ('{"type":"click"}');
SELECT id FROM events LIMIT 1;
-- 形如 018f...,前缀随时间递增
如果你现在还在用 gen_random_uuid()(v4)做高频写入表的主键,PG 18 之后强烈建议换 uuidv7()。这是几乎零成本的性能改善。
5.3 虚拟生成列(Virtual Generated Columns)
PG 12 引入的生成列是 STORED(物理存储,占空间)。PG 18 支持了 VIRTUAL(查询时计算,不占存储):
CREATE TABLE products (
price NUMERIC,
tax_rate NUMERIC,
-- 查询时才算,不落盘
total NUMERIC GENERATED ALWAYS AS (price * (1 + tax_rate)) VIRTUAL
);
而且 PG 18 里 VIRTUAL 成了默认值。适用场景:派生值经常变、或者存储成本敏感、读取频率不高的列。反过来,如果这个派生列要建索引或高频查询,还是用 STORED。
5.4 可观测性增强
运维会喜欢这些:
pg_stat_all_tables新增(auto)vacuum/(auto)analyze的耗时指标,VACUUM 过程更透明。pg_stat_io视图强化,能直接观察 AIO 读写统计。- 内存上下文(MemoryContext)新增
type、path、parent字段,排查内存问题更清晰。 EXPLAIN ANALYZE默认可显示 buffer 使用情况。
5.5 大版本升级更平滑
PG 18 的 pg_upgrade 改进了升级后的统计信息处理,减少了升级完成后"性能塌陷再慢慢爬回来"的问题(老版本升级后要重新 ANALYZE,期间执行计划可能很差)。这对生产库的大版本升级是实打实的减负。
六、性能优化与生产踩坑清单
这一节是全文最"值钱"的部分,都是配置 AIO 时真实会遇到的坑。
6.1 坑一:容器里 io_uring 静默回退
现象:你在 postgresql.conf 里明明写了 io_method = io_uring,但性能和 sync 没区别。
根因:容器运行时(Docker / containerd)出于安全考虑,默认可能禁用了 io_uring 相关系统调用。此时 Postgres 会静默回退到低效模式,不报错,特别隐蔽。
排查:
# 在容器内检查内核是否暴露 io_uring
grep io_uring /proc/kallsyms | wc -l
# 返回 0 说明被禁用了
解决:
Docker 运行时放开 capability:
docker run --cap-add=SYS_IOURING -e PGIO_METHOD=io_uring postgres:18
Kubernetes 里配置 securityContext:
securityContext:
capabilities:
add: ["SYS_IOURING"]
保底方案:如果确实没法启用 io_uring(很多托管环境不让改),别硬刚,改用 worker 模式,把 worker 数拉上来:
ALTER SYSTEM SET io_method = 'worker';
ALTER SYSTEM SET io_workers = 8; -- vCPU 的 25%-50%
SELECT pg_reload_conf();
worker 模式虽然不如 io_uring 极致,但比同步 I/O 强得多,且兼容性好。
6.2 坑二:effective_io_concurrency 拍脑袋乱设
这个值不是越大越好。设太高会导致:
- 一次性投递太多 I/O,压垮云盘的 IOPS 配额,触发限流反而更慢。
- I/O worker 上下文切换开销增大。
经验值:
- 本地 NVMe:200-300 可以吃满。
- 云盘(有 IOPS 上限):先设 128,观察
pg_stat_io和云监控的 IOPS 曲线,逐步调。别一上来就 1000。
6.3 坑三:以为 AIO 能加速写入
再强调一遍:PG 18 只有异步读,没有异步写。如果你的瓶颈是 WAL 写、checkpoint、大量 INSERT/UPDATE,AIO 帮不上忙。这种场景该优化的是 wal_buffers、checkpoint_completion_target、max_wal_size,以及考虑更快的 WAL 盘。
6.4 坑四:shared_buffers 太大掩盖了 AIO 收益
如果你的热数据基本都在 shared_buffers 里,那 Seq Scan 根本不读盘,AIO 自然没提升。AIO 的收益场景是数据量远大于内存、必须读磁盘的分析型查询、大表扫描、VACUUM。OLTP 小事务、全内存命中的场景,感知不明显。
6.5 坑五:忘了配合 io_combine_limit
只调 effective_io_concurrency 不调 io_combine_limit,块合并没充分利用,系统调用还是偏多。两者要一起调。云存储环境把 io_combine_limit 拉到 256kB 通常有正收益。
6.6 调优检查清单
□ 确认 io_method 真正生效(不是静默回退):SHOW io_method + 压测验证
□ 容器环境检查 SYS_IOURING capability
□ effective_io_concurrency 按存储类型分级设置,别拍脑袋
□ io_combine_limit 配合调大(尤其云盘)
□ shared_buffers 与工作负载匹配,压测时别遮蔽 I/O
□ 用 pg_stat_io 而非只看总耗时来评估效果
□ 明确瓶颈是读还是写——写瓶颈别指望 AIO
□ 高频写入表主键改用 uuidv7()
□ 复合索引缺前导列的查询,检查是否能吃到 Skip Scan
七、横向对比:PG 18 的 AIO 在数据库界处于什么位置
放到整个数据库领域看,异步 I/O 并不新鲜——很多商业数据库和一些新兴数据库早就用上了 io_uring 或类似机制。Postgres 这一步某种意义上是"补课"。
但 Postgres 的实现有它的克制和务实:
- 分阶段落地:先做读,再图写。不追求一步到位,降低引入 bug 的风险。这符合 Postgres 一贯"稳"的工程文化。
- 多后端兼容:不绑死 io_uring,提供 worker 和 sync 兜底,保证在 BSD、Windows、老内核、受限容器上都能跑。
- 为未来铺路:ReadStream 和 AIO 框架是基础设施,18 只是用它优化了几个场景。后续版本会有更多路径接入这套异步框架,包括最终的异步写。
所以看待 PG 18 的正确姿势是:它是一个"地基版本"。你现在能拿到的读加速是实打实的红利,但它更大的意义是为 19、20 的性能故事打好了底。
八、总结与展望
把这篇长文压缩成几条你能带走的结论:
- PG 18 最核心的升级是异步 I/O 子系统,读密集场景最高 3 倍提升,本质是把 I/O 等待和 CPU 处理重叠起来,榨干现代硬件。
- 只有异步读,没有异步写。别用错场景,写瓶颈另寻他法。
- io_method 默认是 worker,io_uring 最快但对环境挑剔,容器里注意静默回退这个坑。
- 调优是组合拳:io_method + effective_io_concurrency + io_combine_limit + 合理的 shared_buffers,缺一不可,且要用
pg_stat_io验证。 - 顺手用起来的红利:uuidv7() 换掉 v4 主键、Skip Scan 救活缺前导列的复合索引查询、虚拟生成列省存储、升级更平滑。
展望未来,Postgres 的异步化才刚开始。当异步写、更多执行路径接入 AIO 框架、以及和向量检索等 AI 负载结合之后,Postgres 作为"最先进开源数据库"这句话的含金量还会继续涨。对我们写代码的人来说,能做的就是:现在就把 18 升起来,把 AIO 调对,让那些跑了很久的分析查询,真正快起来。
本文所有配置和压测思路均可在 PostgreSQL 18 正式版上复现。生产环境改参数前请先在预发环境验证,尤其是 io_method 和并发相关参数,务必结合你自己的存储类型压测后再上线。