编程 PostgreSQL 18 深度实战:当数据库内核终于「异步」起来——从 AIO/io_uring、UUID v7 到跳过扫描与虚拟生成列的完整工程指南(2026)

2026-07-21 03:15:52 +0800 CST views 17

PostgreSQL 18 深度实战:当数据库内核终于「异步」起来——从 AIO/io_uring、UUID v7 到跳过扫描与虚拟生成列的完整工程指南(2026)

本文面向一线后端、DBA 与架构师。我们不堆特性清单,而是把 PostgreSQL 18 里真正能改变你系统性能的几把"手术刀"——异步 I/O 子系统、UUID v7、虚拟生成列、B-tree 跳过扫描、MERGE ... RETURNING——从原理、内核架构、可运行代码到生产调优,逐层拆开。配完整 SQL / Docker / 配置示例,读完后你既能说服老板升级,也能自己动手把吞吐调出来。


一、背景:为什么 PostgreSQL 18 是近几年最值得升级的一次

PostgreSQL 保持着一年一个大版本的节奏,通常每年 9 月发布。17 到 18 的跨越,和前面几次"加几个函数、修几个 bug"的体感完全不同:这一次,社区动的是数据库最底层的 I/O 心脏

过去十几年,PostgreSQL 在关系型数据库里以"稳"著称,但它有一个被反复吐槽的老毛病——I/O 是同步的。当一个后端进程(backend)需要从一个大表里读数据,它向操作系统发一个 read(),然后就被挂起,原地等待磁盘把数据送回来。在机械盘时代这还能忍受;但在 NVMe 和云盘时代,问题被放大了:

  • 云块存储延迟高。EBS、云硬盘这类网络块设备在随机小 I/O 上延迟往往是本地 NVMe 的几倍到几十倍,同步等待的代价被成倍放大。
  • CPU 在空转。进程卡在 I/O 上时,CPU 只能计入 iowait,宝贵的算力被白白浪费。
  • 预读不精准。PostgreSQL 长期依赖操作系统的 posix_fadvise 预读,但操作系统并不知道你接下来是要顺序扫描还是索引点查,预读命中率有限。

一句话总结:PostgreSQL 18 之前,数据库把"何时读、读多少、怎么读"的控制权,很大程度交给了操作系统。PG 18 第一次把这块控制权收回到内核自己手里。 这就是本次升级最核心的意义——它让 PostgreSQL 真正"异步"起来了。

除了 AIO 这个地基级特性,PG 18 还带来了一组彼此呼应、都能立刻落地到业务代码的改进:更好局部性的 UUID v7、零写入开销的虚拟生成列、让"烂索引设计"起死回生的B-tree 跳过扫描、以及增强后的 MERGE ... RETURNING。下面我们逐个拆解。


二、核心概念:读懂 PG 18 的四大引擎级变革

2.1 异步 I/O(AIO)子系统

先讲清楚"同步 I/O"到底卡在哪。一个典型的同步读路径是这样的:

  1. 后端进程调用 pread() 读取某个数据块;
  2. 内核发起磁盘 I/O,进程进入睡眠(阻塞);
  3. 磁盘控制器读完数据,内核唤醒进程,数据从内核缓冲区拷进共享缓冲区(shared buffers);
  4. 进程继续执行。

第 2 步到第 3 步之间,进程什么都干不了。对于全表扫描、位图堆扫描(bitmap heap scan)、VACUUM、大表 ANALYZE 这类需要大量顺序/并行读盘的操作,这些阻塞会累积成惊人的 iowait

AIO 的核心思想是把"发起 I/O"和"等待 I/O 完成"解耦:进程把读请求提交出去后立刻转身去干别的事(比如解析下一条 SQL、构建执行计划),等数据准备好了再由内核通知它回来取。CPU 和 I/O 在时间上重叠,形成流水线。

PG 18 用一个统一的 io_method 参数暴露了三种 I/O 模式:

io_method行为适用场景
sync向后兼容模式,走 posix_fadvise 把数据预取到 OS page cache(不进 shared buffers保守、兼容老环境
worker启动一组 I/O 工作进程池,在共享内存队列里接活,用 pread 读进 shared buffers通用、容器友好、无需特殊内核能力
io_uring基于 Linux io_uring 直接把 I/O 请求提交给内核 ring,异步完成现代 Linux(≥5.1)、追求极致读吞吐

另外两个关键参数:

  • io_workers:I/O 工作进程数量,默认 3。这是 worker 模式下真正的并发读通道数。
  • io_combine_limit:把相邻的读请求合并成一个大请求的上限,默认 128kB。合并能显著减少系统调用次数。

AIO 当前覆盖哪些操作? 主要收益集中在顺序扫描、位图堆扫描、VACUUM、大表 ANALYZE 的预读上。WAL 写入、普通索引点查目前仍是同步的——异步写和 WAL 异步是 PG 后续版本的方向,本文第五节会展开。

2.2 UUID v7:时间序列可排序的唯一 ID

如果你用 UUID 做主键(分布式系统常常如此),PG 18 的 uuidv7() 是一个能直接改善性能的"免费午餐"。

问题出在 UUID v4gen_random_uuid() 生成的就是 v4):它是完全随机的。把完全随机的 ID 当 B-tree 主键,意味着每次插入都落到一个随机的叶子页上——已有的页被反复打碎、分裂(page split),Buffer Pool 里热点页被随机 ID 冲散,缓存命中率下降,WAL 量也更大。

UUID v7 的巧妙之处在于:把 48 位毫秒级 Unix 时间戳放在 UUID 的最前面,后面跟随机位。于是:

  • 同一毫秒内生成的 v7 在字节序上天然相邻;
  • 插入近似顺序,B-tree 页分裂大幅减少,写入更紧凑;
  • 天然可按时间排序,无需额外 created_at 列即可做时间范围扫描。

对业务的影响很直接:更紧凑的索引、更少的页分裂、更好的缓存局部性、更小的 WAL。对于日增千万级、以 UUID 为主键的表,这是肉眼可见的收益。

2.3 虚拟生成列(VIRTUAL generated columns)

生成列(generated column)是"由其他列计算而来、不允许手动写入"的列。PG 早期版本只支持 STORED 生成列——也就是在写入时就算好、把结果落盘存储。它的代价是:占存储空间 + 每次写入都要算一次。

PG 18 引入了 VIRTUAL 生成列,并把它设为默认。VIRTUAL 列不在写入时物化,而是在读取时按需计算,因此:

  • 写入零存储、零计算开销(只在查询引用时才算);
  • 对"读少写多"或者"计算列很少被查"的场景非常友好;
  • 需要索引时仍可对该列建立索引(索引会物化计算值),兼顾查询性能。

典型用途:订单金额 qty * unit_price、归一化后的字段、派生状态位等——以前你要么每次查询都 SELECT qty*unit_price,要么忍受 STORED 列的写放大,现在一个 VIRTUAL 列就优雅解决了。

2.4 B-tree 跳过扫描(Skip Scan)

这是给"历史索引设计不合理"应用的救命特性。

假设有一个组合索引 (tenant_id, event_type)。你的很多查询只按 event_type 过滤:

SELECT * FROM logs WHERE event_type = 'type1';

在 PG 18 之前,由于前导列 tenant_id 没有出现在 WHERE 里,优化器基本只能放弃这个索引、走全表扫描——即使 event_type 的选择性很好。

PG 18 的 Skip Scan(跳过扫描) 会在内部对 tenant_id每一个不同值动态生成一个等值约束,逐段"跳着"扫描索引,从而复用这个组合索引。对于"前导列基数低、后列过滤强"的索引,它能把一次全表扫描变成高效的索引扫描。

2.5 顺带的三个实用增强

  • MERGE ... RETURNING:合并(upsert)语句现在能返回受影响行的变更前后值,配合 merge_action()OLD/NEW 别名,做幂等写入 + 审计一条语句搞定。
  • ONLY 关键字用于分区表VACUUM ONLY / ANALYZE ONLY 只处理父表、跳过分区,避免对巨大分区表做无意义的递归维护。
  • 可观测性升级pg_stat_all_tables 新增 vacuum/analyze 耗时;pg_stat_checkpointer 新增 num_donepg_stat_database 新增并行 worker 统计;新增 pg_stat_get_backend_io()pg_aios 视图看每后端 I/O 与进行中的异步 I/O。

三、架构分析:AIO 在内核里到底怎么跑

理解 AIO,关键看 PostgreSQL 怎么改了它的存储管理器(smgr)接口。PG 18 给 smgr 增加了异步读能力:smgr_startreadv(发起异步读)和配套的完成回调结构(PgAioTargetInfoPgAioHandleCallBacks)。三种 io_method 的数据流差异如下。

3.1 sync 模式:把控制权交给 OS

后端直接调用 posix_fadvise() 建议内核预取,数据被读进操作系统 page cache 而非 PostgreSQL 的 shared buffers。好处是改动最小、兼容性最好;坏处是 I/O 调度仍不在 PG 手里,且数据要再过一道 OS 缓存,存在双重缓冲。

3.2 worker 模式:共享内存队列 + 进程池

这是默认的、最稳妥的异步实现:

  1. 后端进程需要读某块时,把读请求(目标文件、偏移、长度、目标 buffer 描述符)塞进共享内存里的一个队列
  2. 一组 I/O 工作进程(io_workers 个)被唤醒,用 pread() 把数据读进 shared buffers;
  3. 完成后通过回调通知后端进程,后端从 shared buffers 取数据继续。

因为读由专门的工作进程执行,后端进程不再被单次 I/O 阻塞,可以并行推进其他工作。worker 模式不依赖任何特殊内核能力,在容器里也能直接跑,这是它最大的工程价值。

3.3 io_uring 模式:把请求直接递给内核

io_uring 是 Linux 5.1 引入的高性能异步 I/O 框架,核心是两个环形队列(SQ 提交队列 / CQ 完成队列)在内核与用户态间共享内存

  1. 后端进程直接往 SQ 里塞一个 SQE(提交队列项),描述这次读;
  2. 内核在后台异步完成 I/O;
  3. 完成后往 CQ 里写一个 CQE(完成队列项)通知进程。

io_uring 避免了 worker 模式的"进程间排队 + 唤醒"开销,系统调用次数也大幅下降,对读密集负载收益最大——社区在开发阶段的基准测试显示,顺序扫描在 io_uring 下读吞吐接近翻倍。它还为 PG 未来启用直接 I/O(DIO) 铺好了路:DIO 能绕过 OS page cache,消除 shared buffers 与 OS 缓存之间的"双重缓冲",进一步降 CPU、降延迟。

3.4 当前边界与监控

需要清醒认识 AIO 的当前边界:

  • 只支持 smgr 异步读,WAL 异步写、smgr 异步写仍在开发中;
  • 收益集中在顺序/位图扫描与 VACUUM;
  • effective_io_concurrency(顺序扫描预取深度)协同工作——AIO 是在这个基础上把"预取"做得更可控。

监控进行中的异步 I/O,用新增的 pg_aios 视图:

SELECT * FROM pg_aios;

它能看到当前有多少个 I/O 请求在飞、处于什么阶段,是排查 AIO 是否生效、是否打满的第一现场。


四、代码实战

下面所有示例在 PostgreSQL 18 上可直接运行。先准备好环境和配置。

4.1 环境准备与配置

方式 A:Docker Compose(启用 io_uring)

services:
  pg18:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: secret
      PGIO_METHOD: io_uring
    command:
      - postgres
      - -c
      - io_method=io_uring
      - -c
      - io_workers=4
    cap_add:
      - SYS_IOURING        # io_uring 在容器内需要此 capability,否则回退同步
    ports:
      - "5432:5432"

方式 B:裸机 postgresql.conf

# PostgreSQL 18 异步 I/O 核心配置
io_method = 'worker'        # 或 'io_uring'(需 Linux 5.1+ 且未被 seccomp 拦截)
io_workers = 4              # 建议设为 vCPU 的 25%~50%
io_combine_limit = '128kB'  # 相邻读请求合并上限

io_method 属于 postmaster 级参数,修改后需要重启io_workers / io_combine_limit 可以 ALTER SYSTEM SET ...; SELECT pg_reload_conf(); 在线调整。

验证版本与 AIO 状态:

SELECT version();
SHOW io_method;
SHOW io_workers;

4.2 UUID v7 实战:索引膨胀对比

-- v4 随机主键
CREATE TABLE events_v4 (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  payload text,
  created_at timestamptz DEFAULT now()
);

-- v7 时间有序主键
CREATE TABLE events_v7 (
  id uuid DEFAULT uuidv7() PRIMARY KEY,
  payload text,
  created_at timestamptz DEFAULT now()
);

INSERT INTO events_v4 (payload) SELECT 'x' FROM generate_series(1, 1_000_000);
INSERT INTO events_v7 (payload) SELECT 'x' FROM generate_series(1, 1_000_000);

-- 对比主键索引大小(v7 通常更小、更紧凑)
SELECT
  pg_size_pretty(pg_relation_size('events_v4_pkey')) AS v4_idx_size,
  pg_size_pretty(pg_relation_size('events_v7_pkey')) AS v7_idx_size;

-- v7 天然可按时间排序
SELECT id, created_at FROM events_v7 ORDER BY id LIMIT 5;

events_v7_pkey 的字节数通常明显小于 events_v4_pkey,因为顺序插入让 B-tree 页分裂更少、填充率更高;同时 WAL 因为写入更"聚簇"而更小。对高频写入表,这是实打实的存储与 I/O 双收益。

4.3 AIO 实战:冷缓存顺序扫描 + 观察在飞 I/O

CREATE TABLE big (id bigint, v text) WITH (fillfactor = 100);
INSERT INTO big SELECT g, repeat('x', 200) FROM generate_series(1, 5_000_000) g;

-- 计时顺序扫描
\timing on
SELECT count(*) FROM big;
\timing off

-- 在另一个会话里观察正在进行的异步 I/O(AIO 生效时这里会有记录)
SELECT * FROM pg_aios;

-- 查看本会话的 I/O 统计(按读类型拆分)
SELECT * FROM pg_stat_get_backend_io(pg_backend_pid());

如果你把 io_methodsync 切到 worker/io_uring 并重启,再跑同样的 SELECT count(*),在读密集、数据远大于 shared buffers 的场景下,耗时会明显下降;pg_aios 里能看到成批的异步读请求在飞,而不再是后端被单次读阻塞。

4.4 虚拟生成列实战

CREATE TABLE orders (
  id          bigserial PRIMARY KEY,
  qty         int                     NOT NULL,
  unit_price  numeric(10,2)           NOT NULL,
  amount      numeric(12,2)
                GENERATED ALWAYS AS (qty * unit_price) VIRTUAL   -- PG18 默认即 VIRTUAL
);

INSERT INTO orders (qty, unit_price) VALUES
  (3, 19.90),
  (10, 5.00),
  (1, 99.00);

SELECT * FROM orders;
--  id | qty | unit_price | amount
-- ----+-----+------------+--------
--   1 |   3 |      19.90 |  59.70
--   2 |  10 |       5.00 |  50.00
--   3 |   1 |      99.00 |  99.00

-- 需要按计算列查询/过滤时仍可建索引(索引会物化计算值)
CREATE INDEX idx_orders_amount ON orders (amount);
EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE amount > 80;

写入时 amount 不占存储、不重复计算;只有在查询引用它时才算。若业务经常按 amount 过滤,建索引即可,二者兼得。

4.5 跳过扫描实战

CREATE TABLE logs (
  tenant_id   int,
  event_type  text,
  created_at  timestamptz,
  payload     jsonb
);
CREATE INDEX idx_logs ON logs (tenant_id, event_type);

INSERT INTO logs
  SELECT (g % 100), 'type' || (g % 5), now(), '{}'::jsonb
  FROM generate_series(1, 1_000_000) g;

ANALYZE logs;

-- 只过滤后列 event_type,PG18 会用 Index Skip Scan 复用组合索引
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM logs WHERE event_type = 'type1';

在 PG 17 及更早版本,这条查询因前导列 tenant_id 缺失,通常只能走 Seq Scan;PG 18 的 EXPLAIN 输出里会出现 Index Skip Scan,读取的块数(Buffers)显著下降,尤其当 tenant_id 基数低、event_type 选择性高时收益最大。

4.6 MERGE ... RETURNING 实战

CREATE TABLE inventory (sku text PRIMARY KEY, qty int NOT NULL);

-- 幂等扣减库存,并一次返回"变更前/后"与动作类型
MERGE INTO inventory t
USING (VALUES ('A-100', -5)) AS s(sku, delta)
ON t.sku = s.sku
WHEN MATCHED THEN
  UPDATE SET qty = t.qty + s.delta
WHEN NOT MATCHED THEN
  INSERT (sku, qty) VALUES (s.sku, s.delta)
RETURNING merge_action(), OLD.qty AS before_qty, NEW.qty AS after_qty;

merge_action() 返回 INSERT / UPDATE / DELETEOLD / NEW 别名让你拿到变更前后的值。对账、审计、幂等 upsert 这类需求,以前要写"先查后插再更"的多条语句,现在一条 MERGE ... RETURNING 收尾,事务语义更干净。

4.7 ONLY 关键字:分区表维护提速

-- 只维护父表元信息,跳过分区(巨型分区表省时利器)
VACUUM ONLY my_partitioned_table;
ANALYZE ONLY my_partitioned_table;

五、性能优化:把 AIO 调出真金白银

5.1 io_workers 调优

io_workers 是 worker 模式下的真实并发读通道。经验值:设为 vCPU 数的 25%~50%。例如 8 核设 4、16 核设 6~8。设太小 I/O 并发不足,设太大则进程间争抢与上下文切换反而抵消收益。io_combine_limit 一般保持默认 128kB 即可,对大顺序扫描可适当上调以合并更多相邻请求。

5.2 io_method 选型决策树

内核 >= 5.1 且 能授予 SYS_IOURING(裸机/已配置 capability)?
   ├─ 是 → io_uring(读密集负载吞吐最高,开发期基准接近翻倍)
   └─ 否 → 在容器/受限环境?
            ├─ 是 → worker(默认,零特殊依赖,立即可用)
            └─ 否 → 保守选 sync

强烈建议:不要一上来就无脑开 io_uring。某些老内核、网络文件系统(如 NFS)对 io_uring 支持有限,强行开启可能回退到同步甚至报错。先用 worker 拿到稳定收益,再针对读写特征决定是否上 io_uring

5.3 容器化陷阱(最容易被坑)

io_uring 在默认容器运行时下常被 seccomp 拦截。若你看到日志提示 io_uring 不可用、或 SHOW io_method 显示仍是 sync,多半是权限问题。解决:

  • Docker:docker run --cap-add=SYS_IOURING ... 或在 compose 里 cap_add: [SYS_IOURING]
  • Kubernetes:securityContext.capabilities.add: ["SYS_IOURING"]

否则 PostgreSQL 会静默回退到同步模式,你以为开了异步其实没开——这正是 pg_aios / pg_stat_get_backend_io() 要用来验证的原因。

5.4 监控三件套

视图 / 函数看什么
pg_aios进行中的异步 I/O 请求数量与阶段,验证 AIO 是否真生效
pg_stat_get_backend_io(pid)单个后端按读类型(正序/随机/预读)拆分的 I/O 统计
pg_stat_database并行 worker 启动/实际启动数,判断并行与 I/O 是否打满

5.5 升级与迁移注意

  • 默认即受益:PG 18 安装后 io_method 默认是 worker,AIO 已经开启,多数读密集负载无需改任何业务代码就能拿到提升。
  • 升级路径:用 pg_upgrade 原地升级到 18;升级前检查自建扩展(extension)对 18 的兼容性(如 PostGIS、pgvector 等需对应新版本)。
  • 逻辑复制增强:PG 18 改进了逻辑复制的稳定性与功能,做跨版本订阅/灾备时注意新行为(如行过滤、冲突处理细节)。
  • 别神话 AIO:它主要改善"读多、数据大于内存"的扫描类负载;纯点查、写密集、或小表全在缓存里的场景,体感提升有限。

六、总结与展望

PostgreSQL 18 值得被称作 "异步元年"。它做的最重要的一件事,是把数据库的 I/O 调度权从操作系统手里夺回来——AIO 是地基,未来在此之上还会长出直接 I/O(DIO)WAL/写异步,进一步消除双重缓冲、把延迟压到更低。

对一线开发者的实际收益是立体的:

  1. 默认即受益的读性能——大表扫描、VACUUM 在 worker 模式下就能提速,上 io_uring 还能再翻倍;
  2. 更好的 ID 局部性——uuidv7() 让 UUID 主键不再"自残"索引;
  3. 更省的写放大——VIRTUAL 生成列把计算推迟到读取时;
  4. 更聪明的优化器——Skip Scan 让历史索引设计重获新生;
  5. 更干净的写入语义——MERGE ... RETURNING 一条语句完成幂等 upsert + 审计。

选型建议很直白:新项目直接上 PG 18;老项目评估扩展兼容性后用 pg_upgrade 升级——这一步的性价比,远高于再去手调一堆补偿性参数。

数据库的"等"时代,在 PG 18 落下了第一锤。当你下次再看到监控图上那条刺眼的 iowait 红线时,记得:这一次,PostgreSQL 终于学会了不等。


参考资料与验证手段:PostgreSQL 18 官方发布公告、PG 开发者邮件列表(Tomas Vondra 的 AIO 调优指南)、IvorySQL / ITPUB 社区对 PG 18 六大特性的拆解,以及 pg_aiospg_stat_get_backend_io() 等内置视图的实测。所有 SQL 示例均基于 PostgreSQL 18 语法,可在本地 postgres:18 容器直接复现。

推荐文章

Vue3 结合 Driver.js 实现新手指引
2024-11-18 19:30:14 +0800 CST
全栈利器 H3 框架来了!
2025-07-07 17:48:01 +0800 CST
三种高效获取图标资源的平台
2024-11-18 18:18:19 +0800 CST
程序员茄子在线接单