编程 PostgreSQL 18 异步 I/O 深度实战:当 30 年的同步阻塞被 io_uring 撕开一道口子

2026-07-15 03:44:46 +0800 CST views 7

PostgreSQL 18 异步 I/O 深度实战:当 30 年的同步阻塞被 io_uring 撕开一道口子

从工程师视角深挖 PostgreSQL 18 的 AIO 子系统:io_method 三种模式(sync / worker / io_uring)、ReadStream 抽象、io_combine_limiteffective_io_concurrency 的调参逻辑,配可复现的基准测试与云盘场景落地策略。顺带把 skip scan、uuidv7()、虚拟生成列这些「顺手就能吃到」的红利一并讲透。

一、背景:为什么这一版值得 DBA 熬夜升级

PostgreSQL 每年一个大版本,大多数版本对普通业务来说是「锦上添花」——多几个函数、优化几个查询计划、修一堆 bug。你可以慢悠悠地等半年再升。

但 PostgreSQL 18(2025 年 9 月 25 日正式发布,本文写作时最新小版本是 18.4)不一样。它动了数据库最底层、最难改、也最影响性能的地方:I/O 模型

官方新闻稿里那句话很克制:「a new I/O subsystem that has demonstrated up to 3× performance improvements when reading from storage」——读取场景最高 3 倍提升。作为一个跟 PG 打了很多年交道的人,我看到这句话的第一反应不是兴奋,而是警惕:凡是宣称 3 倍的特性,背后一定有一堆前提条件、适用边界和调参陷阱

这篇文章不打算给你念一遍 release notes。我想干三件事:

  1. 把 AIO 到底解决了什么问题、怎么解决的,从系统调用层面讲清楚;
  2. io_methodio_workersio_combine_limiteffective_io_concurrency 这几个参数之间的耦合关系和调参逻辑拆开;
  3. 给你一套可复现的测试方法,让你能在自己的机器上验证「3 倍」到底是不是营销数字。

最后再顺带把 PG18 那些「不需要改代码就能吃到」的红利(skip scan、uuidv7()、虚拟生成列、更快的大版本升级)讲一遍。

先说结论,省得你看不到最后:AIO 是真东西,但它主要是「读」的胜利,而且在云盘(高延迟存储)上收益远大于本地 NVMe。如果你的库跑在本地顶配 SSD 上、且瓶颈在 CPU 或锁,那 AIO 给你带来的可能只有个位数百分比的提升。别被 3 倍冲昏头。


二、核心概念:同步 I/O 到底卡在哪

要理解 AIO 的价值,得先看清楚 PG18 之前那套同步 I/O 到底慢在哪。

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

假设一个后端进程要顺序扫描一张大表,需要从磁盘读一个 8KB 的数据页。在 PG18 之前,流程是这样的:

  1. 进程调用 pread() 系统调用,控制权交给内核;
  2. 内核发起磁盘 I/O 请求,把当前进程置入睡眠(D 状态,不可中断睡眠);
  3. 磁盘控制器完成读取,把数据放进内核页缓存,产生一个中断通知内核;
  4. 内核唤醒睡眠的进程,把数据从内核缓冲区拷贝到 PG 的共享缓冲区(shared_buffers);
  5. 进程继续执行,处理这个数据页。

问题出在第 2 步到第 4 步:进程在这段时间里,除了睡觉什么都干不了。CPU 监控图上那根让人头疼的 iowait 曲线,本质上就是 CPU 在空转等 I/O。

对于单页随机读,这种阻塞的绝对时间不算太夸张(本地 NVMe 约 10~100μs)。但一旦碰上以下场景,阻塞就会累积成灾难:

  • 大表顺序扫描(Seq Scan):几百万个数据页,一页一页同步读,等待时间线性累加;
  • VACUUM:要扫遍表的每一个页面判断是否需要清理;
  • 位图堆扫描(Bitmap Heap Scan):大量离散页面读取;
  • 备份/恢复:全库数据搬运。

2.2 老方案的「打补丁」:posix_fadvise

其实 PG 早就意识到这个问题了。老版本里有个参数叫 effective_io_concurrency,它的作用是通过 posix_fadvise(POSIX_FADV_WILLNEED) 给操作系统一个「预读建议」——告诉内核「我待会儿要读这些页,你先帮我准备到页缓存里」。

这招在位图堆扫描里确实有用,但它有几个硬伤:

  • 它只是「建议」(advisory),内核可以听也可以不听,行为不可控;
  • 数据被预读到内核页缓存,而不是 PG 的共享缓冲区,后续还得再拷贝一次;
  • 它对顺序扫描几乎无效,因为内核自己的预读逻辑(readahead)已经在做类似的事,posix_fadvise 反而可能画蛇添足;
  • 它只覆盖读,不覆盖其他 I/O 路径

说白了,posix_fadvise 是在同步模型上打的一块补丁,治标不治本。PG18 的 AIO 才是真正从架构层面重构 I/O 行为。

2.3 AIO 的核心思想:解耦

异步 I/O 的核心就一个词——解耦(decouple)

进程发起 I/O 请求后,不等它完成,立即返回去干别的活:处理已经在内存里的数据、准备下一批 I/O 请求、推进查询计划的其他部分。当先前发起的 I/O 完成时,系统通过某种机制通知进程来「收货」。

这样一来,I/O 操作和 CPU 计算在时间上重叠,形成一条流水线。原来是「读一页→算一页→读一页→算一页」的串行节奏,现在变成「一边算这批,一边后台读下一批」的并行流水线。

对云盘这种单次 I/O 延迟高(相比本地 NVMe 高一个数量级)的存储,这个重叠的价值被放大得尤其明显——因为你能藏起来的等待时间更长了。


三、架构分析:AIO 子系统怎么落地的

理解了「为什么」,我们看「怎么做」。PG18 的 AIO 不是一个孤立的开关,而是由几个层次协作的子系统。

3.1 三种 io_method

AIO 的总入口是参数 io_method,它决定 I/O 请求「实际由谁、以什么方式执行」。有三个取值:

(1)sync——向后兼容模式

这是「假异步」,行为上退化回老路子:在支持的平台上用 posix_fadvise 做同步预读。它存在的意义是给那些不想改行为、或者平台不支持异步的场景留一条退路。默认不是它。

(2)worker——I/O 工作进程池(PG18 默认)

PG18 引入了一批专门的「I/O worker 进程」。当一个后端进程需要读数据块时,它把请求塞进共享内存里的一个队列,然后继续干自己的活。某个 I/O worker 被唤醒,执行真正的 pread,把数据放进共享缓冲区,再通知发起请求的后端进程。

这个模式的最大优点是跨平台——它不依赖 Linux 特有的内核接口,在各种操作系统上都能用。所以 PG18 把它设为默认值,保证「开箱即用的异步」。相关参数是 io_workers(默认 3),控制 worker 进程的数量。

(3)io_uring——Linux 原生异步(性能天花板)

io_uring 是 Linux 5.1 引入的高性能异步 I/O 接口,通过一对共享内存环形队列(提交队列 SQ + 完成队列 CQ)让用户态和内核态高效交换 I/O 请求,极大减少了系统调用开销和上下文切换。

io_method = io_uring 时,后端进程直接把 I/O 请求提交到 io_uring 的环,内核异步完成后放进完成队列。没有额外的 worker 进程做中转,路径最短,延迟最低。这是 PG18 AIO 的性能天花板。

代价是:它只在较新的 Linux 内核上可用(需要内核编译时打开 io_uring 支持,且发行版没禁用),并且需要 PG 在编译时带上 --with-liburing。云厂商的托管 PG 是否开放这个选项,得看具体产品。

一句话选型建议:

  • 本地部署 + 新内核(5.x+):优先 io_uring,榨干性能;
  • 跨平台 / 老内核 / 托管环境:用默认的 worker,稳妥;
  • 只想保持老行为、排查问题时对照:临时切 sync

3.2 ReadStream:AIO 的「适配层」

光有底层的异步执行引擎还不够。PG 的查询执行器里有无数个读数据的地方(顺序扫描、位图扫描、VACUUM……),总不能每个地方都手写一套异步逻辑。

PG18 引入了一个叫 ReadStream 的抽象层。你可以把它理解成一个「智能预读流」:执行器只要告诉 ReadStream「我接下来大概会按这个顺序读这些块」,ReadStream 就会在后台通过 AIO 引擎异步地、成批地把后续可能用到的 buffer 预读进来。

这带来两个关键效果:

  1. 执行器代码和 I/O 方式解耦——上层不用关心底层是 worker 还是 io_uring;
  2. 预读是「主动的、成批的、可控的」,不再是 posix_fadvise 那种「建议性的、单页的、看内核心情的」。

目前 PG18 已经完成 ReadStream 适配、能吃到 AIO 红利的场景主要是:

  • 顺序扫描(Seq Scan)
  • 位图堆扫描(Bitmap Heap Scan)
  • VACUUM 的读取阶段

3.3 一个必须说清楚的边界:目前只有异步「读」

这是最容易被营销话术带偏的地方,我必须重点强调:

PostgreSQL 18 的 AIO 目前只实现了异步读,没有实现异步写。

也就是说,写密集型负载(大量 INSERT/UPDATE、checkpoint 刷脏页、WAL 落盘)暂时享受不到 AIO 的直接加速。18 这一版是给未来的异步写「打地基」——把 AIO 框架、ReadStream 抽象、参数体系都立起来。异步写会在后续版本逐步补齐。

所以如果你的库是典型的 OLTP 写热点,别指望升到 18 就飞起。AIO 的甜区是读密集:分析查询、大表扫描、报表、数据仓库式负载。


四、参数详解:这几个旋钮到底怎么拧

AIO 相关的参数有一组,它们之间有耦合关系,单独调一个往往没意义。我按重要性排个序。

4.1 io_method(重启生效)

# postgresql.conf
io_method = worker      # 可选: sync | worker | io_uring

改这个参数需要重启实例。生产环境切换前,务必先在测试库上验证目标模式在你的内核/编译版本上真的可用(比如误设 io_uring 但没编译支持,启动会报错)。

4.2 io_workers(worker 模式专用)

io_workers = 3          # 默认 3,仅 io_method=worker 时有效

这是 I/O worker 进程的数量。不是越多越好:

  • worker 太少 → 高并发 I/O 时队列积压,异步优势打折;
  • worker 太多 → 进程调度和上下文切换开销上升,还占内存。

经验起点:从默认 3 开始,观察 pg_stat_io 里的 I/O 等待和 worker 忙碌情况,如果发现 worker 长期打满、后端进程仍在等队列,再逐步往上加(比如 CPU 核数较多、I/O 并发高的机器可以试 6~8)。切忌一上来就拉到很高。

4.3 effective_io_concurrency 与 maintenance_io_concurrency

effective_io_concurrency = 300      # 1-1000, 普通查询的并发 I/O 深度
maintenance_io_concurrency = 300    # 1-1000, VACUUM 等维护操作的并发 I/O 深度

注意这两个参数的语义在 PG18 里变了。在老版本里,它们是喂给 posix_fadvise 的「预读页数建议」,默认值只有 1(很多人从来没调过)。在 PG18 的 AIO 体系下,它们表示允许同时在途(in-flight)的异步 I/O 请求数量,也就是 I/O 的「并发深度」。

对高延迟的云盘,把它们从个位数拉高到几百,往往是收益最大的一步——因为你需要足够多的在途请求,才能把长延迟「填满流水线」藏起来。官方示例里直接给到 300,就是这个道理。本地 NVMe 由于延迟本来就低,不需要那么深的队列,适当调小即可。

4.4 io_combine_limit 与 io_max_combine_limit

io_combine_limit = 256kB        # 单次合并 I/O 的上限(运行时可调)
io_max_combine_limit = 256kB    # 硬上限(改动需重启)

AIO 一个很实用的能力是I/O 合并(coalescing):把多个物理上相邻的块请求合并成一次大 I/O 下发。8KB 一页一页读,和一次读 256KB(32 页),对存储设备的效率天差地别,尤其是云盘。

io_combine_limit 控制单次合并的最大字节数,io_max_combine_limit 是它的硬上限(改后者要重启)。对顺序扫描这类天然连续的负载,适当调大合并上限能显著减少 I/O 次数、提升吞吐。

4.5 一份「云盘读密集型」起步配置

给一个可以直接套用、再按监控微调的起点(假设 16 核、云盘、读多写少的分析型负载):

io_method = io_uring                # 若托管环境不支持则退回 worker
# io_workers = 6                    # 仅 worker 模式下才需要,本地部署可先试 3
effective_io_concurrency = 256
maintenance_io_concurrency = 256
io_combine_limit = 256kB
io_max_combine_limit = 256kB
effective_cache_size = 48GB         # 一般设为物理内存的 50%~75%
shared_buffers = 8GB               # 视内存与负载而定

再强调一遍:这是起点不是终点。任何 postgresql.conf 的参数都得配合 pg_stat_ioEXPLAIN (ANALYZE, BUFFERS) 和真实业务压测来收敛。


五、代码实战:怎么验证「3 倍」是不是真的

光看文档没用,自己跑一遍才踏实。下面给一套可复现的验证流程。

5.1 造一张够大的表

先建一张明显超过 shared_buffers 的表,保证扫描时真的会触发磁盘 I/O,而不是全在内存里打转。

-- 建一张约 10GB 量级的宽表
CREATE TABLE bench_big (
    id      bigint,
    payload text,
    v1      double precision,
    v2      double precision,
    created timestamptz
);

-- 灌 5000 万行数据(按机器性能调整行数)
INSERT INTO bench_big
SELECT
    g,
    repeat('x', 120),                       -- 撑大每行,逼近真实宽表
    random() * 1000,
    random() * 1000,
    now() - (random() * 3650) * interval '1 day'
FROM generate_series(1, 50000000) AS g;

-- 让统计信息准确
ANALYZE bench_big;

-- 确认表大小
SELECT pg_size_pretty(pg_relation_size('bench_big'));

5.2 清缓存后做对比扫描

关键是每次测试前清空缓存,否则第二次跑全命中内存,测出来的是内存带宽而不是磁盘 I/O。

# Linux 下清操作系统页缓存(需要 root)
sync
echo 3 > /proc/sys/vm/drop_caches

# 重启 PG 清空 shared_buffers(或用 pg_prewarm 反向验证)
pg_ctl restart -D /path/to/data

然后在 psql 里,先用 sync 模式跑一遍作为基线:

-- 会话级临时切模式做对照(部分参数需重启,io_method 通常需重启,
-- 这里用 explain 观测执行差异;严谨对比请重启切换 io_method)
SET effective_io_concurrency = 1;   -- 模拟"退化"的低并发

EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT count(*), avg(v1)
FROM bench_big
WHERE v2 > 500;

记录下 Execution TimeBuffers: shared read=... 里的 read 数量与耗时。

再切到 AIO 高并发配置(io_method=io_uring + 高 effective_io_concurrency,重启后)重复同样的查询:

SET effective_io_concurrency = 256;

EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT count(*), avg(v1)
FROM bench_big
WHERE v2 > 500;

对比两次的 Execution Time。在云盘上做这个实验,读密集顺序扫描通常能看到 2 倍以上的差距;在本地顶配 NVMe 上差距会小很多——这恰好印证了前面的判断。

5.3 用 pg_stat_io 看清 I/O 全貌

PG18 强化了可观测性,pg_stat_io 是排查 I/O 行为的利器:

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

重点看 read_timereads 的比值(平均单次读延迟)。切到 AIO 后,如果单次读延迟没变但整体查询变快了,说明流水线重叠起了作用;如果你看到 I/O worker 类型的 backend 在忙碌,说明 worker 模式在干活。

5.4 用 pgbench 做端到端压测

自定义一个只读扫描脚本,用 pgbench 打并发:

cat > readonly_scan.sql <<'EOF'
SELECT count(*) FROM bench_big WHERE v2 > (random() * 1000);
EOF

# 8 个并发连接,跑 60 秒
pgbench -n -f readonly_scan.sql -c 8 -j 4 -T 60 mydb

分别在 syncio_uring 两种 io_method 下跑,对比 TPS 和延迟分布(pgbench 会输出 latency average 和 TPS)。这比单条 EXPLAIN 更接近真实并发场景。


六、性能优化:把 AIO 用对的几条实战经验

跑通了基准测试,接下来是把它落到生产的注意事项。

6.1 先定位瓶颈,别盲目升级

升 18 之前,先用下面这套「三连问」确认你的库到底卡在哪:

-- 1. 看等待事件分布,确认是不是 I/O 等待占大头
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY 1, 2
ORDER BY 3 DESC;

-- 2. 看缓存命中率,命中率低说明确实在频繁读盘
SELECT
    sum(heap_blks_read) AS disk_reads,
    sum(heap_blks_hit)  AS cache_hits,
    round(sum(heap_blks_hit)::numeric
        / nullif(sum(heap_blks_hit + heap_blks_read), 0) * 100, 2) AS hit_ratio
FROM pg_statio_user_tables;

如果等待事件里 IO 类占比很高、缓存命中率偏低,且负载偏读——恭喜,你是 AIO 的目标用户。反之如果瓶颈是锁(Lock)、CPU 或写放大,AIO 帮不上太多。

6.2 云盘场景的收益最大化

云盘(EBS、云硬盘等)单次 I/O 延迟高但吞吐带宽可观,是 AIO 的最佳舞台。要点:

  • effective_io_concurrency / maintenance_io_concurrency 往高了调(256~512),把长延迟藏进深队列;
  • io_combine_limit 调大,减少 I/O 次数、提升单次 I/O 效率;
  • 优先 io_uring(若托管环境支持),路径最短。

6.3 本地 NVMe 别过度调参

本地顶配 NVMe 延迟已经很低,深队列的边际收益递减,过高的 effective_io_concurrency 反而可能增加调度开销。这种机器上 AIO 的提升本就有限,参数保守些即可,把精力花在 CPU、索引、查询计划上更划算。

6.4 VACUUM 提速的顺手红利

因为 VACUUM 的读取阶段已适配 ReadStream,升到 18 后大表 VACUUM 的读取会明显变快。配合 PG18 新增的 autovacuum_vacuum_max_threshold(给超大表设一个绝对更新量阈值,避免因「更新比例太低」而迟迟不触发清理),大表的维护体验会舒服不少。


七、顺手就能吃到的其他红利

AIO 是主菜,但 PG18 还有几道「不用改代码、升级即享」的配菜,值得一并知道。

7.1 Skip Scan:多列索引的「补漏」

老版本里,一个 (a, b) 复合索引,如果查询只带 b 的条件、不带 a,索引通常用不上,只能全表扫。PG18 引入 skip scan,让优化器在前导列(a)基数较低时,也能「跳着」利用这个复合索引:

CREATE INDEX idx_ab ON orders (status, created_at);

-- PG18 之前:只查 created_at 大概率走 Seq Scan
-- PG18:若 status 取值很少(如只有 3 种状态),可借助 skip scan 用上 idx_ab
SELECT * FROM orders WHERE created_at > now() - interval '1 day';

这意味着一部分你以前不得不额外建单列索引的场景,现在可能不用建了——省空间、省写入开销。升级后建议重新审视一遍慢查询,说不定有惊喜。

7.2 uuidv7():对索引友好的主键

分布式系统爱用 UUID 做主键,但传统 UUIDv4 是纯随机的,插入 B-tree 时页面分裂严重、缓存命中差、写放大高。PG18 内置了 uuidv7()

-- 时间有序的 UUID,高位是 Unix 毫秒时间戳,低位随机
CREATE TABLE events (
    id      uuid DEFAULT uuidv7() PRIMARY KEY,
    kind    text,
    created timestamptz DEFAULT now()
);

SELECT uuidv7();  -- 生成的值大致按时间递增

UUIDv7 高位是时间戳,因此近似单调递增,插入时集中在 B-tree 右侧、页分裂少、局部性好,索引和读取性能都优于 v4。既保留了 UUID 的全局唯一与无中心生成优势,又拿回了自增主键的写入友好性,分布式场景非常值得切换。

7.3 虚拟生成列(Virtual Generated Columns)

老版本的生成列是「存储型(STORED)」——值算好后落盘,占空间。PG18 支持虚拟生成列,值在查询时才计算,不占存储:

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

适合那些「派生自其他列、读取频率不高、又不想冗余存储」的字段。

7.4 更省心的大版本升级

PG18 让 pg_upgrade 更快,并且改善了升级后统计信息的处理——过去升级完常常因为统计信息缺失导致执行计划劣化、要手动重跑 ANALYZE 才恢复性能。18 缓解了这个「升级后性能塌陷期」,让大版本升级不再那么令人心惊胆战。

7.5 OAuth 2.0 认证

PG18 原生支持 OAuth 2.0,跟企业 SSO / 身份提供商集成更顺手,不用再靠一堆外部中间件绕。对有统一身份治理需求的团队是个实在的省事儿点。


八、升级 checklist:别裸奔上生产

把上面的内容浓缩成一份可执行的升级前清单:

  1. 确认负载类型:读密集且 I/O 等待高 → 强烈建议升;纯写热点 OLTP → 收益有限,按需评估。
  2. 确认存储类型:云盘收益大,本地 NVMe 收益小,据此决定参数激进程度。
  3. 测试库先行:在与生产同配的测试库上,用第五节的方法跑基准对比,拿到你自己环境的真实数字,而不是信「3 倍」。
  4. 确认 io_uring 可用性:本地部署确认内核版本与编译选项;托管环境咨询云厂商是否开放。不可用就用默认 worker
  5. 参数分批调:先升级、保持默认 worker 模式跑稳,再逐步调 effective_io_concurrencyio_combine_limit,每次只动一个变量,用 pg_stat_io 观测。
  6. 升级后重审索引:借 skip scan 清理冗余单列索引。
  7. 新表用 uuidv7():新设计的分布式主键直接上 v7。
  8. 监控 pg_stat_io:把它加进你的日常监控面板,AIO 时代的 I/O 可观测性主要靠它。

九、总结与展望

PostgreSQL 18 的 AIO,是这个 30 年老数据库在 I/O 模型上迈出的关键一步。它的意义不只是「快了 2~3 倍」这个数字,而是架构层面的范式转变:从「进程阻塞等 I/O」变成「I/O 与计算流水线重叠」。

但作为工程师,我们要清醒:

  • 它目前是「读」的胜利,异步写还在路上,写热点负载别抱太高期待;
  • 它在云盘上收益最大,本地 NVMe 上要理性看待;
  • 它需要调参io_methodeffective_io_concurrencyio_combine_limit 这几个旋钮要配合监控收敛,不是开个开关就完事;
  • 3 倍是上限不是均值,一定要在自己的环境里实测。

再加上 skip scan、uuidv7()、虚拟生成列、更平滑的升级、OAuth 2.0 这些顺手红利,PG18 是近几年少有的「值得为它专门排一次升级窗口」的版本。

往前看,异步写一旦补齐,PG 的 I/O 模型才算真正完整——那时候 checkpoint 刷脏、WAL 落盘、批量写入都能吃到异步红利,写密集负载也会迎来自己的「3 倍时刻」。18 这一版打的地基,决定了后面几年 PostgreSQL 在性能上能盖多高的楼。

对我们这些天天跟 iowait 曲线搏斗的人来说,这道口子,撕得漂亮。


本文基于 PostgreSQL 18 正式版及 18.4 文档、官方发布新闻稿与社区技术资料整理,配置示例与基准方法请在你自己的测试环境中验证后再上生产。数据库无小事,实测为王。

推荐文章

git使用笔记
2024-11-18 18:17:44 +0800 CST
使用 Nginx 获取客户端真实 IP
2024-11-18 14:51:58 +0800 CST
Go 中的单例模式
2024-11-17 21:23:29 +0800 CST
程序员茄子在线接单