编程 PostgreSQL 18 深度实战:当异步 I/O、Skip Scan 与 UUIDv7 同时落地,Postgres 到底变快了多少?

2026-07-11 04:44:50 +0800 CST views 8

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 值不值得升

先给几个我关心的判断,省得你看完全文才知道结论:

  1. 如果你的库是 IO 密集型(顺序扫描、位图扫描、VACUUM 频繁),AIO 是这一版最大的红利,某些顺序扫描场景吞吐能有实打实的提升,尤其在云盘、NVMe 上。
  2. 如果你被 pg_upgrade 后"统计信息丢失、必须重新 ANALYZE、上线后第一波查询全是慢查询"坑过,PG 18 直接把这个痛点干掉了——升级会保留 planner 统计信息。
  3. 如果你有大量 WHERE b = ?(跳过复合索引前导列 a)的查询,Skip Scan 让你少建一堆冗余索引。
  4. 如果你在纠结主键用自增还是 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 怎么选:

维度STOREDVIRTUAL
存储占空间不占空间
写入开销写时计算
读取开销无(直接读)读时计算
能否建索引可以一般不行(虚拟列不物化)
适用读多、需要索引、计算重写多、计算轻、只是方便查询

经验法则:这列要拿来 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;

MERGEINSERT ... 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 要做什么"的清单:

  1. 升级前

    • 在测试环境用真实数据量、真实查询回放,重点对比 AIO 带来的 IO 吞吐变化(pg_stat_io)。
    • 审查现有冗余索引:哪些是为了"绕过复合索引前导列"临时建的?PG 18 上可能被 Skip Scan 取代,能删就删,减少写放大。
    • 检查扩展兼容性(PostGIS、pg_partman、TimescaleDB 等对 18 的支持时间线)。
  2. 升级中

    • 优先用 pg_upgrade --link(硬链接)缩短窗口。
    • 享受统计信息保留,但仍留一步兜底 ANALYZE
  3. 升级后

    • 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 的数字跑出来,你就知道该不该升。

推荐文章

使用 node-ssh 实现自动化部署
2024-11-18 20:06:21 +0800 CST
浅谈CSRF攻击
2024-11-18 09:45:14 +0800 CST
Vue3中如何使用计算属性?
2024-11-18 10:18:12 +0800 CST
mysql关于在使用中的解决方法
2024-11-18 10:18:16 +0800 CST
程序员茄子在线接单