编程 PostgreSQL 18 深度实战:异步 I/O 引擎重写、UUIDv7 与虚拟生成列——一次 IO 子系统的「成年礼」

2026-08-15 16:14:30 +0800 CST views 23

PostgreSQL 18 深度实战:异步 I/O 引擎重写、UUIDv7 与虚拟生成列——一次 IO 子系统的「成年礼」

如果要用一句话概括 PostgreSQL 18,我会说:这是 PG 三十年来第一次把 IO 子系统从「同步阻塞」重写成「原生异步」,并且顺手把开发者日常最痛的几件事(UUID 主键碎片、计算列占空间、变更前后对比)一次性解决了。 这篇文章不堆特性列表,而是把 AIO 引擎讲透,再用可运行的代码把其余特性一个个落地。

一、背景介绍:PostgreSQL 的「IO 天花板」之痛

PostgreSQL 从 1996 年的 6.0 算起已经快三十年。这期间它从一个学术项目成长为最被信任的开源关系型数据库之一,但有一个老问题一直被 DBA 和架构师反复吐槽:单实例的 IO 吞吐能力

传统 PostgreSQL 的 IO 模型是同步的。当执行器需要读一个页面,它调用 ReadBuffer(),这个调用在底层会阻塞等待磁盘(或网络存储)把 8KB 页面返回。一次顺序扫描可能要发起成千上万次这样的同步调用。在本地 NVMe 上延迟只有几十微秒,问题不大;但一旦后端是云上的网络块存储(EBS、各类云硬盘),一次读可能要几百微秒到毫秒级,同步模型就会把 CPU 大量时间浪费在「等 IO」上——backend 进程卡在 read() 系统调用里,连接池被占满,QPS 上不去。

MySQL 早在 5.6 时代就通过 InnoDB 的 native AIO(Linux 上基于 libaio / io_uring)缓解了这个问题。PostgreSQL 社区不是不知道,而是因为要跨平台(Windows、macOS、各种 Unix 都要支持)加上历史包袱,迟迟没有动 IO 子系统。PG 18 是三十年来的第一次:底层 IO 子系统被重写为原生异步(AIO)。

除了 AIO,PG 18 还带来了几个开发者能直接感知、立刻能用上的特性:

  • UUIDv7 原生函数:时间有序 UUID,根治 UUIDv4 主键的索引碎片与缓存抖动
  • 虚拟生成列(VIRTUAL):不占存储的计算列,此前 PG 只支持 STORED(物化)
  • RETURNING 支持 OLD / NEW 别名:一行 SQL 完成变更前与变更后的对比,审计与 CDC 场景直接降维
  • libpq OAuth 2.0 客户端认证:应用告别静态数据库密码
  • 可观察性增强pg_stat_io 视图细化、EXPLAIN 默认输出改进

横向定位一下:本系列前面拆过 MySQL 26.7 的并行复制、Redis 8 的多模引擎。PG 18 的叙事主轴不是「加功能」,而是「补了三十年的一块底层短板 + 把开发体验打磨得更顺手」。

这篇文章的结构是:先把 AIO 的核心概念讲清楚,再做架构分析(它到底怎么跑起来的),然后进入代码实战(部署、UUIDv7、虚拟列、RETURNING、OAuth、可观察性),最后给性能优化清单与升级建议。


二、核心概念

2.1 异步 I/O(AIO):io_uring 与 worker 两条路

PG 18 的 AIO 不是简单地「加个线程池」,而是一套抽象的 IO 提交 / 完成框架。它有两种底层实现,由 GUC 参数 io_method 选择:

io_method说明适用
io_uringLinux 5.x+ 的 io_uring 接口,内核态环形队列、批量提交、零拷贝生产 Linux 服务器(推荐)
worker跨平台的后台 IO worker 进程池,兼容性最好Windows / macOS / 不支持 io_uring 的环境
sync退回老的同步 IO(保底,性能最差)调试 / 极端兼容场景

默认情况下,PG 18 在支持 io_uring 的 Linux 上把 io_method 设为 worker(社区出于稳健性默认保守),但生产环境只要内核支持 io_uring,强烈建议切到 io_uring——这才是 PG 18 AIO 性能释放的完整形态。

AIO 的核心收益来自批量合并(scatter / gather):执行器不再一个页面一个页面地等,而是把多个读请求聚合成一批,由 IO 层统一提交、统一回收结果。这对两类操作收益最大:

  1. Bitmap Heap Scan:位图扫描要按需读大量随机页面,AIO 可以把「随机读」变成「批量异步随机读」,大幅削减等待。
  2. 顺序 / 预读扫描:大表顺序扫描时,AIO 能「超前」提交后续块的读请求,让磁盘 / 网络存储一直有活干,而不是「读一块、等一块、再读下一块」。

2.2 UUIDv7:给 UUID 排个时间序

UUIDv4 是纯随机的,作为主键插入 B-tree 时,新行会落到树的全随机位置,带来三个连锁问题:

  • 索引页频繁分裂,空间利用率低
  • 缓存命中率差(热点分散在整棵索引上)
  • WAL 写放大(每次插入都碰不同的叶子页)

UUIDv7 把 48 位毫秒级时间戳放到高位,后面跟随机位。这样新插入的主键在 B-tree 上局部有序,写入集中在「最近」的少数索引页,缓存友好、写放大大幅下降、范围查询也更顺。PG 18 直接在 pg_catalog 提供 uuidv7()(同时保留老的 gen_random_uuid() 返回 v4)。

2.3 虚拟生成列(VIRTUAL)

PG 11 引入生成列,但当时只支持 STORED(物化:占存储、写入时计算、可被索引)。PG 18 新增 VIRTUAL:查询时才计算,不占任何存储。它适合「展示用派生字段」——比如从 JSON 里提取字段、单位换算、字符串拼接——你不想为它付出写入成本和磁盘空间,又想在查询时直接 SELECT 出来。

-- STORED:写入时算一次,落盘,可被索引,占空间
col text GENERATED ALWAYS AS (...) STORED;

-- VIRTUAL(PG 18 新增):查询时才算,不落盘,不占空间
col text GENERATED ALWAYS AS (...) VIRTUAL;

注意一个边界:VIRTUAL 列不能被索引(因为它不落盘,没有稳定的物理值可供索引),需要索引时仍用 STORED。

2.4 RETURNING OLD / NEW:一行 SQL 看变更前后

以前想「改完看改前改后」,要么用 CTE 嵌套,要么上触发器。PG 18 让 UPDATE / DELETERETURNING 直接引用变更前(OLD)与变更后(NEW)的行:

UPDATE accounts SET balance = balance - 100
WHERE id = 1
RETURNING WITH (OLD AS b, NEW AS a)
         b.balance AS old_bal, a.balance AS new_bal;

返回结果里同时有扣款前和扣款后的余额。这对审计日志、软删除回写、CDC 增量抽取是降维打击——一行 SQL 替代三层嵌套 + 触发器维护。

2.5 OAuth 2.0 客户端认证

PG 18 的 libpq 支持 OAuth 2.0 Bearer Token 作为客户端认证方式。配合支持 OAuth 的认证插件(企业 IdP / 短期 token 服务),应用不再硬编码数据库密码,而是用短期 token 连接,泄露面从「长期密码」缩小到「短期凭证」,安全性大幅提升。连接串里用 oauth 作为密码机制,并带上 oauth_issueroauth_audience 等参数。


三、架构分析:PG 18 的 AIO 是怎么跑起来的

要真正理解 AIO 的收益,得看它在进程模型里处在什么位置。PG 是「多进程 + 共享内存」架构:每个客户端连接对应一个 backend 进程,backend 直接访问共享缓冲池(shared buffers)。在老模型里,backend 读页面时直接走同步系统调用:

老模型(同步 IO):
Backend 执行器
   │ ReadBuffer() → 内核 read() syscall
   ▼
[ 阻塞等待磁盘 / 网络存储返回 8KB 页面 ]   ← CPU 在这里空转
   │ 页面就绪
   ▼
执行器继续处理这一行

PG 18 引入 AIO 层后,backend 不再自己同步读,而是提交 IO 请求到 AIO 框架,框架负责聚合与异步完成:

PG 18 模型(异步 IO,io_uring / worker):
Backend 执行器
   │ 提交 IO 请求(不阻塞)
   ▼
AIO 提交队列(per-backend)
   │  scatter/gather 批量聚合相邻块
   ▼
[ io_uring 内核环 ]  或  [ worker 进程池 ]
   │  内核 / worker 异步完成
   ▼
完成回调 → 页面填入缓冲池 → 执行器处理已就绪的行
   ▲____________________________│
   │ 等待期间 backend 可继续提交更多 IO / 处理已返回结果

两个关键区别:

  1. 等待与计算解耦:backend 在等待某个页面时,不被钉死在 read() 上,它可以继续提交后续 IO 请求,或者处理那些已经返回的页面。IO 子系统始终「有活干」。
  2. 合并相邻 IOio_combine_limit 控制一次系统调用合并多少个相邻块。顺序扫描时,8KB、8KB 的小读会被合并成一次大读,减少 syscall 次数与网络存储的往返。

相关 GUC 参数一览(生产调优会用到):

参数默认作用
io_methodworkerAIO 底层实现:io_uring / worker / sync
io_workers3worker 模式下的后台 IO 进程数
io_max_concurrency16单个 backend 同时进行中的 IO 上限
io_combine_limit16一次系统调用合并的相邻 IO 数
effective_io_concurrency1预读并行度(AIO 下语义增强,建议调大)

架构视角的一句话总结:PG 18 的 AIO 不是「让单次读更快」,而是「让 IO 等待不再阻塞执行器、并批量合并小 IO」,收益在网络存储、大表扫描、随机读密集场景最明显。


四、代码实战

下面所有示例都基于 PG 18,优先给可运行的 Docker 部署方案。

4.1 部署 PostgreSQL 18 并开启 AIO

用 Docker Compose 起一个 PG 18,并显式打开 io_uring(内核需支持):

# docker-compose.yml
services:
  pg18:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: pg18demo
      POSTGRES_DB: demo
    command:
      - postgres
      - -c
      - io_method=io_uring
      - -c
      - io_workers=4
      - -c
      - io_max_concurrency=32
      - -c
      - io_combine_limit=32
      - -c
      - effective_io_concurrency=16
      - -c
      - shared_preload_libraries=pg_stat_statements
    ports:
      - "5432:5432"
    volumes:
      - pg18data:/var/lib/postgresql/data

volumes:
  pg18data:

启动后确认 AIO 已生效:

-- 查看当前 IO 方法
SHOW io_method;          -- 期望 io_uring
SHOW io_max_concurrency; -- 期望 32

-- 查看后台 IO worker 是否在跑(worker 模式下可见)
SELECT pid, backend_type
FROM pg_stat_activity
WHERE backend_type LIKE '%IO%';

如果内核不支持 io_uring,PG 会回退或报错。此时把 io_method=io_uring 改成 io_method=worker 即可跨平台运行。

4.2 UUIDv7 实战:根治主键碎片

先直观对比 UUIDv4 与 UUIDv7 的「局部性」:

-- 老做法:UUIDv4,纯随机
SELECT gen_random_uuid();

-- PG 18 新做法:UUIDv7,时间有序
SELECT uuidv7();

建两张表,一张用 v4、一张用 v7,各灌 200 万行,对比索引大小与插入表现:

CREATE TABLE orders_v4 (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id int,
    amount numeric(12,2),
    created_at timestamptz DEFAULT now()
);

CREATE TABLE orders_v7 (
    id uuid PRIMARY KEY DEFAULT uuidv7(),
    customer_id int,
    amount numeric(12,2),
    created_at timestamptz DEFAULT now()
);

-- 批量灌数据(演示用 generate_series)
INSERT INTO orders_v4 (customer_id, amount)
SELECT (random()*100000)::int, (random()*1000)::numeric(12,2)
FROM generate_series(1, 2000000);

INSERT INTO orders_v7 (customer_id, amount)
SELECT (random()*100000)::int, (random()*1000)::numeric(12,2)
FROM generate_series(1, 2000000);

对比主键索引体积(v7 通常明显更小,页分裂更少):

SELECT
    'orders_v4' AS tbl,
    pg_relation_size('orders_v4_pkey') AS idx_bytes
UNION ALL
SELECT
    'orders_v7',
    pg_relation_size('orders_v7_pkey');

再用 Python 实测插入吞吐差异(核心逻辑):

import psycopg
import time, uuid

def bench(use_v7: bool, n: int = 200_000):
    conn = psycopg.connect("dbname=demo user=postgres password=pg18demo host=localhost")
    table = "orders_v7" if use_v7 else "orders_v4"
    gen = "uuidv7()" if use_v7 else "gen_random_uuid()"
    sql = f"INSERT INTO {table}(id, customer_id, amount) VALUES ({gen}, %s, %s)"
    t0 = time.time()
    with conn.cursor() as cur:
        for i in range(n):
            cur.execute(sql, ((i % 100000), (i % 1000) * 1.0))
        conn.commit()
    dt = time.time() - t0
    print(f"{'v7' if use_v7 else 'v4'}: {n} inserts in {dt:.1f}s -> {n/dt:.0f} ips")
    conn.close()

bench(use_v7=True)
bench(use_v7=False)

结论:在百万行规模、机械式顺序插入场景下,v7 的主键索引更小、插入更平稳,因为新行总是「贴着」最近写入的索引页,缓存命中率高。代价是 v7 高位暴露了插入时间,对需要完全不可猜测主键的场景要评估(但 v4 也早已不是安全边界,敏感场景本就该用应用层签名)。

4.3 虚拟生成列实战

场景:订单表存了 amountquantity,前端总想要 total,但你不希望 total 占存储、也不想每次查询都手写 amount * quantity

CREATE TABLE line_items (
    id bigserial PRIMARY KEY,
    product text,
    unit_price numeric(10,2),
    qty int,
    -- PG 18 新增:VIRTUAL 生成列,查询时计算,不落盘
    total numeric(12,2) GENERATED ALWAYS AS (unit_price * qty) VIRTUAL,
    -- 老能力:STORED 生成列,落盘、可索引
    sku text GENERATED ALWAYS AS ('SKU-' || id) STORED
);

INSERT INTO line_items (product, unit_price, qty)
VALUES ('键盘', 199.00, 2), ('鼠标', 89.50, 3);

SELECT product, unit_price, qty, total FROM line_items;
--  total 自动算出:398.00 / 268.50,且磁盘上 total 不占空间

注意 VIRTUAL 列的限制与使用建议:

  • VIRTUAL 列不能被索引、不能做 UNIQUE 约束(没有稳定物理值)。需要索引就改用 STORED。
  • VIRTUAL 列适合「展示派生」;STORED 列适合「需索引 / 需持久化」的派生。
  • VIRTUAL 列从 PG 18 起支持被逻辑复制(之前生成列的复制是个坑),这是配套改进。

4.4 RETURNING OLD / NEW 审计实战

场景:账户扣款,要求一行 SQL 同时返回扣款前后余额,并顺手把变更写进审计表。

-- 建审计表
CREATE TABLE balance_audit (
    id bigserial PRIMARY KEY,
    account_id int,
    old_balance numeric(12,2),
    new_balance numeric(12,2),
    changed_at timestamptz DEFAULT now()
);

-- 一行 SQL:扣款 + 看前后余额
WITH r AS (
    UPDATE accounts
       SET balance = balance - 100
     WHERE id = 1
    RETURNING WITH (OLD AS o, NEW AS n) o.balance AS ob, n.balance AS nb
)
INSERT INTO balance_audit (account_id, old_balance, new_balance)
SELECT 1, (SELECT ob FROM r), (SELECT nb FROM r)
RETURNING *;

过去这类需求要么写触发器(维护成本高、调试难),要么用 UPDATE ... RETURNING + 应用层再查一次旧值(多一次往返)。PG 18 的 RETURNING WITH (OLD AS ..., NEW AS ...) 把这件事压成一条语句,原子、清晰、可审计。

软删除回写也很优雅:

UPDATE documents
   SET deleted = true, deleted_at = now()
 WHERE id = 42
RETURNING WITH (OLD AS o, NEW AS n)
         o.deleted AS was_deleted, n.deleted_at AS deleted_at;

4.5 OAuth 2.0 客户端认证实战

PG 18 的 libpq 支持用 OAuth 2.0 Bearer Token 连接。典型连接串(占位示意,需配套支持 OAuth 的认证插件):

host=db.example.com
port=5432
dbname=demo
user=app_svc
password=oauth
oauth_issuer=https://idp.example.com
oauth_audience=postgres-prod

Python 侧用短期 token:

import psycopg, subprocess

# 向 IdP 申请短期 token(示意)
token = subprocess.check_output(["get-idp-token", "postgres-prod"]).decode().strip()

conn = psycopg.connect(
    host="db.example.com", port=5432, dbname="demo", user="app_svc",
    password=f"oauth:{token}",
    options="-c oauth_issuer=https://idp.example.com -c oauth_audience=postgres-prod",
)
# 之后不再硬编码长期密码,token 过期自动轮换

价值点:长期密码不再进代码仓库、不再进环境变量文件,泄露半径从「永久有效」缩到「token 有效期」。对合规(等保、SOC2)是实打实的加分项。

4.6 可观察性:用 pg_stat_io 看 IO 真功夫

PG 18 强化了 IO 可观察性。最有用的是 pg_stat_io,它能按 backend_type、IO 对象(relation / temp / wal)拆开看 readswritesextendsfsyncs 以及等待时间

-- 按 backend 类型看 IO 分布
SELECT backend_type, object, context,
       reads, read_time, writes, write_time, extends, extend_time
FROM pg_stat_io
ORDER BY read_time DESC
LIMIT 20;

-- 专门看 AIO 是否真在「异步」:对比 read_time 与实际耗时
SELECT object,
       reads,
       round(read_time::numeric, 2) AS read_ms,
       round(extends::numeric, 0)   AS extends
FROM pg_stat_io
WHERE backend_type = 'client backend';

配合 EXPLAIN (ANALYZE, BUFFERS) 看单次查询的 IO 行为:

EXPLAIN (ANALYZE, BUFFERS, I/O)
SELECT count(*) FROM orders_v7 WHERE customer_id BETWEEN 1000 AND 2000;

PG 18 的 EXPLAIN 在 I/O 维度输出更细(含 read / write 的字节与耗时),配合 pg_stat_io 能精确定位「到底是哪类 IO 在拖慢查询」——是随机读太多?还是 extend(扩容建表)太慢?这比过去靠猜强太多。


五、性能优化清单

AIO 不是「开了就快」,调对了才快。下面是 PG 18 生产落地的调优清单:

5.1 AIO 参数调优

# postgresql.conf(Linux 生产,内核支持 io_uring)
io_method = io_uring          # 用 io_uring 而非 worker,性能上限更高
io_workers = 4               # worker 模式才用;io_uring 下基本不依赖
io_max_concurrency = 32      # 单 backend 并发 IO 上限,高并发随机读场景调大
io_combine_limit = 32        # 合并相邻块,顺序扫描收益明显
effective_io_concurrency = 16 # 预读并行度,从默认 1 调大

调优逻辑:

  • 顺序扫描 / 大表报表:重点调大 io_combine_limiteffective_io_concurrency,让一次系统调用搬更多数据。
  • 随机读密集(Bitmap Heap Scan、点查混合):重点调大 io_max_concurrency,让 backend 同时把多个随机读甩给 io_uring。
  • 网络块存储(云硬盘 / EBS):AIO 收益最大,因为单次读延迟高,「等 IO」占比原本就高。本地 NVMe 上收益相对小,但仍为正。

5.2 pgbench 基准对比(sync vs io_uring)

一个最小可复现的对比法:

# 建库 + 初始化(关闭 AIO 跑一轮)
psql -c "ALTER SYSTEM SET io_method=sync; SELECT pg_reload_conf();"
pgbench -i -s 100 demo
pgbench -c 32 -j 8 -T 120 -P 10 demo      # 记 TPS

# 开启 io_uring 跑一轮
psql -c "ALTER SYSTEM SET io_method=io_uring; SELECT pg_reload_conf();"
pgbench -c 32 -j 8 -T 120 -P 10 demo      # 对比 TPS

经验区间(非绝对值,取决于存储):在云网络存储 + 大表混合读写场景,io_uring 相对 sync 常见有 15% ~ 40% 的 TPS 提升,Bitmap Heap Scan 类查询的延迟下降最明显;本地 NVMe 上多在 5% ~ 15%。别信「翻倍」的营销话术,AIO 是「补齐短板」不是「魔法加速」。

5.3 配合 B-tree 与规划器改进

PG 18 还带来了 B-tree 与查询规划器的细节改进:

  • B-tree 对 NULL 处理与去重(deduplication)更稳:大表上 INSERT ... ON CONFLICT DO NOTHING 类的去重索引写入更省空间。
  • 规划器统计增强JOIN 与外键相关的优化器统计更准,多表 JOIN 选错计划的概率下降。
  • 配合前面 UUIDv7 的局部有序主键,B-tree 插入的页分裂显著减少,WAL 体积和缓冲池压力同步下降。

5.4 升级与回退注意

  • PG 18 的 AIO 是实例级能力,从老版本升级后需显式配置 io_method 才会启用,默认仍是保守的 worker / sync 行为,不会自动变快——这是好事,避免升级即「行为突变」。
  • 逻辑复制:PG 18 修复了生成列(含 VIRTUAL)的复制问题,老版本跨版本逻辑复制要注意对齐。
  • 升级建议走 pg_upgrade --link 或逻辑复制,先在非生产验证 io_method=io_uring 在当前内核下是否稳定(老内核 io_uring 有 CVE 历史,注意打补丁)。

六、总结展望

PostgreSQL 18 不是「又一个加功能的版本」,它的主线叙事是底层补课 + 开发体验打磨

  1. AIO 重写补齐了 PG 三十年的 IO 模型短板。它的价值不在「单次读更快」,而在「IO 等待不再阻塞执行器、并能批量合并小 IO」。在网络存储、大表扫描、随机读密集场景收益最大。生产上 io_method=io_uring 值得开,但要先验证内核稳定性。
  2. UUIDv7 用时间有序解决了 UUIDv4 主键的索引碎片与缓存抖动,是「分布式主键」场景的默认首选。
  3. VIRTUAL 生成列把「不占存储的计算列」补上了,展示派生字段不再被迫物化。
  4. RETURNING OLD / NEW 用一行 SQL 替代触发器与嵌套 CTE,审计 / CDC / 软删除直接降维。
  5. OAuth 2.0 客户端认证把长期密码从应用侧拿掉,合规收益实打实。

向后看,PG 19 的讨论已经在进行:WAL 默认压缩算法、AIO 进一步默认化、可观察性继续细化。PG 的方向很清晰——在保持「最被信任的开源关系型数据库」这块招牌的同时,把过去十年被吐槽的底层短板一个一个补齐

给工程师的落地建议很朴素:

  • 新项目:主键默认 uuidv7(),派生展示字段用 VIRTUAL,变更审计用 RETURNING OLD/NEW,连接用 OAuth token。
  • 存量项目升级 PG 18:先在非生产开 io_method=io_uring 压测,确认内核稳定再上生产;别指望「升级即提速」,AIO 需要你主动调 io_max_concurrency / io_combine_limit 才释放。
  • 性能排查:把 pg_stat_io + EXPLAIN (ANALYZE, BUFFERS, I/O) 当成标配,先量化「哪类 IO 在拖」,再决定调哪个参数。

PG 18 这顿「成年礼」,吃的是底层硬功夫。把它用对,你的数据库会在不换硬件的前提下, quietly 地多出一大截余量。

推荐文章

Vue3中的事件处理方式有何变化?
2024-11-17 17:10:29 +0800 CST
Requests库详细介绍
2024-11-18 05:53:37 +0800 CST
向满屏的 Import 语句说再见!
2024-11-18 12:20:51 +0800 CST
Nginx负载均衡详解
2024-11-17 07:43:48 +0800 CST
软件定制开发流程
2024-11-19 05:52:28 +0800 CST
程序员茄子在线接单