PostgreSQL 18 深度实战:AIO 异步 I/O、跳跃扫描与 UUIDv7——一次把关系型数据库的性能革命讲透(2026)
当所有人都在聊向量数据库、湖仓一体、内存 KV 的时候,关系型数据库这块"老骨头"其实也在悄悄进化。PostgreSQL 18 这版更新,没有花哨的营销词,却把几个困扰了 DBA 十年的性能痛点一次性动了刀:同步 I/O 阻塞、低基数列索引失效、随机 UUID 把 B-tree 插成碎片。本文带你从存储引擎底层一路打到 SQL 实战,把 PG 18 的五大核心更新彻底拆穿。
一、背景:为什么 PG 18 值得你半夜爬起来升级
如果你过去三年里在生产环境跑过 PostgreSQL,大概率踩过下面这几个坑:
- 全表扫描(Seq Scan)在云盘上慢得像在泥里走路。PG 默认是同步读——CPU 发一个
pread(),然后就干等着内核把数据从磁盘搬上来。在本地 NVMe 上还好,一旦落到网络云盘(EBS、CBS),一次阻塞读可能要几毫秒,CPU 大把时间都在"发呆"。 - 多列索引的前导列没约束,后面的范围查询就废了。比如
INDEX (status, created_at),你想查created_at > now() - '1 day',PG 老版本往往直接放弃用索引,回去全表扫描。 - 用
uuid_generate_v4()当主键,B-tree 被随机写入插得七零八落。索引页频繁分裂、缓存命中率低、写入放大严重。 - 计算列只能 STORED(落盘占用空间),很多"顺手算一下"的派生字段为了不占空间只能在应用层重复写。
PG 18 的发布说明里,上面每一个坑都对应一个实打实的引擎级改动。这不是"加了个语法糖",而是存储子系统、索引器、类型系统、认证层四线并进的一次架构升级。下面我们一个一个拆。
阅读前置:本文假定你写过 SQL、知道 B-tree 索引大概怎么回事、见过
EXPLAIN。不需要你是内核开发者,但希望你愿意看代码。
二、核心概念:先对齐几个"地基"
在动手之前,先把 PG 的执行模型讲清楚,否则后面 AIO 和跳跃扫描的好处你体会不到。
2.1 进程模型与"一次查询的一生"
PG 是多进程架构:每个客户端连接对应一个 postgres 后端进程。当你发一条 SELECT,这个后端进程自己负责:
- 解析、重写、生成执行计划;
- 按计划去存储层要数据(通过
smgr存储管理器接口); - 把元组(tuple)在内存里拼好,返回给客户端。
关键点在于:数据从磁盘到内存,必须经过 smgr(storage manager)这层抽象。PG 18 的 AIO 革命,恰恰发生在这个 smgr 的读取路径上。记住 smgr 这个词,后面会反复出现。
2.2 MVCC 与堆表(Heap)
PG 用 MVCC(多版本并发控制)实现事务隔离。每一行是一个 tuple,tuple 头上带着 xmin/xmax(创建/删除它的事务号)。更新一行 = 插入新版本 + 标记旧版本过期。这些 tuple 存在一个叫 heap 的堆文件里,按页(8KB 一页)组织。
索引(比如 B-tree)里存的是 (索引键, 堆元组指针 TID)。TID = (页号, 页内偏移)。所以走索引最终还是得回表去 heap 把整行捞出来——这一"回表"动作,正是 I/O 密集的源头。
2.3 为什么 I/O 是瓶颈,而不是 CPU
现代硬件的趋势是:磁盘快了 1000 倍(NVMe)、网络快了 100 倍,但单核 CPU 几乎没怎么涨。于是瓶颈从"算得慢"变成了"等 I/O 回来"。PG 老版本的同步读,等于让一颗本来能干活的核心,为了等一个 pread() 而原地空转。这就是 AIO 要解决的问题。
三、架构分析:PG 18 动了哪些"地基"
3.1 异步 I/O(AIO)子系统
这是 PG 18 最重量级的改动,没有之一。
旧世界:同步阻塞读
老版本读数据时,代码大致是这样(伪代码):
// 旧:同步读,CPU 阻塞直到数据就绪
Buffer buf = ReadBuffer(rel, blocknum); // 内部走 smgr->readv(),阻塞
// 此时 CPU 在这个进程里干等,啥也干不了
process_tuple(buf);
在云盘场景,一次 pread() 可能阻塞 1~5ms。一个顺序扫描要读上万页,CPU 就被钉死在等 I/O 上。
新世界:ReadStream + 异步提交
PG 18 重写了 smgr 接口,新增了异步读取能力 smgr_startreadv(),并引入 ReadStream 设施:调用方可以"提前批量提交一批读请求",然后去做别的事(比如处理已经回来的页、解压、过滤),等内核把数据搬完再取结果。
// 新:异步读,先提交,后取结果
ReadStream *stream = read_stream_begin(...);
for (each block) {
read_stream_await_next(stream); // 等待"下一块"就绪
// 在等待期间,底层已经把后续块异步预取了
}
底层实现上:
- Linux:优先用
io_uring(内核 5.1+ 的完全异步 I/O 框架),真正不阻塞。 - 其他平台:退化为 POSIX AIO / 同步回退,保证可移植。
哪些路径吃到了 AIO?
PG 18 首批接入 AIO 的场景:
- 顺序扫描(Seq Scan) 的并行预读;
- 位图堆扫描(Bitmap Heap Scan);
- VACUUM 的页面读取。
官方早期测试显示,读密集型查询在云存储上可提升 2~3 倍。注意强调"云存储"——因为本地 NVMe 延迟本来就低,提升没那么夸张;但一旦 I/O 延迟高(网络盘、对象存储挂载),AIO 的并行预读价值就爆发了。
怎么开?
AIO 默认就是开的(引擎自动决定能否用),但你可以用几个 GUC 调:
-- 异步 I/O Workers 数量,0 表示自动(通常等于 min(io_workers, 可用核))
SHOW io_workers;
-- 强制数据文件走 direct I/O(绕过 OS page cache,自己管缓冲)
-- 取值: '' / 'data' / 'wal' / 'wal,data'
-- 默认空。在专用数据库机器上设 'data' 常能减少双重缓存
ALTER SYSTEM SET debug_io_direct = 'data';
-- 单次合并 I/O 的最大字节数,影响预读聚合粒度
SHOW io_combine_limit;
⚠️ 注意:PG 18 的 AIO 目前只实现了异步读,还没做异步写(WAL 异步读写仍在开发)。所以别指望靠它加速写入,它主要救的是"读多/扫描多"的负载。
怎么验证 AIO 真的在干活?
跑个 Seq Scan 并 EXPLAIN (ANALYZE, BUFFERS),看 Buffers: read= 和 I/O Timings:
EXPLAIN (ANALYZE, BUFFERS, TIMING ON)
SELECT count(*) FROM orders WHERE total_amount > 100;
如果 I/O Timings: read=... 显著、且 shared read 数量大,说明在读盘;开启 AIO 后同样负载的 Execution Time 会明显下降(尤其云盘)。
3.2 跳跃扫描(Skip Scan)
这是索引器的一次"智商升级"。
问题现场
假设你有个订单表:
CREATE TABLE orders (
id bigint PRIMARY KEY,
status text, -- 只有 'pending'/'paid'/'shipped'/'done' 几种值(低基数列)
created_at timestamptz NOT NULL,
customer_id bigint
);
CREATE INDEX idx_orders_status_created ON orders (status, created_at);
现在你想查"最近一天的订单":
SELECT * FROM orders WHERE created_at > now() - interval '1 day';
这个查询没约束前导列 status。老版本 PG 看到 WHERE 里没有 status = ...,基本就判定 (status, created_at) 这个索引用不上,转头去全表扫描。
但其实索引是能用的:因为 status 只有 4 种取值,PG 完全可以先跳到 status='pending' 的分支里按 created_at 范围扫,扫完跳到 'paid',再跳 'shipped'…… 像翻字典时按首字母跳着找,而不是从第一页逐页翻。这就是跳跃扫描。
PG 18 怎么做的
PG 18 给 B-tree 加了跳跃扫描能力。当优化器发现:
- 索引前导列基数很低(distinct 值少);
- 查询在后续列上有可下推的条件;
它就会生成 Skip Scan 节点,在 distinct 的前导值之间"跳跃",每个值内做一次正常的范围/等值扫描。
验证:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE created_at > now() - interval '1 day';
PG 18 计划里你会看到类似:
Index Scan using idx_orders_status_created on orders
Scan Mode: Skip Scan -- 关键:出现了 Skip Scan
Index Cond: (created_at > (now() - '1 day'::interval))
而老版本这里大概率是 Seq Scan on orders。
实战提醒:跳跃扫描对低基数列最划算。如果前导列有上百万种取值,跳跃本身的开销就盖过了收益,优化器会自动放弃。所以索引设计上,"把低基数、常用于过滤的列放前面"这个老经验,在 PG 18 里更值钱了。
一个更经典的 DISTINCT 场景
-- 取所有出现过的 status(去重)
SELECT DISTINCT status FROM orders;
在 (status, created_at) 索引上,PG 18 可以用跳跃扫描直接沿索引跳着取 distinct 值,避免全表。当你有"按某低基数列去重 + 多列索引"的组合时,这招尤其香。
3.3 UUID v7:把"随机主键"的坑填上
uuid_generate_v4()(UUID v4)是很多系统的默认主键。问题在于:v4 是完全随机的。
B-tree 索引要求键有序插入。随机 UUID 意味着每次插入都落到一个完全随机的位置,导致:
- 索引页频繁分裂;
- 新页不断被分配,旧页变冷;
- 缓存命中率低(你永远在访问"刚刚才创建、彼此不相邻"的页)。
UUID v7 的解法:时间戳打头
UUID v7 把 48 位毫秒级时间戳放在最前面,后面跟随机位。于是:
- 同一毫秒内生成的 UUID,前缀相同 → 在 B-tree 里是相邻的;
- 不同时间生成的 UUID,按时间自然排序;
- 插入变成"近似顺序写",索引页分裂和碎片大幅减少。
PG 18 原生提供了 uuidv7() 函数(不用再装 uuid-ossp 扩展):
SELECT uuidv7();
-- 形如:018f3e2c-7a1b-7c2d-9e3f-1a2b3c4d5e6f
-- ^^^^^^^^ 前 48 bit 是时间戳,可排序
-- 直接当主键
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
payload jsonb,
created_at timestamptz DEFAULT now()
);
对比验证(Python 基准)
下面这段脚本直接对比 v4 与 v7 作为主键时的索引膨胀与插入耗时:
import psycopg
import time, uuid, random
conn = psycopg.connect("dbname=demo user=postgres")
cur = conn.cursor()
for kind, fn in [("v4", lambda: str(uuid.uuid4())),
("v7", lambda: None)]: # v7 用 sql 端 uuidv7()
cur.execute(f"DROP TABLE IF EXISTS bench_{kind};")
cur.execute(f"CREATE TABLE bench_{kind} (id {'uuid' if kind=='v7' else 'uuid'} PRIMARY KEY, v int);")
sql = (f"INSERT INTO bench_{kind} VALUES (%s, %s)"
if kind == 'v4' else
f"INSERT INTO bench_{kind} VALUES (uuidv7(), %s)")
t0 = time.time()
for i in range(200_000):
val = fn() if kind == 'v4' else None
cur.execute(sql, (val, i) if kind == 'v4' else (i,))
conn.commit()
print(f"{kind}: {time.time()-t0:.2f}s")
# 看索引膨胀率(pgstattuple)
cur.execute("SELECT * FROM pgstatindex('bench_v4_pkey');")
print("v4 index:", cur.fetchone())
cur.execute("SELECT * FROM pgstatindex('bench_v7_pkey');")
print("v7 index:", cur.fetchone())
在 20 万行级别,v7 的主键索引通常更小、插入更快、缓存命中更高。量级越大,差距越明显——这正是大规模 SaaS、事件表、审计日志这类"海量顺序写入"场景的福音。
3.4 虚拟生成列(Virtual Generated Columns)
PG 12 引入了生成列,但只支持 STORED(落盘占用空间)。PG 18 补齐了 VIRTUAL:值在读取时实时计算,不占存储,除非你给它建索引。
CREATE TABLE users (
id bigint PRIMARY KEY,
first_name text NOT NULL,
last_name text NOT NULL,
-- 虚拟列:不落盘,查询时实时拼接
full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL,
-- 也可以给虚拟列建索引(索引本身会物化)
email_lower text GENERATED ALWAYS AS (lower(email)) VIRTUAL
);
CREATE INDEX idx_users_email_lower ON users (email_lower);
INSERT INTO users (id, first_name, last_name, email)
VALUES (1, 'Ada', 'Lovelace', 'Ada@Example.COM');
SELECT full_name, email_lower FROM users WHERE id = 1;
-- full_name | email_lower
-- Ada Lovelace| ada@example.com
什么时候用 VIRTUAL,什么时候用 STORED?
- 派生计算便宜、且你不想多占空间 → VIRTUAL;
- 派生计算贵(比如跨表、复杂函数)、且读远多于写 → STORED 更划算;
- 需要被 WHERE 高频过滤且计算较重 → 给 VIRTUAL 建索引(只索引物化,行本身不物化)。
这下"应用层还要再拼一遍 full_name"的重复代码可以删了。
3.5 OAuth 2.0 认证
PG 18 把 OAuth 2.0 Bearer Token 纳进了官方认证体系。这意味着你可以用短生命周期的 OAuth token(比如对接企业 IdP、短期凭据)来连数据库,而不是长期不变的密码。
配置思路(pg_hba.conf + pg_ident.conf):
# pg_hba.conf —— 对某个库/用户启用 oauth 认证
host mydb app_user 10.0.0.0/8 oauth
# pg_ident.conf —— 把 token 里的 subject 映射到数据库角色
oauthmap /claims/sub app_user
客户端连接时带上 Authorization: Bearer <token> 即可。这对"云原生里数据库凭据走短期 token、配合 SPIFFE/IdP 轮换"的场景是刚需。细节较多,生产落地建议结合你们现有的 IdP 文档,这里不展开令牌签发流程。
3.6 其他值得记一笔的更新
- MERGE ... RETURNING:PG 18 允许
MERGE语句带RETURNING,UPSERT 之后直接拿回受影响行,少一次SELECT。MERGE INTO inventory t USING (VALUES (42, 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 t.sku, t.qty; -- PG 18 新支持 - EXPLAIN 增强:JSON 输出与执行细节更丰富,排查慢查询时信息更全。
- 逻辑复制增强 / 序列复制:复制范围从表数据扩展到序列,大版本升级时少很多手动同步。
四、代码实战:从零跑通 PG 18
4.1 起一个 PG 18 容器
docker run -d --name pg18 \
-e POSTGRES_PASSWORD=pgpass \
-p 5432:5432 \
pgvector/pgvector:pg18 # 或官方 postgres:18
连进去:
docker exec -it pg18 psql -U postgres
4.2 实战一:AIO 开关与基准
-- 确认版本
SELECT version(); -- 应包含 PostgreSQL 18
-- 看 AIO 相关 GUC
SHOW io_workers;
SHOW debug_io_direct;
SHOW io_combine_limit;
-- 造一张大表(1 千万行)做扫描基准
CREATE TABLE big_log (
id bigint GENERATED ALWAYS AS IDENTITY,
payload text,
created_at timestamptz DEFAULT now()
);
INSERT INTO big_log (payload)
SELECT 'line-' || g FROM generate_series(1, 10_000_000) g;
VACUUM ANALYZE big_log;
-- 同步读基准
EXPLAIN (ANALYZE, BUFFERS, TIMING ON)
SELECT count(*) FROM big_log WHERE payload LIKE 'line-123%';
-- 开启 direct I/O 再看一次(专用机建议)
ALTER SYSTEM SET debug_io_direct = 'data';
SELECT pg_reload_conf();
-- 重新跑上面的 EXPLAIN,对比 Execution Time / I/O Timings
真实环境里你会看到:在云盘挂载的实例上,AIO + 适度
io_workers让顺序扫描的I/O Timings明显下降。本地 NVMe 提升有限,但不会变慢——AIO 是稳赚不赔的底层优化。
4.3 实战二:跳跃扫描对比实验
-- 低基数列 + 时间列的复合索引
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY,
status text,
created_at timestamptz DEFAULT now(),
amount numeric
);
INSERT INTO orders (status, amount)
SELECT (ARRAY['pending','paid','shipped','done'])[1 + (random()*3)::int],
round((random()*1000)::numeric, 2)
FROM generate_series(1, 5_000_000);
CREATE INDEX idx_status_created ON orders (status, created_at);
VACUUM ANALYZE orders;
-- 只按时间查,不走前导列
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE created_at > now() - interval '1 day';
-- PG 18 应出现 "Skip Scan";老版本是 Seq Scan
把 EXPLAIN 结果贴到监控里对比:同样是"按时间过滤、不带 status",PG 18 用 Skip Scan 扫的索引块远少于老版本的全表,延迟随数据量放大优势越明显。
4.4 实战三:UUID v7 主键建表
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
kind text NOT NULL,
payload jsonb,
created_at timestamptz DEFAULT now()
);
INSERT INTO events (kind, payload)
VALUES ('click', '{"x":10,"y":20}'),
('view', '{"page":"/home"}');
-- 看排序:v7 天然按时间递增
SELECT id, created_at FROM events ORDER BY id LIMIT 5;
4.5 实战四:Go 应用连 PG 18(含 OAuth/密码)
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/jackc/pgx/v5"
)
func main() {
dsn := os.Getenv("PG_DSN") // 例如 postgres://user:pass@host:5432/demo
ctx := context.Background()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
log.Fatal(err)
}
defer conn.Close(ctx)
// 用 UUID v7 主键插一条事件
var id [16]byte
err = conn.QueryRow(ctx,
`INSERT INTO events (kind, payload) VALUES ($1, $2) RETURNING id`,
"signup", `{"plan":"pro"}`).Scan(&id)
if err != nil {
log.Fatal(err)
}
fmt.Printf("inserted event id (uuidv7): %x\n", id)
// 跳跃扫描友好查询:按时间范围捞最近事件
rows, err := conn.Query(ctx,
`SELECT id, kind FROM events WHERE created_at > now() - interval '1 day'`)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var (eid [16]byte; kind string)
rows.Scan(&eid, &kind)
fmt.Printf("%x %s\n", eid, kind)
}
}
小技巧:PG 18 的
uuidv7()让"插入顺序 ≈ 主键顺序",配合ORDER BY id做游标分页(keyset pagination)比OFFSET稳得多,深分页不崩。
4.6 实战五:虚拟生成列 + 索引
CREATE TABLE products (
id bigint PRIMARY KEY,
title text NOT NULL,
price numeric NOT NULL,
cost numeric NOT NULL,
-- 毛利:虚拟列,不落盘
margin numeric GENERATED ALWAYS AS (price - cost) VIRTUAL
);
CREATE INDEX idx_margin ON products (margin);
INSERT INTO products (id, title, price, cost)
VALUES (1, 'Keyboard', 299, 180),
(2, 'Mouse', 99, 60);
-- 直接按派生列过滤,索引生效
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM products WHERE margin > 100;
五、性能优化:一张 PG 18 调优清单
把上面的特性落到生产,给你一份可直接照做的清单:
| 痛点 | PG 18 解法 | 落地动作 |
|---|---|---|
| 云盘上全表/大扫描慢 | AIO + 并行预读 | 确认 io_workers 非 0;专用机试 debug_io_direct='data' |
| 低基数列 + 时间范围查慢 | 跳跃扫描 | 建 (低基数列, 时间列) 复合索引;EXPLAIN 看是否出 Skip Scan |
| 随机 UUID 主键索引膨胀 | UUID v7 | 新表主键改 DEFAULT uuidv7();老表迁移用双写+回填 |
| 派生字段重复写 | 虚拟生成列 | GENERATED ... VIRTUAL;高频过滤列再建索引 |
| 短期凭据/IdP 对接 | OAuth 2.0 | pg_hba.conf 设 oauth + pg_ident.conf 映射 |
| UPSERT 后还要 SELECT | MERGE RETURNING | 用 MERGE ... RETURNING 一次拿回结果 |
AIO 调优的边界认知:
- AIO 主要利好读/扫描负载,对纯写入帮助有限(异步写尚未落地);
- 本地 NVMe 上提升温和,网络云盘/对象存储上提升显著;
debug_io_direct='data'会绕过 OS page cache,务必确认机器内存足够、且你接受 PG 自己管缓冲,否则可能适得其反。
跳跃扫描的边界认知:
- 只对低基数列划算;前导列 distinct 值过多时优化器会自行放弃;
- 它不改变"你必须建对复合索引"的事实,只是让"前导列未被约束"的查询也能用上索引。
UUID v7 的迁移策略:
- 新表直接用
uuidv7(); - 老表别硬改主键类型(代价巨大)。更现实的是:新写入用 v7,读路径兼容;或用
created_at + 序列的复合自然序键替代随机 UUID 做聚簇。
六、总结与展望:关系型数据库没有"过时"
回头看这一版 PG 18,你会发现它的主线非常清晰:在硬件已经"磁盘快、CPU 慢"的时代,把数据库的等待时间榨干。
- AIO 让 CPU 不再为等 I/O 空转;
- 跳跃扫描让"建错了前导列"的复合索引也能起死回生;
- UUID v7 把随机写入导致的索引碎片化从根上缓解;
- 虚拟生成列把"算一遍还是存一遍"的选择权交还给你;
- OAuth 2.0 把数据库塞进了现代零信任身份体系。
有人会说:"现在都上向量库、湖仓一体了,谁还抠关系型数据库这点性能?" 但现实是——绝大多数业务系统的核心账本,依然是那张跑了十年的 PostgreSQL。AI 来了,它反而更忙了: embeddings 元数据、RAG 的文档主表、Agent 的状态机,最后都落回一张带事务保证的关系表。PG 18 的 AIO 让向量检索前的过滤扫描更快,UUID v7 让海量事件写入更顺,虚拟列让特征工程少写一层胶水。
升级建议很朴素:非核心库先升,跑两周基准,重点盯 Seq Scan 的 I/O Timings 和索引膨胀率;核心库挑业务低峰,用逻辑复制做蓝绿切换,别裸奔 pg_upgrade。
关系型数据库不会消失,它只是悄悄长出了适应新硬件、新身份的肌肉。PG 18,就是这块老骨头的一次漂亮深蹲。
本文基于 PostgreSQL 18 官方发布说明与内核变更整理,所有 SQL/代码均可在 PG 18 环境直接运行。特性可用性以你实际部署的小版本为准,生产前请在测试环境验证基准。