PostgreSQL 18 深度实战:异步 I/O 让读性能翻倍,UUID v7 与虚拟生成列重塑主键与派生字段设计
程序员的数据库版本清单里,PostgreSQL 一直是那个「稳得让人忘记它的存在」的老伙计。但 PG 18(2025 年 9 月正式发布,目前 18.x 系列已进入稳定迭代)不一样——它不是一次「修修补补又一年」的例行升级,而是把三个长期被人诟病的痛点一次性动刀:I/O 阻塞、随机主键的索引灾难、以及派生字段的存储浪费。
本文不堆参数、不抄 Release Notes,而是以一个踩过坑的工程师视角,把 PG 18 最硬核的三块(异步 I/O 子系统、UUID v7、虚拟生成列)讲透,配上可原样跑通的代码、真实场景的取舍判断,以及一份升级与压测的实战清单。
一、背景:为什么 PG 18 是近年来「工程含量」最高的大版本
先说一个扎心的事实:过去十年,PostgreSQL 的瓶颈很少出现在「计算」上,而几乎总是出现在 I/O 和 数据建模的笨拙 上。
- 你的
SELECT明明只查几行,却因为seq scan触发大量随机读,CPU 在read()系统调用上干等; - 你用
gen_random_uuid()当主键,表涨到几千万行后,索引膨胀、插入变慢、缓存命中率雪崩; - 你为了一个「总价 = 单价 × 数量」的字段,要么在应用层算两遍,要么冗余存一列还得靠触发器维护一致性。
PG 18 针对这三件事,给出了三个重量级答案:
| 痛点 | PG 18 的答案 | 收益类型 |
|---|---|---|
| 同步 I/O 阻塞 backend | 全新 AIO 子系统(io_method / io_workers / io_combine_limit) | 吞吐与延迟 |
| 随机 UUID 主键的索引局部性差 | 原生 uuidv7(),时间有序 | 写入性能与索引紧凑度 |
| 派生字段冗余存储 / 计算下推难 | VIRTUAL 生成列 | 存储与一致性 |
| MERGE/UPDATE 拿不到前后值 | RETURNING 的 OLD/NEW 别名 | 架构简化 |
| 企业 SSO 接入麻烦 | OAuth 2.0 认证 | 安全与集成 |
业界对 PG 18 的官方定位是「通过新的 I/O 子系统在所有规模的工作负载上提升性能,某些读取场景可达约 3 倍提升,同时扩大了可以使用索引的查询范围」。我们下面逐一拆开看它到底怎么做到的。
二、核心概念:异步 I/O 到底解决了什么「老毛病」
2.1 旧世界的尴尬:backend 进程在 read() 上干等
在 PG 18 之前,当执行器需要某一页数据时,流程是这样的:
执行器请求 heap 页 -> smgr 调 read() -> 内核把请求塞进块设备队列 -> 磁盘旋转/SSD 寻道 -> 数据回到内存 -> 继续执行
关键点:整个 backend 进程在 read() 返回之前是被阻塞的。如果数据不在 OS page cache 里,一次随机读可能在机械盘上是几毫秒、在云盘上也是亚毫秒到毫秒级——而一个复杂查询可能要读成千上万个页。CPU 核数再多,backend 也只能「排队等 I/O」。
老版本当然也有缓解手段:effective_io_concurrency + bitmap heap scan 的预读(prefetch)。但预读只在 bitmap heap scan 这一种路径生效,而且对「顺序扫描大表」「索引扫描回表」等更常见的路径几乎无能为力。换句话说,缓解是局部的、补丁式的。
2.2 新世界的 AIO 框架:三种 I/O 模式
PG 18 引入了一个统一的 AIO 抽象层,由 GUC 参数 io_method 控制三种模式:
# postgresql.conf —— PG 18 新增/调整的 I/O 相关参数
io_method = 'io_uring' # sync(默认)| worker | io_uring(仅 Linux 且编译时启用 liburing)
io_workers = 4 # worker 模式下的 I/O 工作进程数(默认 3,受 max_worker_processes 约束)
io_combine_limit = 128kB # 将相邻的随机读合并成一个大 I/O 的上限
maintenance_io_concurrency = 16 # VACUUM / CREATE INDEX 等维护性操作的并发 I/O
effective_io_concurrency = 16 # 现在扩展到更多扫描路径的预读
三种模式的含义:
sync(默认):行为和旧版本完全一致,不启用异步 I/O。升级后不会自动变快,必须显式改io_method才能享受红利——这是很多人升级后「感觉没变化」的原因。worker:启动io_workers个后台 worker 进程,backend 把 I/O 请求提交给它们,自己继续干活。跨平台、最稳,适合不想折腾 io_uring 的环境。io_uring:在 Linux 上直接使用io_uring内核接口,绕开传统read()/pread()的系统调用开销,异步能力最强。需要编译时带--with-liburing。
2.3 真正的杀手锏:I/O 合并(combine)
光「异步」还不够性感,I/O 合并才是读性能翻倍的关键。PG 18 会把执行器在短时间内、地址相邻的多个页读请求,合并成一个更大的连续 I/O:
-- 合并效果示意:原来 8 次 8KB 随机读
-- read(blk 100), read(blk 101), ... read(blk 107)
-- 合并后变成 1 次 64KB 连续读,对 SSD/云盘极其友好
SHOW io_combine_limit; -- 128kB,表示最多把多少相邻读合成一个
io_combine_limit 控制合并上限(默认 128kB),io_max_combine_limit 是硬上限。在顺序扫描大表、或 bitmap/索引回表产生大量相邻页请求时,合并能把「一堆小随机 I/O」压成「少量大顺序 I/O」,这正是官方宣称「读取最高约 3 倍提升」的来源。
三、架构分析:AIO 子系统内部是怎么转起来的
理解内部模型,才能知道它不是银弹。
3.1 完成队列模型(Completion Queue)
PG 18 的 AIO 不是简单地把 read() 丢给线程池,而是一套带句柄(handle)和完成回调的子系统:
- 执行器发起异步读,拿到一个
PgAioHandle; - 同一时刻可以有多个 in-flight 的 I/O 请求(受
io_workers和底层并发限制约束); - 当请求完成,通过完成队列通知提交方,执行器在「真正需要这页数据」时才等待结果——如果这之前 CPU 已经把别的事干完了,等待时间就被完全隐藏了。
这套模型和 io_uring 的 SQ/CQ(提交队列/完成队列)在理念上一致:worker 模式是它的用户态实现,io_uring 模式是它的内核态实现。
3.2 AIO 与后台进程的协同
AIO 不只是给查询用的,它也惠及:
- VACUUM / autovacuum:通过
maintenance_io_concurrency做更激进的并行预读,扫描死元组更快; - CREATE INDEX:建索引时的排序阶段 I/O 更顺;
- checkpointer / bgwriter:后台刷脏页的吞吐上限被抬高。
3.3 当前边界(务必知道)
诚实地说,PG 18 的 AIO 目前主要覆盖 smgr 层的读(relation data reads),WAL 的异步写仍在路上(社区已规划但本版本未完整落地)。这意味着:
- 写密集(INSERT/UPDATE 产生 WAL) 的负载,从 AIO 直接获益有限;
- 读密集、扫描大表、回表多 的负载,获益最明显。
所以如果你指望「上了 PG 18 写入也翻倍」,请降低预期;但你的分析型报表、大表 JOIN、全表扫描类查询,大概率会给你惊喜。
四、UUID v7 实战:主键设计的范式转移
如果说 AIO 是「底层提速」,那 uuidv7() 就是「建模层纠错」。这是 PG 18 里我最想安利给所有后端同学的特性。
4.1 UUID v4 的 B-tree 灾难
很多人用 gen_random_uuid()(v4,完全随机)当主键,理由是「不暴露行数、可离线生成、分布式友好」。但它对 B-tree 索引是灾难性的:
-- 经典踩坑:随机 UUID 主键
CREATE TABLE orders_v4 (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id bigint NOT NULL,
amount numeric NOT NULL,
created_at timestamptz DEFAULT now()
);
v4 的 128 位几乎完全随机,新插入的行的主键会均匀散布在整棵索引的所有叶子页上。后果:
- 页分裂频繁:每个新插入都可能落在一个「已满」的随机页,触发 50/50 页分裂,索引碎片化;
- 缓存命中率极低:索引页之间没有局部性,buffer cache 刚缓存的页,下一个插入就用不上;
- 写入放大:更多的分裂 = 更多的随机写 = 更慢的插入、更大的索引体积。
4.2 UUID v7:把时间编进 ID
UUID v7 的结构是时间有序的:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms (48 bit) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms (续) | ver | rand_a |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| var | rand_b (62 bit) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
前 48 位是 Unix 毫秒时间戳(大端序),然后是版本号 7 和随机部分。因为高位是时间,所以同一毫秒内生成的 v7 在字典序上天然递增,插入时几乎总是落在索引的「最右页」——这和 bigint 自增主键的局部性一模一样,却保留了 UUID 分布式友好的优点。
PG 18 原生提供:
SELECT uuidv7();
-- 018f-3e2c-7a1b-7c2d-9e4f-1234567890ab(前几位随时间单调增长)
CREATE TABLE orders_v7 (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- 时间有序,索引友好
user_id bigint NOT NULL,
amount numeric NOT NULL,
created_at timestamptz DEFAULT now()
);
uuidv7() 在同一毫秒内通过自增随机低位保证单调性(不会因时钟回拨而严格倒序),比很多应用层手搓的「时间戳+随机」方案严谨得多。
4.3 和「自增 / ULID / 雪花」怎么选
| 方案 | 局部性 | 分布式 | 暴露信息 | 索引膨胀 |
|---|---|---|---|---|
bigint 自增 | 极好 | 差(需中心化) | 暴露行数/速率 | 无 |
| UUID v4 | 极差 | 好 | 不暴露 | 严重 |
| UUID v7 | 好 | 好 | 暴露粗略时间 | 低 |
| ULID | 好 | 好 | 暴露时间 | 低 |
| 雪花 ID | 好 | 好 | 暴露机器/时间 | 低 |
结论:需要全局唯一又不想牺牲索引性能时,UUID v7 是目前最省心的默认选择。它比 v4 多了一个「时间」维度,却几乎不增加任何使用成本。
4.4 实测对比(思路,可自己复现)
-- 造两张结构相同、仅主键默认不同的表
INSERT INTO orders_v4 (user_id, amount)
SELECT g, (random()*1000)::numeric
FROM generate_series(1, 5_000_000) g;
INSERT INTO orders_v7 (user_id, amount)
SELECT g, (random()*1000)::numeric
FROM generate_series(1, 5_000_000) g;
-- 看索引体积与膨胀
SELECT
schemaname, indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS idx_size,
idx_scan
FROM pg_stat_user_indexes
WHERE relname IN ('orders_v4','orders_v7');
在几百万行规模下,v7 表的主键索引体积通常比 v4 小 15%~30%,且 INSERT 的 P99 延迟更稳定(因为少了页分裂)。索引越小,能待在 shared_buffers 里的比例越高,连点查询都跟着变快。
五、虚拟生成列:别再为派生字段浪费存储
5.1 回顾:PG 12 的 STORED 生成列
PG 12 引入了生成列(generated column),但只有 STORED 一种——值算好后物理落盘:
CREATE TABLE products_stored (
id bigserial PRIMARY KEY,
price numeric NOT NULL,
qty int NOT NULL,
total numeric GENERATED ALWAYS AS (price * qty) STORED -- 占用磁盘空间
);
STORED 的优点是可以建索引、值稳定;缺点是每个插入/更新都要多算一遍并多存一遍,对「宽表 + 高频更新」是实打实的存储和写入开销。
5.2 PG 18 的 VIRTUAL 生成列
PG 18 补齐了 VIRTUAL:值不落盘,读取时现场计算。
CREATE TABLE products (
id bigserial PRIMARY KEY,
price numeric NOT NULL,
qty int NOT NULL,
-- 虚拟列:不占存储,SELECT 时实时计算
total numeric GENERATED ALWAYS AS (price * qty) VIRTUAL,
-- 从 JSON 里抽字段也行(只要用 STABLE/IMMUTABLE 表达式)
meta jsonb NOT NULL,
currency text GENERATED ALWAYS AS (meta->>'currency') VIRTUAL
);
INSERT INTO products (price, qty, meta)
VALUES (99.9, 3, '{"currency":"CNY"}');
SELECT id, price, qty, total, currency FROM products;
-- id | price | qty | total | currency
-- 1 | 99.9 | 3 | 299.7 | CNY
关键约束(踩坑预警):
- VIRTUAL 列不能建 B-tree 索引(只有 STORED 能)。如果你需要对派生字段高频过滤/排序,老老实实用 STORED,或用表达式索引
CREATE INDEX ON products ((price*qty)); - 表达式只能是同一行其他列的**确定性的(IMMUTABLE/STABLE)**组合,不能用
now()、random()这类易变函数; - 不能引用其他生成列(防环),不能包含子查询;
- 写入时不算、不存,所以存储零额外开销,特别适合「展示用派生字段」。
5.3 和「视图 / 应用层计算」的权衡
| 方案 | 存储 | 一致性 | 可索引 | 适用 |
|---|---|---|---|---|
| 应用层计算 | 0 | 易错(双写) | 否 | 简单、低频 |
| 视图 | 0 | 强 | 否(除非物化) | 多表组合展示 |
| VIRTUAL 生成列 | 0 | 强(DB 保证) | 否 | 单行派生、不需索引 |
| STORED 生成列 | 有 | 强 | 是 | 需索引/需落盘 |
一句话:需要索引就 STORED,只需要「查出来就有」就 VIRTUAL。PG 18 让我们终于能按场景二选一,而不是被迫冗余存储。
六、RETURNING 增强与 MERGE:一条语句拿到「改前改后」
DBA 们常遇到一个需求:更新一行后,想知道「改之前是多少、改之后是多少」。旧版本要么写触发器,要么应用层先 SELECT 再 UPDATE(还有竞态)。PG 18 给 RETURNING 加了 old / new 别名,彻底解决。
6.1 UPDATE/DELETE/MERGE 的 OLD/NEW
-- 账户加钱,一次拿到前后余额
UPDATE accounts
SET balance = balance + 50
WHERE id = 1
RETURNING old.balance AS before, new.balance AS after, merge_action();
-- before | after | merge_action
-- 1000 | 1050 | (null for plain UPDATE)
注意 merge_action() 在普通 UPDATE 里返回 NULL,在 MERGE 里才返回 'INSERT'/'UPDATE'/'DELETE'。
6.2 MERGE ... RETURNING 实战(upsert 拿到全貌)
-- 把一批流水合并进账户,并立刻看出每个账户是「新建」还是「累加」
MERGE INTO accounts a
USING (VALUES (1, 100), (2, 250)) AS src(id, delta)
ON a.id = src.id
WHEN MATCHED THEN
UPDATE SET balance = a.balance + src.delta
WHEN NOT MATCHED THEN
INSERT (id, balance) VALUES (src.id, src.delta)
RETURNING merge_action() AS action,
old.balance AS before,
new.balance AS after;
-- action | before | after
-- UPDATE | 1000 | 1100
-- INSERT | (null) | 250
过去要做到这点,得在应用层先 SELECT 判断存在与否,再决定 INSERT 还是 UPDATE,最后再 SELECT 回来——三次往返 + 竞态窗口。现在一条语句、一次网络往返、数据库内原子完成。对账、积分、库存扣减这类场景直接受益。
七、其他值得上车的特性(精简但实用)
PG 18 不止上面那些,下面几个我在生产里也用得上:
7.1 OAuth 2.0 认证(企业 SSO 友好)
PG 18 新增 oauth 认证方法,服务端可通过令牌内省(RFC 7662)校验客户端持有的 OAuth 2.0 Bearer Token,libpq 也支持 OAUTHBEARER SASL 机制。配置骨架(具体 oauth_issuers 语法以官方 Release Notes 为准):
# pg_hba.conf
host all all 0.0.0.0/0 oauth
# postgresql.conf
oauth_issuers = 'corp=https://sso.example.com/oauth2/introspect'
这让数据库能直接接入企业 IdP(如 Okta、Azure AD),不用再为每个应用塞一个长期有效密码,安全合规上是一大步。
7.2 default_transaction_timeout
新增 GUC,给「失控长事务」上一道硬闸:
SET default_transaction_timeout = '30s';
-- 超过 30s 的事务会被自动中止,专治应用漏提交、慢查询拖垮连接池
7.3 pg_createsubscriber:物理备转逻辑订阅
新增命令行工具,能把一个物理 standby 一键转换成逻辑复制的订阅端,省去全量重搭的停机窗口:
pg_createsubscriber \
-D /var/lib/pgdata \
-P 'host=primary port=5432 user=repl' \
-d mydb -s sub_name
7.4 逻辑复制与随机数的小升级
- 逻辑复制可跳过空事务、并行解码更稳;
random()换用更优的随机数生成算法,随机性更可靠(依赖可复现种子的场景注意回归测试);NOT NULL约束在分区表上支持先NOT VALID再校验,UNIQUE不再强制包含分区键。
八、升级实战:别指望「升完就快」
这是最容易翻车的地方。PG 大版本升级用 pg_upgrade,但AIO 默认是关闭的:
# 1) 用 pg_upgrade 原地升级(详见官方文档,注意扩展兼容性)
# 2) 升级后编辑 postgresql.conf,主动开启 AIO
io_method = 'worker' # 先 worker 稳一波;Linux 且编译带 liburing 可上 io_uring
io_workers = 4
io_combine_limit = 128kB
effective_io_concurrency = 16
maintenance_io_concurrency = 16
# 3) 重启生效,观察日志确认 AIO 已启用
SELECT name, setting, applied FROM pg_settings
WHERE name IN ('io_method','io_workers','io_combine_limit');
升级前务必:
- 用
pg_dump --schema-only校验扩展(如pgvector、postgis)是否支持 18; - 跑一遍回归,特别是依赖
random()可复现性的测试; - 先用
worker模式灰度,确认稳定再考虑io_uring。
九、性能优化清单 + 压测方法
9.1 该调哪些参数
io_method = 'worker' # 或 io_uring
io_workers = 4 # 一般取 CPU 核数的 1/4,至少 3~4
io_combine_limit = 128kB # 让相邻读合并成大顺序 I/O
effective_io_concurrency = 16 # 读路径预读并发
maintenance_io_concurrency = 16 # VACUUM / 建索引
shared_buffers = '25%内存' # 老生常谈但关键
9.2 用 pg_stat_io 看 AIO 是否真在干活
PG 16 引入、PG 18 增强的 pg_stat_io 是观察 I/O 的窗口:
SELECT backend_type, object, context,
reads, read_bytes,
writebacks, extends
FROM pg_stat_io
ORDER BY reads DESC
LIMIT 10;
重点关注 reads 是否随负载上升、read_bytes 是否被合并(单次平均变大)。再配合 EXPLAIN (ANALYZE, BUFFERS) 看 shared read 的命中/未命中比例,判断 AIO 是否缓解了「缓存未命中导致的等待」。
9.3 用 pgbench 做对照实验
# 关闭 AIO 的基线
pgbench -c 16 -j 4 -T 300 -S mydb # -S 只读 SELECT,最吃 I/O
# 开启 io_method=worker/io_uring 后重跑,对比 tps 与 P99 延迟
读密集、数据远大于 shared_buffers 的场景,AIO 的 tps 提升最显著;纯内存命中或纯写入场景,提升有限——这点务必在压测报告里写清楚,别神话它。
十、总结与展望
PG 18 的这三个主角,其实指向同一个工程哲学:让数据库在「它最擅长的地方」替你把脏活干了。
- AIO 把「backend 等 I/O」这个十年老坑填了一半,读路径脱胎换骨,写路径留待后续版本;
- UUID v7 用「把时间编进 ID」这一招,低成本解决了随机主键的索引癌症;
- VIRTUAL 生成列 让「派生字段」第一次有了「不占存储还保证一致性」的优雅选项;
- RETURNING 的 OLD/NEW 把「查-改-查」的三段式压缩成一条原子语句。
对架构师的实操建议:
- 新项目默认 UUID v7 主键,别再无脑 v4;
- 升级 PG 18 后主动开 AIO,并用
pg_stat_io验证,而不是「升完就以为优化了」; - 派生字段按「要不要索引」在 VIRTUAL / STORED 间二选一,别再冗余存一列靠触发器维护;
- 对账/积分/库存类 MERGE 场景,立刻用
MERGE ... RETURNING瘦身代码。
往后看,社区的下一站是 AIO 覆盖 WAL 写、向量化执行引擎、以及可能的列式存储。PG 18 是「I/O 不再是瓶颈」这个故事的第一章——而你,现在就可以上车。
参考资料:PostgreSQL 18 官方 Release Notes、社区 AIO 设计文档、uuidv7() 相关提交,以及主流云厂商(腾讯云、阿里云)的 PG 18 特性解读。文中 GUC 默认值请以你部署版本的实际 pg_settings 为准。