PostgreSQL 18 深度实战:异步 I/O 引擎重写、UUIDv7 与虚拟生成列——一次 IO 子系统的「成年礼」
如果要用一句话概括 PostgreSQL 18,我会说:这是 PG 三十年来第一次把 IO 子系统从「同步阻塞」重写成「原生异步」,并且顺手把开发者日常最痛的几件事(UUID 主键碎片、计算列占空间、变更前后对比)一次性解决了。 这篇文章不堆特性列表,而是把 AIO 引擎讲透,再用可运行的代码把其余特性一个个落地。
一、背景介绍:PostgreSQL 的「IO 天花板」之痛
PostgreSQL 从 1996 年的 6.0 算起已经快三十年。这期间它从一个学术项目成长为最被信任的开源关系型数据库之一,但有一个老问题一直被 DBA 和架构师反复吐槽:单实例的 IO 吞吐能力。
传统 PostgreSQL 的 IO 模型是同步的。当执行器需要读一个页面,它调用 ReadBuffer(),这个调用在底层会阻塞等待磁盘(或网络存储)把 8KB 页面返回。一次顺序扫描可能要发起成千上万次这样的同步调用。在本地 NVMe 上延迟只有几十微秒,问题不大;但一旦后端是云上的网络块存储(EBS、各类云硬盘),一次读可能要几百微秒到毫秒级,同步模型就会把 CPU 大量时间浪费在「等 IO」上——backend 进程卡在 read() 系统调用里,连接池被占满,QPS 上不去。
MySQL 早在 5.6 时代就通过 InnoDB 的 native AIO(Linux 上基于 libaio / io_uring)缓解了这个问题。PostgreSQL 社区不是不知道,而是因为要跨平台(Windows、macOS、各种 Unix 都要支持)加上历史包袱,迟迟没有动 IO 子系统。PG 18 是三十年来的第一次:底层 IO 子系统被重写为原生异步(AIO)。
除了 AIO,PG 18 还带来了几个开发者能直接感知、立刻能用上的特性:
- UUIDv7 原生函数:时间有序 UUID,根治 UUIDv4 主键的索引碎片与缓存抖动
- 虚拟生成列(VIRTUAL):不占存储的计算列,此前 PG 只支持 STORED(物化)
- RETURNING 支持 OLD / NEW 别名:一行 SQL 完成变更前与变更后的对比,审计与 CDC 场景直接降维
- libpq OAuth 2.0 客户端认证:应用告别静态数据库密码
- 可观察性增强:
pg_stat_io视图细化、EXPLAIN 默认输出改进
横向定位一下:本系列前面拆过 MySQL 26.7 的并行复制、Redis 8 的多模引擎。PG 18 的叙事主轴不是「加功能」,而是「补了三十年的一块底层短板 + 把开发体验打磨得更顺手」。
这篇文章的结构是:先把 AIO 的核心概念讲清楚,再做架构分析(它到底怎么跑起来的),然后进入代码实战(部署、UUIDv7、虚拟列、RETURNING、OAuth、可观察性),最后给性能优化清单与升级建议。
二、核心概念
2.1 异步 I/O(AIO):io_uring 与 worker 两条路
PG 18 的 AIO 不是简单地「加个线程池」,而是一套抽象的 IO 提交 / 完成框架。它有两种底层实现,由 GUC 参数 io_method 选择:
io_method | 说明 | 适用 |
|---|---|---|
io_uring | Linux 5.x+ 的 io_uring 接口,内核态环形队列、批量提交、零拷贝 | 生产 Linux 服务器(推荐) |
worker | 跨平台的后台 IO worker 进程池,兼容性最好 | Windows / macOS / 不支持 io_uring 的环境 |
sync | 退回老的同步 IO(保底,性能最差) | 调试 / 极端兼容场景 |
默认情况下,PG 18 在支持 io_uring 的 Linux 上把 io_method 设为 worker(社区出于稳健性默认保守),但生产环境只要内核支持 io_uring,强烈建议切到 io_uring——这才是 PG 18 AIO 性能释放的完整形态。
AIO 的核心收益来自批量合并(scatter / gather):执行器不再一个页面一个页面地等,而是把多个读请求聚合成一批,由 IO 层统一提交、统一回收结果。这对两类操作收益最大:
- Bitmap Heap Scan:位图扫描要按需读大量随机页面,AIO 可以把「随机读」变成「批量异步随机读」,大幅削减等待。
- 顺序 / 预读扫描:大表顺序扫描时,AIO 能「超前」提交后续块的读请求,让磁盘 / 网络存储一直有活干,而不是「读一块、等一块、再读下一块」。
2.2 UUIDv7:给 UUID 排个时间序
UUIDv4 是纯随机的,作为主键插入 B-tree 时,新行会落到树的全随机位置,带来三个连锁问题:
- 索引页频繁分裂,空间利用率低
- 缓存命中率差(热点分散在整棵索引上)
- WAL 写放大(每次插入都碰不同的叶子页)
UUIDv7 把 48 位毫秒级时间戳放到高位,后面跟随机位。这样新插入的主键在 B-tree 上局部有序,写入集中在「最近」的少数索引页,缓存友好、写放大大幅下降、范围查询也更顺。PG 18 直接在 pg_catalog 提供 uuidv7()(同时保留老的 gen_random_uuid() 返回 v4)。
2.3 虚拟生成列(VIRTUAL)
PG 11 引入生成列,但当时只支持 STORED(物化:占存储、写入时计算、可被索引)。PG 18 新增 VIRTUAL:查询时才计算,不占任何存储。它适合「展示用派生字段」——比如从 JSON 里提取字段、单位换算、字符串拼接——你不想为它付出写入成本和磁盘空间,又想在查询时直接 SELECT 出来。
-- STORED:写入时算一次,落盘,可被索引,占空间
col text GENERATED ALWAYS AS (...) STORED;
-- VIRTUAL(PG 18 新增):查询时才算,不落盘,不占空间
col text GENERATED ALWAYS AS (...) VIRTUAL;
注意一个边界:VIRTUAL 列不能被索引(因为它不落盘,没有稳定的物理值可供索引),需要索引时仍用 STORED。
2.4 RETURNING OLD / NEW:一行 SQL 看变更前后
以前想「改完看改前改后」,要么用 CTE 嵌套,要么上触发器。PG 18 让 UPDATE / DELETE 的 RETURNING 直接引用变更前(OLD)与变更后(NEW)的行:
UPDATE accounts SET balance = balance - 100
WHERE id = 1
RETURNING WITH (OLD AS b, NEW AS a)
b.balance AS old_bal, a.balance AS new_bal;
返回结果里同时有扣款前和扣款后的余额。这对审计日志、软删除回写、CDC 增量抽取是降维打击——一行 SQL 替代三层嵌套 + 触发器维护。
2.5 OAuth 2.0 客户端认证
PG 18 的 libpq 支持 OAuth 2.0 Bearer Token 作为客户端认证方式。配合支持 OAuth 的认证插件(企业 IdP / 短期 token 服务),应用不再硬编码数据库密码,而是用短期 token 连接,泄露面从「长期密码」缩小到「短期凭证」,安全性大幅提升。连接串里用 oauth 作为密码机制,并带上 oauth_issuer、oauth_audience 等参数。
三、架构分析:PG 18 的 AIO 是怎么跑起来的
要真正理解 AIO 的收益,得看它在进程模型里处在什么位置。PG 是「多进程 + 共享内存」架构:每个客户端连接对应一个 backend 进程,backend 直接访问共享缓冲池(shared buffers)。在老模型里,backend 读页面时直接走同步系统调用:
老模型(同步 IO):
Backend 执行器
│ ReadBuffer() → 内核 read() syscall
▼
[ 阻塞等待磁盘 / 网络存储返回 8KB 页面 ] ← CPU 在这里空转
│ 页面就绪
▼
执行器继续处理这一行
PG 18 引入 AIO 层后,backend 不再自己同步读,而是提交 IO 请求到 AIO 框架,框架负责聚合与异步完成:
PG 18 模型(异步 IO,io_uring / worker):
Backend 执行器
│ 提交 IO 请求(不阻塞)
▼
AIO 提交队列(per-backend)
│ scatter/gather 批量聚合相邻块
▼
[ io_uring 内核环 ] 或 [ worker 进程池 ]
│ 内核 / worker 异步完成
▼
完成回调 → 页面填入缓冲池 → 执行器处理已就绪的行
▲____________________________│
│ 等待期间 backend 可继续提交更多 IO / 处理已返回结果
两个关键区别:
- 等待与计算解耦:backend 在等待某个页面时,不被钉死在
read()上,它可以继续提交后续 IO 请求,或者处理那些已经返回的页面。IO 子系统始终「有活干」。 - 合并相邻 IO:
io_combine_limit控制一次系统调用合并多少个相邻块。顺序扫描时,8KB、8KB 的小读会被合并成一次大读,减少 syscall 次数与网络存储的往返。
相关 GUC 参数一览(生产调优会用到):
| 参数 | 默认 | 作用 |
|---|---|---|
io_method | worker | AIO 底层实现:io_uring / worker / sync |
io_workers | 3 | worker 模式下的后台 IO 进程数 |
io_max_concurrency | 16 | 单个 backend 同时进行中的 IO 上限 |
io_combine_limit | 16 | 一次系统调用合并的相邻 IO 数 |
effective_io_concurrency | 1 | 预读并行度(AIO 下语义增强,建议调大) |
架构视角的一句话总结:PG 18 的 AIO 不是「让单次读更快」,而是「让 IO 等待不再阻塞执行器、并批量合并小 IO」,收益在网络存储、大表扫描、随机读密集场景最明显。
四、代码实战
下面所有示例都基于 PG 18,优先给可运行的 Docker 部署方案。
4.1 部署 PostgreSQL 18 并开启 AIO
用 Docker Compose 起一个 PG 18,并显式打开 io_uring(内核需支持):
# docker-compose.yml
services:
pg18:
image: postgres:18
environment:
POSTGRES_PASSWORD: pg18demo
POSTGRES_DB: demo
command:
- postgres
- -c
- io_method=io_uring
- -c
- io_workers=4
- -c
- io_max_concurrency=32
- -c
- io_combine_limit=32
- -c
- effective_io_concurrency=16
- -c
- shared_preload_libraries=pg_stat_statements
ports:
- "5432:5432"
volumes:
- pg18data:/var/lib/postgresql/data
volumes:
pg18data:
启动后确认 AIO 已生效:
-- 查看当前 IO 方法
SHOW io_method; -- 期望 io_uring
SHOW io_max_concurrency; -- 期望 32
-- 查看后台 IO worker 是否在跑(worker 模式下可见)
SELECT pid, backend_type
FROM pg_stat_activity
WHERE backend_type LIKE '%IO%';
如果内核不支持 io_uring,PG 会回退或报错。此时把 io_method=io_uring 改成 io_method=worker 即可跨平台运行。
4.2 UUIDv7 实战:根治主键碎片
先直观对比 UUIDv4 与 UUIDv7 的「局部性」:
-- 老做法:UUIDv4,纯随机
SELECT gen_random_uuid();
-- PG 18 新做法:UUIDv7,时间有序
SELECT uuidv7();
建两张表,一张用 v4、一张用 v7,各灌 200 万行,对比索引大小与插入表现:
CREATE TABLE orders_v4 (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id int,
amount numeric(12,2),
created_at timestamptz DEFAULT now()
);
CREATE TABLE orders_v7 (
id uuid PRIMARY KEY DEFAULT uuidv7(),
customer_id int,
amount numeric(12,2),
created_at timestamptz DEFAULT now()
);
-- 批量灌数据(演示用 generate_series)
INSERT INTO orders_v4 (customer_id, amount)
SELECT (random()*100000)::int, (random()*1000)::numeric(12,2)
FROM generate_series(1, 2000000);
INSERT INTO orders_v7 (customer_id, amount)
SELECT (random()*100000)::int, (random()*1000)::numeric(12,2)
FROM generate_series(1, 2000000);
对比主键索引体积(v7 通常明显更小,页分裂更少):
SELECT
'orders_v4' AS tbl,
pg_relation_size('orders_v4_pkey') AS idx_bytes
UNION ALL
SELECT
'orders_v7',
pg_relation_size('orders_v7_pkey');
再用 Python 实测插入吞吐差异(核心逻辑):
import psycopg
import time, uuid
def bench(use_v7: bool, n: int = 200_000):
conn = psycopg.connect("dbname=demo user=postgres password=pg18demo host=localhost")
table = "orders_v7" if use_v7 else "orders_v4"
gen = "uuidv7()" if use_v7 else "gen_random_uuid()"
sql = f"INSERT INTO {table}(id, customer_id, amount) VALUES ({gen}, %s, %s)"
t0 = time.time()
with conn.cursor() as cur:
for i in range(n):
cur.execute(sql, ((i % 100000), (i % 1000) * 1.0))
conn.commit()
dt = time.time() - t0
print(f"{'v7' if use_v7 else 'v4'}: {n} inserts in {dt:.1f}s -> {n/dt:.0f} ips")
conn.close()
bench(use_v7=True)
bench(use_v7=False)
结论:在百万行规模、机械式顺序插入场景下,v7 的主键索引更小、插入更平稳,因为新行总是「贴着」最近写入的索引页,缓存命中率高。代价是 v7 高位暴露了插入时间,对需要完全不可猜测主键的场景要评估(但 v4 也早已不是安全边界,敏感场景本就该用应用层签名)。
4.3 虚拟生成列实战
场景:订单表存了 amount 和 quantity,前端总想要 total,但你不希望 total 占存储、也不想每次查询都手写 amount * quantity。
CREATE TABLE line_items (
id bigserial PRIMARY KEY,
product text,
unit_price numeric(10,2),
qty int,
-- PG 18 新增:VIRTUAL 生成列,查询时计算,不落盘
total numeric(12,2) GENERATED ALWAYS AS (unit_price * qty) VIRTUAL,
-- 老能力:STORED 生成列,落盘、可索引
sku text GENERATED ALWAYS AS ('SKU-' || id) STORED
);
INSERT INTO line_items (product, unit_price, qty)
VALUES ('键盘', 199.00, 2), ('鼠标', 89.50, 3);
SELECT product, unit_price, qty, total FROM line_items;
-- total 自动算出:398.00 / 268.50,且磁盘上 total 不占空间
注意 VIRTUAL 列的限制与使用建议:
- VIRTUAL 列不能被索引、不能做
UNIQUE约束(没有稳定物理值)。需要索引就改用 STORED。 - VIRTUAL 列适合「展示派生」;STORED 列适合「需索引 / 需持久化」的派生。
- VIRTUAL 列从 PG 18 起支持被逻辑复制(之前生成列的复制是个坑),这是配套改进。
4.4 RETURNING OLD / NEW 审计实战
场景:账户扣款,要求一行 SQL 同时返回扣款前后余额,并顺手把变更写进审计表。
-- 建审计表
CREATE TABLE balance_audit (
id bigserial PRIMARY KEY,
account_id int,
old_balance numeric(12,2),
new_balance numeric(12,2),
changed_at timestamptz DEFAULT now()
);
-- 一行 SQL:扣款 + 看前后余额
WITH r AS (
UPDATE accounts
SET balance = balance - 100
WHERE id = 1
RETURNING WITH (OLD AS o, NEW AS n) o.balance AS ob, n.balance AS nb
)
INSERT INTO balance_audit (account_id, old_balance, new_balance)
SELECT 1, (SELECT ob FROM r), (SELECT nb FROM r)
RETURNING *;
过去这类需求要么写触发器(维护成本高、调试难),要么用 UPDATE ... RETURNING + 应用层再查一次旧值(多一次往返)。PG 18 的 RETURNING WITH (OLD AS ..., NEW AS ...) 把这件事压成一条语句,原子、清晰、可审计。
软删除回写也很优雅:
UPDATE documents
SET deleted = true, deleted_at = now()
WHERE id = 42
RETURNING WITH (OLD AS o, NEW AS n)
o.deleted AS was_deleted, n.deleted_at AS deleted_at;
4.5 OAuth 2.0 客户端认证实战
PG 18 的 libpq 支持用 OAuth 2.0 Bearer Token 连接。典型连接串(占位示意,需配套支持 OAuth 的认证插件):
host=db.example.com
port=5432
dbname=demo
user=app_svc
password=oauth
oauth_issuer=https://idp.example.com
oauth_audience=postgres-prod
Python 侧用短期 token:
import psycopg, subprocess
# 向 IdP 申请短期 token(示意)
token = subprocess.check_output(["get-idp-token", "postgres-prod"]).decode().strip()
conn = psycopg.connect(
host="db.example.com", port=5432, dbname="demo", user="app_svc",
password=f"oauth:{token}",
options="-c oauth_issuer=https://idp.example.com -c oauth_audience=postgres-prod",
)
# 之后不再硬编码长期密码,token 过期自动轮换
价值点:长期密码不再进代码仓库、不再进环境变量文件,泄露半径从「永久有效」缩到「token 有效期」。对合规(等保、SOC2)是实打实的加分项。
4.6 可观察性:用 pg_stat_io 看 IO 真功夫
PG 18 强化了 IO 可观察性。最有用的是 pg_stat_io,它能按 backend_type、IO 对象(relation / temp / wal)拆开看 reads、writes、extends、fsyncs 以及等待时间:
-- 按 backend 类型看 IO 分布
SELECT backend_type, object, context,
reads, read_time, writes, write_time, extends, extend_time
FROM pg_stat_io
ORDER BY read_time DESC
LIMIT 20;
-- 专门看 AIO 是否真在「异步」:对比 read_time 与实际耗时
SELECT object,
reads,
round(read_time::numeric, 2) AS read_ms,
round(extends::numeric, 0) AS extends
FROM pg_stat_io
WHERE backend_type = 'client backend';
配合 EXPLAIN (ANALYZE, BUFFERS) 看单次查询的 IO 行为:
EXPLAIN (ANALYZE, BUFFERS, I/O)
SELECT count(*) FROM orders_v7 WHERE customer_id BETWEEN 1000 AND 2000;
PG 18 的 EXPLAIN 在 I/O 维度输出更细(含 read / write 的字节与耗时),配合 pg_stat_io 能精确定位「到底是哪类 IO 在拖慢查询」——是随机读太多?还是 extend(扩容建表)太慢?这比过去靠猜强太多。
五、性能优化清单
AIO 不是「开了就快」,调对了才快。下面是 PG 18 生产落地的调优清单:
5.1 AIO 参数调优
# postgresql.conf(Linux 生产,内核支持 io_uring)
io_method = io_uring # 用 io_uring 而非 worker,性能上限更高
io_workers = 4 # worker 模式才用;io_uring 下基本不依赖
io_max_concurrency = 32 # 单 backend 并发 IO 上限,高并发随机读场景调大
io_combine_limit = 32 # 合并相邻块,顺序扫描收益明显
effective_io_concurrency = 16 # 预读并行度,从默认 1 调大
调优逻辑:
- 顺序扫描 / 大表报表:重点调大
io_combine_limit与effective_io_concurrency,让一次系统调用搬更多数据。 - 随机读密集(Bitmap Heap Scan、点查混合):重点调大
io_max_concurrency,让 backend 同时把多个随机读甩给 io_uring。 - 网络块存储(云硬盘 / EBS):AIO 收益最大,因为单次读延迟高,「等 IO」占比原本就高。本地 NVMe 上收益相对小,但仍为正。
5.2 pgbench 基准对比(sync vs io_uring)
一个最小可复现的对比法:
# 建库 + 初始化(关闭 AIO 跑一轮)
psql -c "ALTER SYSTEM SET io_method=sync; SELECT pg_reload_conf();"
pgbench -i -s 100 demo
pgbench -c 32 -j 8 -T 120 -P 10 demo # 记 TPS
# 开启 io_uring 跑一轮
psql -c "ALTER SYSTEM SET io_method=io_uring; SELECT pg_reload_conf();"
pgbench -c 32 -j 8 -T 120 -P 10 demo # 对比 TPS
经验区间(非绝对值,取决于存储):在云网络存储 + 大表混合读写场景,io_uring 相对 sync 常见有 15% ~ 40% 的 TPS 提升,Bitmap Heap Scan 类查询的延迟下降最明显;本地 NVMe 上多在 5% ~ 15%。别信「翻倍」的营销话术,AIO 是「补齐短板」不是「魔法加速」。
5.3 配合 B-tree 与规划器改进
PG 18 还带来了 B-tree 与查询规划器的细节改进:
- B-tree 对 NULL 处理与去重(deduplication)更稳:大表上
INSERT ... ON CONFLICT DO NOTHING类的去重索引写入更省空间。 - 规划器统计增强:
JOIN与外键相关的优化器统计更准,多表 JOIN 选错计划的概率下降。 - 配合前面 UUIDv7 的局部有序主键,B-tree 插入的页分裂显著减少,WAL 体积和缓冲池压力同步下降。
5.4 升级与回退注意
- PG 18 的 AIO 是实例级能力,从老版本升级后需显式配置
io_method才会启用,默认仍是保守的worker/sync行为,不会自动变快——这是好事,避免升级即「行为突变」。 - 逻辑复制:PG 18 修复了生成列(含 VIRTUAL)的复制问题,老版本跨版本逻辑复制要注意对齐。
- 升级建议走
pg_upgrade --link或逻辑复制,先在非生产验证io_method=io_uring在当前内核下是否稳定(老内核 io_uring 有 CVE 历史,注意打补丁)。
六、总结展望
PostgreSQL 18 不是「又一个加功能的版本」,它的主线叙事是底层补课 + 开发体验打磨:
- AIO 重写补齐了 PG 三十年的 IO 模型短板。它的价值不在「单次读更快」,而在「IO 等待不再阻塞执行器、并能批量合并小 IO」。在网络存储、大表扫描、随机读密集场景收益最大。生产上
io_method=io_uring值得开,但要先验证内核稳定性。 - UUIDv7 用时间有序解决了 UUIDv4 主键的索引碎片与缓存抖动,是「分布式主键」场景的默认首选。
- VIRTUAL 生成列把「不占存储的计算列」补上了,展示派生字段不再被迫物化。
- RETURNING OLD / NEW 用一行 SQL 替代触发器与嵌套 CTE,审计 / CDC / 软删除直接降维。
- OAuth 2.0 客户端认证把长期密码从应用侧拿掉,合规收益实打实。
向后看,PG 19 的讨论已经在进行:WAL 默认压缩算法、AIO 进一步默认化、可观察性继续细化。PG 的方向很清晰——在保持「最被信任的开源关系型数据库」这块招牌的同时,把过去十年被吐槽的底层短板一个一个补齐。
给工程师的落地建议很朴素:
- 新项目:主键默认
uuidv7(),派生展示字段用VIRTUAL,变更审计用RETURNING OLD/NEW,连接用 OAuth token。 - 存量项目升级 PG 18:先在非生产开
io_method=io_uring压测,确认内核稳定再上生产;别指望「升级即提速」,AIO 需要你主动调io_max_concurrency/io_combine_limit才释放。 - 性能排查:把
pg_stat_io+EXPLAIN (ANALYZE, BUFFERS, I/O)当成标配,先量化「哪类 IO 在拖」,再决定调哪个参数。
PG 18 这顿「成年礼」,吃的是底层硬功夫。把它用对,你的数据库会在不换硬件的前提下, quietly 地多出一大截余量。