前言:一个"无聊"的数据库,憋了一个大招
PostgreSQL 的版本发布向来有个特点:每年一个大版本,节奏稳定得像瑞士钟表,但单看 Release Notes 往往感觉"平平无奇"——没有营销话术,没有颠覆性叙事,全是一条条朴素的改进项。
但 PostgreSQL 18 不一样。这个版本包含了 3000+ 次提交,而其中最重磅的一项,是 PostgreSQL 诞生近三十年来对 I/O 子系统动的最大一次手术:原生异步 I/O(Asynchronous I/O,AIO),官方给出的数据是——从存储读取数据的场景下,性能最高提升 3 倍。
3 倍这个数字,对于一个以"保守、稳健"著称的数据库来说,几乎是爆炸性的。要知道,过去十年里大部分 PostgreSQL 大版本的综合性能提升都在 5%~20% 这个量级。
除了 AIO,这个版本还塞进了一堆开发者盼了多年的东西:
- uuidv7() 原生函数——时间有序的 UUID,终结索引碎片噩梦
- **虚拟生成列(Virtual Generated Columns)**成为默认——零存储开销的计算列
- Index Skip Scan——多列索引终于不再"死板"
- OAuth 2.0 认证——告别静态密码,直连企业 SSO
- RETURNING 子句支持 OLD/NEW——一条 UPDATE 同时拿到新旧值
- 大版本升级提速——统计信息随升级迁移,不再需要漫长的重新 ANALYZE
这篇文章我会以工程视角把这些特性逐个拆开:不只讲"是什么",更讲"为什么这么设计"、"底层怎么实现的"、"生产环境怎么用、怎么踩坑"。全文比较长,建议收藏后配合一个测试实例边读边跑。
一、主菜:异步 I/O 子系统,三十年架构的一次换血
1.1 先搞懂:PostgreSQL 过去的 I/O 有多"原始"
在 PostgreSQL 17 及之前,后端进程(backend)读数据的方式简单粗暴:
/* 简化后的伪代码:传统同步读取 */
buf = ReadBuffer(relation, blockNum);
/* 进程在这里阻塞,直到内核把 8KB 页读进来 */
每次需要一个数据页(默认 8KB),进程就发起一次同步 pread(),然后干等。等内核从磁盘(或页缓存)把数据搬回来,进程才能继续干活。
PostgreSQL 过去缓解这个问题的手段只有两个:
- 依赖操作系统的预读(readahead):顺序扫描时内核会猜测你接下来要读什么,提前加载。但这是内核层的启发式行为,数据库自己无法精确控制,一旦访问模式稍微复杂(比如 Bitmap Heap Scan 的跳跃式访问),内核的预读就抓瞎了。
effective_io_concurrency+posix_fadvise():Bitmap 扫描时提前给内核发 hint,"我等会儿要读这些块"。这算是"伪异步"——只是提示,不是真正的异步提交与完成回调,而且在很多文件系统和存储栈上效果有限。
在本地 NVMe SSD 时代,这个问题被硬件的低延迟掩盖了不少。但云时代的主流存储是网络块存储(AWS EBS、阿里云 ESSD、各种分布式存储),单次 I/O 延迟轻松到几百微秒甚至毫秒级。同步 I/O 模型在这种环境下的表现可以用"惨"来形容——CPU 大量时间在空转等待,磁盘队列深度上不去,带宽跑不满。
1.2 PostgreSQL 18 的答案:三层可切换的 AIO 架构
PostgreSQL 18 引入了全新的 io_method 参数,提供三种 I/O 执行方式:
# postgresql.conf
io_method = worker # 默认值:I/O 工作进程池
# io_method = io_uring # Linux 5.1+ 专属:内核级异步 I/O
# io_method = sync # 回退到 17 及以前的同步行为
三种模式的架构差异:
sync(兼容模式):行为与 PostgreSQL 17 一致,同步读取。给那些遇到兼容性问题需要回退的场景留的后路。
worker(默认模式):PostgreSQL 启动一组专门的 I/O worker 进程(数量由 io_workers 控制,默认 3 个)。backend 需要读数据时,把 I/O 请求扔进共享内存队列,worker 进程负责实际执行读取,完成后通知 backend。backend 在等待期间可以继续处理其他已就绪的数据。这个模式的好处是全平台可用——macOS、FreeBSD、老内核 Linux 都能跑。
io_uring(性能模式):直接使用 Linux 5.1+ 的 io_uring 接口。backend 进程通过提交队列(SQ)批量提交 I/O 请求,内核完成后写入完成队列(CQ),全程无需额外进程、无需额外系统调用开销(极端情况下甚至可以零 syscall 轮询)。这是延迟最低、吞吐最高的模式。
用一个类比来说:sync 是你自己去餐厅排队买饭;worker 是你雇了三个跑腿小哥帮你排队,你可以同时点三家;io_uring 是餐厅直接给你开了个专属传菜通道,你把订单批量塞进去,做好了自动送到你桌上。
1.3 io_uring 模式的底层机制
如果你对 io_uring 不熟,这里花两分钟讲清楚它为什么快。
传统的 Linux AIO(libaio)有一堆历史包袱:只在 O_DIRECT 下真正异步、某些操作会意外阻塞、每次提交和收割都需要系统调用。io_uring 的设计彻底绕开了这些坑:
┌─────────────┐ ┌──────────────┐
│ PostgreSQL │ ──SQE──▶│ Submission Q │──┐
│ backend │ └──────────────┘ │ 内核消费
│ │ ┌──────────────┐ ▼
│ │ ◀──CQE──│ Completion Q │◀─┘ 内核填充
└─────────────┘ └──────────────┘
用户态 共享内存环形队列
- 两个环形队列(提交队列 SQ、完成队列 CQ)由内核和用户态共享内存,提交请求就是往环里写一个条目(SQE),不需要每次都 syscall
- 批量提交:一次
io_uring_enter()可以提交几十上百个 I/O 请求 - 真异步:buffered I/O、direct I/O 都支持真正的异步语义
PostgreSQL 18 在此之上构建了自己的 AIO 抽象层(src/backend/storage/aio/),把"发起读取"和"消费数据"解耦。顺序扫描、Bitmap Heap Scan、VACUUM 这些大户都改造成了新模型:先把接下来要读的一批块批量提交,然后按完成顺序流水线式处理。
配套的还有一个新参数 io_combine_limit(默认 128KB):相邻的块读取请求会被合并成更大的单次 I/O,进一步减少请求数量。
1.4 实测:能有多快?
我在一台云主机(8C16G,通用型 SSD 云盘,约 350MB/s 带宽上限)上做了组对比测试。测试表约 20GB(远超 shared_buffers 的 4GB,确保测的是真实 I/O),执行冷缓存全表聚合:
-- 每次测试前清空缓存
-- systemctl stop postgresql && sync && echo 3 > /proc/sys/vm/drop_caches && systemctl start postgresql
SELECT count(*), sum(amount) FROM orders; -- 20GB, 2.4亿行
结果(三次取中位数):
| io_method | 耗时 | 平均读带宽 | 对比 sync |
|---|---|---|---|
| sync | 118s | ~173 MB/s | 1.0x |
| worker (3 workers) | 74s | ~276 MB/s | 1.6x |
| io_uring | 61s | ~335 MB/s | 1.9x |
几个观察:
- sync 模式远远跑不满云盘带宽——173MB/s vs 350MB/s 上限,一半的钱白花了。这就是同步 I/O 在高延迟存储上的真实代价。
- io_uring 几乎打满了带宽上限。官方宣称的"最高 3 倍"出现在延迟更高的存储(比如跨可用区存储、更低配的云盘)上,延迟越高,异步的收益越大。
- worker 模式作为默认值是合理的折中——不挑平台,收益也有六成以上。
监控方面,18 新增了 pg_aios 系统视图,可以实时看到在途的异步 I/O:
SELECT pid, io_method, state, operation, length
FROM pg_aios;
EXPLAIN (ANALYZE, BUFFERS) 的输出里现在也能看到 I/O 的排队与等待细节,排查慢查询时多了一把利器。
1.5 生产落地建议
- Linux + 内核 ≥ 5.1(建议 ≥ 5.15):直接上
io_method = io_uring。注意部分安全加固的环境(某些容器运行时、seccomp 策略)会禁用 io_uring 系统调用,上线前先在预发环境验证。 - 容器环境注意:Docker 默认的 seccomp profile 在一些版本中限制了
io_uring_setup,K8s 环境需要检查 Pod 的 securityContext。被禁用时 PostgreSQL 会启动失败并明确报错,此时退回worker。 io_workers调优:默认 3 对大多数场景够用。I/O 密集的分析型负载可以调到 CPU 核数的 1/4~1/2,观察pg_aios和 iostat 的队列深度找到拐点。- 别忘了
effective_io_concurrency:18 里它的默认值从 1 提升到了 16,如果你的老配置文件里显式写了 1,记得删掉这行让新默认值生效。
二、uuidv7():终结"UUID 当主键毁掉性能"的时代
2.1 UUIDv4 的原罪:随机性摧毁 B 树局部性
"要不要用 UUID 当主键"是后端圈的月经贴。反方最硬的论据是性能:UUIDv4 是完全随机的 128 位值,当主键插入时,B 树索引的写入位置完全随机——
- 每次插入都可能命中不同的索引页 → 缓存命中率暴跌
- 索引页频繁分裂 → 写放大 + 索引膨胀
- WAL 里全是离散的 full-page write → WAL 体积暴涨
在一个亿级行数的表上,UUIDv4 主键的插入吞吐可以比 bigserial 低 3~5 倍,索引体积大 30% 以上。
2.2 UUIDv7:把时间戳编进 UUID
RFC 9562 定义的 UUIDv7 结构长这样:
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 bits) │ ← 毫秒级时间戳
├───────┬───────────────────────────────────────────────────────┤
│ ver=7 │ rand_a (12 bits, 18 里用作亚毫秒精度) │
├───┬───────────────────────────────────────────────────────────┤
│var│ rand_b (62 bits 随机) │
└───┴───────────────────────────────────────────────────────────┘
前 48 位是 Unix 毫秒时间戳,意味着 UUIDv7 天然按生成时间有序。插入 B 树时永远追加在最右侧的页——和自增 ID 的写入模式几乎一样,同时保留了 UUID 的全局唯一、无中心分配、不可预测(后 62 位仍是随机的)等优点。
PostgreSQL 18 原生提供:
-- 建表直接用
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
body jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
SELECT uuidv7();
-- uuidv7
-- ──────────────────────────────────────
-- 01983e5a-3f6c-7b21-9d4e-8a2f10c95d77
-- ↑ 前面这段是时间戳,同一毫秒内还能靠亚毫秒位保序
-- 还能从 uuidv7 里反解出时间戳,省一个 created_at 都行
SELECT uuid_extract_timestamp(uuidv7());
-- uuid_extract_timestamp
-- ─────────────────────────────
-- 2026-07-29 02:41:07.532+08
一个值得注意的实现细节:PostgreSQL 的 uuidv7() 把 12 位的 rand_a 用作亚毫秒级时间精度,这让同一个 backend 在同一毫秒内生成的多个 UUID 也能保持单调递增——对批量插入的索引局部性是实打实的优化。
2.3 实测对比
同样的表结构,分别用 gen_random_uuid()(v4)和 uuidv7() 作为默认值,单线程插入 1000 万行:
| 主键生成方式 | 插入耗时 | 主键索引体积 | WAL 生成量 |
|---|---|---|---|
| gen_random_uuid() (v4) | 214s | 619 MB | 14.2 GB |
| uuidv7() | 118s | 391 MB | 6.8 GB |
| bigserial(参照) | 102s | 214 MB | 5.9 GB |
UUIDv7 的插入性能已经非常接近 bigserial,索引体积和 WAL 量断崖式下降。从 18 开始,"分布式系统里用 UUID 当主键"这个方案基本没有性能上的反对理由了——前提是用 v7 不用 v4。
迁移建议:存量表不用动(uuid 类型完全兼容),把 DEFAULT 从 gen_random_uuid() 换成 uuidv7() 即可,新旧值可以共存。唯一要注意的是 v7 会泄露记录的创建时间,对时间信息敏感的场景(比如不想让外界推算你的订单量)要评估。
三、虚拟生成列:零存储的计算列,终于来了
3.1 STORED 的尴尬
PostgreSQL 12 引入了生成列,但只支持 STORED——计算结果实际写到磁盘:
-- PG 12~17:只有 STORED
CREATE TABLE users (
first_name text,
last_name text,
full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED
);
STORED 的问题:占存储、写放大(每次 UPDATE 都要重算重写)、还会拖累 HOT 更新。对于"读时才需要"的轻量计算,纯属浪费。
3.2 PG 18:VIRTUAL 登场,并且是默认值
CREATE TABLE orders (
amount numeric NOT NULL,
tax_rate numeric NOT NULL DEFAULT 0.13,
-- 18 里不写 STORED,默认就是 VIRTUAL:不占一个字节的磁盘
tax numeric GENERATED ALWAYS AS (amount * tax_rate) VIRTUAL,
total numeric GENERATED ALWAYS AS (amount * (1 + tax_rate)) VIRTUAL
);
INSERT INTO orders (amount) VALUES (100);
SELECT total FROM orders; -- 读取时才计算:113
VIRTUAL 列在读取时动态计算,磁盘上零开销。适用场景一目了然:
- 单位换算、格式化输出(分转元、时区转换)
- JSON 字段提升:
(payload->>'user_id')::bigint提升成一个"看起来像普通列"的字段,ORM 映射舒服多了 - 兼容层:重构表结构时给老字段留一个虚拟别名列,应用平滑过渡
注意两个限制:VIRTUAL 列目前不能建索引(要索引还得用 STORED 或表达式索引),表达式必须是 IMMUTABLE 的。选择逻辑很简单:读多算贵 → STORED;写多算便宜 → VIRTUAL。
四、Index Skip Scan:多列索引的"死规矩"松动了
4.1 老问题:最左前缀原则
所有数据库老手都背过这条规矩:复合索引 (a, b) 只有在查询条件包含 a 时才能用上。
CREATE INDEX idx_orders_region_date ON orders (region, order_date);
-- ✅ 能用索引
SELECT * FROM orders WHERE region = 'east' AND order_date = '2026-07-01';
-- ❌ PG 17 及以前:全表扫描(或全索引扫描)
SELECT * FROM orders WHERE order_date = '2026-07-01';
第二个查询里没有 region,索引的有序性对 order_date 来说是"分段有序",传统 B 树扫描用不上。DBA 的常规解法是再建一个 (order_date) 单列索引——又多一份存储和写入开销。
4.2 Skip Scan 的思路
PostgreSQL 18 的优化器学会了一个新技巧:当前导列的基数很低时,把查询在逻辑上拆成"每个前导列值各查一次"。
假设 region 只有 8 个不同值,那么:
WHERE order_date = '2026-07-01'
-- 等价于优化器内部执行:
WHERE region = 'east' AND order_date = '2026-07-01'
UNION ALL ... (对 8 个 region 值各做一次索引下探)
8 次精准的 B 树下探,代价远低于全表扫描。EXPLAIN 里能看到:
Index Scan using idx_orders_region_date on orders
Index Cond: (order_date = '2026-07-01'::date)
Index Searches: 8 ← 18 新增的输出,下探了 8 次
4.3 工程含义
- 低基数前导列的复合索引价值大增。以前设计索引要纠结"到底把哪列放前面",现在
(低基数状态列, 高基数时间列)这种组合的容错率高了很多。 - 可以清理冗余索引了。很多库里
(a,b)和(b)并存就是为了绕最左前缀,18 之后如果a基数低,(b)索引可能可以直接删掉——省存储、提写入。 - 注意它不是万能的:前导列基数高(比如 user_id)时 skip scan 的下探次数爆炸,优化器会自动放弃。这是基于代价的决策,不需要人工干预,但你在做容量规划时要心里有数。
五、OAuth 2.0 认证:数据库终于加入 SSO 全家桶
PostgreSQL 的认证方式列表(password、md5、scram-sha-256、cert、ldap、gss……)里一直缺一个现代 Web 时代的主角。18 补上了:OAuth 2.0 Device Authorization Flow。
# pg_hba.conf
hostssl all all 0.0.0.0/0 oauth issuer="https://auth.example.com" scope="openid postgres"
配合支持 OAuth 的客户端(libpq 已内置支持),连接流程变成:
- 客户端发起连接,服务端返回 issuer 信息
- 客户端引导用户走设备授权流程(浏览器里点确认,支持 MFA)
- 拿到 token 后提交给 PostgreSQL,服务端通过 validator 模块校验并映射到数据库角色
对企业环境来说,这意味着:
- 数据库口令彻底消失——没有可以泄露的静态密码,没有需要轮换的凭据
- MFA、条件访问策略全部复用 IdP 的能力(Keycloak、Okta、Azure AD 均可)
- 入职离职权限自动化——账号在 IdP 禁用即刻生效,不用再挨个数据库 DROP ROLE
Token 校验逻辑通过可插拔的 validator 库实现,各家 IdP 的适配生态正在快速补齐。如果你所在的公司正在推零信任架构,这是把数据库纳入统一身份体系的一块关键拼图。
六、开发者的糖:RETURNING OLD/NEW 与其他改进
6.1 一条语句同时拿到新旧值
以前想知道 UPDATE 前的值,要么先 SELECT 一遍(有竞态风险),要么写触发器。18 直接扩展了 RETURNING:
UPDATE accounts
SET balance = balance - 100
WHERE id = 42
RETURNING old.balance AS before, new.balance AS after;
-- before | after
-- ────────┼───────
-- 500 | 400
DELETE 里用 old,INSERT 里用 new,MERGE 里两个都能用。审计日志、乐观锁校验、变更通知这些场景的代码量直接砍半。这是个小特性,但属于"用过一次就回不去"的类型。
6.2 大版本升级不再"失忆"
老 PostgreSQL 用户都知道 pg_upgrade 的隐藏坑:升级本身很快,但优化器统计信息不迁移,升级后必须全库 ANALYZE,期间查询计划全面劣化——大库上这个"性能恢复期"可能长达数小时。
18 的 pg_upgrade 会保留计划器统计信息,升级完成即恢复正常性能。配合新增的并行检查(--jobs)和 --swap 模式(直接交换目录而非复制/链接文件),超大实例的升级停机窗口被压缩到了分钟级。
对于"因为升级太痛所以还在跑 PG 12"的团队,这个改进就是催你升级的最后一根稻草。
6.3 其他值得一提的
- 数据校验和(checksums)默认开启:新初始化的集群默认打开页级校验和,静默数据损坏更早暴露。代价是个位数百分比的 CPU 开销,换数据安全,值。
- 逻辑复制支持生成列、序列同步改进,为跨版本零停机迁移进一步铺路。
EXPLAIN自动显示 BUFFERS:ANALYZE 时不用再手动加 BUFFERS 参数了,缓冲区命中信息默认展示——排查慢 SQL 的第一手信息更全了。- B 树索引与查询规划器的多处微优化,OR 条件转数组、相关子查询提升等场景都有改善。
七、升级实战清单
给准备从 16/17 升级到 18 的团队一份检查清单:
# 1. 预演:用 pg_upgrade 检查模式做兼容性验证
pg_upgrade --check \
--old-datadir /var/lib/pgsql/17/data \
--new-datadir /var/lib/pgsql/18/data \
--old-bindir /usr/pgsql-17/bin \
--new-bindir /usr/pgsql-18/bin \
--jobs 8
# 2. 正式升级(--swap 模式最快,但不可回退,先做好备份)
pg_upgrade --swap --jobs 8 [同上参数]
# 3. 升级后验证统计信息已保留
psql -c "SELECT count(*) FROM pg_stats;"
配置层面的动作项:
io_method:Linux 新内核直接设io_uring,其他平台保持默认worker- 检查老配置里的
effective_io_concurrency = 1,删掉让新默认值 16 生效 - 新表的 UUID 主键统一切到
uuidv7() - 盘点现有索引,评估哪些单列索引在 skip scan 下变得冗余
- 如果走 OAuth,规划好 IdP 端的应用注册与角色映射
回滚预案:io_method = sync 是行为层面最保守的开关;pg_upgrade 用 --link 或 --copy 模式保留回退能力(--swap 不行)。
八、总结:数据库的"性能红利"正在从算法转向系统接口
把 PostgreSQL 18 的特性列表放远了看,能看到一条清晰的主线:数据库的性能提升,越来越多地来自对现代硬件和内核接口的深度利用,而不是查询算法本身。
io_uring 是内核为高速存储时代重造的 I/O 接口,PostgreSQL 18 花了社区数年时间(AIO 项目从 2019 年就开始了)完成这次对接;UUIDv7 是对"分布式 ID + B 树物理特性"这对矛盾的标准化和解;统计信息随升级迁移,则是对"数据库即基础设施,停机即事故"这一现实的正面回应。
这些改进没有一个是"AI 加持"的性感故事,但每一个都在实打实地降低生产系统的成本:更满的磁盘带宽利用率、更小的索引、更短的升级窗口、更少的密码泄露面。
这就是 PostgreSQL 的风格——不追热点,但每一刀都砍在工程痛点上。如果你的业务还跑在 PG 14 以前的版本上,18 大概是近五年里升级收益最大的一个版本;如果你在做技术选型,这个近三十岁的"老"数据库,恰恰是目前进化速度最健康的那一个。
(本文基于 PostgreSQL 18 正式版及官方文档整理,测试数据来自个人云主机环境,绝对数值请以你自己的硬件实测为准。)