PostgreSQL 18 深度实战:当异步 I/O、Skip Scan 与 UUIDv7 同时落地,Postgres 到底变快了多少?
2025 年 9 月 25 日,PostgreSQL 18 正式发布。这一版没有像某些版本那样只堆一堆小修小补,而是干了一件憋了很多年的大事:把异步 I/O(AIO)子系统塞进了内核。再加上 B-tree Skip Scan、UUIDv7、虚拟生成列、OAuth 2.0、
pg_upgrade保留统计信息这一串硬货,PostgreSQL 18 是近几年最值得认真对待的一个大版本。这篇文章不打算复述 release notes,而是站在一个天天被慢查询和 IO 瓶颈折磨的后端工程师视角,把 18 的关键特性一个一个拆开:它到底解决了什么老问题,底层是怎么实现的,怎么用,以及升级时会踩哪些坑。全文配可运行 SQL 和迁移脚本。
一、先说结论:PG 18 值不值得升
先给几个我关心的判断,省得你看完全文才知道结论:
- 如果你的库是 IO 密集型(顺序扫描、位图扫描、VACUUM 频繁),AIO 是这一版最大的红利,某些顺序扫描场景吞吐能有实打实的提升,尤其在云盘、NVMe 上。
- 如果你被
pg_upgrade后"统计信息丢失、必须重新 ANALYZE、上线后第一波查询全是慢查询"坑过,PG 18 直接把这个痛点干掉了——升级会保留 planner 统计信息。 - 如果你有大量
WHERE b = ?(跳过复合索引前导列 a)的查询,Skip Scan 让你少建一堆冗余索引。 - 如果你在纠结主键用自增还是 UUID,PG 18 原生
uuidv7()让你可以既要分布式友好、又要 B-tree 局部性。
下面逐个展开。
二、异步 I/O(AIO):这一版真正的主角
2.1 为什么老 Postgres 的 I/O 是"同步阻塞"的
在 PG 18 之前,Postgres 读数据页的模型非常朴素:需要某个 8KB 的数据页 → 发起 pread() → 进程在这里阻塞,等内核把数据从磁盘拷回来 → 拿到页继续干活。
这个模型在机械盘时代问题不大,因为磁盘本身就是瓶颈。但到了 NVMe SSD 和云盘时代,单块盘的 IOPS 动辄几十万,延迟几十微秒,真正的瓶颈变成了"一次只发一个 IO、发完就干等"。CPU 明明可以在等 IO 的时候去发下一个 IO,结果被同步模型死死摁住。
过去 Postgres 靠一个"打补丁"的机制缓解这个问题:effective_io_concurrency + posix_fadvise(POSIX_FADV_WILLNEED)。位图堆扫描时提前告诉内核"这些页我马上要用,你先预读"。但这只是"提示",不是真正的异步,覆盖场景也有限(主要是 bitmap heap scan)。
2.2 PG 18 的 AIO 做了什么
PG 18 引入了一个完整的 异步 I/O 子系统,把"发起 IO"和"使用 IO 结果"解耦。核心思路:
- 需要读一批页时,一次性批量提交多个读请求,不阻塞等待;
- 由后台机制去 poll 完成情况,数据到位后再消费;
- 天然形成 IO 流水线,把磁盘的并发能力吃满。
它提供了三种 I/O 方法,通过 io_method 参数切换:
io_method | 说明 | 适用 |
|---|---|---|
worker | 默认值。由一组 IO worker 进程代替后端发起 IO | 通用,跨平台 |
io_uring | 基于 Linux io_uring,真正的内核级异步 | 现代 Linux(需编译期开启) |
sync | 退回老的同步行为 | 兜底 / 对比测试 |
看一眼当前配置:
-- 查看 AIO 相关参数
SHOW io_method; -- worker(默认)
SHOW io_workers; -- IO worker 进程数,默认 3
SHOW effective_io_concurrency;-- 现在默认从 1 提升到了 16
-- 观察 IO worker 进程
SELECT pid, backend_type
FROM pg_stat_activity
WHERE backend_type = 'io worker';
如果你在现代 Linux 上追求极致,且发行版内核支持,可以切到 io_uring:
# postgresql.conf
io_method = io_uring
# io_uring 下不需要 io_workers
effective_io_concurrency = 32 # 根据盘的并发能力调
⚠️ 注意:
io_uring需要在编译时--with-liburing,且部分内核/容器环境对io_uring有 seccomp 限制(安全考虑)。生产上默认的worker方法已经能拿到大部分收益,io_uring是锦上添花且需要评估安全策略。
2.3 哪些场景真正受益
AIO 目前主要覆盖这些"批量顺序读"路径:
- 顺序扫描(Seq Scan):大表全表扫、分析型查询;
- 位图堆扫描(Bitmap Heap Scan);
- VACUUM / ANALYZE 的数据页读取。
这意味着数仓类、报表类、批处理类负载受益最明显。而随机点查(走 B-tree 命中单页)本来 IO 就少,提升有限。
2.4 观测:新的 IO 统计视图
PG 18 增强了 pg_stat_io,能看到读延迟、字节数等:
SELECT backend_type,
object,
context,
reads,
read_bytes,
read_time, -- 累计读耗时(毫秒)
round(read_time / nullif(reads,0), 3) AS avg_read_ms
FROM pg_stat_io
WHERE reads > 0
ORDER BY read_time DESC
LIMIT 10;
升级前后各跑一次同样的分析查询,对比 avg_read_ms 和总吞吐,是评估 AIO 收益最直接的办法。别听别人说"提升 2 倍 3 倍",自己的盘、自己的负载,实测才算数。
三、B-tree Skip Scan:少建一堆冗余索引
3.1 老问题:复合索引的"前导列诅咒"
假设你有一张订单表,建了复合索引:
CREATE INDEX idx_orders_status_created
ON orders (status, created_at);
按传统 B-tree 规则,这个索引能高效服务的查询是:
WHERE status = 'paid'WHERE status = 'paid' AND created_at > '2026-01-01'
但如果你只按 created_at 查、不带 status:
SELECT * FROM orders WHERE created_at > '2026-07-01';
老版本里这个索引基本用不上(除非全索引扫描),因为 B-tree 是按前导列 status 组织的,你没给 status 就没法定位。传统解法是再建一个 (created_at) 索引,结果索引越堆越多,写放大越来越严重。
3.2 Skip Scan 怎么救场
PG 18 的 B-tree 支持 Skip Scan:当前导列的去重值不多时(比如 status 只有 pending/paid/shipped/cancelled 几个值),planner 可以"跳着扫"——对每一个 status 取值,分别在 B-tree 里用 created_at 条件定位,相当于自动帮你把查询改写成:
-- 概念上等价于
SELECT * FROM orders WHERE status='pending' AND created_at > '2026-07-01'
UNION ALL
SELECT * FROM orders WHERE status='paid' AND created_at > '2026-07-01'
UNION ALL
SELECT * FROM orders WHERE status='shipped' AND created_at > '2026-07-01'
UNION ALL
SELECT * FROM orders WHERE status='cancelled' AND created_at > '2026-07-01';
于是那个 (status, created_at) 索引就能服务"只查 created_at"的场景了。
实测一下:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE created_at > '2026-07-01';
在 PG 18 上,如果 status 基数低,你会在计划里看到 Index Scan using idx_orders_status_created 且带 Skip Scan 特征,而不再是 Seq Scan。
3.3 什么时候有效,什么时候别指望它
Skip Scan 的收益强依赖前导列的基数(distinct 值数量):
- 前导列基数很低(几个到几十个):Skip Scan 非常香;
- 前导列基数很高(比如 user_id 几百万):跳的次数太多,planner 一般不会选它,也不该选。
所以它不是"以后不用管索引顺序了",而是给低基数前导列的复合索引松了绑。建索引时依然要把选择性高、常用的列放前面。
四、UUIDv7:分布式友好又不牺牲 B-tree 局部性
4.1 UUIDv4 的性能陷阱
很多团队用 UUID 做主键,图的是分布式生成、无需中心发号。但 UUIDv4 是完全随机的,拿它当主键有个隐藏成本:写入时 B-tree 页面到处跳,缓存命中率差,页分裂频繁,索引膨胀严重。高并发写入下,UUIDv4 主键的性能明显不如自增。
4.2 UUIDv7:时间前缀 + 随机后缀
UUIDv7 的结构是"毫秒级时间戳在前 + 随机位在后",所以它大体单调递增。用它当主键,新行总是插到 B-tree 的右端附近,局部性接近自增,同时保留 UUID 的全局唯一和分布式生成能力。
PG 18 原生内置了生成函数:
-- 生成 UUIDv7
SELECT uuidv7();
-- 018f...(前缀含时间,可排序)
-- 建表直接用
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 也内置了 uuidv4() 别名(等价于原来的 gen_random_uuid())
SELECT uuidv4();
-- 从 UUIDv7 里反解时间戳(新增函数)
SELECT uuid_extract_timestamp(uuidv7());
4.3 迁移建议
- 新表:直接
DEFAULT uuidv7(),无脑上。 - 老表已经在用 uuidv4:不用急着全量迁移,混用不影响正确性;但如果那张表是写入热点、索引膨胀严重,可以在新数据上切 uuidv7,逐步收敛。
- 别在应用层再自己拼 UUIDv7:以前很多库为了这个局部性问题自己实现了 "ordered UUID",现在数据库原生支持,能下沉就下沉,减少一层不确定性。
五、虚拟生成列:算了才存,还是用了才算?
PG 12 就有了生成列(Generated Columns),但只支持 STORED(存储型):值在写入时算好、落盘、占空间。PG 18 补上了 VIRTUAL(虚拟) 生成列,而且虚拟成了默认。
CREATE TABLE products (
id bigint PRIMARY KEY,
price numeric(10,2) NOT NULL,
tax_rate numeric(4,3) NOT NULL DEFAULT 0.13,
-- 虚拟生成列:查询时才计算,不占存储
price_with_tax numeric GENERATED ALWAYS AS (price * (1 + tax_rate)) VIRTUAL
);
INSERT INTO products(id, price) VALUES (1, 100.00);
SELECT id, price, price_with_tax FROM products;
-- price_with_tax 在读取时实时算出 = 113.00
STORED 与 VIRTUAL 怎么选:
| 维度 | STORED | VIRTUAL |
|---|---|---|
| 存储 | 占空间 | 不占空间 |
| 写入开销 | 写时计算 | 无 |
| 读取开销 | 无(直接读) | 读时计算 |
| 能否建索引 | 可以 | 一般不行(虚拟列不物化) |
| 适用 | 读多、需要索引、计算重 | 写多、计算轻、只是方便查询 |
经验法则:这列要拿来 WHERE/建索引/被频繁读 → 用 STORED;只是省得每次手写表达式、计算又轻 → 用 VIRTUAL。
六、RETURNING 支持 OLD/NEW:一条语句拿到改前改后
以前 UPDATE ... RETURNING 只能返回新值,想拿旧值得先 SELECT 一次或者上触发器。PG 18 让 RETURNING 能同时引用 OLD 和 NEW:
UPDATE accounts
SET balance = balance - 100
WHERE id = 42
RETURNING
old.balance AS before,
new.balance AS after,
old.balance - new.balance AS delta;
MERGE、INSERT ... ON CONFLICT 里也能用,做审计日志、变更追踪时特别顺手,少一次往返、少一个触发器:
INSERT INTO settings(key, value) VALUES ('theme', 'dark')
ON CONFLICT (key) DO UPDATE SET value = excluded.value
RETURNING
old.value AS old_value,
new.value AS new_value;
七、pg_upgrade 保留统计信息:大版本升级不再"上线即雪崩"
这个特性对运维党来说,重要性不亚于 AIO。
7.1 老痛点
以前用 pg_upgrade 做大版本原地升级,planner 的统计信息(pg_statistic)不会带过去。升级完成、库拉起来那一刻,优化器对每张表的数据分布一无所知,于是:
- 所有查询用的都是瞎猜的默认估算 → 计划全歪 → 上线第一波流量直接被慢查询淹没;
- 必须赶紧
vacuumdb --analyze-in-stages全库重新收集统计,大库要跑很久,这段时间业务就是在裸奔。
无数次线上事故都栽在这个"升级后忘了/来不及 ANALYZE"上。
7.2 PG 18 的改进
PG 18 的 pg_upgrade 会迁移 planner 统计信息。升级完成后优化器立刻有数据分布可用,查询计划从第一秒就是对的,大幅降低升级窗口的风险。
# 典型升级流程(示意)
pg_upgrade \
--old-datadir=/data/pg17 \
--new-datadir=/data/pg18 \
--old-bindir=/usr/lib/postgresql/17/bin \
--new-bindir=/usr/lib/postgresql/18/bin \
--link # 硬链接模式,快
仍建议升级后跑一次轻量
ANALYZE兜底(扩展统计、部分场景仍可能需要刷新),但你不再是"必须在业务恢复前抢时间跑完全库 ANALYZE"的窘境了。
八、OAuth 2.0 认证:企业身份体系原生对接
PG 18 内置了 OAuth 2.0 认证方式,可以在 pg_hba.conf 里配 oauth,让数据库直接对接企业的 IdP(身份提供商,如 Keycloak、Okta、Entra ID)做基于 token 的认证,而不用再自己搭一层代理或依赖外部插件。
# pg_hba.conf(示意)
# TYPE DATABASE USER ADDRESS METHOD OPTIONS
host all all 0.0.0.0/0 oauth issuer="https://idp.example.com" scope="openid postgres"
对大型组织统一身份治理、集中审计、SSO 落地是实打实的减负。中小团队用不上可以先放着,但这标志着 Postgres 在企业级身份集成上补齐了一块短板。
九、其它值得一提的改进
PG 18 还有一堆"不上头条但很实用"的点:
NOT NULL约束支持NOT VALID:给大表加非空约束时,可以先加成NOT VALID不全表校验,之后再VALIDATE CONSTRAINT分离锁开销,避免长时间持锁。- 临时约束 /
WITHOUT OVERLAPS增强:区间不重叠约束更完善,做排班、时段预订这类"时间区间不能撞车"的业务更省心。 - 逻辑复制增强:对 DDL 的处理、以及在升级 / 切换时保留复制槽的能力更好,逻辑复制拓扑升级更平滑。
EXPLAIN默认更啰嗦:EXPLAIN ANALYZE默认带上了 BUFFERS 信息,排查 IO 时不用每次手动加参数。- 可观测性:
pg_stat_all_tables新增 (auto)vacuum / (auto)analyze 耗时字段,pg_stat_checkpointer新增num_done,并行 worker 使用情况可见,诊断 VACUUM 与检查点更直观。
十、升级实战 checklist
把上面的东西串成一套"升级到 18 要做什么"的清单:
升级前
- 在测试环境用真实数据量、真实查询回放,重点对比 AIO 带来的 IO 吞吐变化(
pg_stat_io)。 - 审查现有冗余索引:哪些是为了"绕过复合索引前导列"临时建的?PG 18 上可能被 Skip Scan 取代,能删就删,减少写放大。
- 检查扩展兼容性(PostGIS、pg_partman、TimescaleDB 等对 18 的支持时间线)。
- 在测试环境用真实数据量、真实查询回放,重点对比 AIO 带来的 IO 吞吐变化(
升级中
- 优先用
pg_upgrade --link(硬链接)缩短窗口。 - 享受统计信息保留,但仍留一步兜底
ANALYZE。
- 优先用
升级后
io_method先用默认worker观察,稳定后再评估是否上io_uring。- 根据盘的能力重新设
effective_io_concurrency(默认已提到 16,NVMe 可以更高)。 - 新表主键切
uuidv7();审计场景改用RETURNING old/new简化逻辑。 - 用
pg_stat_io/ 新增的 vacuum 耗时字段建立新基线监控。
十一、一个可复现的对比 Demo
最后给一个能自己跑的小 demo,直观感受 Skip Scan 和 uuidv7:
-- 造数据
CREATE TABLE demo_orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
status text NOT NULL,
created_at timestamptz NOT NULL,
amount numeric(10,2) NOT NULL
);
INSERT INTO demo_orders (status, created_at, amount)
SELECT
(ARRAY['pending','paid','shipped','cancelled'])[1 + (random()*3)::int],
now() - (random() * interval '365 days'),
(random()*1000)::numeric(10,2)
FROM generate_series(1, 2_000_000);
CREATE INDEX idx_demo_status_created ON demo_orders (status, created_at);
ANALYZE demo_orders;
-- 只按 created_at 查(不给 status)——PG 18 上应命中 Skip Scan
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM demo_orders
WHERE created_at > now() - interval '7 days';
-- 验证 uuidv7 的时间可解析性
SELECT id, uuid_extract_timestamp(id) AS id_time, created_at
FROM demo_orders
ORDER BY id
LIMIT 5;
在 PG 17 及以前,第一条查询大概率是 Seq Scan 扫两百万行;在 PG 18 上,若 status 基数低,你会看到它改走索引 Skip Scan,BUFFERS 读取量明显下降。
十二、总结:Postgres 正在补齐"数据库该有的样子"
回看 PostgreSQL 18 这条特性线,你会发现一个清晰的意图:把过去要靠扩展、靠应用层 hack、靠运维人肉兜底的东西,一个个下沉进内核。
- IO 慢 → 内核级 AIO;
- 冗余索引多 → Skip Scan;
- UUID 主键膨胀 → 原生 UUIDv7;
- 升级即雪崩 → 保留统计信息;
- 企业身份对接难 → 原生 OAuth 2.0;
- 审计要写触发器 → RETURNING old/new。
这些没有一个是"炫技型"特性,全是把老工程师被折磨了十年八年的痛点,实打实地干掉。对一个已经足够成熟的开源数据库来说,这种"务实到近乎无聊"的进化,恰恰是最值得信任的信号。
如果你还在 14/15,这一版给了你足够多"该升级"的理由;如果你在 16/17,AIO 和统计信息保留这两条,也够你把 18 排进今年的运维日程了。
务实主义者的结论很简单:能在测试环境把 AIO 的数字跑出来,你就知道该不该升。