PostgreSQL 18 深度实战:当数据库内核终于「异步」起来——从 AIO/io_uring、UUID v7 到跳过扫描与虚拟生成列的完整工程指南(2026)
本文面向一线后端、DBA 与架构师。我们不堆特性清单,而是把 PostgreSQL 18 里真正能改变你系统性能的几把"手术刀"——异步 I/O 子系统、UUID v7、虚拟生成列、B-tree 跳过扫描、MERGE ... RETURNING——从原理、内核架构、可运行代码到生产调优,逐层拆开。配完整 SQL / Docker / 配置示例,读完后你既能说服老板升级,也能自己动手把吞吐调出来。
一、背景:为什么 PostgreSQL 18 是近几年最值得升级的一次
PostgreSQL 保持着一年一个大版本的节奏,通常每年 9 月发布。17 到 18 的跨越,和前面几次"加几个函数、修几个 bug"的体感完全不同:这一次,社区动的是数据库最底层的 I/O 心脏。
过去十几年,PostgreSQL 在关系型数据库里以"稳"著称,但它有一个被反复吐槽的老毛病——I/O 是同步的。当一个后端进程(backend)需要从一个大表里读数据,它向操作系统发一个 read(),然后就被挂起,原地等待磁盘把数据送回来。在机械盘时代这还能忍受;但在 NVMe 和云盘时代,问题被放大了:
- 云块存储延迟高。EBS、云硬盘这类网络块设备在随机小 I/O 上延迟往往是本地 NVMe 的几倍到几十倍,同步等待的代价被成倍放大。
- CPU 在空转。进程卡在 I/O 上时,CPU 只能计入
iowait,宝贵的算力被白白浪费。 - 预读不精准。PostgreSQL 长期依赖操作系统的
posix_fadvise预读,但操作系统并不知道你接下来是要顺序扫描还是索引点查,预读命中率有限。
一句话总结:PostgreSQL 18 之前,数据库把"何时读、读多少、怎么读"的控制权,很大程度交给了操作系统。PG 18 第一次把这块控制权收回到内核自己手里。 这就是本次升级最核心的意义——它让 PostgreSQL 真正"异步"起来了。
除了 AIO 这个地基级特性,PG 18 还带来了一组彼此呼应、都能立刻落地到业务代码的改进:更好局部性的 UUID v7、零写入开销的虚拟生成列、让"烂索引设计"起死回生的B-tree 跳过扫描、以及增强后的 MERGE ... RETURNING。下面我们逐个拆解。
二、核心概念:读懂 PG 18 的四大引擎级变革
2.1 异步 I/O(AIO)子系统
先讲清楚"同步 I/O"到底卡在哪。一个典型的同步读路径是这样的:
- 后端进程调用
pread()读取某个数据块; - 内核发起磁盘 I/O,进程进入睡眠(阻塞);
- 磁盘控制器读完数据,内核唤醒进程,数据从内核缓冲区拷进共享缓冲区(shared buffers);
- 进程继续执行。
第 2 步到第 3 步之间,进程什么都干不了。对于全表扫描、位图堆扫描(bitmap heap scan)、VACUUM、大表 ANALYZE 这类需要大量顺序/并行读盘的操作,这些阻塞会累积成惊人的 iowait。
AIO 的核心思想是把"发起 I/O"和"等待 I/O 完成"解耦:进程把读请求提交出去后立刻转身去干别的事(比如解析下一条 SQL、构建执行计划),等数据准备好了再由内核通知它回来取。CPU 和 I/O 在时间上重叠,形成流水线。
PG 18 用一个统一的 io_method 参数暴露了三种 I/O 模式:
io_method | 行为 | 适用场景 |
|---|---|---|
sync | 向后兼容模式,走 posix_fadvise 把数据预取到 OS page cache(不进 shared buffers) | 保守、兼容老环境 |
worker | 启动一组 I/O 工作进程池,在共享内存队列里接活,用 pread 读进 shared buffers | 通用、容器友好、无需特殊内核能力 |
io_uring | 基于 Linux io_uring 直接把 I/O 请求提交给内核 ring,异步完成 | 现代 Linux(≥5.1)、追求极致读吞吐 |
另外两个关键参数:
io_workers:I/O 工作进程数量,默认3。这是 worker 模式下真正的并发读通道数。io_combine_limit:把相邻的读请求合并成一个大请求的上限,默认128kB。合并能显著减少系统调用次数。
AIO 当前覆盖哪些操作? 主要收益集中在顺序扫描、位图堆扫描、VACUUM、大表 ANALYZE 的预读上。WAL 写入、普通索引点查目前仍是同步的——异步写和 WAL 异步是 PG 后续版本的方向,本文第五节会展开。
2.2 UUID v7:时间序列可排序的唯一 ID
如果你用 UUID 做主键(分布式系统常常如此),PG 18 的 uuidv7() 是一个能直接改善性能的"免费午餐"。
问题出在 UUID v4(gen_random_uuid() 生成的就是 v4):它是完全随机的。把完全随机的 ID 当 B-tree 主键,意味着每次插入都落到一个随机的叶子页上——已有的页被反复打碎、分裂(page split),Buffer Pool 里热点页被随机 ID 冲散,缓存命中率下降,WAL 量也更大。
UUID v7 的巧妙之处在于:把 48 位毫秒级 Unix 时间戳放在 UUID 的最前面,后面跟随机位。于是:
- 同一毫秒内生成的 v7 在字节序上天然相邻;
- 插入近似顺序,B-tree 页分裂大幅减少,写入更紧凑;
- 天然可按时间排序,无需额外
created_at列即可做时间范围扫描。
对业务的影响很直接:更紧凑的索引、更少的页分裂、更好的缓存局部性、更小的 WAL。对于日增千万级、以 UUID 为主键的表,这是肉眼可见的收益。
2.3 虚拟生成列(VIRTUAL generated columns)
生成列(generated column)是"由其他列计算而来、不允许手动写入"的列。PG 早期版本只支持 STORED 生成列——也就是在写入时就算好、把结果落盘存储。它的代价是:占存储空间 + 每次写入都要算一次。
PG 18 引入了 VIRTUAL 生成列,并把它设为默认。VIRTUAL 列不在写入时物化,而是在读取时按需计算,因此:
- 写入零存储、零计算开销(只在查询引用时才算);
- 对"读少写多"或者"计算列很少被查"的场景非常友好;
- 需要索引时仍可对该列建立索引(索引会物化计算值),兼顾查询性能。
典型用途:订单金额 qty * unit_price、归一化后的字段、派生状态位等——以前你要么每次查询都 SELECT qty*unit_price,要么忍受 STORED 列的写放大,现在一个 VIRTUAL 列就优雅解决了。
2.4 B-tree 跳过扫描(Skip Scan)
这是给"历史索引设计不合理"应用的救命特性。
假设有一个组合索引 (tenant_id, event_type)。你的很多查询只按 event_type 过滤:
SELECT * FROM logs WHERE event_type = 'type1';
在 PG 18 之前,由于前导列 tenant_id 没有出现在 WHERE 里,优化器基本只能放弃这个索引、走全表扫描——即使 event_type 的选择性很好。
PG 18 的 Skip Scan(跳过扫描) 会在内部对 tenant_id 的每一个不同值动态生成一个等值约束,逐段"跳着"扫描索引,从而复用这个组合索引。对于"前导列基数低、后列过滤强"的索引,它能把一次全表扫描变成高效的索引扫描。
2.5 顺带的三个实用增强
MERGE ... RETURNING:合并(upsert)语句现在能返回受影响行的变更前后值,配合merge_action()、OLD/NEW别名,做幂等写入 + 审计一条语句搞定。ONLY关键字用于分区表:VACUUM ONLY/ANALYZE ONLY只处理父表、跳过分区,避免对巨大分区表做无意义的递归维护。- 可观测性升级:
pg_stat_all_tables新增 vacuum/analyze 耗时;pg_stat_checkpointer新增num_done;pg_stat_database新增并行 worker 统计;新增pg_stat_get_backend_io()与pg_aios视图看每后端 I/O 与进行中的异步 I/O。
三、架构分析:AIO 在内核里到底怎么跑
理解 AIO,关键看 PostgreSQL 怎么改了它的存储管理器(smgr)接口。PG 18 给 smgr 增加了异步读能力:smgr_startreadv(发起异步读)和配套的完成回调结构(PgAioTargetInfo、PgAioHandleCallBacks)。三种 io_method 的数据流差异如下。
3.1 sync 模式:把控制权交给 OS
后端直接调用 posix_fadvise() 建议内核预取,数据被读进操作系统 page cache 而非 PostgreSQL 的 shared buffers。好处是改动最小、兼容性最好;坏处是 I/O 调度仍不在 PG 手里,且数据要再过一道 OS 缓存,存在双重缓冲。
3.2 worker 模式:共享内存队列 + 进程池
这是默认的、最稳妥的异步实现:
- 后端进程需要读某块时,把读请求(目标文件、偏移、长度、目标 buffer 描述符)塞进共享内存里的一个队列;
- 一组 I/O 工作进程(
io_workers个)被唤醒,用pread()把数据读进 shared buffers; - 完成后通过回调通知后端进程,后端从 shared buffers 取数据继续。
因为读由专门的工作进程执行,后端进程不再被单次 I/O 阻塞,可以并行推进其他工作。worker 模式不依赖任何特殊内核能力,在容器里也能直接跑,这是它最大的工程价值。
3.3 io_uring 模式:把请求直接递给内核
io_uring 是 Linux 5.1 引入的高性能异步 I/O 框架,核心是两个环形队列(SQ 提交队列 / CQ 完成队列)在内核与用户态间共享内存:
- 后端进程直接往 SQ 里塞一个 SQE(提交队列项),描述这次读;
- 内核在后台异步完成 I/O;
- 完成后往 CQ 里写一个 CQE(完成队列项)通知进程。
io_uring 避免了 worker 模式的"进程间排队 + 唤醒"开销,系统调用次数也大幅下降,对读密集负载收益最大——社区在开发阶段的基准测试显示,顺序扫描在 io_uring 下读吞吐接近翻倍。它还为 PG 未来启用直接 I/O(DIO) 铺好了路:DIO 能绕过 OS page cache,消除 shared buffers 与 OS 缓存之间的"双重缓冲",进一步降 CPU、降延迟。
3.4 当前边界与监控
需要清醒认识 AIO 的当前边界:
- 只支持 smgr 异步读,WAL 异步写、smgr 异步写仍在开发中;
- 收益集中在顺序/位图扫描与 VACUUM;
- 与
effective_io_concurrency(顺序扫描预取深度)协同工作——AIO 是在这个基础上把"预取"做得更可控。
监控进行中的异步 I/O,用新增的 pg_aios 视图:
SELECT * FROM pg_aios;
它能看到当前有多少个 I/O 请求在飞、处于什么阶段,是排查 AIO 是否生效、是否打满的第一现场。
四、代码实战
下面所有示例在 PostgreSQL 18 上可直接运行。先准备好环境和配置。
4.1 环境准备与配置
方式 A:Docker Compose(启用 io_uring)
services:
pg18:
image: postgres:18
environment:
POSTGRES_PASSWORD: secret
PGIO_METHOD: io_uring
command:
- postgres
- -c
- io_method=io_uring
- -c
- io_workers=4
cap_add:
- SYS_IOURING # io_uring 在容器内需要此 capability,否则回退同步
ports:
- "5432:5432"
方式 B:裸机 postgresql.conf
# PostgreSQL 18 异步 I/O 核心配置
io_method = 'worker' # 或 'io_uring'(需 Linux 5.1+ 且未被 seccomp 拦截)
io_workers = 4 # 建议设为 vCPU 的 25%~50%
io_combine_limit = '128kB' # 相邻读请求合并上限
io_method属于 postmaster 级参数,修改后需要重启;io_workers/io_combine_limit可以ALTER SYSTEM SET ...; SELECT pg_reload_conf();在线调整。
验证版本与 AIO 状态:
SELECT version();
SHOW io_method;
SHOW io_workers;
4.2 UUID v7 实战:索引膨胀对比
-- v4 随机主键
CREATE TABLE events_v4 (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
payload text,
created_at timestamptz DEFAULT now()
);
-- v7 时间有序主键
CREATE TABLE events_v7 (
id uuid DEFAULT uuidv7() PRIMARY KEY,
payload text,
created_at timestamptz DEFAULT now()
);
INSERT INTO events_v4 (payload) SELECT 'x' FROM generate_series(1, 1_000_000);
INSERT INTO events_v7 (payload) SELECT 'x' FROM generate_series(1, 1_000_000);
-- 对比主键索引大小(v7 通常更小、更紧凑)
SELECT
pg_size_pretty(pg_relation_size('events_v4_pkey')) AS v4_idx_size,
pg_size_pretty(pg_relation_size('events_v7_pkey')) AS v7_idx_size;
-- v7 天然可按时间排序
SELECT id, created_at FROM events_v7 ORDER BY id LIMIT 5;
events_v7_pkey 的字节数通常明显小于 events_v4_pkey,因为顺序插入让 B-tree 页分裂更少、填充率更高;同时 WAL 因为写入更"聚簇"而更小。对高频写入表,这是实打实的存储与 I/O 双收益。
4.3 AIO 实战:冷缓存顺序扫描 + 观察在飞 I/O
CREATE TABLE big (id bigint, v text) WITH (fillfactor = 100);
INSERT INTO big SELECT g, repeat('x', 200) FROM generate_series(1, 5_000_000) g;
-- 计时顺序扫描
\timing on
SELECT count(*) FROM big;
\timing off
-- 在另一个会话里观察正在进行的异步 I/O(AIO 生效时这里会有记录)
SELECT * FROM pg_aios;
-- 查看本会话的 I/O 统计(按读类型拆分)
SELECT * FROM pg_stat_get_backend_io(pg_backend_pid());
如果你把 io_method 从 sync 切到 worker/io_uring 并重启,再跑同样的 SELECT count(*),在读密集、数据远大于 shared buffers 的场景下,耗时会明显下降;pg_aios 里能看到成批的异步读请求在飞,而不再是后端被单次读阻塞。
4.4 虚拟生成列实战
CREATE TABLE orders (
id bigserial PRIMARY KEY,
qty int NOT NULL,
unit_price numeric(10,2) NOT NULL,
amount numeric(12,2)
GENERATED ALWAYS AS (qty * unit_price) VIRTUAL -- PG18 默认即 VIRTUAL
);
INSERT INTO orders (qty, unit_price) VALUES
(3, 19.90),
(10, 5.00),
(1, 99.00);
SELECT * FROM orders;
-- id | qty | unit_price | amount
-- ----+-----+------------+--------
-- 1 | 3 | 19.90 | 59.70
-- 2 | 10 | 5.00 | 50.00
-- 3 | 1 | 99.00 | 99.00
-- 需要按计算列查询/过滤时仍可建索引(索引会物化计算值)
CREATE INDEX idx_orders_amount ON orders (amount);
EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE amount > 80;
写入时 amount 不占存储、不重复计算;只有在查询引用它时才算。若业务经常按 amount 过滤,建索引即可,二者兼得。
4.5 跳过扫描实战
CREATE TABLE logs (
tenant_id int,
event_type text,
created_at timestamptz,
payload jsonb
);
CREATE INDEX idx_logs ON logs (tenant_id, event_type);
INSERT INTO logs
SELECT (g % 100), 'type' || (g % 5), now(), '{}'::jsonb
FROM generate_series(1, 1_000_000) g;
ANALYZE logs;
-- 只过滤后列 event_type,PG18 会用 Index Skip Scan 复用组合索引
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM logs WHERE event_type = 'type1';
在 PG 17 及更早版本,这条查询因前导列 tenant_id 缺失,通常只能走 Seq Scan;PG 18 的 EXPLAIN 输出里会出现 Index Skip Scan,读取的块数(Buffers)显著下降,尤其当 tenant_id 基数低、event_type 选择性高时收益最大。
4.6 MERGE ... RETURNING 实战
CREATE TABLE inventory (sku text PRIMARY KEY, qty int NOT NULL);
-- 幂等扣减库存,并一次返回"变更前/后"与动作类型
MERGE INTO inventory t
USING (VALUES ('A-100', -5)) AS s(sku, delta)
ON t.sku = s.sku
WHEN MATCHED THEN
UPDATE SET qty = t.qty + s.delta
WHEN NOT MATCHED THEN
INSERT (sku, qty) VALUES (s.sku, s.delta)
RETURNING merge_action(), OLD.qty AS before_qty, NEW.qty AS after_qty;
merge_action() 返回 INSERT / UPDATE / DELETE,OLD / NEW 别名让你拿到变更前后的值。对账、审计、幂等 upsert 这类需求,以前要写"先查后插再更"的多条语句,现在一条 MERGE ... RETURNING 收尾,事务语义更干净。
4.7 ONLY 关键字:分区表维护提速
-- 只维护父表元信息,跳过分区(巨型分区表省时利器)
VACUUM ONLY my_partitioned_table;
ANALYZE ONLY my_partitioned_table;
五、性能优化:把 AIO 调出真金白银
5.1 io_workers 调优
io_workers 是 worker 模式下的真实并发读通道。经验值:设为 vCPU 数的 25%~50%。例如 8 核设 4、16 核设 6~8。设太小 I/O 并发不足,设太大则进程间争抢与上下文切换反而抵消收益。io_combine_limit 一般保持默认 128kB 即可,对大顺序扫描可适当上调以合并更多相邻请求。
5.2 io_method 选型决策树
内核 >= 5.1 且 能授予 SYS_IOURING(裸机/已配置 capability)?
├─ 是 → io_uring(读密集负载吞吐最高,开发期基准接近翻倍)
└─ 否 → 在容器/受限环境?
├─ 是 → worker(默认,零特殊依赖,立即可用)
└─ 否 → 保守选 sync
强烈建议:不要一上来就无脑开 io_uring。某些老内核、网络文件系统(如 NFS)对 io_uring 支持有限,强行开启可能回退到同步甚至报错。先用 worker 拿到稳定收益,再针对读写特征决定是否上 io_uring。
5.3 容器化陷阱(最容易被坑)
io_uring 在默认容器运行时下常被 seccomp 拦截。若你看到日志提示 io_uring 不可用、或 SHOW io_method 显示仍是 sync,多半是权限问题。解决:
- Docker:
docker run --cap-add=SYS_IOURING ...或在 compose 里cap_add: [SYS_IOURING]; - Kubernetes:
securityContext.capabilities.add: ["SYS_IOURING"]。
否则 PostgreSQL 会静默回退到同步模式,你以为开了异步其实没开——这正是 pg_aios / pg_stat_get_backend_io() 要用来验证的原因。
5.4 监控三件套
| 视图 / 函数 | 看什么 |
|---|---|
pg_aios | 进行中的异步 I/O 请求数量与阶段,验证 AIO 是否真生效 |
pg_stat_get_backend_io(pid) | 单个后端按读类型(正序/随机/预读)拆分的 I/O 统计 |
pg_stat_database | 并行 worker 启动/实际启动数,判断并行与 I/O 是否打满 |
5.5 升级与迁移注意
- 默认即受益:PG 18 安装后
io_method默认是worker,AIO 已经开启,多数读密集负载无需改任何业务代码就能拿到提升。 - 升级路径:用
pg_upgrade原地升级到 18;升级前检查自建扩展(extension)对 18 的兼容性(如 PostGIS、pgvector 等需对应新版本)。 - 逻辑复制增强:PG 18 改进了逻辑复制的稳定性与功能,做跨版本订阅/灾备时注意新行为(如行过滤、冲突处理细节)。
- 别神话 AIO:它主要改善"读多、数据大于内存"的扫描类负载;纯点查、写密集、或小表全在缓存里的场景,体感提升有限。
六、总结与展望
PostgreSQL 18 值得被称作 "异步元年"。它做的最重要的一件事,是把数据库的 I/O 调度权从操作系统手里夺回来——AIO 是地基,未来在此之上还会长出直接 I/O(DIO) 和 WAL/写异步,进一步消除双重缓冲、把延迟压到更低。
对一线开发者的实际收益是立体的:
- 默认即受益的读性能——大表扫描、VACUUM 在 worker 模式下就能提速,上
io_uring还能再翻倍; - 更好的 ID 局部性——
uuidv7()让 UUID 主键不再"自残"索引; - 更省的写放大——VIRTUAL 生成列把计算推迟到读取时;
- 更聪明的优化器——Skip Scan 让历史索引设计重获新生;
- 更干净的写入语义——
MERGE ... RETURNING一条语句完成幂等 upsert + 审计。
选型建议很直白:新项目直接上 PG 18;老项目评估扩展兼容性后用 pg_upgrade 升级——这一步的性价比,远高于再去手调一堆补偿性参数。
数据库的"等"时代,在 PG 18 落下了第一锤。当你下次再看到监控图上那条刺眼的 iowait 红线时,记得:这一次,PostgreSQL 终于学会了不等。
参考资料与验证手段:PostgreSQL 18 官方发布公告、PG 开发者邮件列表(Tomas Vondra 的 AIO 调优指南)、IvorySQL / ITPUB 社区对 PG 18 六大特性的拆解,以及 pg_aios、pg_stat_get_backend_io() 等内置视图的实测。所有 SQL 示例均基于 PostgreSQL 18 语法,可在本地 postgres:18 容器直接复现。