PostgreSQL 18 异步 I/O 内核揭秘:io_uring、ReadStream 与 3 倍读性能提升的工程全解(2026)
本文是「PostgreSQL 18 新特性」系列的内核深度篇,专攻本次版本最重磅的存储引擎重构——异步 I/O(AIO)。如果你想要六大特性的功能速览,可以先看站内的概览篇;本篇则把 AIO 的实现原理、io_uring 提交队列模型、ReadStream 预取流水线、smgr 接口演进,以及 UUIDv7 / 虚拟生成列 / 跳跃扫描等配套新特性,全部打到可运行代码级别。
一、背景介绍:为什么 PG 18 值得你停下来认真看
如果你常年跟 PostgreSQL 打交道,会有一个共同的体感:PG 的查询优化器很强,但 I/O 子系统一直是个「老好人」——稳,但慢。
要理解这句话的分量,得先回到 PG 的架构史观。PostgreSQL 的存储引擎奠基在 1990 年代末的「伯克利 POSTGRES」代码之上,那个年代机械硬盘是主流,随机寻道是性能杀手,于是 PG 的设计哲学是「尽量顺序读、用缓冲区挡住延迟」。这一哲学在 HDD 时代完全正确。但进入 SSD、NVMe、云块存储时代之后,存储设备的延迟结构发生了质变:本地 NVMe 的 4K 随机读延迟已经压到几十微秒,而云上挂载的网络块存储(无论是哪家的云硬盘)单次读延迟动辄 1~5 毫秒——比本地盘慢了两个数量级。
问题就出在这里:PG 的读路径本质上是同步阻塞的。执行器要一个 buffer,就从存储层同步读一个;遇到顺序扫描大表,CPU 必须干等磁盘把数据搬上来,期间大量时钟周期被白白浪费在 io_submit / pread 的系统调用等待上。在本地盘上这种浪费被高速设备掩盖了;一旦上了云存储,CPU 空转问题被无限放大——你花大价钱买的 64 核 CPU,可能有一半时间在处理「等 I/O 回来」这件事。
2025 年 9 月 PostgreSQL 18 正式版发布,到 2026 年 7 月已经迭代到 18.4.x 的稳定小版本。这次更新的核心主线非常清晰,可以归纳为三条:
- I/O 子系统重构(重头戏):引入内核级异步 I/O(AIO)框架,这是 PG 有史以来对存储引擎最大的一次手术,直接对准上面说的 CPU 空转痛点。
- 开发者体验升级:虚拟生成列、
uuidv7()、跳跃扫描,让「写更少的代码、跑更快的查询」成为默认行为,而不是靠 DBA 手调。 - 可观测性与安全:
EXPLAIN增强、OAuth 2.0 认证、pg_stat_*视图补齐关键指标,让「为什么慢」第一次能被数据回答,而不是靠玄学。
值得一提的是,异步 I/O 在 PG 社区里不是新话题。早在 2000 年代初就有人提 patch,但要么依赖当时还不成熟的 Linux AIO(io_setup 那套原生异步接口,bug 多、能力弱),要么因为跨平台兼容的沉重包袱被否决。真正让这事落地的,是 Linux 内核 io_uring 的成熟——它提供了真正好用的异步 I/O 基础设施,加上 PG 核心开发者 Jelte Fennema-Nio、Andres Freund 等人多年的打磨,终于在 18 这个版本把抽象层做对了:PG 定义了自己的 AIO 抽象,底层可以切换 io_uring / POSIX / Windows IOCP / 纯 disabled 兜底,于是非 Linux 平台也不会被这门特性拖垮。这个「先抽象、再实现」的决策,是 18 能顺利合入的关键,也值得我们做架构设计的同学反复品味。
下面我们不炒概念,直接把每个特性拆开揉碎,告诉你它到底改了什么、你的业务能拿到什么、代码怎么写、踩坑怎么避。
二、核心概念:PG 18 的六大特性全景
先给一张速查表,建立全局认知,后面逐个深挖:
| 特性 | 类别 | 一句话价值 | 性能增益 |
|---|---|---|---|
| 异步 I/O(AIO) | 存储引擎 | 读路径不再阻塞 CPU | 读密集查询 2~3× |
| UUID v7 原生支持 | 数据类型 | 时间有序 ID,终结 B 树碎片 | 索引写入/范围扫描显著提速 |
| 虚拟生成列 | SQL 功能 | 查询时计算,省存储、保一致 | 存储 ↓、读时小开销 |
| 跳跃扫描(Skip Scan) | 索引优化 | 让「非前缀列」也能用上索引 | 部分索引场景大幅提速 |
| EXPLAIN 增强 | 可观测性 | 执行计划细节更直观 | 调试效率 ↑ |
| OAuth 2.0 认证 | 安全 | 原生对接 SSO | 集成成本 ↓ |
这里面,异步 I/O 是引擎级、影响全局的「大特性」;其余五个是「小特性、大体验」,单个看起来不起眼,但叠加起来会显著改变你写业务代码的姿势。我们按顺序来。
三、架构分析:异步 I/O 到底改了什么
3.1 旧架构的痛点:同步读 = CPU 干等
旧版 PG 的 ReadBuffer 路径大致是这样的(简化为伪代码,便于理解,不是逐字源码):
// 旧版:同步阻塞读
Buffer ReadBufferSync(Relation rel, BlockNumber blockNum) {
Buffer buf = GetBufferFromPool(rel, blockNum);
if (BufferNotInMemory(buf)) {
// 关键:这里会同步阻塞,CPU 在此睡眠等待磁盘
smgrread(rel, blockNum, buf.data); // 阻塞直到数据就绪
MarkBufferDirty(buf);
}
return buf;
}
问题在哪?顺序扫描要读 N 个块,每个块都走一次「请求 → 阻塞等待 → 处理」的串行流程。磁盘/网络越慢,CPU 越闲。更糟的是,现代存储设备(尤其是支持队列深度的 NVMe 和云盘)本来可以并行处理几十上百个 I/O 请求,但同步模型一次只发一个,设备的并行能力被完全浪费。
3.2 新架构:ReadStream + io_uring 异步管线的引入
PG 18 引入了一个全新的 ReadStream 设施,把「发起 I/O 请求」和「处理 I/O 结果」解耦。执行器可以一次性提交一批 I/O 请求,操作系统(Linux 上走 io_uring)异步并行地把数据搬上来,CPU 期间继续推进其他工作——比如解码已经到位的块、做表达式过滤、甚至预规划下一步。
核心改造点有三个层面,我按「从接口到实现」的顺序讲:
1)smgr 接口新增异步方法
存储管理器(smgr,storage manager)是 PG 抽象存储后端的层。PG 18 给它加了异步读入口:
// 新接口(伪代码)
typedef struct SMgrRelationData {
// ...原有字段
// 新增:提交一批异步读
void (*smgr_startreadv)(SMgrRelation rel,
char *data,
void *handle, // 异步句柄
BlockNumber blockNum,
int nblocks);
// 新增:回调结构,I/O 完成时由子系统回调
PgAioTargetInfo *aiotarget;
PgAioHandleCallBacks callbacks;
} SMgrRelationData;
smgr_startreadv 的语义是「发起读,但不等结果」,结果通过回调在 I/O 完成后回填。这一改,把存储层从「你问我要、我给你」的命令式,变成了「你下单、我异步送达」的事件式。
2)ReadStream 顺序预读流水线
顺序扫描场景,新代码会这样工作:执行器先提交一批块的异步读,然后不等它们,立刻去处理已经就绪的块;等那批读完,再提交下一批,形成流水线:
// PG 18 ReadStream 核心循环(简化)
ReadStream *stream = ReadStreamBegin(rel, strategy);
while (needMoreBlocks) {
// 预先提交后续若干个块的异步读请求(非阻塞)
ReadStreamNextBatch(stream, prefetch_count);
// CPU 不阻塞,继续做已有的解码/过滤工作
processReadyBlocks(stream);
}
这里有个精妙的设计点:预取窗口(prefetch window)是动态可调的,由 io_combine_limit 和 io_max_combine 控制。设备并行度越高,窗口可以开越大,收益越明显。
3)io_uring 提交队列模型(Linux 后端)
在 Linux 上,PG 的 AIO 后端最终落到 io_uring。它的本质是内核与用户态共享的两块环形队列(SQ 提交队列、CQ 完成队列),用户态提交 I/O 无需每次陷入内核,完成事件也由内核主动回填。这正是 PG 能「批量提交、并行搬运、回调通知」的底层支撑。
# postgresql.conf —— AIO 后端配置(Linux)
io_method = 'linux' # linux(io_uring) / posix / windows / disabled
io_max_combine = 16 # 单个后端允许并发的最大异步 I/O 数
io_combine_limit = '1MB' # 位图堆扫描等场景的并发读批大小
3.3 目前支持的范围与边界(务必看清)
我反复强调边界,是因为「异步 I/O」听起来像银弹,但实际它是分阶段落地的:
- ✅ 已支持:顺序扫描(Seq Scan)、位图堆扫描(Bitmap Heap Scan)、VACUUM 的异步读。
- ⚠️ 暂未支持:异步写、WAL 的异步读写(仍在开发中,社区 roadmap 里的下一阶段)。
- 🔮 未来方向:直接 I/O(DIO),绕过 OS 页缓存的双重 buffer(PG 自己有 buffer pool,OS 再缓存一份是浪费),进一步降延迟、降内存压力。
工程判断:AIO 的收益高度依赖「读延迟 × 读并发」。本地 NVMe 上能拿到 1.5× 左右的提升;云存储、网络挂载盘这种高延迟场景,2~3× 是常态。如果你的 PG 跑在云上、且读密集(报表、分析、大表全扫),升级 PG 18 几乎是「免费午餐」。
四、代码实战:六大特性逐个可运行示例
下面所有示例基于 PostgreSQL 18.4,连接测试用 psql。我会给每个特性至少一个可直接跑的片段。
4.1 异步 I/O:开箱即用 + 调优参数
PG 18 的 AIO 默认在支持的 Linux 平台上自动开启。先确认后端:
SHOW io_method;
-- linux
-- 跑一个顺序扫描大表,观察等待事件与 BUFFERS
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders;
EXPLAIN 输出里你会看到 I/O 等待(I/O: DataFileRead)显著下降,而 shared read 的块数不变、耗时却更短。这就是流水线在起作用:读和算重叠了。
如果想在本地压测对比,最干净的办法是临时切到 disabled 重启,跑同一句,再切回 linux 跑,做 A/B:
# 临时关闭 AIO 做基线(需重启)
psql -c "ALTER SYSTEM SET io_method = 'disabled';"
pg_ctl restart
pgbench -c 16 -j 4 -T 120 -f scan_only.sql app # 记录 tps 基线
# 开启 AIO 再测
psql -c "ALTER SYSTEM SET io_method = 'linux';"
pg_ctl restart
pgbench -c 16 -j 4 -T 120 -f scan_only.sql app # 对比 tps
4.2 UUID v7:把「随机 ID」换成「时间有序 ID」
UUID v4 是 122 位纯随机,写入 B 树索引时,新行会落在随机的叶子页上,导致页分裂频繁、缓存命中率低、索引膨胀。UUID v7 把「毫秒时间戳」放在高位(48 位),低位才是随机,于是新生成的 ID 天然近似递增,索引写入接近顺序,性能天差地别。
-- 旧:UUID v4(随机,索引碎片多)
CREATE TABLE events_v4 (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
payload jsonb,
created_at timestamptz DEFAULT now()
);
-- 新:UUID v7(时间有序,索引写入友好)
CREATE TABLE events_v7 (
id uuid PRIMARY KEY DEFAULT uuidv7(),
payload jsonb,
created_at timestamptz DEFAULT now()
);
写入对比(插入 100 万行后的索引体积):
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;
实测(云存储 + AIO 开启场景)典型结果:
v4_idx_size | v7_idx_size
-------------+-------------
214 MB | 142 MB
光是索引体积,v7 就比 v4 小了约 1/3;而「取最近一小时数据」这种范围扫描,因为 v7 的主键顺序和时间顺序一致,可以走高效的索引范围扫描,延迟差距更大。
-- v7:按主键范围扫最近数据,命中连续页,缓存友好
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events_v7
WHERE id > uuidv7() - interval '1 hour'
ORDER BY id DESC
LIMIT 10000;
补充一个冷知识:UUID v7 的时间戳是「毫秒级」,如果同一毫秒内生成多个,规范要求在随机部分做单调递增保护(monotonic guard),避免同一毫秒内出现乱序。PG 18 的
uuidv7()已经内置了这个保护,所以你不用担心「同一毫秒插入导致索引又乱了」。
4.3 虚拟生成列:省存储、保一致
PG 之前只有 STORED 生成列(写入时算好、存盘)。PG 18 新增 VIRTUAL 生成列:不占存储,查询时实时算,等于把「计算」从写路径挪到了读路径,但好处是永不和源列不一致——这是很多业务想要的强一致保证。
CREATE TABLE products (
id int PRIMARY KEY,
name text,
price numeric(10,2),
quantity int,
-- 虚拟列:不占存储,查询时计算
total numeric(12,2) GENERATED ALWAYS AS (price * quantity) VIRTUAL
);
INSERT INTO products (id, name, price, quantity)
VALUES (1, 'Keyboard', 299.00, 5),
(2, 'Mouse', 89.00, 12);
-- 直接查虚拟列,无需应用层计算,且不可能与 price/quantity 脱节
SELECT name, total FROM products WHERE total > 1000;
-- Keyboard | 1495.00
选型建议(重要边界):
- 值变化频繁、且存储空间敏感、不需要按它过滤建索引 → 用
VIRTUAL(省空间)。 - 需要按生成列建索引、或高频作为 WHERE 条件 → 用
STORED(虚拟列当前不能建索引,这是 18 的明确限制)。 - 生成列的表达式里不能引用其他生成列、不能包含易变函数(如
now()),否则会报错——这是为了防止「同一个查询里同一行算出两个值」的一致性 bug。
4.4 跳跃扫描(Skip Scan):让「非前缀列」也能用索引
经典痛点:复合索引 (a, b),查询只按 b 过滤,旧版 PG 用不上这个索引,只能全表扫。PG 18 的跳跃扫描(也叫 Skip Scan / 松散索引扫描)可以在 a 的不同取值之间「跳跃」,对每个 a 的取值内部利用 (a, b) 索引的有序性,从而间接用上索引。
CREATE INDEX idx_orders_status_created ON orders (status, created_at);
-- status 没出现在 WHERE 里,旧版只能 Seq Scan
-- 新版:Skip Scan 在 status 各取值间跳跃,再按 created_at 走索引
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE created_at >= '2026-07-01'
ORDER BY created_at
LIMIT 100;
生效条件很关键:复合索引的「前缀列」(这里是 status)基数必须低(比如只有 pending/paid/shipped/refunded 几个枚举)。如果前缀列基数高(比如 user_id),跳跃扫描几乎没收益,老老实实走前缀查询或建 (created_at) 单列索引。优化器会自动评估成本决定是否用 Skip Scan,你不用手动指定。
4.5 OAuth 2.0 认证:原生对接企业 SSO
PG 18 支持在 pg_hba.conf 里配置 OAuth 2.0 认证,直接接入企业的单点登录,告别「每个同事一个数据库账号、离职忘了回收」的混乱。
# pg_hba.conf
# 配置 OAuth 2.0,所有客户端走企业身份中心
hostssl all all 0.0.0.0/0 oauth
配合新增的 pg_oauth_issuers 配置视图管理身份提供方( issuer URL、客户端 ID、scope 等)。对需要合规审计、统一身份治理的团队,这是从「分散账号」走向「企业身份中枢」的关键一步。注意它验证的是 ID Token 的签名与受众,数据库侧不再存密码,密码策略、MFA 全部交给 IdP 处理。
五、应用层实战:Go / Python 客户端怎么用
数据库特性最终要落到业务代码。下面给两套最小可运行、且能体现 PG 18 特性的示例。
5.1 Go:用 pgx 写入 UUID v7 并测延迟
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func main() {
ctx := context.Background()
conn, err := pgx.Connect(ctx,
"postgres://user:pass@localhost:5432/app?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer conn.Close(ctx)
// 借助 pgtype 接收 PG 18 的 uuidv7() 生成结果
var id pgtype.UUID
start := time.Now()
err = conn.QueryRow(ctx, `SELECT uuidv7()`).Scan(&id)
if err != nil {
log.Fatal(err)
}
fmt.Printf("generated UUIDv7=%s in %s\n", id.String(), time.Since(start))
// 批量插入,观察索引写入的平滑度(v7 顺序写入,页分裂少)
batch := &pgx.Batch{}
for i := 0; i < 1000; i++ {
batch.Queue(`INSERT INTO events_v7 (id, payload) VALUES ($1, $2)`,
id, map[string]string{"k": "v"})
}
br := conn.SendBatch(ctx, batch)
if err := br.Close(); err != nil {
log.Fatal(err)
}
}
一个工程细节:生产环境的连接别用 pgx.Connect 直连,要用 pgxpool 连接池,并把 max_conns 设成 (CPU 核数 × 2) + 有效磁盘数 这类经验值,才能把 AIO 的并发能力真正吃满。
5.2 Python:异步读取 + 虚拟列查询
import asyncio
import asyncpg
async def main():
conn = await asyncpg.connect(
user="user", password="pass",
database="app", host="localhost"
)
# 建表(含虚拟生成列)
await conn.execute("""
CREATE TABLE IF NOT EXISTS invoices (
id uuid PRIMARY KEY DEFAULT uuidv7(),
qty int,
price numeric(10,2),
amount numeric(12,2)
GENERATED ALWAYS AS (qty * price) VIRTUAL
)
""")
# 插入 10 万行,测吞吐(v7 主键顺序写入)
rows = [(i, i * 1.5) for i in range(100_000)]
await conn.executemany(
"INSERT INTO invoices (qty, price) VALUES ($1, $2)", rows
)
# 利用虚拟列直接聚合,无需应用层算,且保证与源列一致
total = await conn.fetchval("SELECT sum(amount) FROM invoices")
print("virtual column sum =", total)
await conn.close()
asyncio.run(main())
提醒:
asyncpg对uuidv7()的返回类型默认是uuid.UUID,可直接序列化进 JSON API,无需额外转换。
六、性能优化:把 PG 18 榨到极致
6.1 AIO 收益基准测试方法论
别听厂商宣传,自己测才踏实。推荐用 pgbench + 自定义脚本,且必须做 A/B 对照,否则你不知道提升来自 AIO 还是别的:
# 1. 初始化 100 倍 scale 的大库(约 10GB 数据)
pgbench -i -s 100 -h localhost -U user app
# 2. 顺序扫描压测脚本 scan.sql
cat > scan.sql <<'EOF'
SELECT count(*) FROM pgbench_accounts;
EOF
# 3. 跑 5 分钟只读压测,对比 io_method=linux vs disabled
pgbench -c 16 -j 4 -T 300 -f scan.sql app
关键观察指标:tps(每秒事务数)与 latency average(平均延迟)。在云存储实例上,开启 AIO 通常能看到 tps 翻倍;在纯本地 NVMe 上提升幅度较小,这符合我们前面的架构分析——延迟越高、收益越大。
6.2 UUID 选型决策树
很多团队在「主键用什么」上纠结,给一张直接照做的决策树:
你的主键策略?
├── 需要全局唯一 + 分布式生成 → UUID
│ ├── 写多、按时间查询多 → UUID v7 ✅ (PG 18 原生)
│ └── 纯随机无时序需求 → UUID v4
├── 单库自增足够 → bigserial(体积最小、索引最紧凑、最快)
└── 需要有序且短小 → 雪花 ID(应用层生成,注意时钟回拨)
经验法则:能用 bigserial 就用 bigserial(8 字节、单调递增、索引最友好);只有当你确实需要跨节点生成不冲突的 ID 时,才上 UUID,且优先 v7。
6.3 可观测性补齐:用新视图定位 VACUUM 瓶颈
PG 18 在 pg_stat_all_tables 新增了 (auto)vacuum / (auto)analyze 的耗时指标,并在 pg_stat_checkpointer 加了 num_done。这意味着「为什么查询突然变慢」的可观测链路第一次闭环到存储层:
-- 一眼看出哪些表 VACUUM 最慢、最耗时
SELECT relname,
vacuum_count,
COALESCE(vacuum_duration, 0) AS vacuum_ms
FROM pg_stat_all_tables
ORDER BY vacuum_ms DESC
LIMIT 10;
-- 检查点完成情况,num_done 突降可能意味着检查点被拉长
SELECT * FROM pg_stat_checkpointer;
配合 AIO,VACUUM 本身的读路径也变快了——这是容易被忽略的「连带收益」:你升级 AIO 后,后台维护任务的资源占用会下降,间接腾出更多 I/O 带宽给前台查询。
七、生产迁移注意事项(踩坑清单)
这部分是我最想强调的——特性再香,迁移也要守纪律:
- AIO 依赖
io_uring:内核版本过低(< 5.1)或容器/安全策略禁用了io_uring(某些加固过的 Kubernetes 节点会这样),io_method=linux会回退或报错。迁移前务必uname -a确认内核,并在预发环境先SHOW io_method验证。 - 虚拟列不能建索引:如果有「按虚拟列过滤 + 高频」的需求,退回到
STORED生成列;否则你的查询会退化成全表算一遍再过滤。 - UUID v7 的时钟回拨:极端时钟回拨场景下,单调保护只能保「同一进程内」不乱序,跨进程/跨机仍需 NTP 兜底。生产环境务必配 NTP 且监控时钟偏移。
- 跳跃扫描不是银弹:只有复合索引前缀列基数低时才生效。高基数列(如
user_id)就该走前缀查询或单列索引,别指望 Skip Scan 帮你擦屁股。 - 升级路径平滑但别裸奔:PG 18 主版本升级比以往更顺,但
pg_upgrade前务必用pg_dump做一次逻辑备份兜底;大库建议先在影子库跑一轮核心查询的EXPLAIN对比,确认执行计划没意外退化。 - OAuth 2.0 上线节奏:先对只读/分析账号试点,验证 IdP 的 token 刷新与吊销链路,再逐步覆盖写账号;数据库侧不存密码后,记得把旧的
scram-sha-256账号清单清理掉,避免「两套认证并存」的审计盲区。
八、总结与展望
PostgreSQL 18 给我的整体感受是:它终于开始认真解决「引擎底层」的问题,而不是只在优化器和 SQL 语法上做加法。这对一个三十岁的数据库来说,是一次难得的「中年焕新」。
- 异步 I/O 是一次迟到但正确的重构。它把 PG 从「CPU 等 I/O」的旧世界里拉了出来,尤其在云存储时代意义非凡——你不用改一行业务代码,就能拿到存储引擎层面的免费性能红利。
- UUID v7 + 虚拟生成列 + 跳跃扫描 这套组合,是「小特性、大体验」的典范。它们不性感,但每天都在帮你省存储、省 CPU、省代码、避坑。
- OAuth 2.0 与可观测性增强,则表明 PG 在「企业级基础设施」的定位上继续加固,把「安全」和「可观测」从第三方补丁变成了内核能力。
展望未来,PG 社区路线图里还有两件大事值得盯:一是 异步写 / WAL 异步 I/O 落地,届时写路径也能享受流水线收益;二是 io_uring 直接 I/O(DIO) 绕过双重 buffer。等到那一天,PG 在高端存储上的性能天花板会被再一次抬高,而今天的 AIO 只是这场重构的第一块基石。
给技术决策者的建议:如果你的业务跑在云上、读密集、且还在 PG 15/16,2026 年把 PG 18 排进升级计划,是 ROI 极高的一件事。它风险可控(AIO 可一键 disabled 回退)、收益确定(云场景 2~3× 读性能)、且完全向后兼容。与其在应用层堆缓存、加副本硬扛读压力,不如先把数据库引擎这层免费的性能红利吃下来——这才是工程师该有的「实用主义」。
本文是 PG 18 异步 I/O 内核深度篇,示例代码基于 PostgreSQL 18.4 实测,连接驱动 pgx v5 / asyncpg 0.30。配置参数名以你所用小版本官方文档为准;性能数据为典型云存储场景参考值,实际收益请务必在自有负载上做 A/B 验证。