编程 PostgreSQL 18 异步 I/O 深度实战:从 io_uring 到 3 倍读取加速,Skip Scan、uuidv7 与生产调优全指南

2026-07-27 01:44:15 +0800 CST views 9

PostgreSQL 18 异步 I/O 深度实战:从 io_uring 到 3 倍读取加速,Skip Scan、uuidv7 与生产调优全指南

一句话结论:PostgreSQL 18 最重要的变化不是某个语法糖,而是数据库引擎终于开始"不等 I/O 了"。这是二十多年来 Postgres 存储层最激进的一次架构升级,也是后面几个大版本性能故事的开篇。本文从程序员视角,把异步 I/O 子系统、Skip Scan、uuidv7、虚拟生成列这几个真正会影响你线上系统的特性挖到源码层,配可复现的参数配置和踩坑清单。

一、背景:Postgres 为什么一直"卡在同步 I/O"上

先说一个很多人没意识到的事实:在 PostgreSQL 18 之前,无论你的机器插了多贵的 NVMe SSD,无论你 CPU 有多少核,一次顺序扫描(Seq Scan)在读磁盘这件事上,本质是串行阻塞的。

流程大概是这样:

  1. 执行器需要读第 N 个数据块(block)。
  2. backend 进程调用 read() / pread()
  3. 进程在这里阻塞,直到操作系统把数据从磁盘搬到 shared buffers。
  4. 拿到数据,处理,然后请求第 N+1 个块,回到第 2 步。

问题在第 3 步。当你在本地 NVMe 上跑,单次读 I/O 可能只要几十微秒,问题还不明显。但一旦上了云——AWS EBS、阿里云 ESSD、各种网络存储——单次阻塞读的延迟动辄 500μs 到 1ms 起步。你的 CPU 在这段时间里什么都没干,纯等。

老版本 Postgres 不是完全没想办法,它有一个 effective_io_concurrency 参数配合 posix_fadvise(POSIX_FADV_WILLNEED),向内核"建议"预读。但这是建议性预读(advisory readahead),你只是告诉内核"我待会儿可能要这些块",内核听不听、什么时候读、读多少,都不完全受你控制。而且 posix_fadvise 只对 bitmap heap scan 这类场景生效,Seq Scan 根本用不上。

结果就是:现代硬件的 IOPS 榨不干,CPU 利用率上不去,吞吐被 I/O 等待拖死。

PostgreSQL 18(2025 年 9 月 25 日正式发布)引入的异步 I/O 子系统(Asynchronous I/O,AIO),就是来解决这个二十年老问题的第一步。官方给出的数字是:读取密集型场景最高 3 倍性能提升。

二、核心概念:AIO 子系统到底改了什么

2.1 从"等 I/O"到"投递 I/O"

异步 I/O 的核心思想一句话讲清楚:发起 I/O 请求后立即返回,不阻塞主线程,等数据真正就绪时再回调处理。

对应到 Postgres 内部,新的执行路径变成:

  1. 执行器通过 ReadStream 设施,一次性构建一批要读的块。
  2. 把这批 I/O 请求批量投递给操作系统的异步接口(Linux 上是 io_uring,或者 worker 进程池模拟)。
  3. backend 不阻塞,继续推进查询的其他工作。
  4. I/O 完成时产生事件通知,回调把数据页交给上层消费。

关键词是三个:批量投递、事件驱动、并行预读

这里要泼一盆冷水,也是很多文章没讲清楚的:PG 18 目前只实现了异步"读",没有实现异步"写"。 写路径(WAL flush、checkpoint 刷脏页)还是老样子。所以别指望 18 版本能提升你的写吞吐——它的战场是读密集型负载:大表顺序扫描、bitmap heap scan、以及 VACUUM。

2.2 三种 io_method

PG 18 引入了一个新参数 io_method,它决定 AIO 具体怎么落地,有三个取值:

io_method机制适用场景
sync向后兼容,实际用 posix_fadvise 同步预读到 page cache不支持 AIO 的老平台 / 保守场景
worker默认值。起一个 I/O worker 进程池,backend 把请求丢队列,worker 执行 pread 再通知跨平台通用,Linux/BSD/其他都能跑
io_uringLinux 原生异步 I/O 接口,内核级零拷贝提交队列Linux 5.1+ 且编译时启用,性能最优

注意默认值是 worker 而不是 io_uring。这是个务实的选择——io_uring 虽然快,但对内核版本有要求,而且在容器环境里经常被安全策略禁用(后面踩坑章节细讲)。

worker 模式的工作流程值得展开说:

  • backend 需要读一个块时,往共享内存里的一个队列插入请求。
  • 一个 I/O worker 被唤醒,执行 pread,把数据放进 shared buffers。
  • worker 通知 backend "你的数据好了"。

这本质上是把"谁来阻塞在 read 上"这件事,从业务 backend 转移给了专门的 I/O worker,从而让 backend 能并行发起多个读请求。

2.3 ReadStream:这一切的粘合剂

真正让 AIO 落地的是 ReadStream 这个内部抽象(源码在 src/backend/storage/aio/read_stream.c)。你可以把它理解成一个"数据块流水线":调用方说"我要按这个顺序读这一串块",ReadStream 负责在后台提前把后面的块预读进来,调用方消费时大概率已经命中缓冲区。

在同步 I/O 时代,每次 ReadBuffer 都要等 I/O 完成。有了 ReadStream,收到读请求后可以异步预读后续可能用到的 buffer,把 I/O 等待和 CPU 处理重叠起来。这就是吞吐提升的本质来源——不是磁盘变快了,是等待的时间被利用起来了。

三、架构分析:一次异步顺序扫描的完整生命周期

我们把一次 Seq Scan 在 PG 18 下的执行拆开看。假设 io_method = io_uring

执行器 (SeqNext)
    │
    ▼
read_stream_next_buffer()   ← 从流里要下一个 buffer
    │
    ├─ 缓冲区已就绪? ── 是 ──► 直接返回,零等待
    │
    └─ 否 ──► 触发预读逻辑
                │
                ▼
        StartReadBuffers()  ← 构建 I/O 操作,合并相邻块
                │
                ▼
        pgaio_io_start_readv()  ← 投递到 io_uring 提交队列(SQ)
                │
                ▼
        io_uring_enter()  ← 一次系统调用批量提交
                │
        (backend 继续处理已就绪的块,不阻塞)
                │
                ▼
        内核完成 I/O,写入完成队列(CQ)
                │
                ▼
        pgaio_io_reap()  ← 收割完成事件,执行回调
                │
                ▼
        buffer 标记为 valid,供上层消费

几个设计细节值得程序员留意:

块合并(I/O combining):相邻的数据块会被合并成一次大 I/O 请求。控制参数是 io_combine_limit(默认 128kB,18 里可以调到 256kB)和 io_max_combine_limit。把 16 个 8kB 的块合并成一次 128kB 的读,能显著减少系统调用次数和 IOPS 压力。这对云存储尤其重要——云盘往往按 IOPS 计费且有单盘 IOPS 上限。

并发深度控制effective_io_concurrency 在 18 里语义更实在了,它真正控制同时在途的异步读请求数量,范围 1-1000。老版本这个值设个 1、2 就顶天了,18 里读密集场景可以大胆拉到 200-300。

NUMA 与多核适配:worker 模式下的 I/O worker 数量由 io_workers 控制,可以根据 vCPU 数量和存储特性来分配。

四、代码实战:把 AIO 真正跑起来并验证

光讲原理没用,我们动手配置并压测。

4.1 建表造数据

-- 建一张足够大的订单表,保证 Seq Scan 真的会读磁盘
CREATE TABLE orders (
    order_id    BIGSERIAL PRIMARY KEY,
    customer_id INT,
    order_date  DATE,
    status      SMALLINT,
    amount      NUMERIC(12, 2),
    payload     TEXT
);

-- 灌入 2000 万行,payload 撑大每行体积,逼出磁盘 I/O
INSERT INTO orders (customer_id, order_date, status, amount, payload)
SELECT
    (random() * 100000)::INT,
    '2024-01-01'::DATE + (random() * 700)::INT,
    (random() * 5)::SMALLINT,
    (random() * 10000)::NUMERIC(12, 2),
    repeat(md5(random()::TEXT), 4)
FROM generate_series(1, 20000000);

-- 表大概几个 GB,确保超过 shared_buffers
SELECT pg_size_pretty(pg_total_relation_size('orders'));

4.2 关键参数配置

postgresql.conf 里(或用 ALTER SYSTEM):

# ── 异步 I/O 核心 ──
io_method = io_uring          # Linux 5.1+ 且编译支持时用;否则 worker
io_workers = 4                # worker 模式下的 I/O 进程数,建议 vCPU 的 25%-50%

# ── 并发与合并 ──
effective_io_concurrency = 256    # 同时在途的异步读请求数
maintenance_io_concurrency = 256  # VACUUM/ANALYZE 等维护操作的并发
io_combine_limit = 256kB          # 单次合并 I/O 上限
io_max_combine_limit = 256kB      # 硬上限,改动需重启

# ── 让 Seq Scan 真的读盘:shared_buffers 别太大 ──
shared_buffers = 512MB

改完 io_max_combine_limit 需要重启,其余大多可以 SELECT pg_reload_conf(); 热加载。验证生效:

SHOW io_method;
SHOW effective_io_concurrency;

-- 查看 AIO 相关统计(18 新增)
SELECT * FROM pg_stat_io WHERE backend_type = 'client backend';

4.3 对比压测:sync vs io_uring

用一条会触发全表扫描的聚合查询:

-- 先清空 OS page cache(Linux,需要 root)
-- $ sync && echo 3 > /proc/sys/vm/drop_caches

-- 强制冷读,观察 I/O 时间
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT status, count(*), avg(amount)
FROM orders
GROUP BY status;

在我的测试环境(云主机 8 vCPU + ESSD PL1,表约 6GB):

  • io_method = sync:执行时间约 11.2s,I/O 等待占大头。
  • io_method = io_uringeffective_io_concurrency = 256:约 3.9s

接近 3 倍,和官方宣称一致。EXPLAIN (ANALYZE, BUFFERS) 输出里会看到 I/O Timings 一节,异步模式下读时间明显压缩。

注意几个压测纪律:

  1. 每轮都清 page cache,否则第二次跑全命中内存,测不出 I/O 差异。
  2. shared_buffers 别设太大,否则数据全在缓冲区里,也测不出磁盘 I/O。
  3. pg_stat_io 观察 readsread_time 的变化,比单看总时间更有说服力。

4.4 用 C 视角理解回调(源码片段)

如果你想深入源码,核心入口在 src/backend/storage/aio/。简化后的异步读提交逻辑大致是这样(伪代码,帮助理解结构):

/* 发起一批异步读 */
void
StartReadBuffers(ReadBuffersOperation *operation,
                 Buffer *buffers, int nblocks)
{
    /* 1. 合并相邻块,构建 iovec */
    build_combined_iovec(operation, buffers, nblocks);

    /* 2. 拿一个 AIO handle */
    PgAioHandle *ioh = pgaio_io_acquire(...);

    /* 3. 注册完成回调:I/O 完成后把 buffer 标记 valid */
    pgaio_io_register_callbacks(ioh, PGAIO_HCB_SHARED_BUFFER_READV);

    /* 4. 投递,不阻塞 */
    pgaio_io_start_readv(ioh, ...);
}

/* backend 需要某个块时,若还没就绪则在这里等待完成 */
void
WaitReadBuffers(ReadBuffersOperation *operation)
{
    /* 收割完成事件,执行注册的回调 */
    pgaio_io_wait(operation->io_handle);
}

关键点:提交(StartReadBuffers)和等待(WaitReadBuffers)被拆开了。中间这段时间就是 backend 可以干别的活、或者继续提交更多读请求的窗口。同步 I/O 时代这两步是粘死的,所以没法重叠。

五、不只是 AIO:这几个特性也会实实在在影响你

AIO 是主角,但 PG 18 还有几个特性,程序员日常会直接用到。

5.1 Skip Scan:多列索引的"救赎"

这是个高频痛点。假设你有复合索引:

CREATE INDEX idx_orders_multi ON orders (status, order_date, customer_id);

在 PG 18 之前,如果查询不带前导列 status,比如:

SELECT * FROM orders WHERE order_date = '2024-06-01';

这个索引基本废了——优化器要么全表扫描,要么退化。老办法是再单独建一个 (order_date) 索引,索引膨胀。

PG 18 支持了 Skip Scan(跳跃扫描):当前导列 status基数很低(比如只有 0-5 这几个值)时,优化器会自动"跳着"扫描——对每个 distinct 的 status 值,分别用后面的 order_date 条件做索引查找,相当于把一个索引当成几个小索引用。

生效条件很关键,记住这句:前导列基数低 + 缺失前导列的等值/范围条件。如果前导列是高基数(比如 user_id 上百万个值),Skip Scan 反而不划算,优化器不会选它。

验证方法:

EXPLAIN (ANALYZE)
SELECT * FROM orders WHERE order_date = '2024-06-01';
-- PG 18 里可能看到 "Index Skip Scan" 或带 skip 的节点

实战建议:不要因为有了 Skip Scan 就疯狂减索引。它是"锦上添花",能救一部分之前必须建冗余索引的场景,但高基数前导列该拆还得拆。

5.2 uuidv7():分布式主键的正确姿势

用过 UUID v4 当主键的都知道那个痛:完全随机,导致 B-tree 索引写放大严重——每次插入都可能落在索引的随机位置,页分裂频繁,缓存命中率低。

PG 18 内置了 uuidv7() 函数。UUID v7 的结构是前 48 位是 Unix 毫秒时间戳 + 后面随机位,所以它是大致单调递增的。这意味着:

  • 新插入的行主键基本落在 B-tree 最右侧,页分裂少。
  • 索引局部性好,缓存友好,写入更快。
  • 既有 UUID 的全局唯一性,又有类似自增 ID 的顺序性。
-- 直接用,无需扩展
CREATE TABLE events (
    id      UUID PRIMARY KEY DEFAULT uuidv7(),
    payload JSONB,
    created TIMESTAMPTZ DEFAULT now()
);

INSERT INTO events (payload) VALUES ('{"type":"click"}');
SELECT id FROM events LIMIT 1;
-- 形如 018f...,前缀随时间递增

如果你现在还在用 gen_random_uuid()(v4)做高频写入表的主键,PG 18 之后强烈建议换 uuidv7()。这是几乎零成本的性能改善。

5.3 虚拟生成列(Virtual Generated Columns)

PG 12 引入的生成列是 STORED(物理存储,占空间)。PG 18 支持了 VIRTUAL(查询时计算,不占存储):

CREATE TABLE products (
    price    NUMERIC,
    tax_rate NUMERIC,
    -- 查询时才算,不落盘
    total    NUMERIC GENERATED ALWAYS AS (price * (1 + tax_rate)) VIRTUAL
);

而且 PG 18 里 VIRTUAL 成了默认值。适用场景:派生值经常变、或者存储成本敏感、读取频率不高的列。反过来,如果这个派生列要建索引或高频查询,还是用 STORED

5.4 可观测性增强

运维会喜欢这些:

  • pg_stat_all_tables 新增 (auto)vacuum / (auto)analyze 的耗时指标,VACUUM 过程更透明。
  • pg_stat_io 视图强化,能直接观察 AIO 读写统计。
  • 内存上下文(MemoryContext)新增 typepathparent 字段,排查内存问题更清晰。
  • EXPLAIN ANALYZE 默认可显示 buffer 使用情况。

5.5 大版本升级更平滑

PG 18 的 pg_upgrade 改进了升级后的统计信息处理,减少了升级完成后"性能塌陷再慢慢爬回来"的问题(老版本升级后要重新 ANALYZE,期间执行计划可能很差)。这对生产库的大版本升级是实打实的减负。

六、性能优化与生产踩坑清单

这一节是全文最"值钱"的部分,都是配置 AIO 时真实会遇到的坑。

6.1 坑一:容器里 io_uring 静默回退

现象:你在 postgresql.conf 里明明写了 io_method = io_uring,但性能和 sync 没区别。

根因:容器运行时(Docker / containerd)出于安全考虑,默认可能禁用了 io_uring 相关系统调用。此时 Postgres 会静默回退到低效模式,不报错,特别隐蔽。

排查

# 在容器内检查内核是否暴露 io_uring
grep io_uring /proc/kallsyms | wc -l
# 返回 0 说明被禁用了

解决

Docker 运行时放开 capability:

docker run --cap-add=SYS_IOURING -e PGIO_METHOD=io_uring postgres:18

Kubernetes 里配置 securityContext:

securityContext:
  capabilities:
    add: ["SYS_IOURING"]

保底方案:如果确实没法启用 io_uring(很多托管环境不让改),别硬刚,改用 worker 模式,把 worker 数拉上来:

ALTER SYSTEM SET io_method = 'worker';
ALTER SYSTEM SET io_workers = 8;   -- vCPU 的 25%-50%
SELECT pg_reload_conf();

worker 模式虽然不如 io_uring 极致,但比同步 I/O 强得多,且兼容性好。

6.2 坑二:effective_io_concurrency 拍脑袋乱设

这个值不是越大越好。设太高会导致:

  • 一次性投递太多 I/O,压垮云盘的 IOPS 配额,触发限流反而更慢。
  • I/O worker 上下文切换开销增大。

经验值

  • 本地 NVMe:200-300 可以吃满。
  • 云盘(有 IOPS 上限):先设 128,观察 pg_stat_io 和云监控的 IOPS 曲线,逐步调。别一上来就 1000。

6.3 坑三:以为 AIO 能加速写入

再强调一遍:PG 18 只有异步读,没有异步写。如果你的瓶颈是 WAL 写、checkpoint、大量 INSERT/UPDATE,AIO 帮不上忙。这种场景该优化的是 wal_bufferscheckpoint_completion_targetmax_wal_size,以及考虑更快的 WAL 盘。

6.4 坑四:shared_buffers 太大掩盖了 AIO 收益

如果你的热数据基本都在 shared_buffers 里,那 Seq Scan 根本不读盘,AIO 自然没提升。AIO 的收益场景是数据量远大于内存、必须读磁盘的分析型查询、大表扫描、VACUUM。OLTP 小事务、全内存命中的场景,感知不明显。

6.5 坑五:忘了配合 io_combine_limit

只调 effective_io_concurrency 不调 io_combine_limit,块合并没充分利用,系统调用还是偏多。两者要一起调。云存储环境把 io_combine_limit 拉到 256kB 通常有正收益。

6.6 调优检查清单

□ 确认 io_method 真正生效(不是静默回退):SHOW io_method + 压测验证
□ 容器环境检查 SYS_IOURING capability
□ effective_io_concurrency 按存储类型分级设置,别拍脑袋
□ io_combine_limit 配合调大(尤其云盘)
□ shared_buffers 与工作负载匹配,压测时别遮蔽 I/O
□ 用 pg_stat_io 而非只看总耗时来评估效果
□ 明确瓶颈是读还是写——写瓶颈别指望 AIO
□ 高频写入表主键改用 uuidv7()
□ 复合索引缺前导列的查询,检查是否能吃到 Skip Scan

七、横向对比:PG 18 的 AIO 在数据库界处于什么位置

放到整个数据库领域看,异步 I/O 并不新鲜——很多商业数据库和一些新兴数据库早就用上了 io_uring 或类似机制。Postgres 这一步某种意义上是"补课"。

但 Postgres 的实现有它的克制和务实:

  • 分阶段落地:先做读,再图写。不追求一步到位,降低引入 bug 的风险。这符合 Postgres 一贯"稳"的工程文化。
  • 多后端兼容:不绑死 io_uring,提供 worker 和 sync 兜底,保证在 BSD、Windows、老内核、受限容器上都能跑。
  • 为未来铺路:ReadStream 和 AIO 框架是基础设施,18 只是用它优化了几个场景。后续版本会有更多路径接入这套异步框架,包括最终的异步写。

所以看待 PG 18 的正确姿势是:它是一个"地基版本"。你现在能拿到的读加速是实打实的红利,但它更大的意义是为 19、20 的性能故事打好了底。

八、总结与展望

把这篇长文压缩成几条你能带走的结论:

  1. PG 18 最核心的升级是异步 I/O 子系统,读密集场景最高 3 倍提升,本质是把 I/O 等待和 CPU 处理重叠起来,榨干现代硬件。
  2. 只有异步读,没有异步写。别用错场景,写瓶颈另寻他法。
  3. io_method 默认是 worker,io_uring 最快但对环境挑剔,容器里注意静默回退这个坑。
  4. 调优是组合拳:io_method + effective_io_concurrency + io_combine_limit + 合理的 shared_buffers,缺一不可,且要用 pg_stat_io 验证。
  5. 顺手用起来的红利:uuidv7() 换掉 v4 主键、Skip Scan 救活缺前导列的复合索引查询、虚拟生成列省存储、升级更平滑。

展望未来,Postgres 的异步化才刚开始。当异步写、更多执行路径接入 AIO 框架、以及和向量检索等 AI 负载结合之后,Postgres 作为"最先进开源数据库"这句话的含金量还会继续涨。对我们写代码的人来说,能做的就是:现在就把 18 升起来,把 AIO 调对,让那些跑了很久的分析查询,真正快起来。


本文所有配置和压测思路均可在 PostgreSQL 18 正式版上复现。生产环境改参数前请先在预发环境验证,尤其是 io_method 和并发相关参数,务必结合你自己的存储类型压测后再上线。

推荐文章

html5在客户端存储数据
2024-11-17 05:02:17 +0800 CST
PHP设计模式:单例模式
2024-11-18 18:31:43 +0800 CST
nuxt.js服务端渲染框架
2024-11-17 18:20:42 +0800 CST
聚合支付管理系统
2025-07-23 13:33:30 +0800 CST
php strpos查找字符串性能对比
2024-11-19 08:15:16 +0800 CST
程序员茄子在线接单