编程 PostgreSQL 18 深度实战:异步I/O + UUIDv7 + 跳跃扫描 + 虚拟生成列——把"关系型数据库"重写成现代应用底座的全链路拆解

2026-08-16 17:42:34 +0800 CST views 7

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、低基数列的跳跃扫描、以及不占磁盘的虚拟生成列。

这篇文章的目的,是让你在读完之后,能够:

  1. 判断你的业务到底能不能从 PG 18 的异步 I/O 里吃到红利
  2. UUID v7 替代随机 UUID,从根上降低索引膨胀与写入抖动;
  3. 理解跳跃扫描什么时候是神器、什么时候是"鸡肋";
  4. 虚拟生成列把"计算列"的成本压到最低;
  5. 拿到一套可直接落地的配置 + 基准测试脚本

二、核心概念:四个特性的底层逻辑

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_prewarmANALYZE 的 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,收益不是"理论上更优雅",而是可量化的:

  1. 索引体积更小:减少 page split → 更少叶子页;
  2. 缓存命中率更高:顺序写入让热数据集中,buffer pool 利用率上升;
  3. 写入抖动更低:避免随机插入引发的连锁分裂;
  4. 天然按时间可排序ORDER BY idORDER 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 还会再抬一截。

给工程师的落地清单

  1. 升级到 PG 18,先开 worker 模式吃 AIO 红利(最稳);
  2. 新表主键优先 uuidv7(),老表做索引膨胀评估后逐步迁移;
  3. 复合索引查询若常漏前导列,先评估前导列基数再决定是否靠 Skip Scan;
  4. 派生字段一律先想 VIRTUAL,再想 STORED/表达式索引;
  5. pg_aios() + pgbench 把"是否真的生效"量化出来,别靠感觉。

PostgreSQL 用了三十多年证明了一件事:稳健和进化并不矛盾。PG 18 就是这句话最新的注脚。

推荐文章

PHP中获取某个月份的天数
2024-11-18 11:28:47 +0800 CST
程序员茄子在线接单