编程 PostgreSQL 18 深度拆解:当关系型数据库拥抱异步 I/O——从 io_uring 子系统、Skip Scan 到 uuidv7 与虚拟生成列的工程全貌(2026)

2026-07-19 05:13:17 +0800 CST views 12

PostgreSQL 18 深度拆解:当关系型数据库拥抱异步 I/O——从 io_uring 子系统、Skip Scan 到 uuidv7 与虚拟生成列的工程全貌(2026)

2025 年 9 月 25 日,PostgreSQL 18 正式发布。这不是一次常规的年度小修小补,而是把过去十几年积累的架构债一次性还清的版本:一个全新的异步 I/O 子系统让存储读取性能最高提升 3 倍,B-Tree 迎来 Skip Scan,UUID 有了数据库友好的 uuidv7(),还内置了 OAuth 2.0。作为一个常年跟 PG 打交道的后端工程师,我把 PG18 里真正值得升级的东西,从底层原理到生产落地,掰开揉碎讲一遍。


一、背景:PostgreSQL 的「同步 I/O 之殇」

在讲新特性之前,得先说清楚 PG18 到底解决了什么历史问题。否则你只会觉得「哦,又快了 3 倍」,但不知道这 3 倍是从哪里省出来的。

长期以来,PostgreSQL 的存储读取是同步、阻塞、单块的。当执行器需要读取一个数据页(默认 8KB)时,后端进程(backend process)会调用一次 pread(),然后原地等待内核把数据从磁盘搬到内存。在这段等待时间里,这个进程什么都干不了——CPU 空转,等 I/O 回来。

PG 早年靠两个机制缓解这个问题:

  1. 操作系统预读(OS readahead):Linux 内核检测到顺序读,会提前把后面的块加载到 page cache。但这只对顺序扫描有效,对随机 I/O(比如索引扫描回表)几乎无能为力。
  2. effective_io_concurrency + posix_fadvise:仅用于 Bitmap Heap Scan,通过 posix_fadvise(POSIX_FADV_WILLNEED) 提示内核预取。这是「提示」不是「执行」,控制力弱,且覆盖场景极窄。

问题的本质是:PG 没有一个统一的、贯穿所有扫描类型的异步 I/O 抽象层。在 NVMe SSD 动辄百万级 IOPS、云上 EBS 高延迟的今天,单进程同步阻塞读成了明显的瓶颈——磁盘明明还能吞更多请求,但进程在傻等。

PG18 的答案,就是从零构建的 AIO(Asynchronous I/O)子系统


二、核心:全新的异步 I/O 子系统

2.1 三种 I/O 方法:io_method

PG18 引入了新的 GUC 参数 io_method,它有三个取值:

-- 查看当前 I/O 方法
SHOW io_method;

-- 可选值:
-- worker    (默认) 通过一组后台 I/O worker 进程执行异步读
-- io_uring  基于 Linux io_uring,真正的内核级异步
-- sync      退回旧的同步行为(兼容/排障用)

三者的定位:

  • worker:这是 PG18 的默认值,也是跨平台的通用方案。它启动一批专门的 io worker 进程(由 io_workers 控制,默认 3 个),backend 把 I/O 请求丢进共享内存队列,worker 进程去执行 pread,完成后通知 backend。对 backend 而言,I/O 变成了「提交-回调」模式,不再阻塞。
  • io_uring:仅在 Linux(内核 5.1+,实际推荐 5.15+)可用,且需要编译时 --with-liburing。它直接使用 io_uring 的提交队列(SQ)和完成队列(CQ),把 I/O 提交到内核,由内核异步完成,零额外进程、零上下文切换开销。这是性能天花板最高的方案。
  • sync:完全退回 PG17 及之前的行为。当你怀疑 AIO 引入了问题时,可以一键回退排查。

2.2 io_uring 到底快在哪

要理解 io_uring 的价值,得对比传统系统调用模型。

传统模型下,每次 pread() 都是一次用户态→内核态的完整上下文切换。读 1000 个随机块,就是 1000 次切换 + 1000 次阻塞等待。

io_uring 的模型是批量提交、批量收割

用户态                        内核态
┌─────────────┐              ┌─────────────┐
│ Submission  │──── SQE ────▶│  io_uring   │
│ Queue (SQ)  │              │  kernel     │
├─────────────┤              │  poller     │
│ Completion  │◀─── CQE ─────│             │
│ Queue (CQ)  │              └─────────────┘
└─────────────┘
  • backend 把多个读请求作为 SQE(Submission Queue Entry)一次性写入共享的 SQ ring;
  • 一次 io_uring_enter() 系统调用提交一整批(甚至配合 SQPOLL 模式连这次调用都省了);
  • 内核异步完成后把结果写入 CQ ring,backend 轮询收割 CQE。

关键收益:N 个 I/O 请求从「N 次系统调用 + N 次阻塞」降到「1 次系统调用 + 批量非阻塞」。在高并发随机读、大表 Bitmap Heap Scan、并行顺序扫描场景下,磁盘队列深度被真正打满,这才有了官方宣称的最高 3 倍读取性能提升

2.3 哪些操作受益:read stream 抽象

PG18 并不是简单地把 pread 换成异步调用,而是引入了一个叫 read stream 的内部抽象。它是异步 I/O 之上的高层接口:调用方声明「我接下来要按这个顺序读这一串块」,read stream 负责预测、批量提交、预读。

目前已经接入 read stream 的场景包括:

  • 顺序扫描(Sequential Scan)
  • Bitmap Heap Scan
  • VACUUM / ANALYZE 的堆扫描
  • 部分索引扫描的回表读取
  • pg_prewarm、物化视图刷新等

也就是说,你不需要改任何 SQL,升级到 PG18 后这些扫描自动享受异步 I/O。这是 PG18 升级性价比最高的地方——零改造收益。

2.4 生产配置建议

-- 1. Linux 生产环境,且内核 >= 5.15,优先用 io_uring
ALTER SYSTEM SET io_method = 'io_uring';

-- 2. 提高 I/O 并发度上限(AIO 时代这个参数更重要了)
ALTER SYSTEM SET effective_io_concurrency = 32;   -- NVMe SSD 可设更高
ALTER SYSTEM SET maintenance_io_concurrency = 32; -- VACUUM 等维护操作

-- 3. 若用 worker 模式,根据核数调整 worker 数
ALTER SYSTEM SET io_workers = 6;

SELECT pg_reload_conf();

⚠️ 注意事项:

  • io_uring 每个连接会占用额外的文件描述符和内核内存,连接数极高(上万)时要评估 ulimit -n 和内核 io_uring 相关限制。
  • 容器环境要确认 seccomp 没有禁掉 io_uring_* 系统调用(部分默认 Docker profile 会拦截,需自定义 profile 放行)。
  • 云托管数据库(RDS/Aurora)是否暴露 io_method 取决于厂商,自建才有完全控制权。

三、索引革命:B-Tree Skip Scan

如果说 AIO 是「读得更快」,那 Skip Scan 就是「读得更少」。

3.1 老问题:复合索引的「最左前缀」魔咒

假设有一张表和一个复合索引:

CREATE TABLE orders (
    region    text,
    status    text,
    created_at timestamptz,
    amount    numeric
);

CREATE INDEX idx_orders ON orders (status, created_at);

在 PG17 及之前,如果你的查询不带索引第一列 status

SELECT * FROM orders WHERE created_at >= '2026-01-01';

这个索引基本用不上(或者只能做低效的全索引扫描),因为 B-Tree 依赖最左前缀定位。这就是很多人被迫为同一批列建多个冗余索引的原因,浪费空间、拖慢写入。

3.2 Skip Scan 如何工作

PG18 的 B-Tree 支持了 Skip Scan:当索引前导列(status)的distinct 值很少时,优化器可以「跳着扫」——把前导列的每一个不同值当作一个独立的搜索前缀,分别在 B-Tree 里定位后续列的范围。

逻辑上等价于:

-- 优化器内部把它「展开」成对每个 status 值的子搜索
SELECT * FROM orders WHERE status = 'paid'     AND created_at >= '2026-01-01'
UNION ALL
SELECT * FROM orders WHERE status = 'shipped'  AND created_at >= '2026-01-01'
UNION ALL
SELECT * FROM orders WHERE status = 'canceled' AND created_at >= '2026-01-01';

但这一切发生在索引扫描内部,不需要你写 UNION,也不需要你改索引。

3.3 实测验证

-- PG18 中,观察执行计划
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE created_at >= '2026-06-01';

-- 你会在计划里看到类似:
-- Index Scan using idx_orders on orders
--   Index Cond: (created_at >= '2026-06-01')
--   Skip Scan: Yes            <-- 关键标志

适用条件与陷阱

  • Skip Scan 只在前导列基数低时划算。如果 status 有几十万个不同值,跳扫的开销会超过收益,优化器会自动放弃。
  • 它不是万能的替代品,但能显著减少「为了绕过最左前缀而建的冗余索引」。升级 PG18 后,值得重新审视你的索引清单,删掉一批可以被 Skip Scan 覆盖的冗余索引。

四、开发者体验:uuidv7 与虚拟生成列

4.1 uuidv7():终于有了「索引友好」的 UUID

分布式系统里用 UUID 做主键几乎是标配,但 UUIDv4 是个索引杀手

UUIDv4 完全随机,意味着新插入的主键值在 B-Tree 里的位置也完全随机。后果是:

  • 每次插入都可能落到索引的任意页面,导致大量随机写和页分裂;
  • 索引缓存命中率低,冷页反复被换入换出;
  • 相比自增整数,写入吞吐显著下降,索引膨胀更快。

UUIDv7 的结构是「48 位毫秒时间戳 + 随机位」,前缀随时间单调递增:

UUIDv7 布局(128 bit)
┌──────────────────────┬──────┬────────────────────────┐
│ 48-bit unix_ts_ms    │ ver  │  随机位 + 变体位        │
└──────────────────────┴──────┴────────────────────────┘
    时间戳(有序前缀)           唯一性保证

时间有序意味着新主键总是追加到 B-Tree 右端,写入模式接近自增整数,同时保留 UUID 的全局唯一性与不可预测性(低位仍是随机)。

PG18 内置了函数:

-- 生成 UUIDv7(默认基于当前时间)
SELECT uuidv7();
-- 例: 019845a2-1c3f-7abc-8def-0123456789ab

-- 也内置了 uuidv4() 别名(等价于旧的 gen_random_uuid())
SELECT uuidv4();

-- 建表时作为主键默认值
CREATE TABLE events (
    id         uuid PRIMARY KEY DEFAULT uuidv7(),
    payload    jsonb,
    created_at timestamptz DEFAULT now()
);

-- 还能从 uuidv7 反解出时间戳(配合内置提取函数)
SELECT uuid_extract_timestamp(uuidv7());

工程价值:以前你要么引入 pg_uuidv7 扩展,要么在应用层生成。现在数据库原生支持,主键有序性带来的写入性能提升在千万级大表上非常可观——实测大批量插入场景,UUIDv7 相比 UUIDv4 的索引膨胀和写放大能降低一个数量级。

4.2 虚拟生成列(Virtual Generated Columns)

PG12 引入了生成列,但只支持 STORED——值在写入时算好并落盘,占用存储空间。PG18 补上了 VIRTUAL(并且虚拟成为默认):

CREATE TABLE products (
    price     numeric,
    tax_rate  numeric,
    -- 虚拟生成列:查询时才计算,不占存储
    total     numeric GENERATED ALWAYS AS (price * (1 + tax_rate)) VIRTUAL,
    -- 存储生成列:写入时计算并落盘
    price_cny numeric GENERATED ALWAYS AS (price * 7.2) STORED
);

INSERT INTO products (price, tax_rate) VALUES (100, 0.13);
SELECT total FROM products;  -- 113.00,查询时实时计算

STORED vs VIRTUAL 如何选

维度STOREDVIRTUAL
存储开销占用磁盘零存储
计算时机写入时查询时
能否建索引可以不可以(PG18 暂不支持对虚拟列建索引)
适用场景频繁查询、需索引写多读少、只是偶尔取值

对「派生字段但很少读、或不想为它付出存储」的场景,VIRTUAL 是更省的选择。


五、安全与运维:OAuth 2.0 与更顺滑的大版本升级

5.1 内置 OAuth 2.0 认证

PG18 新增了 oauth 认证方式,可以直接对接企业的 SSO / IdP(如 Keycloak、Okta、Azure AD)。在 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 profile"

数据库作为 OAuth 的资源服务器,校验客户端携带的 Bearer Token。这意味着数据库访问可以纳入统一身份体系,不再需要单独维护一套 DB 密码,也便于做集中式的令牌吊销和审计。这是企业级场景里非常实在的一步。

5.2 大版本升级:pg_upgrade 保留统计信息

老 DBA 都知道 pg_upgrade 的痛点:升级本身很快,但升级后优化器统计信息(pg_statistic)会丢失,必须重新跑 ANALYZE。在这个 ANALYZE 完成之前,优化器是「瞎的」,会生成糟糕的执行计划,导致升级后系统性能断崖式下跌、CPU 飙高。生产上很多「升级后卡顿」事故根源就在这。

PG18 的 pg_upgrade 能够迁移优化器统计信息,升级完成后无需长时间 ANALYZE 即可恢复到预期性能。这大幅降低了大版本升级的风险窗口,也让「升级不再需要预热」成为现实。

配套的还有 --jobs 并行升级、--swap 更快的文件迁移模式等,让停机窗口进一步压缩。


六、可观测性升级:把 I/O 看得更清楚

引入 AIO 之后,光有性能提升不够,你得能看见 I/O 到底发生了什么。PG18 在监控上做了配套:

-- 1. 每个 backend 的细粒度 I/O 统计
SELECT * FROM pg_stat_get_backend_io(pg_backend_pid());

-- 2. pg_stat_io 视图扩充(PG16 引入,PG18 增强)
--    可以按 backend 类型、I/O 上下文、读/写/extend 维度看
SELECT backend_type, object, context,
       reads, read_bytes, writes, write_bytes
FROM pg_stat_io
WHERE reads > 0
ORDER BY reads DESC;

-- 3. VACUUM/ANALYZE 耗时进入 pg_stat_all_tables
SELECT relname,
       total_vacuum_time, total_autovacuum_time,
       total_analyze_time, total_autoanalyze_time
FROM pg_stat_all_tables
WHERE relname = 'orders';

-- 4. checkpointer 新增 num_done,跟踪已完成检查点
SELECT num_timed, num_requested, num_done FROM pg_stat_checkpointer;

-- 5. 并行 worker 使用情况
SELECT datname, parallel_workers_to_launch, parallel_workers_launched
FROM pg_stat_database;

这些指标组合起来,能回答几个过去很难量化的问题:

  • 我的 AIO 到底有没有生效?读吞吐 vs 读次数的比值变了吗?
  • autovacuum 是不是拖了业务后腿?它花了多少时间?
  • 并行查询规划的 worker 有没有真正启动,还是被 max_parallel_workers 卡住了?

七、优化器进阶:两个容易被忽略的提升

7.1 Self-Join Elimination(自连接消除)

当查询里一张表用主键和自己做内连接,且连接对结果没有实际影响时,PG18 优化器能识别并直接消除这个自连接

-- ORM 或视图嵌套经常无意中生成这种查询
SELECT a.*
FROM orders a
JOIN orders b ON a.id = b.id
WHERE b.status = 'paid';

-- PG18 优化器会把它简化为:
-- SELECT * FROM orders WHERE status = 'paid';

这对 ORM 生成的、层层嵌套视图展开后的查询特别有用——你没写垃圾 SQL,但框架帮你写了,PG18 帮你收拾。

7.2 Hash Right Semi Join

PG18 新增了 Hash Right Semi Join 的连接策略,并支持并行执行。处理大表 IN (子查询)EXISTS 时,优化器可以选择在较小的一侧构建哈希表:

-- 大表 big 与相对小的过滤集
SELECT * FROM big
WHERE id IN (SELECT big_id FROM small_filter WHERE flag = true);

以前可能被迫在大表侧建哈希(内存爆炸或落盘),现在优化器可以反过来在小侧建哈希做 semi join,内存占用和执行时间都更优。


八、从 0 到 1:PG18 实战部署

用 Docker Compose 快速起一个 PG18 并开启 io_uring:

# docker-compose.yml
services:
  postgres18:
    image: postgres:18
    container_name: pg18
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    # io_uring 需要放行相关系统调用
    security_opt:
      - seccomp:unconfined
    command:
      - "postgres"
      - "-c"
      - "io_method=io_uring"
      - "-c"
      - "effective_io_concurrency=32"

volumes:
  pgdata:

启动并验证:

docker compose up -d
docker exec -it pg18 psql -U app -d appdb -c "SHOW io_method;"
# io_method
# -----------
# io_uring

一个基准对比脚本骨架(用 pgbench 感受差异):

# 初始化 scale=100 的数据集(约 1.5GB)
docker exec -it pg18 pgbench -i -s 100 -U app appdb

# 只读压测,重点看随机读性能
docker exec -it pg18 pgbench -c 16 -j 4 -T 60 -S -U app appdb

# 想对比 sync vs io_uring,改 io_method 重启后再跑一遍

在 NVMe SSD + 冷缓存的随机读场景,io_uring 相对 sync 的 TPS 提升是肉眼可见的;如果数据全在内存(page cache 命中率 100%),差异会小很多——AIO 的收益主要体现在需要真正访问存储的场景,这点务必在你自己的数据规模上实测,别迷信官方的 3x。


九、升级迁移检查清单

从 PG16/17 升级到 PG18,我的实战清单:

  1. 先在预发环境跑 pg_upgrade --check,确认扩展兼容性(尤其 PostGIS、pg_uuidv7、TimescaleDB 等第三方扩展要先确认有 PG18 版本)。
  2. 利用统计信息迁移特性,升级后先验证优化器计划是否正常,再决定是否补跑 ANALYZE。
  3. io_method 灰度:先用默认 worker 模式跑一段,稳定后再切 io_uring,出问题可秒回 sync
  4. 重审索引清单:Skip Scan 上线后,找出那些为绕过最左前缀而建的冗余索引,逐个评估删除。
  5. 主键策略评估:新表主键考虑从 UUIDv4 / gen_random_uuid() 迁到 uuidv7(),老表迁移要谨慎(需评估兼容与数据回填)。
  6. 监控接入:把 pg_stat_io、pg_stat_get_backend_io 纳入你的 Grafana 面板,量化 AIO 收益。
  7. 容器/内核确认:io_uring 需要内核 5.15+ 且 seccomp 放行;云托管数据库看厂商是否开放。

十、总结与展望

PostgreSQL 18 是近几年最「解渴」的一个版本,因为它动的是架构地基

  • AIO 子系统把 PG 从「同步阻塞单块读」带进异步 I/O 时代,read stream 抽象让存量扫描零改造受益,这是长期红利;
  • Skip Scan 让复合索引的价值被榨得更干,减少冗余索引;
  • uuidv7 / 虚拟生成列 是开发者天天能用到的甜点,尤其 uuidv7 解决了分布式主键的老大难;
  • OAuth 2.0 + 统计信息迁移 则是企业级运维的实在改进,降低升级风险、统一身份。

需要清醒的是:这些收益不是无脑白嫖。AIO 的 3x 只在存储瓶颈场景兑现,Skip Scan 只对低基数前导列划算,io_uring 有内核和容器前置条件。真正的工程能力,是搞懂每个特性的适用边界,在自己的负载上实测,然后精准落地。

往前看,AIO 落地只是第一步——异步写、更多扫描类型接入 read stream、以及围绕 io_uring 的进一步优化,会在后续版本持续释放价值。PG18 打好了地基,好戏还在后头。

如果你还在 PG14/15,这次真的值得认真排一次升级;如果你在 PG16/17,PG18 的 AIO 和 uuidv7 也足够构成升级理由。别让你的 NVMe 继续在同步 I/O 里憋着。

推荐文章

Python实现Zip文件的暴力破解
2024-11-19 03:48:35 +0800 CST
Vue 3 路由守卫详解与实战
2024-11-17 04:39:17 +0800 CST
Vue中的异步更新是如何实现的?
2024-11-18 19:24:29 +0800 CST
css模拟了MacBook的外观
2024-11-18 14:07:40 +0800 CST
Go中使用依赖注入的实用技巧
2024-11-19 00:24:20 +0800 CST
程序员茄子在线接单