编程 PostgreSQL 18 异步 I/O 深度解剖:从 io_uring 内核到 3 倍读性能背后的工程真相

2026-07-25 02:14:25 +0800 CST views 8

PostgreSQL 18 异步 I/O 深度解剖:从 io_uring 内核到 3 倍读性能背后的工程真相

数据库世界里最沉默的瓶颈,往往不是 CPU,不是锁,而是那个你以为早就被操作系统页缓存"藏起来"的东西——磁盘 I/O。PostgreSQL 用了近三十年的同步 I/O 模型,直到 18 版本才第一次把"异步"这两个字写进内核。这篇文章不聊营销话术,我们从第一性原理把 PG18 的 AIO 子系统拆开,看看那个"最高 3 倍读性能提升"到底是怎么来的,以及你在生产环境(尤其是容器里)会踩到哪些坑。

一、背景:为什么 PostgreSQL 憋了 30 年才做异步 I/O

要理解 PG18 这次改动的分量,得先明白它之前有多"原始"。

1.1 同步 I/O 的老问题

在 PostgreSQL 18 之前,当一个 backend 进程需要从磁盘读一个 8KB 的数据块时,流程大致是这样的:

backend 进程
   │
   ├─ 发现目标 page 不在 shared_buffers
   ├─ 调用 pread() 系统调用
   ├─ ★ 进程在这里彻底阻塞,交出 CPU
   ├─ 内核去磁盘/文件系统取数据
   ├─ 数据回来,进程被唤醒
   └─ 继续处理

关键就在那个星号。一次 pread() 阻塞期间,这个 backend 什么都干不了。哪怕它明明知道自己接下来还要读第 2、3、4 个块,它也得老老实实一个一个来:读完 1 才能发起 2。

对于一次全表扫描(Seq Scan)、位图堆扫描(Bitmap Heap Scan)或者 VACUUM 这种要连续吃掉成千上万个数据块的操作,这意味着 I/O 延迟无法被隐藏,全部串行累加。假设单块读延迟是 100 微秒,读一万个块就是纯粹的 1 秒钟,而这 1 秒里 CPU 大部分时间在睡觉。

1.2 PG 过去的"打补丁"方案

PostgreSQL 社区当然不是没想过办法。历史上它用过两个"曲线救国"的手段:

posix_fadvise + effective_io_concurrency:从 8.4 版本起,PG 用 posix_fadvise(POSIX_FADV_WILLNEED) 给内核发"预读提示"。它本质上是告诉内核"我待会儿要这些块,你先帮我准备下",然后依赖内核的预读机制。但这只是提示,不是真正的异步,控制力很弱,而且只对 bitmap heap scan 这类场景生效。

操作系统预读(readahead):顺序扫描时依赖文件系统层面的预读。问题是数据库比文件系统更懂自己的访问模式,把决策权交给 OS 本身就是次优解。

这些方案的共同问题:PostgreSQL 无法主动、批量、精确地控制"多个 I/O 同时在飞"这件事

1.3 PG18 的答案:一个真正的 AIO 子系统

PostgreSQL 18(2025 年 9 月正式发布)第一次引入了一个统一的异步 I/O 子系统(AIO subsystem)。官方给出的数据是:在从存储读取数据的工作负载上,最高可获得约 3 倍的性能提升。

它的核心思路一句话概括:让 backend 进程可以一次性发起多个 I/O 请求,然后继续干别的活,等数据真正需要用到时再去"收货"。这就是异步的本质——发起(submit)和完成(completion)解耦。

下面我们把这套系统一层层拆开。

二、核心概念:AIO 子系统的三种 io_method

PG18 的 AIO 不是单一实现,而是通过 io_method 这个 GUC 参数提供了三种后端:

io_method平台原理适用场景
sync全平台退化为传统同步 I/O(伪异步)兼容/排障基线
worker全平台由专门的 I/O worker 进程池代为执行默认值,通用安全
io_uring仅 Linux直接使用内核 io_uring 接口高性能、追求极致

2.1 io_method = sync

这是最保守的模式,本质上关掉了异步。它保留了新的 AIO 代码路径(read stream API 等),但在真正提交 I/O 时立即同步执行。它存在的意义主要是给你一个对照基线——当你怀疑异步 I/O 引起问题时,切回 sync 就能快速验证。

2.2 io_method = worker(默认值)

这是 PG18 的默认模式,也是社区精心选择的"最大公约数"。

它的工作原理是:PostgreSQL 启动一批专门的 I/O worker 进程(数量由 io_workers 控制,默认 3 个)。当某个 backend 想发起异步读时,它把 I/O 请求放进一个共享内存队列,I/O worker 进程从队列里取出请求,用普通的 pread() 去执行,完成后通知发起方。

backend A ──┐
backend B ──┼──> [共享内存 I/O 队列] ──> I/O worker 1 ─┐
backend C ──┘                          I/O worker 2 ─┼─> 磁盘
                                       I/O worker 3 ─┘

好处:跨平台,不依赖任何特定内核特性,Windows、macOS、各种 Linux 发行版都能跑。坏处:请求进队列、worker 出队列、跨进程通知,这中间有额外的调度和上下文切换开销。

为什么默认选 worker 而不是更快的 io_uring?因为 io_uring 只在 Linux 上有,而且需要编译期支持 + 内核版本 + 权限配置,社区选择了"到处都能安全跑"的方案作为默认。

2.3 io_method = io_uring(性能天花板)

这是这次改动真正的性能王牌,但它有前提条件:

  1. 仅限 Linux,且内核需支持 io_uring(5.1+,实际建议 5.10+ 甚至更高以规避早期 bug);
  2. PostgreSQL 编译时必须带 --with-liburing(依赖 liburing 库);
  3. 运行环境要允许 io_uring 相关系统调用(容器里这点是大坑,后面详说)。

io_uring 是 Linux 在 5.1 引入的下一代异步 I/O 接口,它的精妙之处在于用两个共享环形缓冲区(提交队列 SQ、完成队列 CQ)在用户态和内核态之间传递 I/O,极大减少了系统调用次数。

用户态 (PostgreSQL backend)          内核态
┌─────────────────────┐
│  Submission Queue    │ ──写入──> 内核读取 SQ,执行 I/O
│  (SQ 环形缓冲)        │
├─────────────────────┤
│  Completion Queue    │ <──写入── 内核写入完成事件
│  (CQ 环形缓冲)        │
└─────────────────────┘
       ↑
  一次 io_uring_enter() 可提交/收割一批 I/O

对比 worker 模式,io_uring 少了跨进程队列和进程间通知这一层,backend 直接和内核对话,延迟更低、吞吐更高。这就是"3 倍"数据主要的来源场景。

三、架构分析:一次异步读到底发生了什么

光知道三种模式还不够,我们需要理解 PG18 内部是怎么把"异步"这个抽象落地的。核心是三个东西:AIO handle、read stream、staged/submitted/completed 生命周期

3.1 pgaio:统一的 AIO 抽象层

PG18 在 src/backend/storage/aio/ 下新增了一整套代码,核心是 pgaio 模块。它对上层屏蔽了 sync/worker/io_uring 的差异,对外提供统一的 AIO handle 抽象。

一个 I/O 的生命周期被拆成几个明确的阶段:

   [空闲] 
     │ pgaio_io_acquire()  申请一个 handle
     ▼
  [Staged]  ── 定义好这次要读哪个文件的哪些块
     │ pgaio_io_stage()
     ▼
 [Submitted] ── 真正丢给 io_method 后端(worker/io_uring/sync)
     │
     ▼
 [Completed] ── I/O 完成,回调触发(校验、错误处理)
     │ pgaio_io_release()
     ▼
   [回收]

这个状态机的价值在于:发起和完成被彻底解耦。一个 backend 可以连续 stage + submit 好几个 I/O,然后去处理已经到手的数据,等真正需要下一块时再去"收割"(reap)完成事件。

3.2 Read Stream API:让上层算子"无痛"用上异步

如果只提供裸的 AIO handle,那每个扫描算子都要自己管理一堆异步状态,代码会非常难写。PG17 引入、PG18 大幅铺开的 Read Stream APIread_stream.h)就是那层"甜味剂"。

它的设计哲学是回调驱动的预取流:算子只需要提供一个回调函数,告诉 read stream"我下一个想读的块是哪个",read stream 内部就会自动帮你维护一个"正在飞行的 I/O 窗口",提前把后面的块读进来。

伪代码感受一下这个 API 的用法:

/* 1. 定义"下一个块是谁"的回调 */
static BlockNumber
my_next_block(ReadStream *stream, void *priv,
              void *per_buffer_data)
{
    MyScanState *state = priv;
    if (state->current >= state->nblocks)
        return InvalidBlockNumber;   /* 流结束 */
    return state->current++;
}

/* 2. 创建 read stream */
ReadStream *stream = read_stream_begin_relation(
    READ_STREAM_SEQUENTIAL,   /* 访问模式提示 */
    strategy,                 /* buffer 访问策略 */
    relation,
    MAIN_FORKNUM,
    my_next_block,
    scan_state,
    0);

/* 3. 循环取 buffer——异步预取在背后自动发生 */
Buffer buf;
while ((buf = read_stream_next_buffer(stream, NULL)) != InvalidBuffer)
{
    Page page = BufferGetPage(buf);
    /* ... 处理这个 page ... */
    ReleaseBuffer(buf);
}

read_stream_end(stream);

关键点在于第 3 步:当你调用 read_stream_next_buffer() 拿第 1 个 buffer 时,read stream 内部已经悄悄把第 2、3、4……个块的异步读发出去了。等你处理完第 1 块回来拿第 2 块,它很可能已经在内存里了——I/O 延迟被你处理数据的时间"藏"掉了。

在 PG18 里,越来越多的核心算子被改造成使用 read stream:顺序扫描、位图堆扫描、VACUUM、ANALYZE、pg_prewarm 等。这也是为什么"读密集"负载受益最明显。

3.3 访问模式提示:SEQUENTIAL vs 随机

read stream 创建时可以传入访问模式提示(如 READ_STREAM_SEQUENTIAL),这会影响预取的激进程度和 I/O 合并策略。顺序扫描可以放心大胆地大批量预取并合并相邻块;而随机访问(如索引回表)则要更克制,避免读进一堆用不上的块浪费带宽。

四、代码实战:配置、监控与调优

理论讲完,进入实操。这一节全是能直接用在生产上的东西。

4.1 查看和设置 io_method

先看当前用的是哪种模式:

SHOW io_method;
--  io_method
-- -----------
--  worker

切到 io_uring(需要重启,且编译/内核/权限都满足):

-- postgresql.conf 里设置
ALTER SYSTEM SET io_method = 'io_uring';
-- io_method 变更需要重启实例
# 重启后确认
psql -c "SHOW io_method;"

如果你设了 io_uring 但环境不支持,PostgreSQL 启动时会报错或(视配置)回退——这里要格外小心,别以为设了就一定生效了,一定要用 SHOW 复核。

4.2 调节并发度:effective_io_concurrency

这是 PG18 里语义被"翻新"的关键参数。它控制单个会话可以同时发起多少个异步读 I/O

一个重要变化:PG18 把 effective_io_concurrency 的默认值从 1 提高到了 16。这个默认值的跳变本身就说明了社区对异步 I/O 的信心。

-- 查看
SHOW effective_io_concurrency;   -- PG18 默认 16

-- 针对 SSD/NVMe 可以调更高
ALTER SYSTEM SET effective_io_concurrency = 32;
SELECT pg_reload_conf();

还有一个配套参数 maintenance_io_concurrency,专门管 VACUUM、CREATE INDEX 这类维护操作的并发度,PG18 默认也提高到了较高的值(通常 16)。维护操作往往可以比常规查询用更激进的 I/O 并发,因为它们不那么在意打扰前台事务。

调优心法

  • 传统机械盘:effective_io_concurrency 别设太高(2~4),并发寻道反而更慢;
  • SATA SSD:16~32 是合理区间;
  • NVMe/云盘高 IOPS:可以往 32~64 甚至更高试,配合压测找拐点。

4.3 I/O 合并:io_combine_limit 与 io_max_combine_limit

PG18 的另一个大杀器是把多个相邻块的读合并成一次大 I/O。这就是 io_combine_limit

SHOW io_combine_limit;      -- 默认 128kB(= 16 个 8KB 块)
SHOW io_max_combine_limit;  -- 默认 128kB,重启才能改的硬上限

原理:顺序扫描时,与其发 16 次 8KB 的读,不如发 1 次 128KB 的读。系统调用次数少了,磁盘也更喜欢大块顺序 I/O。

# postgresql.conf
io_combine_limit = 256kB      # 运行时可改(reload)
io_max_combine_limit = 256kB  # 硬上限,改这个要重启

注意两者的关系:io_combine_limit 是运行时可调的"当前值",但它不能超过 io_max_combine_limit 这个"重启才能变的天花板"。想把合并粒度拉到 256KB 以上,必须先把 io_max_combine_limit 提上去并重启。

大块合并对大表全表扫描、VACUUM、备份类顺序读收益巨大;但对高度随机的 OLTP 点查意义不大,别指望它救随机读。

4.4 监控利器:pg_aios 视图

PG18 新增了一个专门观察在途异步 I/O 的系统视图 pg_aios。这是排障时的"透视镜":

SELECT pid, io_id, io_generation, state,
       operation, off, length, target_desc
FROM pg_aios
ORDER BY pid;

它能告诉你:此刻有哪些 I/O 正在飞、处于什么状态(staged/submitted/completed)、读的是哪个文件的哪段偏移、大小多少。当你怀疑某个查询卡在 I/O 上时,配合 pg_stat_activity 一起看,能快速定位是不是异步 I/O 队列堆积了。

4.5 结合 pg_stat_io 看全局 I/O 画像

PG16 引入的 pg_stat_io 在 PG18 里更有用了,它把 I/O 按对象类型、上下文(normal/vacuum/bulkread 等)、操作(read/write/extend)分类统计:

SELECT backend_type, object, context,
       reads, read_time, writes, write_time
FROM pg_stat_io
WHERE reads > 0
ORDER BY reads DESC;

异步 I/O 上线后,观察 read_time 相对 reads 的比值变化,是判断"延迟是否真的被隐藏掉"的好指标。

五、性能优化实战:让 3 倍真正落到你的库上

"最高 3 倍"是理想场景数字,现实中能拿到多少取决于你怎么调。下面给一套可落地的优化路径。

5.1 一个可复现的对照实验

准备一张足够大、无法完全放进内存的表:

-- 造一张约几 GB 的表
CREATE TABLE bench AS
SELECT g AS id,
       md5(g::text) AS payload,
       repeat('x', 200) AS filler
FROM generate_series(1, 50000000) g;

-- 清掉页缓存影响(重启或用 pg_prewarm 反向操作)

分别在三种 io_method 下跑冷缓存全表扫描:

-- 每次测试前清 shared_buffers(重启实例最干净)
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT count(*) FROM bench WHERE filler LIKE '%zzz%';

对比 EXPLAIN ANALYZE 里的执行时间和 BUFFERS 里的 read 数。典型结论:在 NVMe + io_uring + 合理并发下,冷读全表扫描相比 PG17(同步)能有数倍提升;而在页缓存全热的情况下,AIO 帮不上忙(因为根本没真去读盘)。

这条结论很重要:AIO 优化的是"真正打到存储的读",不是内存命中的读。 如果你的库工作集完全在内存里,别指望 AIO 有肉眼可见的变化。

5.2 三个参数的协同调优

异步 I/O 的收益来自三个参数的乘法效应:

有效吞吐 ≈ 并发度 (effective_io_concurrency)
         × 单 I/O 大小 (io_combine_limit)
         × 后端效率 (io_method: io_uring > worker > sync)

调优顺序建议:

  1. 先定 io_method:Linux 生产且环境允许 → io_uring;否则保持 worker。
  2. 再调 io_combine_limit:顺序读为主的分析库,拉到 256KB 甚至更高(记得同步抬 io_max_combine_limit 并重启)。
  3. 最后调并发度:从默认 16 开始,用真实负载压测,观察 IOPS/延迟拐点,逐步上探。

5.3 worker 模式下别忘了 io_workers

如果你出于兼容性用 worker 模式,但发现高并发下 I/O worker 成了瓶颈(队列堆积、pg_aios 里大量 submitted 状态),可以适当增加 worker 数:

ALTER SYSTEM SET io_workers = 6;   -- 默认 3
-- 需要重启

经验值:不要盲目拉高,通常设成 vCPU 数的一个较小比例(如 25%~50%),太多 worker 会带来调度开销和内存占用,反而得不偿失。

5.4 VACUUM 与维护任务的隐性提速

一个容易被忽略的收益点:VACUUM 变快了。因为 vacuum 需要顺序扫过大量堆页和索引页,read stream 化之后,这些读可以异步批量预取。对于动辄跑几小时的大表 autovacuum,这意味着更短的清理窗口、更少的膨胀累积。配合 maintenance_io_concurrency 调高,效果更明显。

六、容器化与生产环境踩坑指南

这一节是最"实战"的部分,因为 io_uring 在容器里的坑真的能坑死人。

6.1 io_uring 在容器里为什么会静默失效

io_uring 需要一系列系统调用(io_uring_setupio_uring_enterio_uring_register)。而现代容器运行时(Docker、containerd)出于安全考虑,默认的 seccomp profile 往往会屏蔽 io_uring 相关系统调用——因为 io_uring 历史上出过不少内核安全漏洞,云厂商和运行时对它相当警惕。

后果是:你在容器里设了 io_method = io_uring,但实际根本调不动 io_uring 系统调用,轻则启动报错,重则静默回退到低效路径,你以为开了异步,其实没有。

6.2 排查与解法

第一步,确认是否真的在用 io_uring

# 进容器看进程系统调用
strace -f -e trace=io_uring_setup,io_uring_enter -p <backend_pid>
# 如果完全没有 io_uring_* 调用,说明没生效

第二步,放开 seccomp / 授予能力(仅在你信任的环境这么做):

# Docker:授予 io_uring 相关能力
docker run --cap-add=SYS_IOURING \
  -e PGIO_METHOD=io_uring \
  postgres:18

Kubernetes 里对应地在 securityContext 里放开:

securityContext:
  capabilities:
    add: ["SYS_IOURING"]
  # 或使用自定义 seccompProfile 放开 io_uring 系统调用
  seccompProfile:
    type: Localhost
    localhostProfile: profiles/allow-io-uring.json

第三步,实在搞不定 io_uring,就大方用 worker

ALTER SYSTEM SET io_method = 'worker';
ALTER SYSTEM SET io_workers = 8;   -- 补偿性地多给几个 worker

worker 模式虽然比 io_uring 慢一点,但它不依赖任何特权,在受限容器里稳定可预期。对绝大多数业务来说,从 sync 到 worker 的提升已经很香了,不必非追 io_uring 不可。

6.3 五个最常见的错误配置

  1. 设了 io_uring 但没重启:io_method 变更必须重启,pg_reload_conf() 不生效。
  2. 设了 io_uring 但没检查是否真生效:一定要 SHOW + strace 双重确认。
  3. effective_io_concurrency 在机械盘上拉太高:并发随机寻道让 HDD 更慢。
  4. io_combine_limit 想超过 io_max_combine_limit:后者是重启才能改的硬上限,先抬它。
  5. 容器 seccomp 没放开就硬上 io_uring:静默失效,性能反而不如预期。

6.4 监控告警建议

上线异步 I/O 后,建议把这几个指标纳入监控:

  • pg_aios 中长期处于 submitted 状态的 I/O 数量(堆积信号);
  • pg_stat_ioread_time / reads 的平均单次读延迟趋势;
  • I/O worker 进程的 CPU 占用(worker 模式下判断是否需要加 worker);
  • 内核层面 io_uring 的错误计数(dmesg 里的相关告警)。

七、PG18 其他值得一提的配套改进

异步 I/O 是主角,但 PG18 还有几个和"读性能/开发体验"高度相关的改动,顺带一提:

7.1 Skip Scan:多列索引的救赎

PG18 支持 B-tree 的 skip scan。过去如果你有 (a, b) 复合索引,但查询只带了 b 的条件(没带 a),索引基本用不上。现在优化器可以在 a 的不同取值之间"跳跃"扫描,让这类查询也能吃到索引。这对那些"前导列基数很低"的复合索引场景是实打实的福音。

7.2 UUIDv7:可排序的主键

PG18 内置了 uuidv7() 函数。UUIDv4 完全随机,用作主键会让 B-tree 索引插入到处乱跳、页分裂严重;UUIDv7 把毫秒级时间戳放在高位,生成的 UUID 天然按时间近似有序,插入局部性好得多,对分布式系统和时序数据尤其友好。

SELECT uuidv7();
-- 高位是时间戳,连续生成的值近似递增

7.3 虚拟生成列(Virtual Generated Columns)

PG12 有存储型(stored)生成列,PG18 加入了虚拟(virtual)生成列——值不落盘,查询时才计算。省存储、省写放大,适合那种"经常读、由其他列算出来、但不想占空间"的派生字段,现在还成了默认行为。

7.4 更平滑的大版本升级

PG18 优化了 pg_upgrade,升级时间更短,而且升级后能更快达到预期性能(比如保留优化器统计信息、减少升级后重新 analyze 的痛苦)。对运维来说,这降低了从旧版本迁移的心理门槛。

八、总结与展望

把这次 PG18 的异步 I/O 放到 PostgreSQL 的演进史里看,它的意义可能被很多人低估了。

它补齐的是一块地基级的短板。 过去 PG 在查询优化器、并行查询、分区、逻辑复制上都做得很出色,唯独 I/O 层还停留在"一次一个块阻塞读"的原始状态。AIO 子系统的引入,让 PostgreSQL 第一次能像现代高性能系统那样,把 I/O 延迟藏在计算背后。

几个可以带走的核心结论:

  1. AIO 优化的是真正打到存储的读,不是内存命中。 工作集全在内存里的库感受不明显,冷读/大扫描/VACUUM 场景收益最大。
  2. 默认 worker 模式已经够用且安全,io_uring 是性能天花板但有环境门槛,尤其在容器里要过 seccomp 这关。
  3. 三个旋钮协同调优:io_method 定后端、io_combine_limit 定单次 I/O 大小、effective_io_concurrency 定并发度,是乘法关系。
  4. 一定要用 SHOW + pg_aios + strace 验证异步是否真生效,别被"设了就以为开了"坑到。

展望未来,PG18 的 AIO 目前主要覆盖读路径,写路径的异步化(WAL 写、checkpoint 刷脏、buffer 回写)是社区下一步的重头戏。可以预见 PG19、PG20 会把这套 AIO 框架继续往写侧推进,届时 PostgreSQL 在高并发写入和大批量导入场景的表现还会再上一个台阶。

对我们这些天天和数据库打交道的工程师来说,最实际的建议是:如果你有读密集型的大库,PG18 值得认真评估一次升级。先在测试环境用 worker 模式跑起来,压测对比,确认收益后再决定要不要挑战 io_uring。把 I/O 这块最沉默的瓶颈打开,往往比你反复调那几条慢 SQL 的收益还要大。

数据库性能优化的世界里,最大的进步常常来自最底层的地基。PG18 这次,就是在给地基换钢筋。

推荐文章

windows下mysql使用source导入数据
2024-11-17 05:03:50 +0800 CST
Vue3中如何使用计算属性?
2024-11-18 10:18:12 +0800 CST
css模拟了MacBook的外观
2024-11-18 14:07:40 +0800 CST
PHP 代码功能与使用说明
2024-11-18 23:08:44 +0800 CST
Python 微软邮箱 OAuth2 认证 Demo
2024-11-20 15:42:09 +0800 CST
Nginx 实操指南:从入门到精通
2024-11-19 04:16:19 +0800 CST
程序员茄子在线接单