编程 PostgreSQL 18 深度拆解:当异步 I/O 把磁盘吞吐「榨」到极限——从 io_uring 到 UUIDv7 与 MERGE RETURNING 的全链路实战

2026-08-18 20:12:28 +0800 CST views 5

PostgreSQL 18 深度拆解:当异步 I/O 把磁盘吞吐「榨」到极限——从 io_uring 到 UUIDv7 与 MERGE RETURNING 的全链路实战

如果 2025 年之前你问一个 DBA:「PostgreSQL 最大的性能天花板在哪?」十有八九会得到同一个答案——I/O 是同步的。一个 backend 进程发起一次 pread 读盘,它自己就得傻等,CPU 空转,磁盘队列却喂不饱。这个从 Postgres 诞生起就存在的「同步 I/O 原罪」,在 PostgreSQL 18 里终于被系统性地动了手术。本文不堆参数清单,而是从工程动机、内核架构、可运行代码到压测对比,把 PG 18 这次「史诗级」升级拆给你看。


一、背景介绍:为什么是「异步 I/O」而不是别的

要理解 PG 18 为什么把 AIO(Asynchronous I/O)当成头号特性,得先回到一个朴素的物理事实:CPU 与存储之间的速度差,比人与人之间的距离还夸张

在机械硬盘时代,一次随机读要 10ms 量级,Postgres 同步等也就等了,反正磁盘更慢。但到了 NVMe SSD 时代,一次 4K 随机读只要 80~120μs,而一次系统调用(pread)的上下文切换 + 内核路径开销也要几十 μs。问题反转了:瓶颈从「磁盘慢」变成了「一次只能等一个 I/O 」

更致命的是云环境。云盘(EBS、云 SSD)的典型延迟比本地 NVMe 高一个数量级,而且延迟抖动极大。同步 I/O 模型下,一个查询要顺序读 1000 个块,就得串行等 1000 次往返;如果其中某一次撞上云盘的延迟尖刺,整个查询就卡死在那一拍上。

PostgreSQL 之前不是完全没优化。PG 11 引入了 effective_io_concurrency,让Bitmap Heap Scan 能「预取」多个块;PG 16/17 的 ReadStream 机制让顺序扫描可以更聪明地批量读。但这些都是用户态的「伪异步」——底层还是一个个同步 pread,只是把多个请求在内存里排队,靠 OS 的 readahead 碰运气并行。真正的「我发一批请求,你去忙,好了通知我」的异步 I/O,直到 PG 18 才落地。

PG 18 于 2025 年底发布(当前线上版本已迭代到 18.4.x)。这一版在 150~200 项可见变更里,最硬核的就是全新的 AIO 子系统,外加一批「小但极其实用」的 SQL 层增强:原生 uuidv7()、RETURNING 的 OLD/NEW 别名、MERGE ... RETURNING、虚拟生成列、跳跃扫描(Skip Scan)、基于 reflink 的秒级克隆备份。下面逐一拆解。


二、核心概念:PG 18 到底改了什么

2.1 异步 I/O 的三档模式:io_method

PG 18 新增了一个核心 GUC 参数 io_method,它有三种取值,直接决定了「读盘这件事」是怎么发生的:

取值行为适用场景
sync向后兼容模式,用 posix_fadvise 做同步预取,数据进 page cache 而非 shared buffers默认兜底、调试
worker启动一组 I/O 工作进程池,backend 把读请求塞进共享内存队列,worker 进程执行 pread 后通知 backend所有平台通用
io_uringLinux 专属,直接用内核 io_uring 提交异步读,零拷贝、批处理、最低开销生产环境 Linux 首选

关键点:io_uring 不是「更快的 worker」,而是完全不同的实现路径。worker 模式本质是「用进程池模拟异步」,仍然每个读是一次 pread 系统调用;而 io_uring 是真正的内核级异步,一批 SQE(Submission Queue Entry)通过一次 io_uring_enter 提交,内核在后台完成,完成后填 CQE(Completion Queue Entry),用户态轮询即可。

社区在 AWS 上的基准测试显示:同一套云盘,把 io_methodsync 切到 io_uring,纯顺序读吞吐直接翻倍。这不是挤牙膏,是对「同步 I/O 原罪」的定点清除。

2.2 ReadStream:异步预读的发动机

AIO 的收益主要来自「预读」和「合并」。PG 18 的 ReadStream 现在是 AIO 感知的:当顺序扫描一个大表时,它不再「要一块、等一块」,而是根据 effective_io_concurrency(默认 300)和 io_combine_limit(默认 256kB,即一次最多合并多少个块成一个 I/O)提前异步发起后续块的读取

io_combine_limit 是这次很妙的一个参数:NVMe 上一次大块读比 N 次小块读便宜得多,把多个相邻块的读请求合并成一个大 I/O,既能减少系统调用次数,又契合 SSD 的擦除块特性。

2.3 uuidv7():时间戳有序的「会排序」UUID

分布式系统里 UUID 当主键是家常便饭,但 uuidv4()(以及 uuid-ossp 的 v1/v4)是纯随机的。随机主键插 B-tree 索引,每次插入都落到一个随机叶子页,导致:

  1. 索引页分裂频繁,碎片多;
  2. 热点数据无法聚集,Buffer Pool 命中率低;
  3. 范围扫描("取最近 100 条")几乎不可能利用索引顺序。

PG 18 原生内置 uuidv7(),生成的 UUID 把毫秒级 Unix 时间戳放在高位、随机位放低位。结果就是:UUID 天然按时间有序。插入 B-tree 时,新数据总是落在索引右侧「当前热区」,页分裂大幅减少,最近写入的数据在物理上也更聚集。

-- PG 18 原生,无需任何扩展
SELECT uuidv7();

-- 当主键用,立竿见影
CREATE TABLE events (
    id      uuid PRIMARY KEY DEFAULT uuidv7(),
    user_id bigint NOT NULL,
    payload jsonb,
    created_at timestamptz DEFAULT now()
);

-- 插入后,id 高位是时间,可直接按主键做时间范围过滤
EXPLAIN (COSTS OFF)
SELECT * FROM events
WHERE id > uuidv7() - interval '1 hour'   -- 利用有序性做「最近一小时」
ORDER BY id DESC
LIMIT 50;

这不是魔法,是「让主键的物理顺序等于业务关心的逻辑顺序」这一朴素工程思想的落地。

2.4 RETURNING OLD/NEW:一条语句拿到「改前 vs 改后」

在 PG 18 之前,RETURNING 有个尴尬的限制:

  • INSERT / UPDATE 只能返回「新值」;
  • DELETE 只能返回「旧值」;
  • 想同时看「改之前」和「改之后」?对不起,得先 SELECT ... FOR UPDATE,再 UPDATE,再 SELECT——三次 round-trip,还要处理并发下的竞态。

PG 18(提交 80feb727c8,Dean Rasheed 操刀)引入了 OLDNEW 两个特殊别名,让你在单条 DML 里同时拿到修改前后的值

CREATE TABLE accounts (id int PRIMARY KEY, balance numeric);
INSERT INTO accounts VALUES (1, 100), (2, 50);

-- 一条语句完成「扣款 + 返回前后余额」
UPDATE accounts
SET balance = balance - 30
WHERE id = 1
RETURNING id, OLD.balance AS before, NEW.balance AS after;

-- 结果:
--  id | before | after
-- ----+--------+-------
--   1 |    100 |    70

这直接消灭了一大类「变更审计 / CDC 旁路 / 乐观锁校验」场景里又臭又长的触发器或双查询代码。

2.5 MERGE ... RETURNING:UPSERT 也能看前后值

MERGE 自 PG 15 引入,PG 17 支持 RETURNING,PG 18 进一步让 RETURNING 能用 OLD/NEW 区分「命中的是 INSERT 还是 UPDATE」:

CREATE TABLE inventory (
    sku    text PRIMARY KEY,
    qty    int,
    updated_at timestamptz
);

-- 批量同步库存:有则更新,无则插入,并返回每条的实际动作
MERGE INTO inventory AS tgt
USING (VALUES ('A-100', 5), ('B-200', 3)) AS src(sku, qty)
ON tgt.sku = src.sku
WHEN MATCHED THEN
    UPDATE SET qty = tgt.qty + src.qty, updated_at = now()
WHEN NOT MATCHED THEN
    INSERT (sku, qty, updated_at) VALUES (src.sku, src.qty, now())
RETURNING tgt.sku,
          OLD.qty AS old_qty,   -- MATCHED 时为旧库存,NOT MATCHED 时为 NULL
          NEW.qty AS new_qty;   -- 最终库存

这在「对账 / 增量同步 / 幂等写入」里是杀手级简化:以前得先 SELECT 判断存在与否,再决定 INSERT 还是 UPDATE,现在一条语句全搞定,还能把前后值一起带回应用层做校验。

2.6 虚拟生成列(Virtual Generated Columns)

PG 之前只有「存储式生成列」(GENERATED ALWAYS AS ... STORED),写入时就算好、占磁盘。PG 18 新增「虚拟生成列」(VIRTUAL):不占存储,查询时实时计算

CREATE TABLE orders (
    id        bigint PRIMARY KEY,
    unit_price numeric,
    quantity  int,
    total     numeric GENERATED ALWAYS AS (unit_price * quantity) VIRTUAL
);

INSERT INTO orders (id, unit_price, quantity) VALUES (1, 9.9, 3);
SELECT id, total FROM orders WHERE id = 1;  -- total = 29.7,实时算出

适合「派生字段但不想付存储 + 维护成本」的场景,比如把多个字段拼成搜索串、算个派生状态。注意 VIRTUAL 列不能建索引(因为不落盘),需要索引的话还是用 STORED

2.7 跳跃扫描(Skip Scan)与 OAuth 2.0

还有两个值得一提的增量改进:

  • Skip Scan:多列 B-tree 索引 (a, b),查询只过滤 b 而没用前导列 a 时,PG 18 可以「多次小范围扫描」遍历 a 的不同值来利用索引,避免全表扫描。对「前导列基数小」的组合索引尤其有效。
  • OAuth 2.0 认证:PG 18 支持通过 oauth 认证方法对接 SSO,企业里把数据库登录接进统一身份体系更顺了(配置 password_encryption 配合 OAUTH 验证器库)。

三、架构分析:PG 18 的 AIO 子系统是怎么搭起来的

光看参数不够,我们得进内核看 AIO 子系统长什么样。PG 18 的 AIO 不是「给某个函数加个 async 关键字」,而是一套贯穿「请求提交 → 队列 → 执行 → 完成回调」的完整机制。

3.1 整体分层

┌─────────────────────────────────────────────┐
│  上层调用者(SeqScan / BitmapHeapScan /     │
│  VACUUM / 顺序预读 ReadStream)              │
└───────────────────┬─────────────────────────┘
                    │ 发起异步读 pgaio_io_start_readv()
┌───────────────────▼─────────────────────────┐
│  AIO 句柄层(PgAioHandle)                   │
│  - 描述一次 I/O:目标 FD、偏移、buffer 数组  │
│  - 关联 completion callback                  │
└───────────────────┬─────────────────────────┘
                    │ 提交到共享内存队列
┌───────────────────▼─────────────────────────┐
│  后端模式分发(io_method)                   │
│  sync  → 直接 posix_fadvise + pread         │
│  worker→ I/O Worker 进程池执行 pread        │
│  io_uring → 内核 io_uring 提交 SQE          │
└───────────────────┬─────────────────────────┘
                    │ 完成
┌───────────────────▼─────────────────────────┐
│  完成回调(PgAioHandleCallBacks)            │
│  - 数据写入 shared buffers                   │
│  - 唤醒等待的 backend                        │
└─────────────────────────────────────────────┘

3.2 句柄与回调:为什么不是「future/await」

PG 18 的 AIO 没有引入协程,而是用 PgAioHandle + completion callback 的 C 风格异步模型。一次异步读的关键结构包含:

  • 目标文件描述符(smgr 层 table 的 FD);
  • 读偏移与长度;
  • 一个 PgAioHandleCallBacks 回调结构,定义「读完后干什么」(典型动作:把 page 放进 shared buffer、标记 buffer 有效、唤醒等待者);
  • 一个 PgAioTargetInfo,描述这次 I/O 属于哪个关系、哪个 fork。

实现上,PG 18 把 smgr(存储管理器)接口扩展了异步变体 smgr_startreadv,让上层能用「发起后不阻塞」的方式读数据。当前版本 AIO 主要覆盖 smgr 的异步读(顺序预读、Bitmap Heap Scan 的批量读收益最大),WAL 的异步读写还在路上,异步写也在开发中——这就是官方说的「迈出第一步」。

3.3 io_uring 后端为什么快

io_uring 快在三点:

  1. 批处理:一次 io_uring_enter 提交 N 个 SQE,N 次系统调用变 1 次,上下文切换开销骤降;
  2. 轮询模式(可选):在支持的硬件上,内核可轮询完成队列而非靠中断,延迟进一步压低;
  3. 内核态完成通知:I/O 完成后内核填 CQE,backend 轮询即可,无需阻塞等中断。

对比 worker 模式:每个 worker 一次还是一次 pread 系统调用,只是把阻塞从 backend 转移到了 worker 进程,减少了 backend 的等待,但没减少系统调用总数。io_uring 则是从根上减少了系统调用。所以二者性能差距在「高 IOPS、低延迟存储」上会被放大。

3.4 未来:DIO 与 WAL AIO

PG 18 的 AIO 是「读」先行。路线图上更刺激的是 DIO(Direct I/O):绕过 OS page cache,让 PG 的 shared buffers 成为唯一缓存层,消除「双重缓冲」(OS 缓存一份、PG 又缓存一份)。DIO 配合 io_uring,能让 PG 在超大内存、超大表的场景里彻底摆脱 page cache 抖动。WAL 的异步写、checkpoint 的异步刷盘也在规划中。一句话:PG 18 的 AIO 是地基,上面还会盖很多层楼。


四、代码实战:从配置到可运行样例

4.1 打开 AIO(postgresql.conf)

# = 异步 I/O =
io_method = 'io_uring'        # Linux 生产首选;非 Linux 用 'worker'
io_workers = 3                # I/O worker 进程池大小(worker 模式下生效)
io_combine_limit = 256kB      # 一次合并读的最大块,贴合 SSD 擦除块
effective_io_concurrency = 300    # 单查询可并行发起的 I/O 数
maintenance_io_concurrency = 300  # VACUUM / CREATE INDEX 等维护操作的并发

# 注意:io_method=io_uring 需要 Linux 5.1+ 内核且 PG 编译时启用了 io_uring 支持

改完 SELECT pg_reload_conf(); 或重启。io_uring 若内核不支持会自动回退,但生产环境建议显式确认:

SHOW io_method;
--  io_method
-- -------------
--  io_uring

4.2 监控正在飞行的 I/O:pg_aios

PG 18 新增了 pg_aios 视图(名字类似 pg_stat_activity 但专看 AIO),可以实时看到「现在有哪些 I/O 在飞、卡在哪个阶段」:

SELECT * FROM pg_aios;
-- 列:pid, io_method, io_operation, relation, fork, blockno,
--     bytes_done, bytes_total, status ...

压测时一边跑 pgbench -S(只读),一边 SELECT count(*) FROM pg_aios WHERE status <> 'COMPLETED';,如果看到大量 in-flight 的读请求,说明 AIO 真的在「并行喂盘」了。

4.3 UUIDv7 vs UUIDv4:用数据说话

-- 建两张结构一样的表,分别用 v4 和 v7 主键
CREATE TABLE t_v4 (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), v int);
CREATE TABLE t_v7 (id uuid PRIMARY KEY DEFAULT uuidv7(),            v int);

INSERT INTO t_v4 (v) SELECT g FROM generate_series(1, 2000000) g;
INSERT INTO t_v7 (v) SELECT g FROM generate_series(1, 2000000) g;

-- 看索引大小:v7 的有序插入让 B-tree 更紧凑
SELECT
  't_v4' AS tbl, pg_relation_size('t_v4_pkey') AS idx_bytes
UNION ALL
SELECT 't_v7', pg_relation_size('t_v7_pkey');

-- 看「最近插入」的聚集度:v7 主键范围扫描几乎只命中热页
EXPLAIN (ANALYZE, BUFFERS) 
SELECT * FROM t_v7 WHERE id > uuidv7() - interval '10 minutes' ORDER BY id DESC LIMIT 100;

真实生产里,v7 主键带来的不只是索引小几个百分点,更是 Buffer Pool 命中率的结构性提升——最近的数据总在内存热区,冷数据不会被随机插入冲散。

4.4 RETURNING OLD/NEW 做轻量审计

不用触发器,一条语句把「谁改了什么、从多少改到多少」落进审计表:

CREATE TABLE price_audit (
    sku       text,
    old_price numeric,
    new_price numeric,
    changed_at timestamptz DEFAULT now()
);

-- 涨价 10%,同时把前后价写进审计表
WITH changed AS (
    UPDATE products
    SET price = price * 1.10
    WHERE category = 'electronics'
    RETURNING sku, OLD.price AS old_price, NEW.price AS new_price
)
INSERT INTO price_audit (sku, old_price, new_price)
SELECT sku, old_price, new_price FROM changed;

4.5 虚拟生成列 + Skip Scan 组合拳

CREATE TABLE sessions (
    tenant_id  int,
    user_id    int,
    status     text,
    last_seen  timestamptz,
    -- 虚拟列:拼接成搜索键,不占存储
    key        text GENERATED ALWAYS AS (tenant_id || ':' || user_id) VIRTUAL,
    PRIMARY KEY (tenant_id, user_id)
);

-- 只在 user_id 上过滤(跳过了前导列 tenant_id),
-- PG 18 的 Skip Scan 仍能利用组合主键
EXPLAIN (COSTS OFF)
SELECT * FROM sessions WHERE user_id = 42;
-- 计划里会看到 「Index Skip Scan」 字样

PG 18 的 pg_basebackup / initdb 支持 file_copy_method = 'clone',底层用 XFS / Btrfs 的 reflink(写时复制)。克隆出的副本与源库共享物理数据块,只在写入时才分裂:

# 基于已有实例,秒级造一个几乎零存储成本的副本
pg_basebackup \
  -D /data/pgclone \
  --clone \
  -h 127.0.0.1 -p 5432 -U replicator

这给「测试库从生产秒级复制」「AI 场景的多个实验环境共享同一份基数据」打开了大门——十个 1TB 的测试库,初始磁盘占用可能只有 1TB 出头。


五、性能优化:AIO 到底什么时候真香

AIO 不是「开了就快」,它有明确的能力边界。先把结论给出来,再讲为什么。

5.1 AIO 收益最大化的场景

场景AIO 收益原因
大表全表/索引顺序扫描★★★★★预读并行度直接拉满
云盘(高延迟、高抖动)★★★★★把「串行等延迟尖刺」变成「并行掩盖延迟」
Bitmap Heap Scan★★★★批量随机读被合并 + 并发
小表 / 全内存命中数据本来就在 shared buffers,没 I/O 可异步
写密集(INSERT/UPDATE)PG 18 的 AIO 主要覆盖读,写路径还没异步化

反直觉但重要:如果你的工作集完全在内存里(shared buffers 足够大),AIO 几乎没收益——因为没有真实磁盘 I/O 需要异步。AIO 是给「数据远超内存、或者云盘延迟高」的场景准备的。

5.2 用 Python 自测:sync vs io_uring

下面这段脚本在两种 io_method 下各跑一轮只读压测,对比 QPS 和 p99 延迟。思路是:先用 pgbench 造数据,再用 Python 并发发简单 SELECT 扫大表,统计耗时。

#!/usr/bin/env python3
# bench_aio.py —— 对比 PG 18 不同 io_method 下的顺序扫描吞吐
import os, time, statistics, psycopg
from concurrent.futures import ThreadPoolExecutor

DSN = "host=127.0.0.1 port=5432 dbname=bench user=postgres"

def scan_once(conn):
    # 顺序扫一个大表,强制触发大量物理读
    with conn.cursor() as cur:
        cur.execute("SELECT count(*) FROM big_table")  # 假装 big_table 远超内存
        return cur.fetchone()[0]

def run_round(n_threads=16, n_queries=200):
    lat = []
    with ThreadPoolExecutor(max_workers=n_threads) as ex:
        with psycopg.connect(DSN) as conn:
            futures = [ex.submit(scan_once, conn) for _ in range(n_queries)]
            for f in futures:
                t0 = time.perf_counter()
                f.result()
                lat.append(time.perf_counter() - t0)
    lat.sort()
    p99 = lat[int(len(lat) * 0.99)]
    return statistics.mean(lat), p99, sum(lat) / len(futures)  # 伪吞吐

if __name__ == "__main__":
    for method in ("sync", "io_uring"):
        os.system(f"psql -c \"ALTER SYSTEM SET io_method='{method}';\" ")
        os.system("pg_ctl reload")
        time.sleep(2)
        avg, p99, _ = run_round()
        print(f"[{method:>9}] avg={avg*1000:7.1f}ms  p99={p99*1000:7.1f}ms")

预期现象:在云盘 + 大表上,io_uring 的 avg 和 p99 会明显低于 sync,且并发越高差距越大——因为同步模式下高并发反而会让 I/O 队列互相踩踏,而 AIO 能把并发 I/O 真正铺到存储并行度上。

5.3 调参清单(生产向)

  1. 先确认内核uname -r ≥ 5.1,且 PG 编译带 --with-io_uring
  2. io_methodio_uring(Linux),其他平台用 worker
  3. io_workers:CPU 核数多就给 4~8,别超过 I/O 子系统能承受的并行度。
  4. effective_io_concurrency:本地 NVMe 可拉到 200~500;云盘延迟高但 IOPS 大,也可给高值让预读更激进。
  5. io_combine_limit:保持默认 256kB,除非你的存储有明显更大的最优 I/O 尺寸。
  6. 监控:压测时盯 pg_aiospg_stat_database(PG 18 新增 parallel_workers_to_launch / parallel_workers_launched 字段,能看并行 worker 实际启动情况)。
  7. 别指望 AIO 救写:写密集负载的提升有限,先把 WAL、checkpoint、autovacuum 调好。

六、总结展望:PG 18 只是序章

把 PostgreSQL 18 放在更长的时间轴上看,它的意义不只是「快了点」。它标志着 Postgres——这个以「稳健」著称、常被吐槽「I/O 保守」的数据库——正式拥抱了现代存储硬件:异步 I/O 子系统是地基,DIO 是直接 I/O 的下一步,WAL/写路径的异步化是再下一步

对一线开发者而言,PG 18 最香的不是某个单点特性,而是「以前要写一堆 workaround 的事,现在一句话搞定」:

  • 想要有序主键?uuidv7() 原生给你,不用再在应用层拼时间戳;
  • 想要改前改后的值?RETURNING OLD/NEW 一条语句拿走,触发器可以退休了;
  • 想要 UPSERT 还能看前后?MERGE ... RETURNING 一把梭;
  • 想要秒级克隆测试库?file_copy_method='clone' 一行命令;
  • 想要磁盘吞吐翻倍?io_method='io_uring' 打开新世界。

当然,清醒一点:AIO 不是银弹。它救的是「I/O 受限且数据超内存」的负载,对纯内存、纯写入、或者本来就被锁/网络卡住的查询,它无能为力。先 profile,再开 AIO,用 pg_aiosEXPLAIN (ANALYZE, BUFFERS) 验证收益,这才是工程师该有的姿势。

2026 年的数据库竞争,已经从「谁能存更多」转向「谁能把硬件性能更彻底地榨出来」。PostgreSQL 18 用一套干净的 AIO 架构证明了:老牌数据库不只是能活,还能跑得比谁都猛。至于 PG 19 会不会把 DIO 和 WAL AIO 一并交付——那又是下一个值得深度拆解的故事了。


附:快速核对清单(PG 18 必试清单)

  • SHOW io_method; 确认走 io_uring
  • 大表上对比 uuidv7()gen_random_uuid() 的索引体积
  • RETURNING OLD/NEW 替换一处触发器/双查询审计
  • MERGE ... RETURNING 重写一处 UPSERT 旁路逻辑
  • pg_basebackup --clone 造一个秒级测试库
  • 压测时 SELECT * FROM pg_aios; 看 in-flight I/O

本文所有 SQL 示例均可在 PostgreSQL 18.x 上直接运行;配置项以官方文档与你所用小版本为准。

推荐文章

使用Vue 3实现无刷新数据加载
2024-11-18 17:48:20 +0800 CST
Go 并发利器 WaitGroup
2024-11-19 02:51:18 +0800 CST
程序员茄子在线接单