编程 PostgreSQL 18 异步 I/O 深度拆解:io_uring 如何终结 Postgres 二十年的同步阻塞

2026-08-17 20:19:01 +0800 CST views 7

PostgreSQL 18 异步 I/O 深度拆解:io_uring 如何终结 Postgres 二十年的同步阻塞

一、先说结论:这不是性能补丁,是地基重浇

先把观点抛出来,省得你读到第三节才知道我要说什么。

PostgreSQL 18 的异步 I/O(AIO)子系统,在 release note 里只是若干条不起眼的条目:新增 io_method 参数、新增 pg_aios 视图、effective_io_concurrency 默认值从 1 改成 16。看起来像是"又调了几个旋钮"。

但如果你翻过 src/backend/storage/buffer/bufmgr.c 的代码,你会知道这件事的分量:Postgres 从 1996 年开源到 2025 年,读一个数据页的路径本质上一直是 pread(fd, buf, 8192, offset) 这一行同步系统调用。 一个 backend 进程发起读,然后进入内核态睡觉,等磁盘把 8KB 搬回来,醒了继续跑。整整二十多年,无论底下是 5400 转的机械盘、SAS SSD、还是能吃 100 万 IOPS 的 NVMe,Postgres 看到的世界都是"我发一个请求,我等一个结果"。

这个模型在机械盘时代是合理的——反正单盘 IOPS 就那么点,队列深度给再高也没用,寻道时间是绝对瓶颈。但在 NVMe 时代它是荒谬的:一块普通企业级 NVMe 盘要跑满带宽,队列深度至少要 32~128。而单个同步 Postgres backend 提供的队列深度永远是 1。 你花几万块买的盘,被一个 pread 死死掐在单通道上。

PG18 做的事,是在 buffer manager 和 storage manager 之间插入了一整层 AIO 抽象:请求的"发起"和"等待"被彻底解耦,并且提供了三种可切换的后端实现(同步、worker 进程池、Linux io_uring)。这不是优化某个算子,这是把地基重新浇了一遍——所以它的收益范围、它的坑、它的调参方式,都和你熟悉的 Postgres 调优经验不太一样。

这篇文章我会按这个顺序拆:旧路径为什么烂 → 新抽象长什么样 → 三种 io_method 的架构差异 → 怎么开、怎么观测、怎么压测 → 怎么调参 → 哪些场景根本没收益。 代码和配置都能直接抄,但请在你自己的机器上验证数字,因为 I/O 这件事,换个云盘型号结论就能翻转。


二、背景:Postgres 的 I/O 到底"同步"在哪

2.1 一次读页的完整旧路径

假设你执行 SELECT * FROM orders WHERE id = 12345,走索引,需要读一个堆页。旧路径(PG17 及之前)简化成伪代码是这样:

/* 上层调用 */
Buffer buf = ReadBuffer(relation, blocknum);

/* ReadBuffer 内部(极度简化) */
Buffer ReadBuffer(Relation rel, BlockNumber blocknum)
{
    /* 1. 在 shared buffers 的哈希表里查这个页在不在 */
    BufferDesc *bufHdr = BufTableLookup(&tag);
    if (bufHdr != NULL)
        return BufferDescriptorGetBuffer(bufHdr);   /* 命中,直接返回 */

    /* 2. 未命中:找一个可复用的 buffer slot(可能触发脏页回写) */
    bufHdr = StrategyGetBuffer(...);
    if (BufferIsDirty(bufHdr))
        FlushBuffer(bufHdr);                        /* 同步写! */

    /* 3. 关键:同步读磁盘,进程在这里睡死 */
    smgrread(rel->rd_smgr, MAIN_FORKNUM, blocknum, bufBlock);
    /*         └─> FileRead() └─> pread(fd, buf, BLCKSZ, offset)  */

    /* 4. 校验 checksum,标记有效,返回 */
    return BufferDescriptorGetBuffer(bufHdr);
}

第 3 步是全部问题的根源。pread 返回之前,这个 backend 进程什么都干不了。它不能去预读下一个页,不能去算表达式,不能去处理别的元组。它就是一个等待 I/O 的空壳。

2.2 一次 I/O 等待到底浪费了多少

给个成本模型,你就知道为什么这事值得动地基:

存储介质单次 8KB 随机读延迟折算 CPU 周期(按 3GHz)
shared_buffers 命中(内存)~100 ns~300
OS page cache 命中13 μs3,0009,000
本地 NVMe80150 μs240,000450,000
云盘(网络块存储)~300 μs ~ 2 ms900,0006,000,000
机械盘随机读510 ms15,000,00030,000,000

一次云盘读,等于白扔掉近百万个 CPU 周期。而一个顺序扫描 10GB 的表要读 128 万个页——如果全部冷读、全部同步、每次 500μs,光 I/O 等待就是 640 秒。这不是理论:任何做过大表冷扫描的人都见过那种"CPU 5%、磁盘 20MB/s、查询跑十分钟"的诡异画面。磁盘不忙,CPU 不忙,但查询就是慢,因为瓶颈是"往返次数 × 单次延迟",跟带宽和算力都没关系。

2.3 老 hack:posix_fadvise 和 effective_io_concurrency

Postgres 社区当然知道这个问题。PG 8.4 引入了一个补丁式方案:effective_io_concurrency + posix_fadvise(POSIX_FADV_WILLNEED)

思路是:在真正 pread 之前,先告诉内核"我马上要读这几个块,你先帮我预热到 page cache"。这确实能制造并发,但它有三个硬伤:

  1. 只在极少数算子里用了。 主要是 Bitmap Heap Scan。普通顺序扫描、索引扫描的堆访问、VACUUM,大多没吃到。
  2. fadvise 是"建议",不是契约。 内核可以完全忽略。而且它只对走 page cache 的 buffered I/O 有效,对 Direct I/O 毫无意义。
  3. 它做的是双份工作。 先 fadvise 把数据搬进 page cache,再 pread 从 page cache 拷进 shared buffers。数据在内存里被搬了两次,还占了双份内存。

PG17 往前走了一步,引入了 Read Stream APIread_stream.c)和 io_combine_limit:把连续的块合并成一次 preadv 向量读,把"128 次 8KB 读"变成"1 次 1MB 读"。这个改动收益很实在,但它解决的是"请求太碎",不是"请求太串行"。

PG18 才是终局:请求本身变成异步的。


三、核心概念:三层新抽象

理解 PG18 AIO,抓住三个东西就够了:PgAioHandleio_methodReadStream。它们分别对应"一次 I/O 的载体"、"谁去执行 I/O"、"上层怎么用"。

3.1 PgAioHandle:一次 I/O 的生命周期

新代码在 src/backend/storage/aio/ 下。核心数据结构是 PgAioHandle,你可以理解成"一张 I/O 工单"。它有一个明确的状态机:

IDLE  ─(acquire)→  HANDED_OUT  ─(定义好 target/op)→  DEFINED
                                                      │
                                                   (staged)
                                                      ↓
                                                   STAGED
                                                      │
                                                  (submit)
                                                      ↓
                                                  SUBMITTED
                                                      │
                                            (I/O 完成,被 reap)
                                                      ↓
                                            COMPLETED_IO
                                                      │
                                          (跑 shared callbacks)
                                                      ↓
                                          COMPLETED_SHARED
                                                      │
                                        (发起者跑 local callbacks)
                                                      ↓
                                          COMPLETED_LOCAL → IDLE

关键在于 COMPLETED_SHARED 和 COMPLETED_LOCAL 的分裂。这是整个设计里最见功力的地方,值得单独讲。

3.2 为什么回调必须分成 shared 和 local

问题:在 worker 模式下,I/O 是由另一个进程(io worker)执行完成的。谁来做"校验 checksum、标记 buffer 有效"这些收尾工作?

如果让发起者做:那 io worker 完成后必须唤醒发起者,发起者才能收尾。可万一发起者在忙别的事呢?这个 buffer 就一直处于"数据到了但没标记有效"的中间态,别的 backend 想读这个 buffer 就得干等。

Postgres 的选择是:允许任何进程完成收尾工作。 谁先发现 I/O 完成了,谁就顺手把 shared 部分的收尾做掉——包括 checksum 验证、把 buffer 标记为 BM_VALID、唤醒等待者。这样即使发起者去泡咖啡了,其他 backend 也能"帮它"把 buffer 变成可用状态。

这带来一个非常硬的约束:shared callback 不能依赖任何进程本地状态。

  • 不能捕获闭包(C 里本来也没有,但不能塞指向本地内存的指针)
  • 不能引用 backend-local 的 relation cache
  • 不能在里面 ereport(ERROR)——因为你在替别人干活,把自己的事务搞崩了算谁的?

所以回调不是普通的函数指针,而是注册在全局表里的 ID

/* src/include/storage/aio.h 的思路(简化示意) */
typedef enum PgAioHandleCallbackID
{
    PGAIO_HCB_INVALID = 0,
    PGAIO_HCB_MD_READV,
    PGAIO_HCB_SHARED_BUFFER_READV,
    PGAIO_HCB_LOCAL_BUFFER_READV,
    /* ... */
} PgAioHandleCallbackID;

typedef struct PgAioHandleCallbacks
{
    /* I/O 完成后,任何进程都可能执行:做共享状态的收尾 */
    PgAioHandleCallbackComplete complete_shared;
    /* 只有发起者执行:可以报错、可以碰本地状态 */
    PgAioHandleCallbackComplete complete_local;
    /* 生成错误信息用 */
    PgAioHandleCallbackReport   report;
} PgAioHandleCallbacks;

PgAioHandle 里存的是 callback ID 数组(而不是指针),因为进程间共享内存里存函数指针在有 ASLR 的系统上根本不可靠。ID → 函数指针的映射由每个进程在启动时各自建立。

错误处理同样精妙:I/O 失败(比如 EIO)时,shared callback 只把错误码记录到 handle 里(raw_result),绝不抛异常。真正的 ereport(ERROR, "could not read block ...") 留给发起者在 complete_local 阶段抛。这样错误永远归属正确的事务。

这个设计我认为是整个 patch 最值得学的部分——任何做异步化改造的系统都会遇到"完成回调该在谁的上下文里跑"这个问题,Postgres 给出的答案是"按副作用范围切成两半",而不是简单地"谁发起谁负责"。

3.3 io_method:三种执行后端

io_method 决定 STAGED → SUBMITTED 之后,实际由谁来跑系统调用。三个值:

机制平台真异步?
sync提交即在本进程内 preadv 同步完成全平台否,兼容/回退用
worker交给独立的 io worker 进程池执行全平台是(进程级并发)
io_uring提交到内核 io_uring 环,内核异步完成Linux(需编译时链接 liburing)是(内核级)

PG18 的默认值是 worker 这个选择很务实:io_uring 需要编译期依赖 + 较新内核 + 容器 seccomp 放行,做不了默认;sync 等于什么都没变。worker 是唯一能开箱即用地提供真并发的选项。

io_methodpostmaster 级参数,改了必须重启。这一点在生产上很重要——你不能靠 reload 来 A/B 测试。

3.4 ReadStream:上层真正的入口

AIO 子系统再牛,也得有人调用它。PG18 的做法不是让每个算子直接操作 PgAioHandle(那太底层、太容易写错),而是让它们统一走 Read Stream

Read Stream 的心智模型是"给我一个块号生成器,我负责流水线地把页喂给你":

/* 使用 Read Stream 的典型骨架(顺序扫描的思路) */
static BlockNumber
seq_next_block(ReadStream *stream, void *cb_private, void *per_buffer_data)
{
    MyScanState *st = cb_private;
    if (st->cur >= st->last)
        return InvalidBlockNumber;      /* 流结束 */
    return st->cur++;
}

/* 建流 */
ReadStream *stream = read_stream_begin_relation(
    READ_STREAM_SEQUENTIAL,     /* 访问模式提示 */
    strategy,                   /* buffer access strategy,如 BAS_BULKREAD */
    relation,
    MAIN_FORKNUM,
    seq_next_block,             /* 块号回调 */
    scanstate,                  /* 回调私有数据 */
    0);                         /* per-buffer data 大小 */

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

Read Stream 内部干了三件你不想自己写的事:

  1. 预读窗口管理:根据 effective_io_concurrency 提前把后面 N 个块的读请求发出去(distance 是自适应的,命中率高就缩、缺页多就扩)。
  2. 请求合并:连续块合并成一次 preadv,上限由 io_combine_limit 控制。
  3. 命中短路:如果块已经在 shared buffers 里,直接返回,不产生 I/O。

这就是为什么 PG18 的收益分布很不均匀:只有已经改造成走 Read Stream 的代码路径才吃得到异步 I/O。PG17 已经改造了顺序扫描、ANALYZE、部分 VACUUM 阶段;PG18 继续扩到了 Bitmap Heap Scan 等更多路径。但普通索引扫描的堆页随机访问,仍然是逐个页取——因为在拿到当前元组之前,你根本不知道下一个要读哪个页,这是逻辑上的依赖,不是实现问题。

记住这条判断准则:能提前知道块号的路径 → 能异步;必须读完上一个才知道下一个 → 无法异步。 后面调参章节全靠这条。


四、架构分析:worker 与 io_uring 的真实差异

4.1 worker 模式:共享内存队列 + 进程池

worker 模式的结构:

backend A ──┐
backend B ──┼──→ [共享内存提交队列] ──→ io worker 0 ──→ preadv()
backend C ──┘                      ├──→ io worker 1 ──→ preadv()
                                   └──→ io worker 2 ──→ preadv()
     ↑                                        │
     └────────── 完成通知 / 状态置位 ←─────────┘
  • io worker 是真正的独立进程(postmaster 的子进程),你能在 ps 里看到,在 pg_stat_activitybackend_type = 'io worker'
  • 数量由 io_workers 控制,默认 3,范围 1~32,可以 reload 生效(不像 io_method 要重启)。
  • backend 把工单挂到共享队列,io worker 摘下来执行 preadv,完成后跑 shared callback。

优点:全平台可用,不依赖内核特性,实现相对好审计。

代价也很明确:

  1. 上下文切换和进程间通知开销。每个 I/O 都要走一遍"挂队列 → 唤醒 worker → worker 执行 → 置位/唤醒发起者"。对小 I/O 来说,这个固定开销占比不低。
  2. worker 数量是全局资源。3 个 worker 面对 200 个 backend 的突发冷读,队列一样会堵。这是一个新的、以前不存在的瓶颈点。
  3. worker 自己是同步阻塞的。每个 worker 同一时刻只能有少量 in-flight I/O(它执行的是 preadv)。所以整个实例的有效队列深度大致是 io_workers × 每 worker 并发,而不是无限。

所以 worker 模式的心智模型应该是:把"每个 backend 队列深度 1"改成了"全局有一个可调深度的 I/O 线程池"。这是巨大的进步,但它离硬件极限还有距离。

4.2 io_uring 模式:把队列深度交给内核

io_uring 模式下,每个 backend 自己持有一个 io_uring 实例(提交队列 SQ + 完成队列 CQ 的一对环形缓冲区):

backend A ── SQ/CQ ring ──→ 内核 io_uring ──→ block layer ──→ NVMe
backend B ── SQ/CQ ring ──→ 内核 io_uring ──→ ...

提交是批量的、零系统调用开销的(写 SQ 环 + 一次 io_uring_enter,甚至可以配 SQPOLL 完全免除系统调用)。完成是内核直接写 CQ 环,backend 轮询或等待即可。

优势:

  • 单 backend 就能打出很高队列深度。一个顺序扫描的 backend 可以同时挂 32、64 个 in-flight 读,直接把 NVMe 喂饱。
  • 没有额外进程间跳转。提交和完成都在本进程上下文,少一次唤醒往返。
  • 天然适配未来的 Direct I/O。io_uring + DIO 才是绕过 page cache 双拷贝的完整答案。

代价和坑(这部分是生产上真会踩的):

  1. 编译期依赖。二进制必须 ./configure --with-liburing(meson 下 -Dliburing=enabled)。发行版官方包不一定开了这个开关——先查,别猜

    -- 如果这句报错说 invalid value,说明你的二进制没编 io_uring
    SET io_method = 'io_uring';
    

    或者在 shell 里直接看:

    pg_config --configure | tr ' ' '\n' | grep -i uring
    ldd $(which postgres) | grep -i uring
    
  2. 文件描述符消耗。每个 backend 一个 ring,每个 ring 至少吃一个 fd。max_connections = 1000 就意味着上千个额外 fd。必须同步抬高 ulimit -n / systemd 的 LimitNOFILE,否则表现是"连接数上去就报 too many open files",排查起来很折磨。

    # /etc/systemd/system/postgresql.service.d/override.conf
    [Service]
    LimitNOFILE=1048576
    LimitMEMLOCK=infinity
    
  3. 容器里可能被 seccomp 拦死。Docker 的默认 seccomp profile 在较新版本里对 io_uring_setup 等系统调用是拒绝的(历史上 io_uring 出过一串内核提权漏洞,容器运行时对它态度保守)。症状是 Postgres 启动时报无法初始化 io_uring。解决要么放行 syscall,要么老老实实用 worker:

    # 仅在你明确接受安全代价时使用
    docker run --security-opt seccomp=unconfined ...
    

    我的建议很直接:在 K8s / 托管容器环境里,默认就用 worker io_uring 的收益不值得你去动集群的 seccomp 基线。

  4. 内核版本。io_uring 的行为和性能在内核 5.x 到 6.x 之间差异巨大。老内核(尤其是各种"企业发行版长期支持内核"回合过来的 5.4/5.10)上开 io_uring,可能既不快也不稳。生产建议 6.1+,能上 6.6+ 更好。

4.3 写路径还没异步

必须泼一盆冷水:PG18 的异步 I/O 目前主要覆盖读路径。 WAL 写入、checkpoint 的脏页刷盘,仍然走原来的同步路径。

这意味着:

  • 你的 OLTP 写入延迟不会因为开了 AIO 而降低。想优化写,还是老三样:wal_bufferscommit_delay/组提交、full_page_writes 与存储的原子写能力、以及最重要的——WAL 盘别和数据盘抢 IOPS。
  • checkpoint 风暴不会消失。checkpoint_completion_targetbgwriter_* 那套调参逻辑完全照旧。

所以如果有人跟你说"升 PG18,写性能翻倍",你可以直接判定他没读过代码。PG18 的 AIO 是给读密集、大扫描、冷数据分析型负载准备的礼物,写路径要等后续版本。


五、代码实战:开启、观测、压测

5.1 配置:一份可直接抄的 postgresql.conf 片段

# ============ PG18 异步 I/O ============
# 执行后端:sync | worker | io_uring (改动需重启)
io_method = 'worker'

# worker 模式下的 I/O 进程数(可 reload)
# 经验起点:min(CPU 核数 / 2, 8);读密集大实例可上探到 12~16
io_workers = 6

# 普通查询的 I/O 并发度(PG18 默认已从 1 提到 16)
# 本地 NVMe:32~64;网络云盘:16~32;机械盘:2~4
effective_io_concurrency = 32

# 维护操作(VACUUM / ANALYZE / 索引构建)的 I/O 并发度
maintenance_io_concurrency = 32

# 单次合并读的上限(默认 128kB)
io_combine_limit = '256kB'
# 上面这个值的硬顶,需重启才能改
io_max_combine_limit = '512kB'

# 想看到真实 I/O 耗时,必须打开(有少量开销,但值得)
track_io_timing = on

# 顺带:AIO 让大扫描更容易吃满内存带宽,把这两个一起看
shared_buffers = '16GB'
huge_pages = try

改完之后验证:

SELECT name, setting, unit, context, source
FROM pg_settings
WHERE name IN ('io_method','io_workers','effective_io_concurrency',
               'maintenance_io_concurrency','io_combine_limit',
               'io_max_combine_limit','track_io_timing')
ORDER BY name;

注意 context 列:postmaster 表示要重启,sighup 表示 reload 即可,user 表示单会话可改。effective_io_concurrencyuser 级的——这点非常有用,你可以只给分析型会话开高并发,而不影响 OLTP

-- 只在这个跑报表的会话里激进一点
SET effective_io_concurrency = 64;
SET io_combine_limit = '512kB';
SET work_mem = '512MB';

5.2 观测:pg_aios 视图

PG18 新增的 pg_aios 视图能让你看到当前正在飞的 I/O。这是以前完全没有的能力。

SELECT pid, io_id, state, operation,
       pg_size_pretty(length::bigint) AS len,
       target, target_desc, f_sync, f_localmem, f_buffered
FROM pg_aios
ORDER BY pid, io_id;

典型输出(大表冷扫描进行中):

  pid  | io_id |   state   | operation |  len   | target |        target_desc         | f_sync | f_localmem | f_buffered
-------+-------+-----------+-----------+--------+--------+----------------------------+--------+------------+------------
 41230 |    12 | SUBMITTED | readv     | 256 kB | smgr   | blocks 81920..81951 of ... | f      | f          | t
 41230 |    13 | SUBMITTED | readv     | 256 kB | smgr   | blocks 81952..81983 of ... | f      | f          | t
 41230 |    14 | STAGED    | readv     | 256 kB | smgr   | blocks 81984..82015 of ... | f      | f          | t

怎么读这张表,直接给你判断规则:

  • pg_aios 长期空表 + 查询慢:说明你的慢查询根本没走 Read Stream 路径(大概率是索引随机访问),AIO 帮不上忙,别再调 effective_io_concurrency 了,去优化索引和数据布局。
  • 同一 pid 只有 1~2 条 in-flight:预读窗口没打开。要么 effective_io_concurrency 太小,要么访问模式被判定为随机。
  • 大量 SUBMITTED 堆积、length 都很小(8kB):请求没合并起来,检查 io_combine_limit,也可能是表碎片严重导致块号不连续。
  • operation = readvlen 接近 io_combine_limit:这是理想状态,合并和并发都生效了。

配合一个实时监控脚本,压测时开着看:

#!/usr/bin/env bash
# aio-watch.sh — 每秒采样一次在途 I/O 分布
while true; do
  psql -qtAX -d mydb <<'SQL'
SELECT to_char(now(),'HH24:MI:SS')
       || ' inflight=' || count(*)
       || ' submitted=' || count(*) FILTER (WHERE state='SUBMITTED')
       || ' staged='    || count(*) FILTER (WHERE state='STAGED')
       || ' avg_len='   || coalesce(round(avg(length)/1024)::text,'0') || 'kB'
       || ' backends='  || count(DISTINCT pid)
FROM pg_aios;
SQL
  sleep 1
done

5.3 pg_stat_io:算清 I/O 的总账

pg_aios 看瞬时,pg_stat_io 看累计。PG18 给它补了字节级统计(read_bytes / write_bytes / extend_bytes),终于不用自己拿"次数 × 8192"去估了:

SELECT backend_type, object, context,
       reads,
       pg_size_pretty(read_bytes) AS read_bytes,
       round(read_time::numeric, 1) AS read_ms,
       CASE WHEN reads > 0
            THEN round((read_time / reads)::numeric, 3)
       END AS ms_per_read,
       CASE WHEN reads > 0
            THEN pg_size_pretty((read_bytes / reads)::bigint)
       END AS avg_read_size
FROM pg_stat_io
WHERE reads > 0
ORDER BY read_time DESC NULLS LAST
LIMIT 15;

avg_read_size 是我最看重的一列。 它直接告诉你请求合并有没有生效:

  • 接近 8kB → 完全没合并,纯随机小 I/O
  • 32~128kB → 合并生效了
  • 接近 io_combine_limit → 合并跑满

ms_per_read 告诉你存储的真实延迟。这两个数一乘,就是你的有效吞吐。

顺便注意 backend_type 里会出现 io worker

SELECT backend_type, sum(reads) AS reads, pg_size_pretty(sum(read_bytes)) AS bytes
FROM pg_stat_io
GROUP BY backend_type
ORDER BY sum(reads) DESC;

如果 io worker 的读占比很低,而 client backend 很高,说明大部分 I/O 还是同步走掉了——AIO 名义上开了,实际没用上。这是最容易自欺欺人的场景,一定要查这一行。

5.4 一个可复现的冷读压测

要测 AIO,关键是保证冷缓存。否则你测的是内存速度。

#!/usr/bin/env bash
# pg18-aio-bench.sh
# 用法: ./pg18-aio-bench.sh <io_method> <eic> <io_workers>
set -euo pipefail

METHOD=${1:-worker}
EIC=${2:-16}
WORKERS=${3:-3}
DB=aiobench
PGDATA=${PGDATA:-/var/lib/postgresql/18/main}

echo ">>> 配置: io_method=$METHOD eic=$EIC io_workers=$WORKERS"

# 1. 写配置并重启(io_method 需要重启)
psql -d postgres -c "ALTER SYSTEM SET io_method = '$METHOD';"
psql -d postgres -c "ALTER SYSTEM SET io_workers = $WORKERS;"
psql -d postgres -c "ALTER SYSTEM SET effective_io_concurrency = $EIC;"
psql -d postgres -c "ALTER SYSTEM SET maintenance_io_concurrency = $EIC;"
psql -d postgres -c "ALTER SYSTEM SET track_io_timing = on;"
pg_ctl -D "$PGDATA" restart -m fast -w

# 2. 准备数据(约 12GB,确保远大于 shared_buffers)
psql -d postgres -c "SELECT 1 FROM pg_database WHERE datname='$DB'" | grep -q 1 || {
  createdb "$DB"
  psql -d "$DB" <<'SQL'
CREATE TABLE big AS
SELECT g AS id,
       (random()*1e6)::int AS v,
       md5(g::text) AS h,
       repeat('x', 180) AS pad
FROM generate_series(1, 40000000) g;
ALTER TABLE big SET (autovacuum_enabled = off);
VACUUM ANALYZE big;
SQL
}

# 3. 清缓存:重启 PG + 丢弃 OS page cache(需要 root)
pg_ctl -D "$PGDATA" restart -m fast -w
sync
if [ "$(id -u)" = "0" ]; then echo 3 > /proc/sys/vm/drop_caches; fi
pg_ctl -D "$PGDATA" restart -m fast -w

# 4. 冷顺序扫描
echo ">>> 冷顺序扫描:"
psql -d "$DB" -c "\timing on" \
  -c "EXPLAIN (ANALYZE, BUFFERS, TIMING) SELECT count(*) FROM big WHERE v < 500;"

# 5. 冷 Bitmap Heap Scan(PG18 也走 Read Stream)
psql -d "$DB" -c "CREATE INDEX IF NOT EXISTS big_v_idx ON big(v);" >/dev/null
sync; [ "$(id -u)" = "0" ] && echo 3 > /proc/sys/vm/drop_caches
pg_ctl -D "$PGDATA" restart -m fast -w
echo ">>> 冷 Bitmap Heap Scan:"
psql -d "$DB" \
  -c "SET enable_seqscan = off;" \
  -c "EXPLAIN (ANALYZE, BUFFERS) SELECT count(h) FROM big WHERE v BETWEEN 1000 AND 30000;"

# 6. I/O 总账
psql -d "$DB" <<'SQL'
SELECT backend_type, object, context, reads,
       pg_size_pretty(read_bytes) AS bytes,
       round(read_time::numeric,1) AS read_ms,
       CASE WHEN reads>0 THEN pg_size_pretty((read_bytes/reads)::bigint) END AS avg_sz
FROM pg_stat_io WHERE reads > 0 ORDER BY read_time DESC NULLS LAST;
SQL

跑三轮(sync 16 3 / worker 16 6 / io_uring 64 0),对比 EXPLAIN ANALYZE 里的 I/O Timings: shared read=... 和总执行时间。

看 EXPLAIN 输出时重点盯这一段:

 Seq Scan on big  (cost=... rows=... width=...) (actual time=0.412..38221.7 rows=... loops=1)
   Buffers: shared read=1523810
   I/O Timings: shared read=35102.441

I/O Timings / actual time 的比值就是"I/O 占比"。同步模式下这个比值经常高到 85%~95%;异步生效后,同样的 shared read 次数,I/O Timings 应该显著下降——因为等待被重叠掉了。如果 shared read 次数没变而 I/O Timings 掉了一半,恭喜,AIO 起作用了。

5.5 用 Python 做多并发混合压测

单会话测不出 worker 池的瓶颈。加一个并发脚本:

#!/usr/bin/env python3
"""pg18_aio_concurrent.py — 多并发冷扫描,观察 io_workers 是否成为瓶颈"""
import argparse, statistics, threading, time
import psycopg  # pip install "psycopg[binary]"

DSN = "dbname=aiobench"

QUERY = """
SELECT count(*) FROM big
WHERE id BETWEEN %s AND %s AND v < 500
"""

def worker(idx, span, eic, results, barrier):
    with psycopg.connect(DSN, autocommit=True) as conn:
        with conn.cursor() as cur:
            cur.execute(f"SET effective_io_concurrency = {eic}")
            cur.execute("SET max_parallel_workers_per_gather = 0")  # 排除并行查询干扰
            lo = idx * span + 1
            hi = lo + span - 1
            barrier.wait()                       # 所有线程同时起跑
            t0 = time.perf_counter()
            cur.execute(QUERY, (lo, hi))
            cur.fetchone()
            results[idx] = time.perf_counter() - t0

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--conc", type=int, default=8)
    ap.add_argument("--span", type=int, default=4_000_000)
    ap.add_argument("--eic", type=int, default=32)
    args = ap.parse_args()

    results = [0.0] * args.conc
    barrier = threading.Barrier(args.conc)
    threads = [threading.Thread(target=worker,
                                args=(i, args.span, args.eic, results, barrier))
               for i in range(args.conc)]

    wall0 = time.perf_counter()
    for t in threads: t.start()
    for t in threads: t.join()
    wall = time.perf_counter() - wall0

    print(f"并发数        : {args.conc}")
    print(f"eic          : {args.eic}")
    print(f"总墙钟时间    : {wall:.2f}s")
    print(f"单查询 p50   : {statistics.median(results):.2f}s")
    print(f"单查询 max   : {max(results):.2f}s")
    print(f"有效并行度    : {sum(results)/wall:.2f}x")

if __name__ == "__main__":
    main()

"有效并行度"这个指标是关键。 理想情况下 8 个并发查询的有效并行度接近 8。如果你在 worker 模式下测出来只有 3.2,而 io_workers = 3——不用猜了,就是 worker 池满了。把 io_workers 调到 8 再测,这个数应该往上走。这是我见过最快定位 io_workers 是否够用的方法,比看任何监控图都直接。

5.6 Go 版本:持续负载下的延迟分布

如果你想看长尾延迟(p99),Go 写起来更顺手:

// pg18_aio_load.go — go run pg18_aio_load.go -conc 16 -dur 60s
package main

import (
	"context"
	"flag"
	"fmt"
	"math/rand"
	"sort"
	"sync"
	"time"

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

func main() {
	conc := flag.Int("conc", 16, "并发数")
	dur := flag.Duration("dur", 60*time.Second, "压测时长")
	eic := flag.Int("eic", 32, "effective_io_concurrency")
	flag.Parse()

	cfg, err := pgxpool.ParseConfig("dbname=aiobench pool_max_conns=64")
	if err != nil {
		panic(err)
	}
	// 每条新连接都设好会话级参数
	cfg.AfterConnect = func(ctx context.Context, c *pgx.Conn) error {
		_, err := c.Exec(ctx, fmt.Sprintf(
			"SET effective_io_concurrency=%d; SET io_combine_limit='256kB'", *eic))
		return err
	}
	pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
	if err != nil {
		panic(err)
	}
	defer pool.Close()

	var mu sync.Mutex
	var lat []time.Duration
	deadline := time.Now().Add(*dur)
	var wg sync.WaitGroup

	for i := 0; i < *conc; i++ {
		wg.Add(1)
		go func(seed int64) {
			defer wg.Done()
			rng := rand.New(rand.NewSource(seed))
			for time.Now().Before(deadline) {
				lo := rng.Int63n(39_000_000)
				t0 := time.Now()
				var n int64
				err := pool.QueryRow(context.Background(),
					`SELECT count(*) FROM big WHERE id BETWEEN $1 AND $2 AND v < 500`,
					lo, lo+200_000).Scan(&n)
				if err != nil {
					continue
				}
				d := time.Since(t0)
				mu.Lock()
				lat = append(lat, d)
				mu.Unlock()
			}
		}(int64(i) + 1)
	}
	wg.Wait()

	sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
	pct := func(p float64) time.Duration {
		if len(lat) == 0 {
			return 0
		}
		return lat[int(float64(len(lat)-1)*p)]
	}
	fmt.Printf("样本数 : %d\n", len(lat))
	fmt.Printf("QPS   : %.1f\n", float64(len(lat))/dur.Seconds())
	fmt.Printf("p50   : %v\np95   : %v\np99   : %v\nmax   : %v\n",
		pct(0.50), pct(0.95), pct(0.99), lat[len(lat)-1])
}

长尾延迟是 worker 模式最容易翻车的地方。 我的经验判断:如果 p99 / p50 的比值在同步模式下是 3 倍,切到 worker 模式后变成 8 倍,那基本是 io worker 队列排队造成的——降低并发或加 worker。io_uring 模式因为没有进程间排队,长尾通常更平。


六、性能优化:调参的取舍与顺序

调参这件事,最怕"把所有旋钮都往大扭"。给一个有优先级的方法论。

6.1 第一步:先确认你的负载吃不吃 AIO

别急着调参,先做判定。 用这个查询把你 Top 慢 SQL 的 I/O 特征打出来:

-- 需要 pg_stat_statements
SELECT
  left(query, 70) AS q,
  calls,
  round(total_exec_time::numeric/1000, 1) AS total_s,
  round((shared_blk_read_time + local_blk_read_time)::numeric/1000, 1) AS io_read_s,
  round(100.0 * (shared_blk_read_time + local_blk_read_time)
        / nullif(total_exec_time, 0), 1) AS io_pct,
  shared_blks_read,
  round(shared_blks_read::numeric / nullif(calls,0), 0) AS blks_per_call
FROM pg_stat_statements
WHERE shared_blks_read > 10000
ORDER BY (shared_blk_read_time + local_blk_read_time) DESC
LIMIT 20;

判定规则:

特征结论
io_pct > 50%blks_per_call 很大(万级以上)AIO 主战场,认真调,收益能到 2~5 倍
io_pct > 50%blks_per_call 很小(几十)随机小点查,AIO 帮不上,去查索引/数据布局
io_pct < 20%你是 CPU 或锁瓶颈,调 AIO 是浪费时间
shared_blks_read 本来就小缓存命中良好,别动,加内存的钱省下来

我见过太多人在第三、四种情况下疯狂调 effective_io_concurrency,然后得出"PG18 的 AIO 是营销噱头"的结论。不是它没用,是你的瓶颈不在那儿。

6.2 effective_io_concurrency:怎么找到那个数

这个参数在 PG18 里的语义比以前干净多了:它就是"Read Stream 允许的在途 I/O 上限"。

调参方法不是猜,是二分:

for eic in 1 4 16 32 64 128; do
  echo "=== eic=$eic"
  # 清缓存
  sync; echo 3 | sudo tee /proc/sys/vm/drop_caches >/dev/null
  sudo -u postgres pg_ctl -D "$PGDATA" restart -m fast -w
  psql -d aiobench -c "SET effective_io_concurrency=$eic;" \
    -c "EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM big WHERE v < 500;" \
    | grep -E 'I/O Timings|Execution Time'
done

典型曲线长这样(本地 NVMe,12GB 表,仅供理解形状):

eic执行时间I/O Timings边际收益
1基准 100%92%
4~42%34%巨大
16~24%15%明显
32~19%9%还有
64~18%8%基本饱和
128~19%9%反而变差

注意最后一行。 超过存储的最优队列深度后,继续加并发只会增加排队延迟,不增加吞吐,而且会挤占别的会话。这就是典型的"过载即退化"曲线。

存储类型对应的起点建议:

  • 本地 NVMe(单盘):32~64
  • NVMe RAID / 多盘:64~128
  • 云盘(网络块存储):16~32(延迟高但队列深,能吃并发;但注意云厂商的 IOPS 配额)
  • 机械盘 / RAID with HDD:2~8(队列深了只会加剧寻道)
  • 全内存命中的实例:随便,反正不产生 I/O

6.3 io_workers:worker 模式的隐藏瓶颈

默认 3 太保守了。判断方法就是 5.5 节那个"有效并行度":

有效并行度 ≈ io_workers × 单 worker 并发  (在 I/O bound 时)

调参步骤:

  1. 用 8~16 并发跑冷读压测,记录有效并行度。
  2. ALTER SYSTEM SET io_workers = 8; + pg_reload_conf()不用重启,这点很友好)。
  3. 重测。如果有效并行度上升 → 继续加;持平 → 已经不是 worker 瓶颈了,停手。
-- 在线调整并确认
ALTER SYSTEM SET io_workers = 8;
SELECT pg_reload_conf();
SELECT pid, backend_type, backend_start
FROM pg_stat_activity WHERE backend_type = 'io worker' ORDER BY pid;

上限提醒:io_workers真实进程,每个都吃内存和调度资源。在 8 核机器上开 32 个 io worker 是自残。我的经验区间是 CPU 核数 / 4CPU 核数 / 2

6.4 io_combine_limit:合并读的甜点

io_combine_limit 默认 128kB(16 个 8KB 块)。要往上调,必须先抬 io_max_combine_limit(需重启)。

io_max_combine_limit = '1MB'   # 硬顶,重启生效
io_combine_limit = '256kB'     # 实际值,可 reload / 会话级设置

什么时候值得调大:

  • 大表全表扫描、CTAS、COPY TO、逻辑备份 → 调大有收益
  • 数据文件物理连续度好(新导入、刚 VACUUM FULL/CLUSTER 过)→ 调大有收益

什么时候没用甚至有害:

  • 表碎片严重、块号不连续 → 合不起来,白搭
  • 随机点查为主 → 每次就读 1 个块,io_combine_limit 完全不参与
  • 内存紧张 → 更大的合并读需要更多临时 buffer

验证是否生效,回到 pg_stat_ioavg_read_size:调大之后这个数应该跟着涨。涨不上去就说明瓶颈不在这儿,把值改回去。

6.5 别忘了 maintenance_io_concurrency

VACUUM 和 ANALYZE 也走 Read Stream。大表 VACUUM 慢的实例,这个参数收益经常比 effective_io_concurrency 更明显——因为 VACUUM 是纯顺序扫描,最吃预读。

-- 给一次性的大维护操作单独开高
SET maintenance_io_concurrency = 64;
SET maintenance_work_mem = '2GB';
VACUUM (VERBOSE, ANALYZE) big;

顺带一个实用技巧:在业务低峰期跑维护时,把这两个值临时拉高,做完改回来。写成脚本:

psql -d mydb <<'SQL'
SET maintenance_io_concurrency = 64;
SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 4;
REINDEX INDEX CONCURRENTLY big_v_idx;
SQL

6.6 一张调参优先级表

按投入产出比排序,从上往下做:

优先级动作预期收益风险
1确认负载是否 I/O bound(6.1 节)避免白干
2track_io_timing = on拿到可观测性极小开销
3effective_io_concurrency 二分调优过大会挤占资源
4io_workers 从 3 提到 6~8中~大(高并发时)多几个进程
5io_combine_limit 128kB → 256kB中(大扫描)内存占用
6maintenance_io_concurrency中(维护窗口)
7io_uring中(延迟长尾)编译/内核/容器依赖
8Direct I/O未来目前仍是开发者选项

第 8 项特别说明debug_io_direct 这个参数存在,但名字里的 debug_ 就是社区的态度——它不是给生产用的。绕过 page cache 的完整收益需要 AIO + DIO + 更聪明的 Postgres 内部缓存管理三者齐备,那是后续版本的事。现在开它,你只会失去 OS page cache 这层免费缓存,然后性能暴跌。


七、给不同角色的行动清单

如果你是 DBA / SRE

  1. 升级不等于开启。 PG18 默认 io_method = workerio_workers = 3。这是"能用但没调"的状态。
  2. 升级后第一件事:track_io_timing = on,然后跑 6.1 节的查询摸清 I/O 画像。
  3. effective_io_concurrency 默认从 1 变 16 是行为变更。在机械盘或 IOPS 有配额的云盘上,这可能让某些查询变慢或者打爆配额。升级前务必在预生产验证,别信"默认值都是安全的"。
  4. pg_aios 加进你的监控采集:in-flight 数量、平均 length、涉及的 backend 数。这三个指标能让你第一次真正"看见"数据库的 I/O 行为。
  5. 容器环境默认用 worker,别碰 seccomp。

如果你是应用/后端开发

  1. 你不需要改任何代码。但你可以在会话级精细控制:报表连接池设高 effective_io_concurrency,OLTP 连接池保持默认。
  2. EXPLAIN (ANALYZE, BUFFERS) 里的 I/O Timings 现在是你最该看的一行。它把"慢在等磁盘"和"慢在算数据"区分开了。
  3. AIO 不会救你的 N+1 查询。逻辑上串行的访问永远无法异步化——1000 次单行查询依然是 1000 个往返。这个问题只能在应用层用批量查询解决。

如果你是内核/存储方向的工程师

那个 shared / local callback 的切分,值得单独读一遍源码。它是"异步化改造中如何处理完成回调的副作用归属"这个通用难题的一个高质量工业答案:按副作用可见范围切分,共享状态谁都能收尾,私有状态和错误报告严格归属发起者。 这个模式可以直接搬到任何有共享资源池的异步系统里。


八、总结与展望

把这篇文章压缩成几句能带走的话:

  1. PG18 的 AIO 是地基级改动,不是旋钮级优化。 它把"发起 I/O"和"等待 I/O"解耦,第一次让单个 Postgres backend 能打出大于 1 的队列深度。

  2. 收益高度集中在"能提前知道块号"的读路径:顺序扫描、Bitmap Heap Scan、VACUUM、ANALYZE。索引随机访问和写路径目前基本不受益。

  3. 三种 io_method 各有定位sync 是回退,worker 是全平台默认(有进程间开销和池容量上限),io_uring 是性能上限(但有编译、内核、容器 seccomp 三重门槛)。生产上先用 worker 调好 io_workers,把 io_uring 当进阶选项。

  4. pg_aios 是这一代最被低估的新特性。 以前 Postgres 的 I/O 行为是黑盒,现在你能实时看到每一个在飞的请求。光这一点就值得升级。

  5. 调参有顺序:先判定负载是否 I/O bound,再二分 effective_io_concurrency,再看 io_workers 是否成为瓶颈,最后才动 io_combine_limit。跳过第一步的所有调优都是撞运气。

往前看,这条路线的后续拼图已经很清晰:WAL 与脏页刷盘的异步化(写路径),真正生产可用的 Direct I/O(消除 page cache 双拷贝和双份内存),以及更激进的、基于 AIO 的预读策略(比如让 planner 把访问模式信息传给 Read Stream)。这三块拼齐,Postgres 才算真正把现代存储硬件的性能吃干榨净。

而现在这一版,是二十年来最关键的一次转身。它自己不一定让你的库快多少,但它让"快"这件事第一次成为可能。

最后一句实在话:升级 PG18 之后,请务必在你自己的硬件上跑一遍第五节那套压测。I/O 这个领域没有普适的最佳参数——你的盘、你的内核、你的数据分布,共同决定那个最优值是 16 还是 64。别抄我的数字,抄我的方法。

推荐文章

Vue3中怎样处理组件引用?
2024-11-18 23:17:15 +0800 CST
程序员茄子在线接单