编程 PostgreSQL 18 异步 I/O 深度拆解:从 25 年的 pread 阻塞到 io_uring 流水线,存储层的一次范式迁移

2026-08-15 05:47:52 +0800 CST views 4

PostgreSQL 18 异步 I/O 深度拆解:从 25 年的 pread 阻塞到 io_uring 流水线,存储层的一次范式迁移

一句话总结:PostgreSQL 18 的 AIO 不是"加了个参数",而是把「谁发起 I/O、谁等待 I/O、谁完成 I/O」这三件事第一次拆开了。理解这个拆分,你才知道 io_method 该怎么调、为什么有人调完变快 3 倍、有人调完一点变化都没有。


一、背景:那根永远下不去的 iowait 曲线

先说一个我们都熟悉的场景。

凌晨两点,报表任务开始跑。监控面板上 CPU 使用率只有 30%,但 iowait 一路飙到 40%。你上机器看 pidstat,几十个 postgres 后端进程全在 D 状态(不可中断睡眠)。磁盘 IOPS 明明还有余量——云盘标称 20000 IOPS,实际只跑到 3000。

你把 shared_buffers 从 8GB 加到 32GB,第二天晚上照旧。你把云盘从 SSD 换成 ESSD PL2,延迟从 0.8ms 降到 0.3ms,报表快了 20%,但 iowait 还是那个形状。

这就是同步 I/O 的结构性瓶颈:不是磁盘不够快,是数据库一次只敢问磁盘要一块数据,然后原地等。

PostgreSQL 18 之前的顺序扫描长什么样?简化成伪代码:

/* PG 17 及之前:概念上的顺序扫描主循环 */
for (blkno = 0; blkno < nblocks; blkno++)
{
    buf = ReadBuffer(rel, blkno);   /* 未命中 → pread(),进程进入 D 状态 */
    page = BufferGetPage(buf);

    for_each_tuple_in(page) {       /* CPU 干活 */
        if (qual_matches(tuple))
            emit(tuple);
    }

    ReleaseBuffer(buf);
}

注意这个循环的形状:I/O 和 CPU 严格串行。等磁盘的时候 CPU 空转,算数据的时候磁盘空转。在一块延迟 0.5ms 的云盘上,单个后端进程扫描的理论上限是:

1 秒 / 0.5ms = 2000 次 I/O
2000 × 8KB = 16 MB/s

16 MB/s。这就是单进程同步 8KB 随机读的天花板。你的云盘写着"吞吐 350 MB/s",一个进程只能用掉它的 5%。

那 PG 之前靠什么活着?靠操作系统的预读(readahead)和 posix_fadvise(POSIX_FADV_WILLNEED)

  • 内核发现你在顺序读,就自动多读几个块塞进 page cache;
  • Bitmap Heap Scan 会用 effective_io_concurrency 提前发一批 posix_fadvise,让内核把页预热到 page cache。

这套方案有三个致命缺陷,也正是 PG18 要解决的:

  1. 数据落在 page cache,不在 shared buffers。 fadvise 只是让内核把数据准备好,PG 还得再走一次 pread 把它从 page cache 拷进共享缓冲区。这是一次多余的内存拷贝 + 一次多余的系统调用。
  2. 内核不懂数据库的访问模式。 索引扫描的跳跃、Bitmap Heap Scan 的块号列表、VACUUM 的二次扫描,内核的顺序预读启发式全部失效。
  3. fadvise 是"祈祷式"预读。 你无法知道它有没有生效、什么时候生效、有没有被内存回收提前踢掉。没有任何可观测性。

PostgreSQL 18(2025-09-25 发布,本文写作时最新为 18.6)第一次把这件事做对了:官方宣称在特定场景下读取性能最高提升 3 倍,覆盖的操作包括顺序扫描(Seq Scan)、Bitmap Heap Scan 和 VACUUM

关键在于最后半句——只覆盖这三类。这是全文最重要的一条信息,后面细说。


二、为什么 PostgreSQL 用了 25 年才做 AIO

这不是社区懒。是 PG 的进程模型让 AIO 变成了一件极其别扭的事。

2.1 进程模型的代价

MySQL 是多线程的,InnoDB 从 5.5 就有 native AIO:一个线程发 io_submit,另一个线程收 io_getevents,共享的是同一份进程地址空间,指针随便传。

PostgreSQL 是一连接一进程。想让 A 进程发起的 I/O 由 B 进程完成,你得面对:

  • 目标缓冲区在哪? 数据要读进 shared_buffers,这是共享内存,好在这块没问题。
  • I/O 状态放哪? "这个 I/O 发出去了没有、完成了没有、错误码是多少"——这些元数据必须在共享内存里,不能在进程私有内存里。
  • 完成回调谁来跑? 读完一个数据页不是拷完字节就结束了:要做 checksum 校验、要设置 buffer 的 BM_VALID 标志、要处理零页、要按需 WAL 回放。这些逻辑跑在哪个进程的上下文?
  • 进程死了怎么办? 后端进程崩了,它发出去的 I/O 还在飞。回调状态在共享内存里,谁负责清理?

2.2 Buffer 的 pin 语义

更棘手的是 shared_buffers 的锁语义。同步模型下逻辑很干净:

拿到 buffer 描述符 → pin → 加 IO_IN_PROGRESS 标志 → pread → 校验 → 置 BM_VALID → unpin

异步之后,pread 那一步变成了"提交请求、立即返回"。这时候 buffer 处于一个新的中间态:已分配、已 pin、内容是垃圾、且有一个飞在外面的 I/O 会在未来某个时刻往里写数据。

如果这期间另一个进程想读同一个块怎么办?如果 checkpointer 想把它刷盘怎么办?如果发起的进程在 I/O 完成前就报错回滚了怎么办?

PG18 的答案是引入了一层AIO handle(I/O 句柄) 抽象和明确的状态机。这是整个子系统的核心。


三、核心概念:AIO 的三层抽象

看懂这张图,你就看懂了 PG18 的存储层:

┌──────────────────────────────────────────────────────────┐
│  第 3 层:消费者(Consumer)                              │
│  Seq Scan / Bitmap Heap Scan / VACUUM / ANALYZE          │
│  → 通过 Read Stream API 声明"我接下来要读这些块"          │
├──────────────────────────────────────────────────────────┤
│  第 2 层:Read Stream(read_stream.c)                    │
│  维护"距离"(distance)、批量合并相邻块、下发 AIO handle  │
│  → 自适应预读窗口:命中率高就拉长,随机就缩短             │
├──────────────────────────────────────────────────────────┤
│  第 1 层:AIO 子系统(aio*.c)                            │
│  AIO handle 状态机 + 共享内存中的 I/O 元数据 + 完成回调   │
├──────────────────────────────────────────────────────────┤
│  第 0 层:io_method 后端实现                              │
│  sync(posix_fadvise + pread)                            │
│  worker(I/O worker 进程池 + 共享队列)                   │
│  io_uring(每后端一个 ring,内核异步)                    │
└──────────────────────────────────────────────────────────┘

3.1 Read Stream:把"我要读什么"和"什么时候读"解耦

Read Stream API 其实在 PG 17 就落地了(当时主要给 ANALYZE 和顺序扫描用),PG 18 把它扩展到了更多路径,并接上了真正的 AIO 后端。它的接口形状大致是这样:

/* 概念示意:消费者只提供一个"下一个要读哪块"的回调 */
static BlockNumber
seq_scan_next_block(ReadStream *stream, void *callback_private,
                    void *per_buffer_data)
{
    HeapScanDesc scan = (HeapScanDesc) callback_private;

    if (scan->cur_block >= scan->nblocks)
        return InvalidBlockNumber;      /* 流结束 */

    return scan->cur_block++;
}

/* 建流 */
ReadStream *stream = read_stream_begin_relation(
        READ_STREAM_SEQUENTIAL,        /* 提示:顺序访问 */
        strategy,                      /* 环形缓冲区策略 */
        relation,
        MAIN_FORKNUM,
        seq_scan_next_block,
        scan,                          /* callback_private */
        0);

/* 消费 */
while ((buf = read_stream_next_buffer(stream, NULL)) != InvalidBuffer)
{
    process_page(BufferGetPage(buf));
    ReleaseBuffer(buf);
}

这个设计的精妙之处在于控制反转。旧代码是执行器主动喊"给我第 N 块",新代码是执行器告诉框架"这是我未来要读的块序列的生成器,你自己安排"。

框架于是有了全局视野,可以做三件旧代码做不到的事:

  1. 提前发起:当消费者还在处理第 5 块时,第 6~20 块的 I/O 已经在飞了。
  2. 批量合并:发现第 6~13 块物理连续,直接合并成一次 64KB 的 preadv,而不是 8 次 8KB 的 pread
  3. 自适应窗口:Read Stream 内部维护一个 distance(预读距离)。全命中缓存就把距离收缩到 0(省掉预读开销),频繁未命中就把距离拉长到上限。

第 3 点特别值得强调,因为它解释了一个常见困惑:为什么我开了 AIO,pg_aios 里什么都看不到?

答案往往是:你的表已经全在 shared_buffers 里了,Read Stream 判断不需要预读,distance 收缩为 0,一个 I/O 都没发。AIO 在这种场景下的正确行为就是"什么都不做"。

3.2 AIO handle:一个 I/O 的完整生命周期

每个飞在外面的 I/O 在共享内存里对应一个 handle,状态机大致是:

   IDLE
    │  pgaio_io_acquire()
    ▼
  HANDED_OUT        ← 后端拿到句柄,还没填参数
    │  设置 target(relation/fork/blocknum)、注册回调
    ▼
  DEFINED
    │  pgaio_io_stage()
    ▼
  STAGED            ← 已进入待提交队列,还没交给内核
    │  submit(可批量)
    ▼
  SUBMITTED         ← 内核 / worker 正在处理
    │  硬件完成
    ▼
  COMPLETED_IO      ← 字节已就位,但回调没跑
    │  执行 shared callback(checksum、置 BM_VALID …)
    ▼
  COMPLETED_SHARED
    │  发起方回收结果
    ▼
  COMPLETED_LOCAL → IDLE

STAGED 这一步是 PG18 设计里最容易被忽略、但最有价值的一环。

为什么不 stage 完立刻 submit?因为攒批。如果你有 8 个相邻块要读,分 8 次 submit 就是 8 次系统调用;先都 stage 起来,合并成一次 io_uring_submit,系统调用降到 1 次。在高频小 I/O 场景,系统调用开销(尤其是开了 Spectre/Meltdown 缓解措施的机器上,一次 syscall 可能上千纳秒)是实打实的成本。

回调的两段式设计也很关键:

  • shared callback:跑在"谁完成 I/O 谁执行"的上下文(可能是 I/O worker,也可能是任意一个恰好在收割 ring 的后端)。只做与进程无关的事:数据校验、设置 buffer 标志位。
  • local callback:跑在发起方的上下文。处理需要报错、需要事务上下文的事。

这个拆分保证了即使发起方进程已经死了,shared callback 依然能正确完成——buffer 不会永远卡在 IO_IN_PROGRESS 状态。这是异步化能在多进程架构下保持崩溃安全的关键。


四、架构拆解:三种 io_method 的真实执行路径

io_method 只能在服务器启动时设置(改完必须重启,不是 reload)。三个值的路径差别巨大。

4.1 io_method = sync:向后兼容的"假异步"

后端进程
  │ pgaio_io_stage()
  ▼
立即在当前进程执行 pread / preadv(阻塞)
  │
  ▼
回调同步执行 → 直接返回 COMPLETED

这是 PG17 的行为,用 posix_fadvise 做预热。没有任何异步:stage 的瞬间就在当前进程同步执行完了。

它的价值有两个:一是排障时作对照组;二是在不支持 io_uring、又不想引入 worker 进程的环境(比如极小规格实例、或某些非 Linux 平台)保持旧行为。

4.2 io_method = worker:默认值,跨平台的稳妥选择

这是 PG18 的默认值,也是绝大多数人实际会跑的模式。

后端进程                      共享内存队列              I/O worker 池(默认 3 个)
   │                              │                            │
   │ stage: 把请求塞进队列 ───────▶│                            │
   │                              │◀───── worker 被唤醒 ────────│
   │ 继续处理已有数据(不阻塞)    │        执行 pread/preadv    │
   │                              │        数据直接落 shared_buffers
   │                              │        跑 shared callback(checksum、置 BM_VALID)
   │◀───── 需要该块时才等待 ───────│        通知发起方           │
   ▼

优点:纯用户态实现,不依赖任何内核特性,Linux / macOS / FreeBSD / Windows 通吃,容器里也不会被 seccomp 拦。

代价也很明确:

  1. 进程间通信开销。 每个 I/O 至少一次入队 + 一次唤醒(SetLatch → 信号/eventfd)+ 一次通知。相比 io_uring 的零系统调用提交,这是纯增量成本。
  2. worker 数量是硬瓶颈。 io_workers 默认 3。如果你有 32 个后端在并发跑大表扫描,所有 I/O 都挤在 3 个 worker 上排队。
  3. worker 自己会阻塞。 worker 执行的是同步 pread。3 个 worker 意味着最多 3 个飞行中的 I/O(合并后是 3 个 I/O 请求,可能对应更多块)。

第 3 点是很多人第一次调 AIO 就翻车的原因。云盘的高 IOPS 是靠队列深度堆出来的:单个 I/O 延迟 0.3ms,要跑到 10000 IOPS 需要队列深度约 10000 × 0.0003 = 3。听起来 3 个 worker 刚好够?但那是整个实例共享的 3,不是每连接 3。

4.3 io_method = io_uring:真正的内核异步

后端进程(自带一个 io_uring 实例)
   │ stage → 写 SQ(提交队列,共享内存环,无系统调用)
   │ submit → io_uring_enter()(一次系统调用可提交 N 个 I/O)
   │
   │ 继续处理已有数据
   │
   │ 需要结果时 → 读 CQ(完成队列,无系统调用即可 peek)
   │ 在自己的上下文跑 shared callback + local callback
   ▼

优势:

  • 提交零拷贝、批量提交:SQ/CQ 是内核与用户态共享的环形缓冲区,写请求不需要系统调用。
  • 真正的高队列深度:不受 worker 数限制,队列深度由 effective_io_concurrency 和 Read Stream 的 distance 决定。
  • 无进程间唤醒:完成回调在发起方自己的上下文跑,省掉一次 latch 唤醒。

限制(这些是运维现场最常踩的):

  1. 编译期依赖:PG 必须用 --with-liburing 编译。很多发行版包和大多数云厂商托管版没有开这个选项。检查方式:

    -- 如果这句报错,说明二进制不支持 io_uring
    ALTER SYSTEM SET io_method = 'io_uring';
    

    或者直接看编译参数:

    SELECT pg_config();  -- 需要 pg_config 扩展;或在 shell 里跑 pg_config --configure
    
    pg_config --configure | tr ' ' '\n' | grep -i uring
    
  2. 只有 Linux,且内核越新越好(5.10+ 可用,5.15/6.1+ 更稳)。

  3. 每个后端一个 ring = 额外文件描述符。500 个连接就是 500 个 ring。你需要检查 ulimit -nmax_files_per_process,否则高连接数下会 Too many open files

  4. 容器/沙箱可能禁用 io_uring。gVisor、部分 seccomp 默认 profile、某些 K8s 运行时策略会屏蔽 io_uring_setup 系统调用。这时 PG 可能启动失败或退化。检查方式:

    # 内核是否有 io_uring 符号
    grep -c io_uring /proc/kallsyms
    
    # seccomp 是否拦截(返回非 -EPERM 才算通过)
    python3 - <<'PY'
    import ctypes, ctypes.util
    libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
    # io_uring_setup = 425 on x86_64
    res = libc.syscall(425, 8, ctypes.c_void_p(0))
    print("syscall ret:", res, "errno:", ctypes.get_errno())
    PY
    

    如果 errnoEPERM(1),基本可以确认被 seccomp 拦了;如果是 EFAULT(14),说明系统调用本身是通的(只是我们传了空指针)。

4.4 三种模式的取舍表

维度syncworkerio_uring
真异步✅(进程级)✅(内核级)
平台全平台全平台仅 Linux + liburing
提交开销系统调用入队 + 唤醒共享环,可零系统调用
最大飞行 I/O1io_workers(默认 3)effective_io_concurrency 与 ring 深度约束
额外进程0io_workers0
额外 fd0少量每后端 1 个 ring
容器友好⚠️ 可能被 seccomp 拦
适合场景排障对照 / 保守默认,通用高并发大扫描 + 高延迟云盘

我的选型建议:托管云数据库你没得选(基本是 worker)。自建且是 Linux + 新内核 + 大量分析型扫描,值得为 io_uring 重新编译一次。其余情况,worker + 调大 io_workers 就是性价比最高的组合。


五、I/O 合并:io_combine_limit 与那个叫 128kB 的魔数

AIO 的另一半收益来自向量化 I/O

PG18 之前,读 16 个连续块 = 16 次 pread(8KB)。PG18 会把它们合并成 preadv() 一次读 128KB。

相关参数:

SHOW io_combine_limit;      -- 默认 128kB,可运行时改
SHOW io_max_combine_limit;  -- 默认 128kB,只能启动时改,是上面那个的硬上限

为什么默认是 128kB(= 16 个 8KB 块)?这是几个约束的交集:

  1. IOV_MAX = 1024(Linux),preadv 的 iovec 数量上限,不是瓶颈。

  2. 块设备的 max_sectors_kb:很多设备默认 128KB 或 512KB。超过这个值内核会把请求拆开,合并收益打折。查一下你的盘:

    cat /sys/block/nvme0n1/queue/max_sectors_kb
    cat /sys/block/nvme0n1/queue/max_hw_sectors_kb
    
  3. 合并需要连续的 buffer 槽位。要一次读 128KB 进 shared_buffers,需要同时 pin 住 16 个 buffer。合并粒度越大,pin 住的 buffer 越多,缓冲区压力越大——极端情况下会挤掉真正的热数据。

  4. 延迟与吞吐的权衡。合并越大,单次 I/O 延迟越高。对纯分析负载有利,对混合负载(OLTP 点查 + 后台报表)可能拖慢点查的尾延迟。

调优判断:

-- 只在纯扫描型会话里放大,别全局改
SET io_combine_limit = '256kB';
-- 前提:io_max_combine_limit 已经在 postgresql.conf 里设到 >= 256kB 并重启过

如果你的盘 max_sectors_kb 是 128,把 io_combine_limit 设成 512kB 是无效优化——内核照样拆成 4 个请求,你只是白白多 pin 了 48 个 buffer。先看 /sys/block/*/queue/max_sectors_kb,再决定要不要动这个参数,这是最容易被跳过的一步。


六、代码实战:从零搭一套可复现的对比环境

下面这套东西我建议你真跑一遍。AIO 的收益极度依赖存储介质和负载形状,别人的数字对你没有参考价值。

6.1 编译一个带 io_uring 的 PG18

# Ubuntu 24.04 / Debian 13
sudo apt-get update
sudo apt-get install -y build-essential liburing-dev libicu-dev libreadline-dev \
                        zlib1g-dev bison flex pkg-config

curl -LO https://ftp.postgresql.org/pub/source/v18.6/postgresql-18.6.tar.bz2
tar xf postgresql-18.6.tar.bz2 && cd postgresql-18.6

./configure --prefix=/opt/pg18 \
            --with-liburing \
            --with-icu \
            --enable-debug          # 生产环境去掉 --enable-debug
make -j"$(nproc)" && sudo make install

# 验证 io_uring 编译进去了
/opt/pg18/bin/pg_config --configure | tr ' ' '\n' | grep uring
# 期望输出:'--with-liburing'

初始化 + 基础配置:

export PATH=/opt/pg18/bin:$PATH
export PGDATA=/data/pg18
initdb -D "$PGDATA" --encoding=UTF8 --locale=C.UTF-8

cat >> "$PGDATA/postgresql.conf" <<'CONF'
# ---- 基础 ----
shared_buffers = 4GB
work_mem = 64MB
maintenance_work_mem = 1GB
max_wal_size = 16GB
checkpoint_timeout = 15min

# ---- AIO(本文重点)----
io_method = worker           # 先用默认值建立 baseline
io_workers = 3
io_max_combine_limit = 512kB # 留出上调空间,只能启动时设
io_combine_limit = 128kB
effective_io_concurrency = 16
maintenance_io_concurrency = 16

# ---- 观测必开 ----
track_io_timing = on
track_wal_io_timing = on
compute_query_id = on
shared_preload_libraries = 'pg_stat_statements'
CONF

pg_ctl -D "$PGDATA" -l /tmp/pg18.log start

6.2 造一张打不进内存的表

关键:数据量必须显著大于 shared_buffers + page cache,否则测的是内存带宽,不是 I/O。

CREATE DATABASE aiolab;
\c aiolab
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS pg_prewarm;

-- 约 40GB:单机 4GB shared_buffers + 32GB RAM 的机器上肯定放不下
CREATE TABLE events (
    id          bigint,
    user_id     bigint,
    ts          timestamptz,
    status      smallint,
    amount      numeric(12,2),
    payload     text
);

-- 分批灌,避免一个事务撑爆 WAL
DO $$
DECLARE
    i int;
BEGIN
    FOR i IN 0..39 LOOP
        INSERT INTO events
        SELECT
            g,
            (random() * 5e6)::bigint,
            now() - (random() * interval '365 days'),
            (random() * 5)::smallint,
            (random() * 10000)::numeric(12,2),
            repeat(md5(g::text), 4)
        FROM generate_series(i * 5000000 + 1, (i + 1) * 5000000) AS g;
        RAISE NOTICE 'batch % done', i;
        COMMIT;   -- PG11+ 的过程内事务控制
    END LOOP;
END $$;

VACUUM (ANALYZE, VERBOSE) events;
SELECT pg_size_pretty(pg_total_relation_size('events'));

6.3 三种 io_method 的对比脚本

#!/usr/bin/env bash
# aio_bench.sh —— 对比 sync / worker(N) / io_uring 的顺序扫描吞吐
set -euo pipefail

PGDATA=${PGDATA:-/data/pg18}
DB=aiolab
Q="SELECT count(*) FROM events WHERE status = 3 AND amount > 5000;"

run_case() {
  local method=$1 workers=$2 label=$3

  pg_ctl -D "$PGDATA" -m fast stop >/dev/null 2>&1 || true
  # 关键:清 page cache,否则第二轮全是内存命中
  sync && sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'

  # io_method 只能启动时设,用命令行覆盖最省事
  pg_ctl -D "$PGDATA" -l /tmp/pg18.log \
         -o "-c io_method=$method -c io_workers=$workers" start >/dev/null
  sleep 3

  psql -d $DB -q -c "SELECT pg_stat_reset_shared('io');" >/dev/null

  local t0 t1
  t0=$(date +%s.%N)
  psql -d $DB -q -c "$Q" >/dev/null
  t1=$(date +%s.%N)

  printf '%-22s elapsed=%.2fs\n' "$label" "$(echo "$t1 - $t0" | bc)"

  psql -d $DB -qAt -F' | ' -c "
    SELECT backend_type,
           pg_size_pretty(sum(read_bytes)::bigint)  AS bytes_read,
           round(sum(read_time)::numeric, 1)        AS read_ms
    FROM pg_stat_io
    WHERE read_bytes > 0
    GROUP BY backend_type
    ORDER BY 2 DESC;"
  echo '---'
}

run_case sync     3  'sync'
run_case worker   3  'worker(3, default)'
run_case worker   8  'worker(8)'
run_case worker   16 'worker(16)'
run_case io_uring 3  'io_uring'

注意:pg_stat_io 在 PG18 里提供了字节级列(如 read_bytes / write_bytes)以及 I/O 耗时列。不同小版本列名可能微调,跑之前先 \d pg_stat_io 确认一下,别照抄。

我在一台 16 vCPU / 32GB RAM / 云盘(标称 350MB/s、~0.35ms 延迟)的机器上跑出来的形状大致是:

配置单会话扫描耗时相对提升
sync基准1.00×
worker (3)明显下降~1.7×
worker (8)继续下降~2.3×
worker (16)基本持平 8~2.4×
io_uring最低~2.8×

这些数字请当作"形状"看,不要当作"结论"。 我要强调三个可复现的规律,它们比绝对数字更有价值:

  1. io_workers = 3 是明显偏保守的默认值。 单会话大扫描下从 3 调到 8 还有可观提升。
  2. 收益在 8~16 个 worker 之间饱和。 继续加 worker 只会增加进程数和调度开销。
  3. 本地 NVMe 上收益会小得多。 NVMe 延迟约 0.08ms,同步 I/O 的天花板本来就高(单进程可到 ~100MB/s 级别),AIO 的相对收益自然被压缩。AIO 是"高延迟存储的解药",云盘和网络存储受益最大。

6.4 用 pg_aios 亲眼看到飞行中的 I/O

这是 PG18 最被低估的新东西:一个能看到当前飞行中 I/O 的系统视图。以前 I/O 层对我们是纯黑盒,只能看聚合统计。

开一个会话跑大扫描:

-- 会话 A
SELECT count(*) FROM events WHERE amount > 9999;

另一个会话实时观察:

-- 会话 B
SELECT pid, io_id, state, operation, off, length, target, target_desc
FROM pg_aios
ORDER BY pid, io_id;

你会看到类似(字段以你的 18.x 为准,用 \d pg_aios 确认):

  pid  | io_id |     state      | operation |    off     | length  | target
-------+-------+----------------+-----------+------------+---------+--------------
 41231 |    12 | SUBMITTED      | read      | 1073741824 |  131072 | smgr
 41231 |    13 | SUBMITTED      | read      | 1073872896 |  131072 | smgr
 41231 |    14 | STAGED         | read      | 1074003968 |  131072 | smgr

三个可以立刻拿去排障的读法:

  1. length = 131072 → 合并生效了,128KB 一次。如果全是 8192,说明合并没发生(块不连续,或 io_combine_limit 被压到 8kB)。
  2. 同一个 pid 有多行 SUBMITTED → 真并发。如果永远只有 1 行,你的"异步"没生效(检查 io_method 是否是 sync、Read Stream distance 是否被收缩为 0)。
  3. 大量 STAGED 堆积但 SUBMITTED 很少 → 提交侧被卡住。worker 模式下通常是 io_workers 不够。

配一个简单的采样脚本,比盯着看靠谱:

#!/usr/bin/env bash
# aio_sample.sh —— 每 200ms 采样一次飞行 I/O 分布,跑 30 秒
for _ in $(seq 1 150); do
  psql -qAt -d aiolab -c "
    SELECT to_char(clock_timestamp(),'HH24:MI:SS.MS')
        || ' inflight=' || count(*)
        || ' submitted=' || count(*) FILTER (WHERE state = 'SUBMITTED')
        || ' staged='    || count(*) FILTER (WHERE state = 'STAGED')
        || ' avg_len='   || coalesce(round(avg(length))::text, '-')
    FROM pg_aios;"
  sleep 0.2
done

6.5 等待事件:别背名字,去查表

网上很多文章会列一串 AIO 相关的等待事件名。我的建议是别记,因为小版本之间会变。PG 从 17 开始提供了 pg_wait_events 视图,直接查你自己这套二进制里有什么:

-- 你这个版本里所有 AIO 相关等待事件,附官方描述
SELECT type, name, description
FROM pg_wait_events
WHERE name ILIKE '%aio%' OR name ILIKE '%io_uring%' OR name ILIKE '%iouring%'
ORDER BY type, name;

然后在压测时抓实际分布:

SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active' AND backend_type IN ('client backend', 'io worker')
GROUP BY 1, 2
ORDER BY 3 DESC;

判读逻辑很简单:

  • 后端大量等在 I/O 完成类事件上 → 存储是瓶颈,或者预读窗口不够,I/O 发得太晚。
  • 后端大量等在 worker 提交队列类事件上 → io_workers 不够,加它。
  • I/O worker 进程自己长期忙 → worker 打满了,加它。
  • 什么都不等但 CPU 满 → 恭喜,I/O 不再是瓶颈,去优化执行计划吧。

七、性能优化:15 条能直接落地的生产建议

  1. 先建 baseline,再调参。io_method = sync 跑一轮记下数字。没有对照组的"优化"全是玄学。

  2. io_workers 默认 3 太小,但别乱加。 起步值:min(vCPU / 2, 8)。之后看 worker 进程的 CPU 占用和等待事件决定加不加。不要设成 vCPU 数——worker 是同步阻塞的,多数时间在等 I/O 而不是烧 CPU,设太多只会增加调度和唤醒开销。

  3. effective_io_concurrency 已经从 1 提到 16,但对高延迟云盘还可以更高。 网络存储(EBS/云盘/NFS)延迟 0.31ms,需要更深队列才能吃满 IOPS。3264 值得一试。必须实测:设太高会让 Read Stream 预读大量最终用不上的块,白烧 IOPS 和 buffer。

  4. maintenance_io_concurrency 单独调。 VACUUM / CREATE INDEX / ANALYZE 走这个参数。它们通常在业务低谷跑,可以给得比 effective_io_concurrency 更激进。

  5. io_combine_limit 前先看 /sys/block/*/queue/max_sectors_kb 超过设备上限的合并粒度是纯浪费。

  6. io_max_combine_limit 只能启动时设,趁重启一次性设到 512kB。 这只是抬高上限,不是立即生效;真正生效的是 io_combine_limit,后者可以运行时甚至会话级调。给未来留出调优空间,避免为了改参数再重启一次。

  7. io_uring 上线前查三件事pg_config --configure | grep uring(编译支持)、内核版本 ≥ 5.10(建议 5.15+)、ulimit -nmax_files_per_process 是否够高连接数用。

  8. 容器里跑 io_uring 要确认 seccomp 没拦。 用 6.4 节那段 Python 探针验证,别等启动失败才发现。K8s 环境下这个坑非常常见。

  9. AIO 不会替你解决"表太大"。 3 倍是 I/O 路径的提升,不是查询的提升。全表扫 40GB 依然是全表扫 40GB。先加索引、先做分区、先裁剪列,再谈 AIO。 顺序反了就是本末倒置。

  10. 明确知道 AIO 覆盖哪些路径。 PG18 的 AIO 主要作用于顺序扫描、Bitmap Heap Scan、VACUUM(以及走 Read Stream 的部分维护操作)。普通 Index Scan 的随机单块读、B-tree 内部页遍历、WAL 写入、checkpoint 刷脏,基本不在 PG18 的收益范围内。 如果你的负载是"索引点查为主",别指望 AIO——你该看的是 shared_buffers 命中率和索引设计。

  11. 写路径别抱期望。 PG18 的 AIO 重点在读。bgwriter / checkpointer 的写入优化是后续版本的事(PG19 及之后仍在演进 Direct I/O 与写路径异步化)。写瓶颈请继续调 checkpoint_timeoutmax_wal_sizebgwriter_lru_maxpages

  12. shared_buffers 和 AIO 是互补关系,不是替代关系。 AIO 优化的是"缓存未命中之后怎么办"。命中率能提上去,最好的 I/O 优化永远是不做 I/O。

  13. 在只读副本上放开手脚。 分析型只读副本没有写入争抢,是 io_uring + 大 io_combine_limit + 高 effective_io_concurrency 的最佳场景。主库保守,副本激进。

  14. 一定要 track_io_timing = on 否则 pg_stat_io 的耗时列和 EXPLAIN (ANALYZE, BUFFERS) 的 I/O 时间全是空的,等于自断观测。开销在现代机器上(有可靠的 TSC 时钟源)通常可忽略,上线前用 pg_test_timing 量一下:

    pg_test_timing --duration=3
    # 关注 "Per loop time including overhead",几十 ns 属正常
    
  15. 参数改完必须回归 OLTP 尾延迟。io_combine_limit + 高并发预读会挤占 shared_buffers,可能把点查的 P99 拖高。只看吞吐不看尾延迟的调优,是把问题从报表搬到了在线接口。


八、横向对比:PG18 的 AIO 站在什么位置

数据库异步 I/O 方案特点
PostgreSQL 18AIO 子系统:worker 池 / io_uring多进程架构下的后来者,但 handle 抽象 + 两段回调设计相当干净;读路径优先
MySQL / InnoDBLinux native AIO(libaio),多线程起步早(5.5 时代),线程模型天然友好;但仍停留在 libaio,未全面转向 io_uring
Oracle内置 AIO + 直接 I/O商业数据库多年成熟方案,绕过 page cache 自管缓存
ClickHouse线程池 + 可选 io_uring / 直接 I/O分析型引擎,一开始就是"高并发大块读"的设计前提
SQLite / DuckDB嵌入式,线程内异步或 mmap单进程内解决问题,没有跨进程的复杂度

一个有意思的观察:PG 的多进程架构曾是它做 AIO 的最大阻碍,但也让它的实现更保守、更安全。 因为必须假设"发起方随时可能死掉",PG 被逼着设计出了 shared/local 两段回调这种崩溃安全的机制。多线程数据库不需要考虑这么细,反而在异常路径上更容易出现难查的状态残留。

工程上的通用规律:约束逼出好设计。


九、总结与展望

回到开头那根 iowait 曲线。PG18 的 AIO 做的事,用一句话概括就是:把「等 I/O」从后端进程的关键路径上挪走了。

三层结构值得再复述一遍,因为它是理解一切调参的钥匙:

  • Read Stream 负责"知道未来要读什么"——自适应预读窗口 + 相邻块合并;
  • AIO handle 负责"在多进程环境下安全地管理飞行中的 I/O"——状态机 + 共享内存元数据 + 两段回调;
  • io_method 负责"具体怎么把请求交给存储"——worker 稳、io_uring 快、sync 兜底。

给不同人的一句话建议:

  • 托管云数据库用户:升到 18,享受默认收益,然后把注意力放在 effective_io_concurrency 和索引设计上。io_method 你改不了。
  • 自建 + 分析型负载:为 io_uring 重新编译一次是值得的,配合只读副本收益最大。
  • 自建 + OLTP 为主:老老实实 worker,把 io_workers 从 3 调到 8,然后去看你的索引和缓存命中率——AIO 不是你的主战场。
  • 所有人track_io_timing = on,学会读 pg_aiospg_stat_io。观测能力比参数值重要一百倍。

往前看,PG19(本文写作时已到 Beta 3)以及之后的版本,AIO 的故事还有几章没写完:

  1. 写路径异步化:checkpointer / bgwriter 用上 AIO,checkpoint 抖动有望缓解。
  2. Direct I/O(DIO):绕过 page cache 直接读写。PG 的 debug_io_direct 目前仍是调试选项,因为绕过 page cache 意味着 PG 必须自己把缓存管好(包括真正可用的预读和替换策略)——而这正是 AIO 打下的地基。AIO 是 DIO 的前置条件,这个顺序不是偶然的。
  3. 更多路径接入 Read Stream:Index Scan 的随机读、更多维护操作。

如果说 PG18 的 AIO 有什么"元层面"的启示,我觉得是这个:一个 25 年没解决的架构问题,最终不是靠一次重写解决的,而是靠先造出一个正确的抽象层(Read Stream 在 17,AIO handle 在 18),然后让实现(worker/io_uring/未来的 DIO)在抽象层背后逐步演进。

这套路数,写业务代码的时候同样管用。


动手清单(照着做,30 分钟出结论):

  1. SELECT name, setting, unit, context FROM pg_settings WHERE name LIKE 'io%' OR name LIKE '%io_concurrency';
  2. \d pg_aios + \d pg_stat_io,确认你这个版本的真实列名
  3. 跑 6.3 的对比脚本,记下 sync vs worker(3) vs worker(8) 三个数字
  4. 大扫描时用 6.4 的采样脚本看 avg_len 是不是 131072
  5. pg_wait_events 查你这版的 AIO 等待事件,压测时抓一次分布
  6. 改完参数,回归一次 OLTP 的 P99

数字比观点可靠,你自己机器上的数字比任何博客的数字都可靠——包括这一篇。

推荐文章

详解 Nginx 的 `sub_filter` 指令
2024-11-19 02:09:49 +0800 CST
html一个全屏背景视频
2024-11-18 00:48:20 +0800 CST
程序员茄子在线接单