编程 PostgreSQL 18 深度实战:异步 I/O 让读性能翻倍,UUID v7 与虚拟生成列重塑主键与派生字段设计

2026-07-12 17:13:02 +0800 CST views 14

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 位几乎完全随机,新插入的行的主键会均匀散布在整棵索引的所有叶子页上。后果:

  1. 页分裂频繁:每个新插入都可能落在一个「已满」的随机页,触发 50/50 页分裂,索引碎片化;
  2. 缓存命中率极低:索引页之间没有局部性,buffer cache 刚缓存的页,下一个插入就用不上;
  3. 写入放大:更多的分裂 = 更多的随机写 = 更慢的插入、更大的索引体积。

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

关键约束(踩坑预警)

  1. VIRTUAL 列不能建 B-tree 索引(只有 STORED 能)。如果你需要对派生字段高频过滤/排序,老老实实用 STORED,或用表达式索引 CREATE INDEX ON products ((price*qty))
  2. 表达式只能是同一行其他列的**确定性的(IMMUTABLE/STABLE)**组合,不能用 now()random() 这类易变函数;
  3. 不能引用其他生成列(防环),不能包含子查询;
  4. 写入时不算、不存,所以存储零额外开销,特别适合「展示用派生字段」。

5.3 和「视图 / 应用层计算」的权衡

方案存储一致性可索引适用
应用层计算0易错(双写)简单、低频
视图0否(除非物化)多表组合展示
VIRTUAL 生成列0强(DB 保证)单行派生、不需索引
STORED 生成列需索引/需落盘

一句话:需要索引就 STORED,只需要「查出来就有」就 VIRTUAL。PG 18 让我们终于能按场景二选一,而不是被迫冗余存储。


六、RETURNING 增强与 MERGE:一条语句拿到「改前改后」

DBA 们常遇到一个需求:更新一行后,想知道「改之前是多少、改之后是多少」。旧版本要么写触发器,要么应用层先 SELECTUPDATE(还有竞态)。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 校验扩展(如 pgvectorpostgis)是否支持 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 把「查-改-查」的三段式压缩成一条原子语句。

对架构师的实操建议:

  1. 新项目默认 UUID v7 主键,别再无脑 v4;
  2. 升级 PG 18 后主动开 AIO,并用 pg_stat_io 验证,而不是「升完就以为优化了」;
  3. 派生字段按「要不要索引」在 VIRTUAL / STORED 间二选一,别再冗余存一列靠触发器维护;
  4. 对账/积分/库存类 MERGE 场景,立刻用 MERGE ... RETURNING 瘦身代码。

往后看,社区的下一站是 AIO 覆盖 WAL 写、向量化执行引擎、以及可能的列式存储。PG 18 是「I/O 不再是瓶颈」这个故事的第一章——而你,现在就可以上车。


参考资料:PostgreSQL 18 官方 Release Notes、社区 AIO 设计文档、uuidv7() 相关提交,以及主流云厂商(腾讯云、阿里云)的 PG 18 特性解读。文中 GUC 默认值请以你部署版本的实际 pg_settings 为准。

推荐文章

介绍Vue3的静态提升是什么?
2024-11-18 10:25:10 +0800 CST
Vue3中如何使用计算属性?
2024-11-18 10:18:12 +0800 CST
JavaScript 的模板字符串
2024-11-18 22:44:09 +0800 CST
XSS攻击是什么?
2024-11-19 02:10:07 +0800 CST
程序员茄子在线接单