PostgreSQL 18 深度实战:异步I/O + UUIDv7 + 跳跃扫描 + 虚拟生成列——把"关系型数据库"重写成现代应用底座的全链路拆解
本文不是一份"PG 18 更新清单",而是一份面向生产环境的深度拆解。我们从内核架构(异步 I/O 子系统、io_uring 后端)、标识符工程(UUID v7 为什么能救活你的 B-tree)、优化器进化(跳跃扫描的真正适用边界)、存储权衡(虚拟生成列 vs 视图 vs 触发器)四个维度,配上完整可运行的 SQL 与基准测试,讲清楚 PG 18 到底解决了什么、又有哪些"坑"。
一、背景介绍:一次跨越三十年的 I/O 之痛
PostgreSQL 全球开发组在 2025 年 9 月 25 日正式发布了 PostgreSQL 18。如果你只把它当成"又一年一度的大版本",那就错过了过去十年里 PG 内核最重要的一次存储层手术。
回顾历史:PostgreSQL 的存储引擎(heap + B-tree + 共享缓冲区)从 8.x 时代基本定型,I/O 模型一直是同步阻塞的。一个后端进程(backend)在顺序扫描一张大表、做位图堆扫描、或者跑 VACUUM 时,每读一个数据块都要调用 pread(),然后原地等待内核把数据从磁盘搬进页缓存,再继续。磁盘越慢、网络存储(EBS、云盘)延迟越高,这个"等"的代价就越大。
到了 2026 年,硬件格局彻底变了:
- NVMe / 云块存储的带宽极大(GB/s 级),但单次 I/O 延迟依然有 ms 级,且云网络存储的延迟比本地盘高一个数量级;
- 多核 CPU 成为标配,但同步 I/O 让 CPU 在
iowait里空转; - 业务侧对"大表上的分析查询""定时 VACUUM""数据仓库式扫描"的依赖越来越重。
于是 PG 18 做了一件过去十年没人敢动的事:把 I/O 子系统异步化,并顺手补齐了现代应用最需要的几个"工程化零件"——时间戳有序的 UUID v7、低基数列的跳跃扫描、以及不占磁盘的虚拟生成列。
这篇文章的目的,是让你在读完之后,能够:
- 判断你的业务到底能不能从 PG 18 的异步 I/O 里吃到红利;
- 用 UUID v7 替代随机 UUID,从根上降低索引膨胀与写入抖动;
- 理解跳跃扫描什么时候是神器、什么时候是"鸡肋";
- 用虚拟生成列把"计算列"的成本压到最低;
- 拿到一套可直接落地的配置 + 基准测试脚本。
二、核心概念:四个特性的底层逻辑
2.1 异步 I/O 到底是什么:从 read() 阻塞到 io_uring
传统同步读路径(PG 18 之前的默认行为):
backend 进程
└─ pread(fd, buf, 8KB) → 陷入内核
└─ 磁盘控制器搬数据 → 内核缓冲区
└─ 拷贝到用户空间 → backend 才被唤醒
步骤 2~4 之间,backend 除了等什么也干不了。当一个 Seq Scan 要读 100 万个块,进程就被钉死在 I/O 等待上,CPU 算力被白白浪费。
异步 I/O 的核心是解耦:backend 把读请求"派发"出去后立刻返回,转头去处理已经读进内存的数据、或准备下一个请求;数据就绪后由内核/worker 通知 backend 来取。于是 I/O 与计算在时间上重叠成一条流水线。
Linux 上的工业级实现是 io_uring:一套内核态的无锁环形队列(SQ/CQ),配合 SQPOLL 轮询模式,可以把系统调用开销压到极低,并真正实现并行预读。
2.2 ReadStream:PG 自己掌握的"预读引擎"
PG 18 不是简单地"把 pread 换成 io_uring 的提交",而是先重构了上层的 ReadStream 抽象——一个由执行器控制的预读调度器。以前 PG 只能依赖 OS 的 posix_fadvise 做"建议性预读"(OS 并不知道你接下来要全表扫还是会跳着读);现在 PG 自己知道查询计划,能精准地、异步地预取后续要用到的 buffer。
关键边界:PG 18 的异步 I/O 目前只覆盖"读",不覆盖"写"。顺序扫描、位图堆扫描、VACUUM 的读路径已经异步化;WAL 写入、脏页刷盘仍然是同步的。这是理性取舍——写路径的异步化风险更高,留给了后续版本。
2.3 UUID v7:把"时间戳"焊进全局 ID
UUID v4 是 122 位随机数,全局唯一但完全无序。把它当主键塞进 B-tree,插入点会散布在索引的任意位置,引发大量 page split(页分裂) 和索引膨胀,缓存命中率也惨不忍睹。
UUID v7 的结构是:48 位 Unix 毫秒时间戳 + 74 位随机位。时间戳在前,意味着同一毫秒内生成的 ID 天然有序。插入 B-tree 时,新行几乎总是落在索引的"最右端",页分裂极少、填充率高、顺序 I/O 友好。对分布式系统而言,v7 还顺带解决了"按 ID 大致排序 ≈ 按时间排序"的刚需。
2.4 跳跃扫描 Skip Scan:复合索引的"借道超车"
假设你在 (tenant_id, created_at) 上建了复合主键,却经常只按 created_at 做范围查询(漏掉了前导列 tenant_id)。旧版 PG 只能放弃索引、走全表扫描。PG 18 的跳跃扫描会把前导列 tenant_id 的每一个不同值都"借道"一遍:先拿到 distinct 的 tenant 列表,再对每个 tenant 在其索引范围内做一次高效的 range scan。当前导列基数很低(比如 100 个租户)时,这比全表扫描便宜得多。
2.5 虚拟生成列:不占磁盘的"计算列"
PG 12 就支持了 STORED 生成列(写入时算好、占磁盘、可建索引)。PG 18 补齐了 VIRTUAL 生成列:值只在查询时计算、完全不占存储。它像"表内联的视图列"——省了磁盘,代价是每次读都现算。对于低频访问的派生字段,这是比 STORED、比触发器、比应用层维护都更优雅的方案。
三、架构分析:PG 18 异步 I/O 子系统内部
3.1 三种 I/O 模式
io_method 参数决定 AIO 的执行方式:
| 模式 | 行为 | 适用场景 |
|---|---|---|
sync | 向后兼容,用 posix_fadvise 做建议性预读(数据进页缓存而非共享缓冲区) | 旧内核 / 不支持 io_uring 的环境 |
worker | 启动一组 I/O worker 进程,backend 把读请求投进共享内存队列,worker 执行 pread 并填共享缓冲区 | 通用、稳定、无需特殊权限 |
io_uring | 走 Linux io_uring 内核接口,真正的并行异步 I/O | 现代 Linux 内核(5.1+,建议 5.10+)、本地 NVMe |
3.2 worker 模式:共享内存里的请求队列
worker 模式下的流转:
backend 共享内存队列 I/O worker 进程池
│ 需要读 block N │ │
│──────── 投递请求 ────────>│ (请求入队) │
│ │──────── 唤醒一个 worker ────>│ pread() 从文件读
│ (backend 立即去干别的) │ │ 填入共享缓冲区
│<──────── 完成通知 ────────│<──────── 完成回写 ──────────│
│ 取走数据,继续推进查询 │ │
io_workers(默认 3)控制 worker 进程数量。它的本质是用进程级并行,把 backend 从阻塞读里解放出来。
3.3 io_uring 模式:内核态无锁环形队列
io_uring 模式绕过了"每请求一次系统调用"的老路:PG 通过 io_uring_setup 建立一对 SQ(提交队列)/ CQ(完成队列)的共享内存环形结构,backend 把 I/O 请求塞进 SQ 就返回;内核消费 SQ、完成后再填 CQ。SQPOLL 模式下连提交都不用陷入内核。这正是云存储/高 IOPS 场景下读取性能能翻倍甚至更高的底层原因。
3.4 内核接口扩展
PG 18 在存储管理器(smgr)层做了接口扩展来支持异步读:
- 新增
smgr_startreadv:发起异步读,立即返回; - 引入回调结构
PgAioTargetInfo/PgAioHandleCallBacks:描述"这次异步读的目标"和"完成后怎么办"; - 上层
ReadStream改造为异步预取,使顺序扫描、pg_prewarm、ANALYZE的 I/O 性能显著优于旧的posix_fadvise方案。
3.5 当前边界与未来
- 只异步读,不异步写;WAL 仍同步。
io_uring需要较新内核;旧内核自动回退。- 未来方向:直接 I/O(DIO)。一旦启用 io_uring 的 DIO,可以消除"OS 页缓存 buffer + PG 共享缓冲区"的双重缓冲,进一步降低内存占用与拷贝开销——这是 PG 存储层下一阶段的重头戏。
四、代码实战:从零跑通 PG 18 四大特性
4.1 准备环境
用官方镜像一键起一个 PG 18(注意:需要较新内核才能用 io_uring):
docker run -d --name pg18 \
-e POSTGRES_PASSWORD=secret \
-p 5432:5432 \
postgres:18
# 进入 psql
docker exec -it pg18 psql -U postgres
4.2 开启异步 I/O(三种方式)
修改 postgresql.conf:
# ---------- I/O ----------
io_method = 'io_uring' # sync | worker | io_uring
io_workers = 4 # worker 模式下的 I/O 进程数
io_combine_limit = 256kB # 合并相邻读请求上限(减少 syscalls)
effective_io_concurrency = 300 # 1-1000,0 关闭多路并发读
maintenance_io_concurrency = 300
也可以在线改(注意 io_method 切换通常需要重启才能生效,io_workers 可 reload):
ALTER SYSTEM SET io_method = 'worker';
ALTER SYSTEM SET io_workers = 8;
SELECT pg_reload_conf();
-- 若改了 io_method,需重启实例:
-- pg_ctl restart / docker restart pg18
4.3 容器化 / Kubernetes 场景的坑(必看)
这是 PG 18 上云最常见的翻车点:io_uring 需要 SYS_IOURING 能力,否则 PG 会静默回退到低效的同步模式,你还会以为自己配对了。
Docker:
docker run --cap-add=SYS_IOURING -e PGIO_METHOD=io_uring postgres:18
Kubernetes securityContext:
securityContext:
capabilities:
add: ["SYS_IOURING"]
若无法开启 io_uring,改用 worker 模式并调大 io_workers(建议为 vCPU 数的 25%~50%),收益同样可观。
4.4 UUID v7 实战:用 100 万行对比 v4 与 v7
-- 早期 v4 依赖 pgcrypto;PG 18 原生 uuidv7() 无需扩展
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE events_v4 (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 随机 v4
payload text,
created_at timestamptz DEFAULT now()
);
CREATE TABLE events_v7 (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- 时间戳有序 v7
payload text,
created_at timestamptz DEFAULT now()
);
INSERT INTO events_v4 (payload)
SELECT 'event-' || g FROM generate_series(1, 1000000) g;
INSERT INTO events_v7 (payload)
SELECT 'event-' || g FROM generate_series(1, 1000000) g;
对比主键索引的物理体积与膨胀:
SELECT 'events_v4' AS tbl, pg_relation_size('events_v4_pkey') AS idx_bytes
UNION ALL
SELECT 'events_v7', pg_relation_size('events_v7_pkey');
-- 查看 B-tree 填充情况(叶子页平均利用率)
SELECT indexrelname,
round(100.0 * leaf_pages / (leaf_pages + empty_pages), 2) AS fill_ratio
FROM pgstatindex('events_v7_pkey');
预期结论:v7 的索引更小、填充率更高、page split 更少。原因是 v7 的插入近似"追加写"到索引最右,B-tree 只需在右边界分裂;而 v4 的插入点随机散布,导致频繁的中间分裂与碎片。
应用层生成 v7 也很容易(以 Go 为例,google/uuid 1.6+):
package main
import (
"fmt"
"github.com/google/uuid"
)
func main() {
id, _ := uuid.NewV7() // 时间戳有序
fmt.Println(id.String())
}
Python(psycopg3)直接让数据库生成:
import psycopg
conn = psycopg.connect("dbname=postgres user=postgres")
cur = conn.cursor()
cur.execute(
"INSERT INTO events_v7 (payload) VALUES (%s) RETURNING id",
("hello",),
)
print(cur.fetchone()) # 拿到一个 UUID v7
4.5 跳跃扫描实战:复合索引 + 低基数列
CREATE TABLE orders (
tenant_id int, -- 低基数列(前导列)
created_at timestamptz, -- 范围查询列
amount numeric,
PRIMARY KEY (tenant_id, created_at)
);
-- 100 个租户,每个租户 1 万条订单
INSERT INTO orders (tenant_id, created_at, amount)
SELECT (g % 100) + 1,
now() - (g || ' seconds')::interval,
random() * 1000
FROM generate_series(1, 1000000) g;
ANALYZE orders;
-- 没有 tenant_id 条件,但要用 created_at 范围——旧版只能全表扫描
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE created_at > now() - interval '1 hour';
怎么判断跳跃扫描生效了? 在 EXPLAIN 输出里观察计划节点:当你看到优化器对 (tenant_id, created_at) 这个索引选择了跳跃式访问(计划中会体现"按前导列去重后逐段 range scan"的形态),而不是 Seq Scan,就说明 Skip Scan 接管了。
工程判断(很重要):跳跃扫描不是银弹。它只有在前导列基数足够低时才划算——100 个租户,优化器做 100 次索引段扫描,远胜于扫 100 万行。但如果前导列有 10 万个不同值,跳跃 10 万次反而会爆雷。如果你的查询经常漏掉前导列且前导列基数高,正确做法仍是建一个以 created_at 为前导的单独索引,别迷信跳跃扫描。
4.6 虚拟生成列实战
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
first_name text NOT NULL,
last_name text NOT NULL,
-- 虚拟生成列:查询时计算,不占磁盘
full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL,
-- 存储生成列:写入时计算,占磁盘,可建索引
name_upper text GENERATED ALWAYS AS (upper(first_name)) STORED
);
INSERT INTO users (first_name, last_name) VALUES ('Ada', 'Lovelace');
SELECT id, full_name FROM users; -- full_name 实时算出 "Ada Lovelace"
要点:
- VIRTUAL 不占存储,每次
SELECT现算;适合低频派生字段。 - STORED 占存储、可建索引,适合需要被 WHERE / JOIN / ORDER BY 高频命中的字段。
- 想给 VIRTUAL 列加速?在底层表达式上建表达式索引:
CREATE INDEX ON users (lower(first_name || ' ' || last_name));——这和虚拟列的语义是一致的。
4.7 可观测性:盯住在途 I/O
PG 18 强化了 I/O 层面的可观测性。你可以用 pg_aios() 查看当前在途的异步 I/O 请求,用后端 I/O 统计判断 AIO 是否真的在干活:
-- 当前在途的异步 I/O 请求(调试 AIO 是否生效极有用)
SELECT * FROM pg_aios();
-- 各后端 I/O 统计
SELECT pid, pg_stat_get_backend_io(pid)
FROM pg_stat_activity
WHERE pid <> pg_backend_pid();
4.8 OAuth 2.0 认证(SSO 集成)
PG 18 原生支持 OAuth 2.0 认证,方便接入企业 SSO。在 pg_hba.conf 中把认证方法设为 oauth 即可:
# pg_hba.conf
hostssl all all 0.0.0.0/0 oauth
配合角色侧配置的 OAuth 身份映射,数据库层面的访问可以和公司统一身份体系打通——对合规与运维都是实打实的减法。
五、性能优化:红利在哪、坑在哪
5.1 异步 I/O 的收益边界(别盲目开)
AIO 的红利集中在**"需要大量读、且读可以被并行预取"**的场景:
- ✅ 大表顺序扫描(报表、分析)
- ✅ 位图堆扫描(多条件过滤后的大批量回表)
- ✅ VACUUM / ANALYZE /
pg_prewarm - ✅ 云网络存储(延迟高,并行预取收益最大,早期测试显示读取密集查询可提升 2~3 倍)
- ❌ 纯点查(按主键查单行)——本来就读一块,异步化没空间
- ❌ 写密集(WAL 仍同步,AIO 帮不上)
5.2 io_workers 调优公式
worker 模式没有"越大越好":
io_workers ≈ vCPU 数 × (25% ~ 50%)
太小则 I/O 并发不足;太大则进程上下文切换和锁竞争反噬。建议从 vCPU 的 1/3 起步,用 pgbench 跑 SELECT-only 压测逐步逼近最优。
5.3 UUID v7 的量化收益
把主键从 v4 换成 v7,收益不是"理论上更优雅",而是可量化的:
- 索引体积更小:减少 page split → 更少叶子页;
- 缓存命中率更高:顺序写入让热数据集中,buffer pool 利用率上升;
- 写入抖动更低:避免随机插入引发的连锁分裂;
- 天然按时间可排序:
ORDER BY id≈ORDER BY 创建时间,省一个索引。
5.4 跳跃扫描的"鸡肋"争议
社区里对 Skip Scan 早有讨论:它确实能救活"漏前导列 + 低基数"的查询,但当基数一高就退化。把它当"索引设计的免死金牌"是危险的。正确姿势:
- 低基数前导列 + 常漏前导列查询 → 让 Skip Scan 上;
- 高基数前导列 → 老老实实补一个以查询列为前导的索引。
5.5 虚拟生成列 vs 视图 vs 触发器
| 方案 | 存储 | 计算时机 | 可索引 | 维护成本 |
|---|---|---|---|---|
| VIRTUAL 生成列 | 不占 | 读时 | 需表达式索引 | 极低(声明式) |
| STORED 生成列 | 占 | 写时 | 可直接建 | 低 |
| 视图 | 不占 | 读时 | 物化视图才可 | 中 |
| 触发器维护 | 占 | 写时 | 可直接建 | 高(易出错) |
派生字段首选 VIRTUAL;需要被高频过滤/排序再上 STORED 或表达式索引;触发器基本可以退休了。
5.6 一套可复现的基准测试
# 初始化 100 倍缩放的数据集
pgbench -i -s 100 postgres
# 1) sync 模式(默认)压测 SELECT-only 60 秒
pgbench -c 16 -j 4 -T 60 -S postgres
# 2) 切到 io_uring / 调大 io_workers,重启后重测
# ALTER SYSTEM SET io_method='io_uring'; 然后 docker restart pg18
pgbench -c 16 -j 4 -T 60 -S postgres
对比 TPS 与 iowait:在云盘 + 读密集负载下,io_uring 相对 sync 通常有成倍提升;worker 模式也能拿到可观收益且更稳。
六、总结与展望:PG 18 把"关系型数据库"重写成了现代应用底座
把四个特性放在一起看,PG 18 的野心很清楚:它不再满足于"一个可靠的关系型数据库",而是要成为云原生时代的应用底座。
- 异步 I/O 让它在现代高延迟存储上吃得开,把 CPU 从
iowait里抢回来; - UUID v7 解决了分布式系统最基础的"全局有序 ID"痛点,顺手救了索引健康;
- 跳跃扫描是优化器对"不完美索引设计"的宽容,但工程师仍要懂它的边界;
- 虚拟生成列把"派生字段"的成本压到最低,让 schema 表达更干净。
横向看,MySQL 8 在异步 I/O 与 UUID 有序化上步子更慢;而 PG 18 在"内核级存储重构 + 现代工程化零件"的组合拳上,已经明显领先。至于眼下火热的"PG 原生向量搜索"——它确实让 PG 更像一个多模数据库,但那是另一条赛道(和我们这篇文章的 OLTP/存储内核主线不同),落地前请务必拿你的真实数据做召回率与成本的对照测试,别被"一个数据库搞定一切"的口号带偏。
展望未来,PG 存储层还有两块硬骨头:异步写 / WAL 异步化,以及 io_uring 直接 I/O(DIO)消除双重缓冲。这两块一旦落地,PG 在高 IOPS 场景的 ceilings 还会再抬一截。
给工程师的落地清单:
- 升级到 PG 18,先开
worker模式吃 AIO 红利(最稳); - 新表主键优先
uuidv7(),老表做索引膨胀评估后逐步迁移; - 复合索引查询若常漏前导列,先评估前导列基数再决定是否靠 Skip Scan;
- 派生字段一律先想 VIRTUAL,再想 STORED/表达式索引;
- 用
pg_aios()+pgbench把"是否真的生效"量化出来,别靠感觉。
PostgreSQL 用了三十多年证明了一件事:稳健和进化并不矛盾。PG 18 就是这句话最新的注脚。