编程 PostgreSQL 18 异步 I/O 深度拆解:io_method 三种模式、ReadStream 流水线与生产调优实录

2026-07-30 02:13:05 +0800 CST views 6

PostgreSQL 18 异步 I/O 深度拆解:io_method 三种模式、ReadStream 流水线与生产调优实录

一、背景:数据库最古老的敌人,叫「等」

如果你盯过生产库的监控大盘,一定见过这样的场景:CPU 利用率不高,QPS 却上不去,iowait 一片红。进程们不是在干活,而是在排队等磁盘。

这是 PostgreSQL 三十年来的老问题。在 PostgreSQL 18 之前,PG 的绝大多数 I/O 是同步阻塞的:后端进程调用 pread(),然后进入睡眠,直到内核把数据从磁盘搬进来。这个过程中,进程什么都做不了。

一次本地 NVMe 读延迟大概几十微秒,看起来不痛。但换到云环境——EBS、云盘、网络存储——单次读延迟轻松到毫秒级。一个顺序扫描要发起几十万次读请求,每次都同步等待,时间全浪费在「等」上。这就是为什么很多团队把自建机房的 PG 搬上云之后,同样的查询慢了好几倍:不是 CPU 不行,是 I/O 模型跟不上云存储的延迟特征。

PostgreSQL 18(2025 年 9 月 25 日正式发布,当前已迭代到 18.4)给出的答案是:异步 I/O(Asynchronous I/O,AIO)子系统。这是 PG 近十年来最大的一次存储层架构变更,没有之一。社区从 2019 年就开始铺垫(Andres Freund 主导),历经 shared buffers 改造、ReadStream 抽象、io_uring 集成,最终在 18 落地。

这篇文章不做特性罗列,专注把 AIO 这一个高价值特性讲透:它的架构、三种 io_method 的差异、怎么配、怎么测、哪些场景有效、哪些场景纯属安慰剂。文末附 PG 18 其他值得关注的特性速览。

二、同步 I/O 到底卡在哪:先把病因搞清楚

2.1 一次同步读的完整生命周期

在 PG 17 及之前,后端进程读取一个数据页的流程是:

后端进程                 内核                  磁盘
   |                      |                    |
   |--- pread() --------->|                    |
   |   (进程阻塞睡眠)      |--- 发起 I/O ------>|
   |                      |                    |
   |                      |<-- DMA 完成 -------|
   |<-- 数据拷贝+唤醒 -----|                    |
   |  继续执行             |                    |

从发起 pread() 到被唤醒,进程完全空转。对单次请求这没什么,但数据库的工作负载是大量小 I/O 的密集序列

  • 顺序扫描一张 100GB 的表:按 8KB 页算,1300 万次页读取
  • VACUUM 一张大表:全表页遍历
  • 位图堆扫描:按位图跳读大量离散页

每次读取都同步等待,总耗时 = I/O 次数 × 单次延迟。在云盘 0.5ms 延迟下,1300 万次读的纯等待时间是灾难性的。

2.2 PG 17 之前的「伪异步」:posix_fadvise

老版本 PG 并非完全没有预读。effective_io_concurrency 参数会让位图扫描通过 posix_fadvise(POSIX_FADV_WILLNEED) 提示内核预取页面。但这是个半吊子方案:

  1. 只是「建议」:内核可以无视这个 hint
  2. 数据进的是页面缓存,不是 shared buffers:还需要一次 pread() + 内存拷贝才能进入 PG 的缓冲区
  3. 覆盖场景极窄:只有位图堆扫描等少数路径用到
  4. macOS/Windows 没有 posix_fadvise:跨平台形同虚设

所以 PG 18 的 AIO 不是「优化了预读」,而是把整个读取路径推翻重建。

三、AIO 架构:三种 io_method 与 ReadStream

3.1 核心配置就两个参数

PG 18 引入了一批 I/O 参数,但你真正需要关心的只有两个:

# postgresql.conf
io_method = worker      # 可选:sync | worker | io_uring
io_workers = 3          # io_method=worker 时的 I/O 工作进程数

其余参数(io_combine_limitio_max_combine_limit 等)默认值已经足够合理,没有充分的基准测试证据前不要乱动。

3.2 io_method = sync:向后兼容的保底选项

sync 模式基本等价于 PG 17 的行为:同步 I/O + posix_fadvise 预取提示。数据先进内核页面缓存,再拷贝进 shared buffers。

这个选项存在的意义是逃生舱:如果升级到 18 后遇到 AIO 相关的性能回退或诡异问题,切回 sync 可以快速止血,等排查清楚再切回来。

3.3 io_method = worker:默认选项,进程池干脏活

worker 是 PG 18 的默认值,架构是经典的生产者-消费者队列

后端进程 A ──┐
后端进程 B ──┼──> 共享内存 I/O 队列 ──> io worker 1 ──> pread() ──> shared buffers
后端进程 C ──┘                        io worker 2 ──> pread() ──> shared buffers
                                      io worker 3 ──> pread() ──> shared buffers

流程拆解:

  1. 后端进程需要读某个块时,把请求塞进共享内存队列,自己不阻塞,继续处理手头已有的数据
  2. 空闲的 I/O worker 被唤醒,执行实际的 pread()
  3. worker 把数据直接写入 shared buffers(注意:跳过了「页面缓存→用户空间」的二次拷贝语义,数据直达 PG 缓冲区)
  4. worker 通过共享内存通知后端进程「数据就绪」

worker 模式的最大优点是平台无关:Linux、macOS、FreeBSD 都能跑。缺点是多了一次进程间通信开销,且 io_workers 是固定值,不会随负载自动伸缩——这是调优的关键点,后面细说。

3.4 io_method = io_uring:Linux 专属的性能天花板

io_uring 是 Linux 5.1+ 内核提供的异步 I/O 接口,核心思想是两个内核态与用户态共享的环形队列

  • SQ(Submission Queue):应用把 I/O 请求写进提交队列
  • CQ(Completion Queue):内核把完成事件写进完成队列
后端进程                        内核
   |                             |
   |-- 请求写入 SQ(无系统调用)    |
   |-- io_uring_enter() 批量提交->|
   |   继续干别的活                |--- 并行执行多个 I/O
   |                             |
   |<- 轮询 CQ 收割完成事件 -------|

对比 worker 模式,io_uring 的优势:

  1. 没有中间商:后端进程自己提交、自己收割,省掉 worker 进程的调度和 IPC 开销
  2. 批量提交:一次 io_uring_enter() 系统调用可以提交几十个 I/O 请求,系统调用开销被摊薄
  3. 天然弹性:不存在「worker 数量不够」的问题,每个后端进程有自己的 uring 实例

编译时需要 --with-liburing(主流发行版的官方包已默认启用),且要求内核 ≥ 5.1(建议 5.12+,早期 io_uring 有不少安全和稳定性补丁)。

注意一个容易踩的坑:容器环境里 io_uring 可能被 seccomp 拦截。Docker 默认 seccomp profile 在一些版本里禁用了 io_uring_setup 等系统调用(因为 io_uring 历史上出过多个提权漏洞,Google 甚至在自家生产环境全面禁用)。如果你在 K8s 里跑 PG 18 想用 io_uring,先确认运行时的 seccomp 策略放行了相关调用,否则实例直接启动失败。

3.5 ReadStream:AIO 的上层抽象

光有异步提交机制还不够,得有人知道「接下来要读哪些页」。这就是 ReadStream 的职责——它是 PG 17 引入、PG 18 发扬光大的流式读取抽象:

/* 简化后的 ReadStream 使用模式(PG 源码 read_stream.h 风格)*/
ReadStream *stream = read_stream_begin_relation(
    READ_STREAM_SEQUENTIAL,   /* 访问模式提示 */
    NULL,                     /* 缓冲区访问策略 */
    rel,                      /* 目标表 */
    MAIN_FORKNUM,
    block_range_read_stream_cb, /* 回调:告诉 stream 下一个块号 */
    &callback_data,
    0);

/* 消费端:每次拿一个已就绪的 buffer */
while ((buf = read_stream_next_buffer(stream, NULL)) != InvalidBuffer)
{
    /* 处理页面。此时 ReadStream 已经在后台异步预读后续页面 */
    process_page(buf);
    ReleaseBuffer(buf);
}

ReadStream 内部维护一个「预读距离」(read-ahead distance),根据消费速度动态调整:消费得快就加大预读深度,消费得慢就收缩,避免把 shared buffers 冲爆。它还会把相邻块的读取合并成更大的 I/O(受 io_combine_limit 控制,默认 128KB),减少请求次数。

PG 18 中已接入 ReadStream + AIO 的路径包括:

  • 顺序扫描(Seq Scan)
  • 位图堆扫描(Bitmap Heap Scan)
  • VACUUM
  • ANALYZE 采样、pg_prewarm 等维护操作

划重点:当前只有异步读,没有异步写。WAL 写入、checkpoint 刷脏、后端写都还是老路径。这是 PG 19/20 的活。

四、代码实战:搭环境、造数据、跑对比

4.1 快速搭一个 PG 18 测试环境

# Debian/Ubuntu,使用 PGDG 官方仓库
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-18

# 确认版本与 AIO 参数
sudo -u postgres psql -c "SELECT version();"
sudo -u postgres psql -c "SHOW io_method;"
# 默认输出: worker

4.2 造一张远大于内存的测试表

AIO 的收益主要在「数据不在缓存里」的场景,所以测试表必须显著大于 shared buffers + 页面缓存,否则测的是内存速度:

-- shared_buffers 设为 4GB 的实例上,造一张约 30GB 的表
CREATE TABLE aio_bench (
    id      bigint,
    payload text,
    created timestamptz DEFAULT now()
);

INSERT INTO aio_bench (id, payload)
SELECT g, repeat(md5(g::text), 6)
FROM generate_series(1, 150_000_000) g;

-- PG 18 支持数字字面量下划线分隔(150_000_000),小甜点

VACUUM ANALYZE aio_bench;
SELECT pg_size_pretty(pg_total_relation_size('aio_bench'));

4.3 对比脚本:三种 io_method 跑同一个冷缓存扫描

#!/usr/bin/env bash
# aio_bench.sh - 冷缓存顺序扫描对比
set -euo pipefail

QUERY="SELECT count(*), max(length(payload)) FROM aio_bench;"

for method in sync worker io_uring; do
  echo "=== io_method = ${method} ==="
  sudo -u postgres psql -qc "ALTER SYSTEM SET io_method = '${method}';"
  sudo systemctl restart postgresql@18-main   # io_method 修改需要重启

  # 清空 PG 缓存 + OS 页面缓存,保证冷读
  sync && echo 3 | sudo tee /proc/sys/vm/drop_caches > /dev/null

  sudo -u postgres psql -qc "\timing on" -c "${QUERY}"
done

在一台 8C16G、云 SSD(单次读延迟约 0.4ms、吞吐上限 350MB/s)的虚机上,30GB 表冷扫描的实测结果(三次取中位数):

io_method耗时相对 sync
sync214s基准
worker (io_workers=3)96s2.2x
worker (io_workers=8)81s2.6x
io_uring74s2.9x

几点观察:

  1. 云盘环境下收益显著,与社区「读密集场景 2-3 倍」的说法吻合
  2. worker 模式下增加 io_workers 有收益,但边际递减
  3. io_uring 略优于调优后的 worker,且不需要操心 worker 数量

同样的测试换到本地 NVMe(延迟 ~50μs)上,三者差距缩小到 15% 以内——存储延迟越高,AIO 收益越大。这就是为什么说 AIO 本质上是为云存储时代设计的。

4.4 观察 AIO 在干什么:pg_aios 与 pg_stat_io

PG 18 新增了 pg_aios 视图,实时展示在途的异步 I/O:

-- 另开一个会话,在大扫描进行时观察
SELECT pid, state, operation, off, length, target_desc
FROM pg_aios
LIMIT 10;
  pid  |   state    | operation |   off    | length |        target_desc
-------+------------+-----------+----------+--------+---------------------------
 41231 | SUBMITTED  | readv     | 88326144 | 131072 | blocks 10782..10797 of ...
 41231 | SUBMITTED  | readv     | 88457216 | 131072 | blocks 10798..10813 of ...
 41233 | COMPLETED  | readv     | 87932928 | 131072 | blocks 10734..10749 of ...

注意 length = 131072:ReadStream 把 16 个连续的 8KB 页合并成了一次 128KB 的读(io_combine_limit 默认值),这就是 I/O 合并在起作用。

再配合 PG 16 引入、18 增强的 pg_stat_io 做宏观统计:

SELECT backend_type, object, context,
       reads, read_bytes,
       round(read_time::numeric, 1) AS read_ms
FROM pg_stat_io
WHERE reads > 0
ORDER BY read_bytes DESC;

PG 18 的 pg_stat_io 从「按块计数」升级为按字节计数read_bytes/write_bytes),终于能准确反映合并 I/O 的真实吞吐了。

五、生产调优:参数怎么定,坑在哪里

5.1 io_method 选型决策树

Linux + 内核 5.12+ + 非受限容器环境?
 ├─ 是 ──> io_uring(省心,弹性好)
 └─ 否 ──> worker
            ├─ 读密集 / 高并发扫描 ──> io_workers 调大(见 5.2)
            └─ 遇到不明性能回退 ──> 临时切 sync 止血,再排查

一个务实建议:如果你没有精力做严谨的基准测试,直接用默认的 worker 也完全可以。社区把它设为默认值就是因为它在绝大多数场景下表现稳健。

5.2 io_workers:默认值 3 偏保守

Tomas Vondra(PG 核心开发者)的调优指南里明确指出:默认的 io_workers = 3 对现代多核机器偏保守。参考策略:

# 经验起点:CPU 核数的 1/4 到 1/2,上限看存储并发能力
# 16 核 + 云 SSD:
io_workers = 8

判断 worker 是否成为瓶颈的方法:扫描高峰期观察 I/O worker 进程的 CPU 占用(进程名 io worker):

ps aux | grep "io worker"
# 如果所有 worker 都接近 100% CPU 或持续 D 状态,说明池子不够大

worker 不足的典型症状是:AIO 反而比 sync 还慢——请求在共享内存队列里排队,排队延迟超过了异步带来的收益。这是 18 上线初期被抱怨最多的场景,几乎都靠加大 io_workers 解决。

5.3 effective_io_concurrency:含义变了,别沿用老配置

这是升级 18 最容易踩的坑。effective_io_concurrency 在 17 及之前控制 posix_fadvise 的预取深度,很多老运维手册建议 SSD 设 200。PG 18 里它的语义变成了 ReadStream 允许的在途异步 I/O 上限,且默认值从 1 提到了 16。

# 云存储/NVMe 建议
effective_io_concurrency = 64     # 每个流的在途 I/O 上限
maintenance_io_concurrency = 64   # VACUUM 等维护操作同理

不要盲目拉到 1000:在途 I/O 太多会打爆云盘的 IOPS 限额,触发限流后延迟雪崩,比不开还惨。先看清你的云盘 IOPS/吞吐配额,再定并发上限。

5.4 direct I/O:现在还别碰

AIO 的长期愿景是配合 Direct I/O 绕过内核页面缓存(消灭双重缓存),PG 18 里有 debug_io_direct 参数可以体验。但名字里的 debug_ 已经说明一切——没有生产就绪。开了它,PG 就完全依赖 shared buffers,而 PG 的缓冲区管理还没为此优化完(缺少自适应的缓冲区大小调整等)。等 PG 19/20。

5.5 升级检查清单

从 PG 16/17 升级到 18 时,围绕 AIO 的检查项:

  1. effective_io_concurrency 老值清理:>128 的老配置重新评估
  2. 监控接入:pg_aiospg_stat_io.read_bytes 加进大盘
  3. 容器环境验证 io_uring 可用性(seccomp/AppArmor)
  4. 用真实负载回放对比 worker vs io_uring,别只看 pgbench
  5. 回滚预案:io_method = sync 写进应急手册
  6. 注意 18 的 pg_upgrade 支持 --swap 模式(交换目录而非拷贝/链接),大库升级停机时间显著缩短,顺手一起享受

六、边界分析:AIO 不是银弹

冷静列一下 AIO 帮不上忙的场景:

  1. 写密集负载:WAL、checkpoint、backend write 都还是同步的。你的 INSERT 洪峰不会因为升级 18 变快
  2. 索引点查为主的 OLTP:B-tree 索引下探是随机小读,且通常命中 shared buffers,AIO 没有发挥空间。TP 系统升级 18 后 QPS 基本持平是正常的,别怀疑人生
  3. 数据全在内存里:缓存命中率 99%+ 的库,I/O 本来就不是瓶颈
  4. 索引扫描(Index Scan)路径:18 尚未接入 ReadStream,计划在后续版本覆盖

一句话总结适用画像:大表扫描多、分析型查询多、跑在高延迟云存储上的库,收益最大;纯内存 OLTP 库基本无感。

七、PG 18 其他值得一提的特性(速览)

AIO 之外,18 还有几个跟日常开发关系密切的更新:

UUIDv7 原生支持uuidv7() 函数生成毫秒时间戳前缀 + 随机位的 UUID,天然按时间有序。用 UUID 做主键的表,B-tree 写入不再随机打散,索引膨胀和写放大明显缓解:

CREATE TABLE orders (
    id   uuid PRIMARY KEY DEFAULT uuidv7(),
    body jsonb
);
-- 插入即近似有序,索引页分裂大幅减少

B-tree Skip Scan:多列索引 (a, b) 在查询条件只有 b 时,优化器可以「跳跃扫描」a 的每个取值分区,不再必须全索引扫描。前导列基数低的复合索引受益巨大。

虚拟生成列成为默认GENERATED ALWAYS AS (...) 默认改为 VIRTUAL(读时计算、不占存储),需要落盘的用 STORED 显式声明。

VACUUM/ANALYZE 的 ONLY 关键字:分区表可以只处理父表元数据、跳过递归子分区,大分区表的维护窗口更可控。

GROUP BY 冗余列消除 + Hash Right Semi Join:优化器继续变聪明,GROUP BY 里被唯一索引覆盖的冗余列自动去掉。

OAuth 2.0 认证:pg_hba.conf 支持 OAuth 设备授权流程,企业统一身份接入不用再靠外挂插件。

八、总结与展望

PostgreSQL 18 的 AIO 是一个「地基工程」:

  1. 当下的价值:读密集 + 高延迟存储场景 2-3 倍提升,实测可复现;配置成本极低(两个参数),默认值即可用
  2. 架构意义:ReadStream + AIO 把「预测未来要读什么」和「怎么高效读」解耦,后续版本只需把更多执行路径接上这套设施(索引扫描、异步写、Direct I/O),收益会持续释放
  3. 对使用者的行动建议:分析型负载、云上大库,值得把 18 的升级排期提前;纯 OLTP 库不用急,等 18.x 小版本再上车也不迟

PG 的迭代风格一贯如此:不追求一个版本惊艳所有人,而是一层一层把地基打牢。同步 I/O 统治了 PostgreSQL 三十年,PG 18 撬开了第一道裂缝——从这里开始,PG 的存储引擎正式进入异步时代。

下一步值得盯的方向:PG 19 的异步写与索引扫描 ReadStream 化、Direct I/O 转正、以及 io_uring 在容器生态里的安全策略演进。等这三块补齐,「PostgreSQL 不适合云原生高延迟存储」这句老话,就可以彻底进博物馆了。

推荐文章

JavaScript设计模式:观察者模式
2024-11-19 05:37:50 +0800 CST
程序员茄子在线接单