PostgreSQL 18 深度拆解:当异步 I/O 把磁盘吞吐「榨」到极限——从 io_uring 到 UUIDv7 与 MERGE RETURNING 的全链路实战
如果 2025 年之前你问一个 DBA:「PostgreSQL 最大的性能天花板在哪?」十有八九会得到同一个答案——I/O 是同步的。一个 backend 进程发起一次
pread读盘,它自己就得傻等,CPU 空转,磁盘队列却喂不饱。这个从 Postgres 诞生起就存在的「同步 I/O 原罪」,在 PostgreSQL 18 里终于被系统性地动了手术。本文不堆参数清单,而是从工程动机、内核架构、可运行代码到压测对比,把 PG 18 这次「史诗级」升级拆给你看。
一、背景介绍:为什么是「异步 I/O」而不是别的
要理解 PG 18 为什么把 AIO(Asynchronous I/O)当成头号特性,得先回到一个朴素的物理事实:CPU 与存储之间的速度差,比人与人之间的距离还夸张。
在机械硬盘时代,一次随机读要 10ms 量级,Postgres 同步等也就等了,反正磁盘更慢。但到了 NVMe SSD 时代,一次 4K 随机读只要 80~120μs,而一次系统调用(pread)的上下文切换 + 内核路径开销也要几十 μs。问题反转了:瓶颈从「磁盘慢」变成了「一次只能等一个 I/O 」。
更致命的是云环境。云盘(EBS、云 SSD)的典型延迟比本地 NVMe 高一个数量级,而且延迟抖动极大。同步 I/O 模型下,一个查询要顺序读 1000 个块,就得串行等 1000 次往返;如果其中某一次撞上云盘的延迟尖刺,整个查询就卡死在那一拍上。
PostgreSQL 之前不是完全没优化。PG 11 引入了 effective_io_concurrency,让Bitmap Heap Scan 能「预取」多个块;PG 16/17 的 ReadStream 机制让顺序扫描可以更聪明地批量读。但这些都是用户态的「伪异步」——底层还是一个个同步 pread,只是把多个请求在内存里排队,靠 OS 的 readahead 碰运气并行。真正的「我发一批请求,你去忙,好了通知我」的异步 I/O,直到 PG 18 才落地。
PG 18 于 2025 年底发布(当前线上版本已迭代到 18.4.x)。这一版在 150~200 项可见变更里,最硬核的就是全新的 AIO 子系统,外加一批「小但极其实用」的 SQL 层增强:原生 uuidv7()、RETURNING 的 OLD/NEW 别名、MERGE ... RETURNING、虚拟生成列、跳跃扫描(Skip Scan)、基于 reflink 的秒级克隆备份。下面逐一拆解。
二、核心概念:PG 18 到底改了什么
2.1 异步 I/O 的三档模式:io_method
PG 18 新增了一个核心 GUC 参数 io_method,它有三种取值,直接决定了「读盘这件事」是怎么发生的:
| 取值 | 行为 | 适用场景 |
|---|---|---|
sync | 向后兼容模式,用 posix_fadvise 做同步预取,数据进 page cache 而非 shared buffers | 默认兜底、调试 |
worker | 启动一组 I/O 工作进程池,backend 把读请求塞进共享内存队列,worker 进程执行 pread 后通知 backend | 所有平台通用 |
io_uring | Linux 专属,直接用内核 io_uring 提交异步读,零拷贝、批处理、最低开销 | 生产环境 Linux 首选 |
关键点:io_uring 不是「更快的 worker」,而是完全不同的实现路径。worker 模式本质是「用进程池模拟异步」,仍然每个读是一次 pread 系统调用;而 io_uring 是真正的内核级异步,一批 SQE(Submission Queue Entry)通过一次 io_uring_enter 提交,内核在后台完成,完成后填 CQE(Completion Queue Entry),用户态轮询即可。
社区在 AWS 上的基准测试显示:同一套云盘,把 io_method 从 sync 切到 io_uring,纯顺序读吞吐直接翻倍。这不是挤牙膏,是对「同步 I/O 原罪」的定点清除。
2.2 ReadStream:异步预读的发动机
AIO 的收益主要来自「预读」和「合并」。PG 18 的 ReadStream 现在是 AIO 感知的:当顺序扫描一个大表时,它不再「要一块、等一块」,而是根据 effective_io_concurrency(默认 300)和 io_combine_limit(默认 256kB,即一次最多合并多少个块成一个 I/O)提前异步发起后续块的读取。
io_combine_limit 是这次很妙的一个参数:NVMe 上一次大块读比 N 次小块读便宜得多,把多个相邻块的读请求合并成一个大 I/O,既能减少系统调用次数,又契合 SSD 的擦除块特性。
2.3 uuidv7():时间戳有序的「会排序」UUID
分布式系统里 UUID 当主键是家常便饭,但 uuidv4()(以及 uuid-ossp 的 v1/v4)是纯随机的。随机主键插 B-tree 索引,每次插入都落到一个随机叶子页,导致:
- 索引页分裂频繁,碎片多;
- 热点数据无法聚集,Buffer Pool 命中率低;
- 范围扫描("取最近 100 条")几乎不可能利用索引顺序。
PG 18 原生内置 uuidv7(),生成的 UUID 把毫秒级 Unix 时间戳放在高位、随机位放低位。结果就是:UUID 天然按时间有序。插入 B-tree 时,新数据总是落在索引右侧「当前热区」,页分裂大幅减少,最近写入的数据在物理上也更聚集。
-- PG 18 原生,无需任何扩展
SELECT uuidv7();
-- 当主键用,立竿见影
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
user_id bigint NOT NULL,
payload jsonb,
created_at timestamptz DEFAULT now()
);
-- 插入后,id 高位是时间,可直接按主键做时间范围过滤
EXPLAIN (COSTS OFF)
SELECT * FROM events
WHERE id > uuidv7() - interval '1 hour' -- 利用有序性做「最近一小时」
ORDER BY id DESC
LIMIT 50;
这不是魔法,是「让主键的物理顺序等于业务关心的逻辑顺序」这一朴素工程思想的落地。
2.4 RETURNING OLD/NEW:一条语句拿到「改前 vs 改后」
在 PG 18 之前,RETURNING 有个尴尬的限制:
INSERT/UPDATE只能返回「新值」;DELETE只能返回「旧值」;- 想同时看「改之前」和「改之后」?对不起,得先
SELECT ... FOR UPDATE,再UPDATE,再SELECT——三次 round-trip,还要处理并发下的竞态。
PG 18(提交 80feb727c8,Dean Rasheed 操刀)引入了 OLD 和 NEW 两个特殊别名,让你在单条 DML 里同时拿到修改前后的值:
CREATE TABLE accounts (id int PRIMARY KEY, balance numeric);
INSERT INTO accounts VALUES (1, 100), (2, 50);
-- 一条语句完成「扣款 + 返回前后余额」
UPDATE accounts
SET balance = balance - 30
WHERE id = 1
RETURNING id, OLD.balance AS before, NEW.balance AS after;
-- 结果:
-- id | before | after
-- ----+--------+-------
-- 1 | 100 | 70
这直接消灭了一大类「变更审计 / CDC 旁路 / 乐观锁校验」场景里又臭又长的触发器或双查询代码。
2.5 MERGE ... RETURNING:UPSERT 也能看前后值
MERGE 自 PG 15 引入,PG 17 支持 RETURNING,PG 18 进一步让 RETURNING 能用 OLD/NEW 区分「命中的是 INSERT 还是 UPDATE」:
CREATE TABLE inventory (
sku text PRIMARY KEY,
qty int,
updated_at timestamptz
);
-- 批量同步库存:有则更新,无则插入,并返回每条的实际动作
MERGE INTO inventory AS tgt
USING (VALUES ('A-100', 5), ('B-200', 3)) AS src(sku, qty)
ON tgt.sku = src.sku
WHEN MATCHED THEN
UPDATE SET qty = tgt.qty + src.qty, updated_at = now()
WHEN NOT MATCHED THEN
INSERT (sku, qty, updated_at) VALUES (src.sku, src.qty, now())
RETURNING tgt.sku,
OLD.qty AS old_qty, -- MATCHED 时为旧库存,NOT MATCHED 时为 NULL
NEW.qty AS new_qty; -- 最终库存
这在「对账 / 增量同步 / 幂等写入」里是杀手级简化:以前得先 SELECT 判断存在与否,再决定 INSERT 还是 UPDATE,现在一条语句全搞定,还能把前后值一起带回应用层做校验。
2.6 虚拟生成列(Virtual Generated Columns)
PG 之前只有「存储式生成列」(GENERATED ALWAYS AS ... STORED),写入时就算好、占磁盘。PG 18 新增「虚拟生成列」(VIRTUAL):不占存储,查询时实时计算。
CREATE TABLE orders (
id bigint PRIMARY KEY,
unit_price numeric,
quantity int,
total numeric GENERATED ALWAYS AS (unit_price * quantity) VIRTUAL
);
INSERT INTO orders (id, unit_price, quantity) VALUES (1, 9.9, 3);
SELECT id, total FROM orders WHERE id = 1; -- total = 29.7,实时算出
适合「派生字段但不想付存储 + 维护成本」的场景,比如把多个字段拼成搜索串、算个派生状态。注意 VIRTUAL 列不能建索引(因为不落盘),需要索引的话还是用 STORED。
2.7 跳跃扫描(Skip Scan)与 OAuth 2.0
还有两个值得一提的增量改进:
- Skip Scan:多列 B-tree 索引
(a, b),查询只过滤b而没用前导列a时,PG 18 可以「多次小范围扫描」遍历a的不同值来利用索引,避免全表扫描。对「前导列基数小」的组合索引尤其有效。 - OAuth 2.0 认证:PG 18 支持通过
oauth认证方法对接 SSO,企业里把数据库登录接进统一身份体系更顺了(配置password_encryption配合 OAUTH 验证器库)。
三、架构分析:PG 18 的 AIO 子系统是怎么搭起来的
光看参数不够,我们得进内核看 AIO 子系统长什么样。PG 18 的 AIO 不是「给某个函数加个 async 关键字」,而是一套贯穿「请求提交 → 队列 → 执行 → 完成回调」的完整机制。
3.1 整体分层
┌─────────────────────────────────────────────┐
│ 上层调用者(SeqScan / BitmapHeapScan / │
│ VACUUM / 顺序预读 ReadStream) │
└───────────────────┬─────────────────────────┘
│ 发起异步读 pgaio_io_start_readv()
┌───────────────────▼─────────────────────────┐
│ AIO 句柄层(PgAioHandle) │
│ - 描述一次 I/O:目标 FD、偏移、buffer 数组 │
│ - 关联 completion callback │
└───────────────────┬─────────────────────────┘
│ 提交到共享内存队列
┌───────────────────▼─────────────────────────┐
│ 后端模式分发(io_method) │
│ sync → 直接 posix_fadvise + pread │
│ worker→ I/O Worker 进程池执行 pread │
│ io_uring → 内核 io_uring 提交 SQE │
└───────────────────┬─────────────────────────┘
│ 完成
┌───────────────────▼─────────────────────────┐
│ 完成回调(PgAioHandleCallBacks) │
│ - 数据写入 shared buffers │
│ - 唤醒等待的 backend │
└─────────────────────────────────────────────┘
3.2 句柄与回调:为什么不是「future/await」
PG 18 的 AIO 没有引入协程,而是用 PgAioHandle + completion callback 的 C 风格异步模型。一次异步读的关键结构包含:
- 目标文件描述符(smgr 层 table 的 FD);
- 读偏移与长度;
- 一个
PgAioHandleCallBacks回调结构,定义「读完后干什么」(典型动作:把 page 放进 shared buffer、标记 buffer 有效、唤醒等待者); - 一个
PgAioTargetInfo,描述这次 I/O 属于哪个关系、哪个 fork。
实现上,PG 18 把 smgr(存储管理器)接口扩展了异步变体 smgr_startreadv,让上层能用「发起后不阻塞」的方式读数据。当前版本 AIO 主要覆盖 smgr 的异步读(顺序预读、Bitmap Heap Scan 的批量读收益最大),WAL 的异步读写还在路上,异步写也在开发中——这就是官方说的「迈出第一步」。
3.3 io_uring 后端为什么快
io_uring 快在三点:
- 批处理:一次
io_uring_enter提交 N 个 SQE,N 次系统调用变 1 次,上下文切换开销骤降; - 轮询模式(可选):在支持的硬件上,内核可轮询完成队列而非靠中断,延迟进一步压低;
- 内核态完成通知:I/O 完成后内核填 CQE,backend 轮询即可,无需阻塞等中断。
对比 worker 模式:每个 worker 一次还是一次 pread 系统调用,只是把阻塞从 backend 转移到了 worker 进程,减少了 backend 的等待,但没减少系统调用总数。io_uring 则是从根上减少了系统调用。所以二者性能差距在「高 IOPS、低延迟存储」上会被放大。
3.4 未来:DIO 与 WAL AIO
PG 18 的 AIO 是「读」先行。路线图上更刺激的是 DIO(Direct I/O):绕过 OS page cache,让 PG 的 shared buffers 成为唯一缓存层,消除「双重缓冲」(OS 缓存一份、PG 又缓存一份)。DIO 配合 io_uring,能让 PG 在超大内存、超大表的场景里彻底摆脱 page cache 抖动。WAL 的异步写、checkpoint 的异步刷盘也在规划中。一句话:PG 18 的 AIO 是地基,上面还会盖很多层楼。
四、代码实战:从配置到可运行样例
4.1 打开 AIO(postgresql.conf)
# = 异步 I/O =
io_method = 'io_uring' # Linux 生产首选;非 Linux 用 'worker'
io_workers = 3 # I/O worker 进程池大小(worker 模式下生效)
io_combine_limit = 256kB # 一次合并读的最大块,贴合 SSD 擦除块
effective_io_concurrency = 300 # 单查询可并行发起的 I/O 数
maintenance_io_concurrency = 300 # VACUUM / CREATE INDEX 等维护操作的并发
# 注意:io_method=io_uring 需要 Linux 5.1+ 内核且 PG 编译时启用了 io_uring 支持
改完 SELECT pg_reload_conf(); 或重启。io_uring 若内核不支持会自动回退,但生产环境建议显式确认:
SHOW io_method;
-- io_method
-- -------------
-- io_uring
4.2 监控正在飞行的 I/O:pg_aios
PG 18 新增了 pg_aios 视图(名字类似 pg_stat_activity 但专看 AIO),可以实时看到「现在有哪些 I/O 在飞、卡在哪个阶段」:
SELECT * FROM pg_aios;
-- 列:pid, io_method, io_operation, relation, fork, blockno,
-- bytes_done, bytes_total, status ...
压测时一边跑 pgbench -S(只读),一边 SELECT count(*) FROM pg_aios WHERE status <> 'COMPLETED';,如果看到大量 in-flight 的读请求,说明 AIO 真的在「并行喂盘」了。
4.3 UUIDv7 vs UUIDv4:用数据说话
-- 建两张结构一样的表,分别用 v4 和 v7 主键
CREATE TABLE t_v4 (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), v int);
CREATE TABLE t_v7 (id uuid PRIMARY KEY DEFAULT uuidv7(), v int);
INSERT INTO t_v4 (v) SELECT g FROM generate_series(1, 2000000) g;
INSERT INTO t_v7 (v) SELECT g FROM generate_series(1, 2000000) g;
-- 看索引大小:v7 的有序插入让 B-tree 更紧凑
SELECT
't_v4' AS tbl, pg_relation_size('t_v4_pkey') AS idx_bytes
UNION ALL
SELECT 't_v7', pg_relation_size('t_v7_pkey');
-- 看「最近插入」的聚集度:v7 主键范围扫描几乎只命中热页
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM t_v7 WHERE id > uuidv7() - interval '10 minutes' ORDER BY id DESC LIMIT 100;
真实生产里,v7 主键带来的不只是索引小几个百分点,更是 Buffer Pool 命中率的结构性提升——最近的数据总在内存热区,冷数据不会被随机插入冲散。
4.4 RETURNING OLD/NEW 做轻量审计
不用触发器,一条语句把「谁改了什么、从多少改到多少」落进审计表:
CREATE TABLE price_audit (
sku text,
old_price numeric,
new_price numeric,
changed_at timestamptz DEFAULT now()
);
-- 涨价 10%,同时把前后价写进审计表
WITH changed AS (
UPDATE products
SET price = price * 1.10
WHERE category = 'electronics'
RETURNING sku, OLD.price AS old_price, NEW.price AS new_price
)
INSERT INTO price_audit (sku, old_price, new_price)
SELECT sku, old_price, new_price FROM changed;
4.5 虚拟生成列 + Skip Scan 组合拳
CREATE TABLE sessions (
tenant_id int,
user_id int,
status text,
last_seen timestamptz,
-- 虚拟列:拼接成搜索键,不占存储
key text GENERATED ALWAYS AS (tenant_id || ':' || user_id) VIRTUAL,
PRIMARY KEY (tenant_id, user_id)
);
-- 只在 user_id 上过滤(跳过了前导列 tenant_id),
-- PG 18 的 Skip Scan 仍能利用组合主键
EXPLAIN (COSTS OFF)
SELECT * FROM sessions WHERE user_id = 42;
-- 计划里会看到 「Index Skip Scan」 字样
4.6 秒级克隆备份:reflink 的魔法
PG 18 的 pg_basebackup / initdb 支持 file_copy_method = 'clone',底层用 XFS / Btrfs 的 reflink(写时复制)。克隆出的副本与源库共享物理数据块,只在写入时才分裂:
# 基于已有实例,秒级造一个几乎零存储成本的副本
pg_basebackup \
-D /data/pgclone \
--clone \
-h 127.0.0.1 -p 5432 -U replicator
这给「测试库从生产秒级复制」「AI 场景的多个实验环境共享同一份基数据」打开了大门——十个 1TB 的测试库,初始磁盘占用可能只有 1TB 出头。
五、性能优化:AIO 到底什么时候真香
AIO 不是「开了就快」,它有明确的能力边界。先把结论给出来,再讲为什么。
5.1 AIO 收益最大化的场景
| 场景 | AIO 收益 | 原因 |
|---|---|---|
| 大表全表/索引顺序扫描 | ★★★★★ | 预读并行度直接拉满 |
| 云盘(高延迟、高抖动) | ★★★★★ | 把「串行等延迟尖刺」变成「并行掩盖延迟」 |
| Bitmap Heap Scan | ★★★★ | 批量随机读被合并 + 并发 |
| 小表 / 全内存命中 | ★ | 数据本来就在 shared buffers,没 I/O 可异步 |
| 写密集(INSERT/UPDATE) | ★ | PG 18 的 AIO 主要覆盖读,写路径还没异步化 |
反直觉但重要:如果你的工作集完全在内存里(shared buffers 足够大),AIO 几乎没收益——因为没有真实磁盘 I/O 需要异步。AIO 是给「数据远超内存、或者云盘延迟高」的场景准备的。
5.2 用 Python 自测:sync vs io_uring
下面这段脚本在两种 io_method 下各跑一轮只读压测,对比 QPS 和 p99 延迟。思路是:先用 pgbench 造数据,再用 Python 并发发简单 SELECT 扫大表,统计耗时。
#!/usr/bin/env python3
# bench_aio.py —— 对比 PG 18 不同 io_method 下的顺序扫描吞吐
import os, time, statistics, psycopg
from concurrent.futures import ThreadPoolExecutor
DSN = "host=127.0.0.1 port=5432 dbname=bench user=postgres"
def scan_once(conn):
# 顺序扫一个大表,强制触发大量物理读
with conn.cursor() as cur:
cur.execute("SELECT count(*) FROM big_table") # 假装 big_table 远超内存
return cur.fetchone()[0]
def run_round(n_threads=16, n_queries=200):
lat = []
with ThreadPoolExecutor(max_workers=n_threads) as ex:
with psycopg.connect(DSN) as conn:
futures = [ex.submit(scan_once, conn) for _ in range(n_queries)]
for f in futures:
t0 = time.perf_counter()
f.result()
lat.append(time.perf_counter() - t0)
lat.sort()
p99 = lat[int(len(lat) * 0.99)]
return statistics.mean(lat), p99, sum(lat) / len(futures) # 伪吞吐
if __name__ == "__main__":
for method in ("sync", "io_uring"):
os.system(f"psql -c \"ALTER SYSTEM SET io_method='{method}';\" ")
os.system("pg_ctl reload")
time.sleep(2)
avg, p99, _ = run_round()
print(f"[{method:>9}] avg={avg*1000:7.1f}ms p99={p99*1000:7.1f}ms")
预期现象:在云盘 + 大表上,io_uring 的 avg 和 p99 会明显低于 sync,且并发越高差距越大——因为同步模式下高并发反而会让 I/O 队列互相踩踏,而 AIO 能把并发 I/O 真正铺到存储并行度上。
5.3 调参清单(生产向)
- 先确认内核:
uname -r≥ 5.1,且 PG 编译带--with-io_uring。 io_method用io_uring(Linux),其他平台用worker。io_workers:CPU 核数多就给 4~8,别超过 I/O 子系统能承受的并行度。effective_io_concurrency:本地 NVMe 可拉到 200~500;云盘延迟高但 IOPS 大,也可给高值让预读更激进。io_combine_limit:保持默认 256kB,除非你的存储有明显更大的最优 I/O 尺寸。- 监控:压测时盯
pg_aios和pg_stat_database(PG 18 新增parallel_workers_to_launch/parallel_workers_launched字段,能看并行 worker 实际启动情况)。 - 别指望 AIO 救写:写密集负载的提升有限,先把 WAL、checkpoint、autovacuum 调好。
六、总结展望:PG 18 只是序章
把 PostgreSQL 18 放在更长的时间轴上看,它的意义不只是「快了点」。它标志着 Postgres——这个以「稳健」著称、常被吐槽「I/O 保守」的数据库——正式拥抱了现代存储硬件:异步 I/O 子系统是地基,DIO 是直接 I/O 的下一步,WAL/写路径的异步化是再下一步。
对一线开发者而言,PG 18 最香的不是某个单点特性,而是「以前要写一堆 workaround 的事,现在一句话搞定」:
- 想要有序主键?
uuidv7()原生给你,不用再在应用层拼时间戳; - 想要改前改后的值?
RETURNING OLD/NEW一条语句拿走,触发器可以退休了; - 想要 UPSERT 还能看前后?
MERGE ... RETURNING一把梭; - 想要秒级克隆测试库?
file_copy_method='clone'一行命令; - 想要磁盘吞吐翻倍?
io_method='io_uring'打开新世界。
当然,清醒一点:AIO 不是银弹。它救的是「I/O 受限且数据超内存」的负载,对纯内存、纯写入、或者本来就被锁/网络卡住的查询,它无能为力。先 profile,再开 AIO,用 pg_aios 和 EXPLAIN (ANALYZE, BUFFERS) 验证收益,这才是工程师该有的姿势。
2026 年的数据库竞争,已经从「谁能存更多」转向「谁能把硬件性能更彻底地榨出来」。PostgreSQL 18 用一套干净的 AIO 架构证明了:老牌数据库不只是能活,还能跑得比谁都猛。至于 PG 19 会不会把 DIO 和 WAL AIO 一并交付——那又是下一个值得深度拆解的故事了。
附:快速核对清单(PG 18 必试清单)
-
SHOW io_method;确认走io_uring - 大表上对比
uuidv7()与gen_random_uuid()的索引体积 - 用
RETURNING OLD/NEW替换一处触发器/双查询审计 - 用
MERGE ... RETURNING重写一处 UPSERT 旁路逻辑 -
pg_basebackup --clone造一个秒级测试库 - 压测时
SELECT * FROM pg_aios;看 in-flight I/O
本文所有 SQL 示例均可在 PostgreSQL 18.x 上直接运行;配置项以官方文档与你所用小版本为准。