编程 PostgreSQL 18 深度实战:AIO 异步 I/O、跳跃扫描与 UUIDv7——一次把关系型数据库的性能革命讲透(2026)

2026-07-14 07:12:43 +0800 CST views 12

PostgreSQL 18 深度实战:AIO 异步 I/O、跳跃扫描与 UUIDv7——一次把关系型数据库的性能革命讲透(2026)

当所有人都在聊向量数据库、湖仓一体、内存 KV 的时候,关系型数据库这块"老骨头"其实也在悄悄进化。PostgreSQL 18 这版更新,没有花哨的营销词,却把几个困扰了 DBA 十年的性能痛点一次性动了刀:同步 I/O 阻塞、低基数列索引失效、随机 UUID 把 B-tree 插成碎片。本文带你从存储引擎底层一路打到 SQL 实战,把 PG 18 的五大核心更新彻底拆穿。


一、背景:为什么 PG 18 值得你半夜爬起来升级

如果你过去三年里在生产环境跑过 PostgreSQL,大概率踩过下面这几个坑:

  1. 全表扫描(Seq Scan)在云盘上慢得像在泥里走路。PG 默认是同步读——CPU 发一个 pread(),然后就干等着内核把数据从磁盘搬上来。在本地 NVMe 上还好,一旦落到网络云盘(EBS、CBS),一次阻塞读可能要几毫秒,CPU 大把时间都在"发呆"。
  2. 多列索引的前导列没约束,后面的范围查询就废了。比如 INDEX (status, created_at),你想查 created_at > now() - '1 day',PG 老版本往往直接放弃用索引,回去全表扫描。
  3. uuid_generate_v4() 当主键,B-tree 被随机写入插得七零八落。索引页频繁分裂、缓存命中率低、写入放大严重。
  4. 计算列只能 STORED(落盘占用空间),很多"顺手算一下"的派生字段为了不占空间只能在应用层重复写。

PG 18 的发布说明里,上面每一个坑都对应一个实打实的引擎级改动。这不是"加了个语法糖",而是存储子系统、索引器、类型系统、认证层四线并进的一次架构升级。下面我们一个一个拆。

阅读前置:本文假定你写过 SQL、知道 B-tree 索引大概怎么回事、见过 EXPLAIN。不需要你是内核开发者,但希望你愿意看代码。


二、核心概念:先对齐几个"地基"

在动手之前,先把 PG 的执行模型讲清楚,否则后面 AIO 和跳跃扫描的好处你体会不到。

2.1 进程模型与"一次查询的一生"

PG 是多进程架构:每个客户端连接对应一个 postgres 后端进程。当你发一条 SELECT,这个后端进程自己负责:

  • 解析、重写、生成执行计划;
  • 按计划去存储层要数据(通过 smgr 存储管理器接口);
  • 把元组(tuple)在内存里拼好,返回给客户端。

关键点在于:数据从磁盘到内存,必须经过 smgr(storage manager)这层抽象。PG 18 的 AIO 革命,恰恰发生在这个 smgr 的读取路径上。记住 smgr 这个词,后面会反复出现。

2.2 MVCC 与堆表(Heap)

PG 用 MVCC(多版本并发控制)实现事务隔离。每一行是一个 tuple,tuple 头上带着 xmin/xmax(创建/删除它的事务号)。更新一行 = 插入新版本 + 标记旧版本过期。这些 tuple 存在一个叫 heap 的堆文件里,按页(8KB 一页)组织。

索引(比如 B-tree)里存的是 (索引键, 堆元组指针 TID)。TID = (页号, 页内偏移)。所以走索引最终还是得回表去 heap 把整行捞出来——这一"回表"动作,正是 I/O 密集的源头。

2.3 为什么 I/O 是瓶颈,而不是 CPU

现代硬件的趋势是:磁盘快了 1000 倍(NVMe)、网络快了 100 倍,但单核 CPU 几乎没怎么涨。于是瓶颈从"算得慢"变成了"等 I/O 回来"。PG 老版本的同步读,等于让一颗本来能干活的核心,为了等一个 pread() 而原地空转。这就是 AIO 要解决的问题。


三、架构分析:PG 18 动了哪些"地基"

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

这是 PG 18 最重量级的改动,没有之一。

旧世界:同步阻塞读

老版本读数据时,代码大致是这样(伪代码):

// 旧:同步读,CPU 阻塞直到数据就绪
Buffer buf = ReadBuffer(rel, blocknum);   // 内部走 smgr->readv(),阻塞
// 此时 CPU 在这个进程里干等,啥也干不了
process_tuple(buf);

在云盘场景,一次 pread() 可能阻塞 1~5ms。一个顺序扫描要读上万页,CPU 就被钉死在等 I/O 上。

新世界:ReadStream + 异步提交

PG 18 重写了 smgr 接口,新增了异步读取能力 smgr_startreadv(),并引入 ReadStream 设施:调用方可以"提前批量提交一批读请求",然后去做别的事(比如处理已经回来的页、解压、过滤),等内核把数据搬完再取结果。

// 新:异步读,先提交,后取结果
ReadStream *stream = read_stream_begin(...);
for (each block) {
    read_stream_await_next(stream);   // 等待"下一块"就绪
    // 在等待期间,底层已经把后续块异步预取了
}

底层实现上:

  • Linux:优先用 io_uring(内核 5.1+ 的完全异步 I/O 框架),真正不阻塞。
  • 其他平台:退化为 POSIX AIO / 同步回退,保证可移植。

哪些路径吃到了 AIO?

PG 18 首批接入 AIO 的场景:

  • 顺序扫描(Seq Scan) 的并行预读;
  • 位图堆扫描(Bitmap Heap Scan)
  • VACUUM 的页面读取。

官方早期测试显示,读密集型查询在云存储上可提升 2~3 倍。注意强调"云存储"——因为本地 NVMe 延迟本来就低,提升没那么夸张;但一旦 I/O 延迟高(网络盘、对象存储挂载),AIO 的并行预读价值就爆发了。

怎么开?

AIO 默认就是开的(引擎自动决定能否用),但你可以用几个 GUC 调:

-- 异步 I/O  Workers 数量,0 表示自动(通常等于 min(io_workers, 可用核))
SHOW io_workers;

-- 强制数据文件走 direct I/O(绕过 OS page cache,自己管缓冲)
-- 取值: '' / 'data' / 'wal' / 'wal,data'
-- 默认空。在专用数据库机器上设 'data' 常能减少双重缓存
ALTER SYSTEM SET debug_io_direct = 'data';

-- 单次合并 I/O 的最大字节数,影响预读聚合粒度
SHOW io_combine_limit;

⚠️ 注意:PG 18 的 AIO 目前只实现了异步读,还没做异步写(WAL 异步读写仍在开发)。所以别指望靠它加速写入,它主要救的是"读多/扫描多"的负载。

怎么验证 AIO 真的在干活?

跑个 Seq Scan 并 EXPLAIN (ANALYZE, BUFFERS),看 Buffers: read=I/O Timings

EXPLAIN (ANALYZE, BUFFERS, TIMING ON)
SELECT count(*) FROM orders WHERE total_amount > 100;

如果 I/O Timings: read=... 显著、且 shared read 数量大,说明在读盘;开启 AIO 后同样负载的 Execution Time 会明显下降(尤其云盘)。


3.2 跳跃扫描(Skip Scan)

这是索引器的一次"智商升级"。

问题现场

假设你有个订单表:

CREATE TABLE orders (
    id          bigint PRIMARY KEY,
    status      text,                 -- 只有 'pending'/'paid'/'shipped'/'done' 几种值(低基数列)
    created_at  timestamptz NOT NULL,
    customer_id bigint
);
CREATE INDEX idx_orders_status_created ON orders (status, created_at);

现在你想查"最近一天的订单":

SELECT * FROM orders WHERE created_at > now() - interval '1 day';

这个查询没约束前导列 status。老版本 PG 看到 WHERE 里没有 status = ...,基本就判定 (status, created_at) 这个索引用不上,转头去全表扫描。

但其实索引是能用的:因为 status 只有 4 种取值,PG 完全可以先跳到 status='pending' 的分支里按 created_at 范围扫,扫完跳到 'paid',再跳 'shipped'…… 像翻字典时按首字母跳着找,而不是从第一页逐页翻。这就是跳跃扫描

PG 18 怎么做的

PG 18 给 B-tree 加了跳跃扫描能力。当优化器发现:

  • 索引前导列基数很低(distinct 值少);
  • 查询在后续列上有可下推的条件;

它就会生成 Skip Scan 节点,在 distinct 的前导值之间"跳跃",每个值内做一次正常的范围/等值扫描。

验证:

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE created_at > now() - interval '1 day';

PG 18 计划里你会看到类似:

Index Scan using idx_orders_status_created on orders
  Scan Mode: Skip Scan          -- 关键:出现了 Skip Scan
  Index Cond: (created_at > (now() - '1 day'::interval))

而老版本这里大概率是 Seq Scan on orders

实战提醒:跳跃扫描对低基数列最划算。如果前导列有上百万种取值,跳跃本身的开销就盖过了收益,优化器会自动放弃。所以索引设计上,"把低基数、常用于过滤的列放前面"这个老经验,在 PG 18 里更值钱了。

一个更经典的 DISTINCT 场景

-- 取所有出现过的 status(去重)
SELECT DISTINCT status FROM orders;

(status, created_at) 索引上,PG 18 可以用跳跃扫描直接沿索引跳着取 distinct 值,避免全表。当你有"按某低基数列去重 + 多列索引"的组合时,这招尤其香。


3.3 UUID v7:把"随机主键"的坑填上

uuid_generate_v4()(UUID v4)是很多系统的默认主键。问题在于:v4 是完全随机的

B-tree 索引要求键有序插入。随机 UUID 意味着每次插入都落到一个完全随机的位置,导致:

  • 索引页频繁分裂;
  • 新页不断被分配,旧页变冷;
  • 缓存命中率低(你永远在访问"刚刚才创建、彼此不相邻"的页)。

UUID v7 的解法:时间戳打头

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

  • 同一毫秒内生成的 UUID,前缀相同 → 在 B-tree 里是相邻的
  • 不同时间生成的 UUID,按时间自然排序;
  • 插入变成"近似顺序写",索引页分裂和碎片大幅减少。

PG 18 原生提供了 uuidv7() 函数(不用再装 uuid-ossp 扩展):

SELECT uuidv7();
-- 形如:018f3e2c-7a1b-7c2d-9e3f-1a2b3c4d5e6f
--        ^^^^^^^^ 前 48 bit 是时间戳,可排序

-- 直接当主键
CREATE TABLE events (
    id    uuid PRIMARY KEY DEFAULT uuidv7(),
    payload jsonb,
    created_at timestamptz DEFAULT now()
);

对比验证(Python 基准)

下面这段脚本直接对比 v4 与 v7 作为主键时的索引膨胀与插入耗时

import psycopg
import time, uuid, random

conn = psycopg.connect("dbname=demo user=postgres")
cur = conn.cursor()

for kind, fn in [("v4", lambda: str(uuid.uuid4())),
                ("v7", lambda: None)]:  # v7 用 sql 端 uuidv7()
    cur.execute(f"DROP TABLE IF EXISTS bench_{kind};")
    cur.execute(f"CREATE TABLE bench_{kind} (id {'uuid' if kind=='v7' else 'uuid'} PRIMARY KEY, v int);")
    sql = (f"INSERT INTO bench_{kind} VALUES (%s, %s)"
           if kind == 'v4' else
           f"INSERT INTO bench_{kind} VALUES (uuidv7(), %s)")
    t0 = time.time()
    for i in range(200_000):
        val = fn() if kind == 'v4' else None
        cur.execute(sql, (val, i) if kind == 'v4' else (i,))
    conn.commit()
    print(f"{kind}: {time.time()-t0:.2f}s")

# 看索引膨胀率(pgstattuple)
cur.execute("SELECT * FROM pgstatindex('bench_v4_pkey');")
print("v4 index:", cur.fetchone())
cur.execute("SELECT * FROM pgstatindex('bench_v7_pkey');")
print("v7 index:", cur.fetchone())

在 20 万行级别,v7 的主键索引通常更小、插入更快、缓存命中更高。量级越大,差距越明显——这正是大规模 SaaS、事件表、审计日志这类"海量顺序写入"场景的福音。


3.4 虚拟生成列(Virtual Generated Columns)

PG 12 引入了生成列,但只支持 STORED(落盘占用空间)。PG 18 补齐了 VIRTUAL:值在读取时实时计算,不占存储,除非你给它建索引。

CREATE TABLE users (
    id        bigint PRIMARY KEY,
    first_name text NOT NULL,
    last_name  text NOT NULL,
    -- 虚拟列:不落盘,查询时实时拼接
    full_name  text GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL,
    -- 也可以给虚拟列建索引(索引本身会物化)
    email_lower text GENERATED ALWAYS AS (lower(email)) VIRTUAL
);

CREATE INDEX idx_users_email_lower ON users (email_lower);

INSERT INTO users (id, first_name, last_name, email)
VALUES (1, 'Ada', 'Lovelace', 'Ada@Example.COM');

SELECT full_name, email_lower FROM users WHERE id = 1;
--  full_name   |  email_lower
--  Ada Lovelace| ada@example.com

什么时候用 VIRTUAL,什么时候用 STORED?

  • 派生计算便宜、且你不想多占空间 → VIRTUAL
  • 派生计算贵(比如跨表、复杂函数)、且读远多于写 → STORED 更划算;
  • 需要被 WHERE 高频过滤且计算较重 → 给 VIRTUAL 建索引(只索引物化,行本身不物化)。

这下"应用层还要再拼一遍 full_name"的重复代码可以删了。


3.5 OAuth 2.0 认证

PG 18 把 OAuth 2.0 Bearer Token 纳进了官方认证体系。这意味着你可以用短生命周期的 OAuth token(比如对接企业 IdP、短期凭据)来连数据库,而不是长期不变的密码。

配置思路(pg_hba.conf + pg_ident.conf):

# pg_hba.conf —— 对某个库/用户启用 oauth 认证
host    mydb    app_user    10.0.0.0/8    oauth
# pg_ident.conf —— 把 token 里的 subject 映射到数据库角色
oauthmap    /claims/sub    app_user

客户端连接时带上 Authorization: Bearer <token> 即可。这对"云原生里数据库凭据走短期 token、配合 SPIFFE/IdP 轮换"的场景是刚需。细节较多,生产落地建议结合你们现有的 IdP 文档,这里不展开令牌签发流程。


3.6 其他值得记一笔的更新

  • MERGE ... RETURNING:PG 18 允许 MERGE 语句带 RETURNING,UPSERT 之后直接拿回受影响行,少一次 SELECT
    MERGE INTO inventory t
    USING (VALUES (42, 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 t.sku, t.qty;   -- PG 18 新支持
    
  • EXPLAIN 增强:JSON 输出与执行细节更丰富,排查慢查询时信息更全。
  • 逻辑复制增强 / 序列复制:复制范围从表数据扩展到序列,大版本升级时少很多手动同步。

四、代码实战:从零跑通 PG 18

4.1 起一个 PG 18 容器

docker run -d --name pg18 \
  -e POSTGRES_PASSWORD=pgpass \
  -p 5432:5432 \
  pgvector/pgvector:pg18   # 或官方 postgres:18

连进去:

docker exec -it pg18 psql -U postgres

4.2 实战一:AIO 开关与基准

-- 确认版本
SELECT version();   -- 应包含 PostgreSQL 18

-- 看 AIO 相关 GUC
SHOW io_workers;
SHOW debug_io_direct;
SHOW io_combine_limit;

-- 造一张大表(1 千万行)做扫描基准
CREATE TABLE big_log (
    id   bigint GENERATED ALWAYS AS IDENTITY,
    payload text,
    created_at timestamptz DEFAULT now()
);
INSERT INTO big_log (payload)
SELECT 'line-' || g FROM generate_series(1, 10_000_000) g;
VACUUM ANALYZE big_log;

-- 同步读基准
EXPLAIN (ANALYZE, BUFFERS, TIMING ON)
SELECT count(*) FROM big_log WHERE payload LIKE 'line-123%';

-- 开启 direct I/O 再看一次(专用机建议)
ALTER SYSTEM SET debug_io_direct = 'data';
SELECT pg_reload_conf();
-- 重新跑上面的 EXPLAIN,对比 Execution Time / I/O Timings

真实环境里你会看到:在云盘挂载的实例上,AIO + 适度 io_workers 让顺序扫描的 I/O Timings 明显下降。本地 NVMe 提升有限,但不会变慢——AIO 是稳赚不赔的底层优化。

4.3 实战二:跳跃扫描对比实验

-- 低基数列 + 时间列的复合索引
CREATE TABLE orders (
    id         bigint GENERATED ALWAYS AS IDENTITY,
    status     text,
    created_at timestamptz DEFAULT now(),
    amount     numeric
);
INSERT INTO orders (status, amount)
SELECT (ARRAY['pending','paid','shipped','done'])[1 + (random()*3)::int],
       round((random()*1000)::numeric, 2)
FROM generate_series(1, 5_000_000);

CREATE INDEX idx_status_created ON orders (status, created_at);
VACUUM ANALYZE orders;

-- 只按时间查,不走前导列
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE created_at > now() - interval '1 day';
-- PG 18 应出现 "Skip Scan";老版本是 Seq Scan

EXPLAIN 结果贴到监控里对比:同样是"按时间过滤、不带 status",PG 18 用 Skip Scan 扫的索引块远少于老版本的全表,延迟随数据量放大优势越明显。

4.4 实战三:UUID v7 主键建表

CREATE TABLE events (
    id         uuid PRIMARY KEY DEFAULT uuidv7(),
    kind       text NOT NULL,
    payload    jsonb,
    created_at timestamptz DEFAULT now()
);

INSERT INTO events (kind, payload)
VALUES ('click', '{"x":10,"y":20}'),
       ('view',  '{"page":"/home"}');

-- 看排序:v7 天然按时间递增
SELECT id, created_at FROM events ORDER BY id LIMIT 5;

4.5 实战四:Go 应用连 PG 18(含 OAuth/密码)

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "github.com/jackc/pgx/v5"
)

func main() {
    dsn := os.Getenv("PG_DSN") // 例如 postgres://user:pass@host:5432/demo
    ctx := context.Background()

    conn, err := pgx.Connect(ctx, dsn)
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close(ctx)

    // 用 UUID v7 主键插一条事件
    var id [16]byte
    err = conn.QueryRow(ctx,
        `INSERT INTO events (kind, payload) VALUES ($1, $2) RETURNING id`,
        "signup", `{"plan":"pro"}`).Scan(&id)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("inserted event id (uuidv7): %x\n", id)

    // 跳跃扫描友好查询:按时间范围捞最近事件
    rows, err := conn.Query(ctx,
        `SELECT id, kind FROM events WHERE created_at > now() - interval '1 day'`)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()
    for rows.Next() {
        var (eid [16]byte; kind string)
        rows.Scan(&eid, &kind)
        fmt.Printf("%x %s\n", eid, kind)
    }
}

小技巧:PG 18 的 uuidv7() 让"插入顺序 ≈ 主键顺序",配合 ORDER BY id游标分页(keyset pagination)比 OFFSET 稳得多,深分页不崩。

4.6 实战五:虚拟生成列 + 索引

CREATE TABLE products (
    id    bigint PRIMARY KEY,
    title text NOT NULL,
    price numeric NOT NULL,
    cost  numeric NOT NULL,
    -- 毛利:虚拟列,不落盘
    margin numeric GENERATED ALWAYS AS (price - cost) VIRTUAL
);
CREATE INDEX idx_margin ON products (margin);

INSERT INTO products (id, title, price, cost)
VALUES (1, 'Keyboard', 299, 180),
       (2, 'Mouse',    99,  60);

-- 直接按派生列过滤,索引生效
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM products WHERE margin > 100;

五、性能优化:一张 PG 18 调优清单

把上面的特性落到生产,给你一份可直接照做的清单:

痛点PG 18 解法落地动作
云盘上全表/大扫描慢AIO + 并行预读确认 io_workers 非 0;专用机试 debug_io_direct='data'
低基数列 + 时间范围查慢跳跃扫描(低基数列, 时间列) 复合索引;EXPLAIN 看是否出 Skip Scan
随机 UUID 主键索引膨胀UUID v7新表主键改 DEFAULT uuidv7();老表迁移用双写+回填
派生字段重复写虚拟生成列GENERATED ... VIRTUAL;高频过滤列再建索引
短期凭据/IdP 对接OAuth 2.0pg_hba.confoauth + pg_ident.conf 映射
UPSERT 后还要 SELECTMERGE RETURNINGMERGE ... RETURNING 一次拿回结果

AIO 调优的边界认知:

  • AIO 主要利好读/扫描负载,对纯写入帮助有限(异步写尚未落地);
  • 本地 NVMe 上提升温和,网络云盘/对象存储上提升显著;
  • debug_io_direct='data' 会绕过 OS page cache,务必确认机器内存足够、且你接受 PG 自己管缓冲,否则可能适得其反。

跳跃扫描的边界认知:

  • 只对低基数列划算;前导列 distinct 值过多时优化器会自行放弃;
  • 它不改变"你必须建对复合索引"的事实,只是让"前导列未被约束"的查询也能用上索引。

UUID v7 的迁移策略:

  • 新表直接用 uuidv7()
  • 老表别硬改主键类型(代价巨大)。更现实的是:新写入用 v7,读路径兼容;或用 created_at + 序列 的复合自然序键替代随机 UUID 做聚簇。

六、总结与展望:关系型数据库没有"过时"

回头看这一版 PG 18,你会发现它的主线非常清晰:在硬件已经"磁盘快、CPU 慢"的时代,把数据库的等待时间榨干

  • AIO 让 CPU 不再为等 I/O 空转;
  • 跳跃扫描让"建错了前导列"的复合索引也能起死回生;
  • UUID v7 把随机写入导致的索引碎片化从根上缓解;
  • 虚拟生成列把"算一遍还是存一遍"的选择权交还给你;
  • OAuth 2.0 把数据库塞进了现代零信任身份体系。

有人会说:"现在都上向量库、湖仓一体了,谁还抠关系型数据库这点性能?" 但现实是——绝大多数业务系统的核心账本,依然是那张跑了十年的 PostgreSQL。AI 来了,它反而更忙了: embeddings 元数据、RAG 的文档主表、Agent 的状态机,最后都落回一张带事务保证的关系表。PG 18 的 AIO 让向量检索前的过滤扫描更快,UUID v7 让海量事件写入更顺,虚拟列让特征工程少写一层胶水。

升级建议很朴素:非核心库先升,跑两周基准,重点盯 Seq Scan 的 I/O Timings 和索引膨胀率;核心库挑业务低峰,用逻辑复制做蓝绿切换,别裸奔 pg_upgrade

关系型数据库不会消失,它只是悄悄长出了适应新硬件、新身份的肌肉。PG 18,就是这块老骨头的一次漂亮深蹲。


本文基于 PostgreSQL 18 官方发布说明与内核变更整理,所有 SQL/代码均可在 PG 18 环境直接运行。特性可用性以你实际部署的小版本为准,生产前请在测试环境验证基准。

推荐文章

Vue 3 是如何实现更好的性能的?
2024-11-19 09:06:25 +0800 CST
LLM驱动的强大网络爬虫工具
2024-11-19 07:37:07 +0800 CST
Vue3中如何实现状态管理?
2024-11-19 09:40:30 +0800 CST
mysql 计算附近的人
2024-11-18 13:51:11 +0800 CST
XSS攻击是什么?
2024-11-19 02:10:07 +0800 CST
前端如何一次性渲染十万条数据?
2024-11-19 05:08:27 +0800 CST
资源文档库
2024-12-07 20:42:49 +0800 CST
CentOS 镜像源配置
2024-11-18 11:28:06 +0800 CST
程序员茄子在线接单