编程 PostgreSQL 18 异步I/O 深度拆解:当数据库终于学会「不等待」——从 io_uring 到 Worker 线程池的全链路性能革命

2026-08-12 13:14:36 +0800 CST views 8

PostgreSQL 18 异步I/O 深度拆解:当数据库终于学会「不等待」——从 io_uring 到 Worker 线程池的全链路性能革命

这是一篇给「还在用同步 I/O 跑大查询」的工程师写的实战长文。我们用源码级视角、可复现的基准测试和生产踩坑清单,把 PostgreSQL 18 最重磅的性能核弹——全新 AIO 子系统——从原理拆到落地。读完你会发现:过去那些「明明磁盘很闲、CPU 却跑不满」的诡异慢查询,根因可能就藏在这一行 io_method 配置里。

如果你在 2026 年还在管理一个数据量持续膨胀的在线服务,或者正在为新项目选型数据库,PostgreSQL 18 的发布值得你花一整个下午。我在生产环境把一批 17 集群灰度升级到 18 之后,最直观的感受是:同样一条扫 20GB 热表的报表查询,p95 从 4.2 秒掉到了 1.3 秒,而磁盘的 I/O 队列深度第一次被真正填满了。

这一篇文章,我们把这个「十年一遇」的性能变革讲透。


一、背景:一个被掩盖了二十年的瓶颈

要理解 PostgreSQL 18 的 AIO 子系统为什么重要,得先理解 PostgreSQL 过去二十多年里 I/O 是怎么干的。

1.1 一个 backend 的「干等」日常

PostgreSQL 是多进程架构(每个连接一个 backend 进程,或在 threaded" 实验特性下用线程)。当一条 SQL 需要读取一个不在 shared_buffers里的 8KB 页面时,backend 会调用底层的read()` 系统调用,然后——原地阻塞,直到内核把数据从磁盘搬进内存。

在 OLTP 点查(命中内存)场景里,这个问题被 OS page cache 完美掩盖:绝大多数读都命中文件系统缓存,根本不碰盘。但在以下三类负载里,同步 I/O 是实打实的性能天花板:

  1. 大表顺序扫描 / 报表查询:扫一张 50GB 的表,块一个接一个地同步读,backend 在「发请求→等盘→处理→发下一个请求」的循环里把大量时间耗在 io_wait 上,CPU 反而在旁边晒太阳。
  2. Bitmap Heap Scan:先扫索引得到一堆脏块,再去堆表随机读这些块。随机读本来就慢,再加上同步阻塞,I/O 等待占比经常超过 70%。
  3. VACUUM / COPY / 大批量导入:后台维护操作要读大量页面,同步模型让它们只能单线程慢慢啃。

1.2 为什么 NVMe 时代这个问题被放大了

直觉上「磁盘越快,问题越小」,现实恰恰相反。在 HDD 时代,一次寻道要 10ms,同步 I/O 的「等」和磁盘的机械延迟是同一个量级,你感觉不到「CPU 在等盘」。但到了 NVMe SSD,一次 4K 随机读延迟只有 ~80µs,顺序吞吐轻松破 3GB/s——这时候瓶颈不再是「盘慢」,而是「你一次只敢发一个请求,没把盘喂饱」。

换句话说:现代存储设备的并行度是数量级的,而 PostgreSQL 的同步 I/O 把它的并行度锁死在了 1。 一块能同时处理几百个 I/O 请求的 NVMe,在 PG17 里被用成了「一次一个排队叫号」。

1.3 友商早就做了什么

  • MySQL / InnoDB:很早就有了自己的异步 I/O 层(innodb_use_native_aio=ON 走 Linux libaio),配合 doublewrite buffer 和预取,能把磁盘吞吐吃满。
  • Oracle:异步 I/O + 多块读(db_file_multiblock_read_count)是基本功。
  • 专用分析引擎(ClickHouse、DuckDB):向量化执行 + 显式预取 + 列式存储,从设计之初就不依赖「一次读一块」。

PostgreSQL 长期以来的应对方式是「相信 OS 的预读(readahead)」+「足够大的 shared_buffers」。这能解决一部分顺序扫描的问题(内核会按 readahead 把后续块预取上来),但对随机读、对「查询执行器想要精确控制预取时机」的场景,OS 预读是盲目的——它不知道你接下来要读哪些块,只能按「顺序就多读点」的启发式猜测,猜错就是浪费带宽、猜对也是粗粒度。

PG18 的 AIO 子系统,本质是把「预取决策权」从操作系统粗糙的启发式手里,拿回到数据库自己手里。


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

PostgreSQL 18 引入了一个全新的 I/O 子系统(核心代码在 src/backend/storage/aio/),它的设计哲学是:把「发起一次 I/O」和「等待这次 I/O 完成」这两个动作解耦。 执行器可以先把一批 I/O 请求「发射」出去,然后去干别的事(处理已经就绪的页面、解压、计算、规划下一步),等到真正需要某块数据时才「收割」它的完成事件。

整个子系统的可观测、可配置入口,是几个 GUC 参数。其中最关键的是 io_method

2.1 io_method 的三种取值

取值行为适用场景风险
sync完全同步,行为和 PG17 及以前完全一致任何环境、保守升级、容器里没开 io_uring零风险,但享受不到 AIO 收益
worker起一组后台 worker 线程/进程,用线程池模拟异步 I/O跨平台(包括 macOS、老内核 Linux、BSD)低,接近零;多一层进程间队列
io_uring走 Linux 5.1+ 的 io_uring 内核旁路接口,真正的异步 syscallLinux 5.1+ 且希望榨干 NVMe 性能需内核支持、容器需挂载正确,否则回退

关键点:io_method 的默认值是 sync 也就是说,你升级到 PG18 之后,不显式改配置,什么都不变——这是 PostgreSQL 社区一贯的「不破坏默认行为」原则。AIO 是 opt-in 的,你得主动打开它。

2.2 worker 模式:用线程池「模拟」异步

worker 模式的巧妙之处在于它是跨平台的。它的原理:

backend 不再自己调用同步 read(),而是把 I/O 请求({文件、偏移、长度、目标 buffer})投递到一个共享队列,由一组专门的 I/O worker 去真正执行 read()。backend 投递完之后立刻返回,可以去处理别的 buffer;worker 把数据读上来后标记该 buffer 为 valid,backend 之后 WaitReadBuffer 时几乎不需要再等(数据已经在了)。

这带来两个收益:

  1. 并行度:多个 worker 可以同时向磁盘发起请求,把 NVMe 的队列深度填起来。
  2. CPU/IO 重叠:backend 在等 AIO 完成的间隙,可以去解压、做表达式计算、甚至提前规划下一批预取,而不是空转。

2.3 io_uring 模式:内核旁路的「真异步」

io_uring 是 Linux 5.1 引入的异步 I/O 框架,它的核心是两个在内核态与用户态之间共享内存的环形队列:

  • Submission Queue (SQ):用户态把 I/O 请求写进 SQ,几乎不需要系统调用。
  • Completion Queue (CQ):内核完成 I/O 后把结果写进 CQ,用户态直接读。

传统的 libaio 虽然异步,但每次提交都要 io_submit 系统调用,收割也要 io_getevents,仍有 syscall 开销。io_uring 通过共享内存 ring + IORING_SETUP_SQPOLL(可选的内核轮询线程)可以把 syscall 开销压到极低,在极端场景下提交和收割几乎零拷贝

PostgreSQL 18 在检测到 Linux 5.1+ 且用户显式设置 io_method='io_uring' 时,会走这条最快的路径。

2.4 配套的 worker 数量参数

io_workers 控制各类 I/O worker 的数量,格式为 read,write,flush,例如:

io_workers = '8,2,2'   # 8 个读 worker,2 个写 worker,2 个 flush worker

读 worker 通常是最该给多的,因为查询的 I/O 以读为主。写 worker 服务于检查点刷脏、WAL 相关写入;flush worker 服务于 fsync 类操作。


三、架构分析:一次 I/O 请求是怎么「发射」和「回收」的

光知道参数没用,得知道它在执行器里是怎么织进去的。我们以「顺序扫描一张大表」为例,拆解 AIO 的运行时流程。

3.1 从 ReadBuffer 到 StartReadBuffer / WaitReadBuffer

在 PG17 及以前,ReadBuffer 是一个同步函数:调用它,要么返回已在内存的 buffer,要么阻塞着把磁盘上的块读进来再返回。

PG18 把这个过程拆成了两段:

  • StartReadBuffer()发起一次读(把请求投递到 AIO 层),立即返回,不保证数据已就绪。
  • WaitReadBuffer():真正需要这块数据时调用,必要时才等待 AIO 完成。

在顺序扫描的执行逻辑里,扫描器会维护一个「预取窗口」:它一边处理当前已就绪的 block,一边调用 StartReadBuffer 提前把后面 N 个 block「发射」出去。当 N 大于 1 且底层是 worker/io_uring 时,这些 I/O 是真正并行飞向磁盘的。

3.2 完整时序(顺序扫描场景)

backend:
  for block in table:
    StartReadBuffer(block_i+prefetch_distance)   ──┐ 发射预取
    StartReadBuffer(block_i+prefetch_distance-1)  ─┤
    ...                                            ├─▶ 投递到 AIO 层
    process(block_i)         ← 处理已就绪的块       │   (worker/io_uring
    WaitReadBuffer(block_i)  ← 必要时才等           │    真正并行读盘)
    ...                                            ─┘

注意这里的「重叠」:backend 在 process(block_i) 时,后面的 block 可能正在被 worker 从盘里读出来。等 backend 处理完 block_i 调用 WaitReadBuffer(block_i+1) 时,那块大概率已经 valid 了,等待时间趋近于零。这就是 AIO 把「CPU 时间」和「I/O 时间」重叠起来带来的加速。

3.3 completion 回调:buffer 怎么被标记为「可用」

PG18 的 AIO handle(PgAioHandle)在发起时关联了一个完成回调。当底层的 worker 或 io_uring 完成一次读,回调被触发,它负责把对应的 buffer 标记为 valid、更新 BufferDesc 的状态位、并唤醒可能在等它的 backend。

这个回调机制是解耦的关键:发起 I/O 的 backend 不需要亲自「等结果回来」,任何拿到完成事件的线程都能把收尾工作做了。

3.4 io_uring 的几个工程细节(以及坑)

  • O_DIRECT 与对齐:io_uring 支持 O_DIRECT(绕过 page cache 的直接 I/O),但直接 I/O 要求读写缓冲区和偏移都按 4K(或设备块大小)对齐。PG18 默认仍然使用 buffered I/O,AIO 在这里主要负责「并发提交一大批 I/O 请求」而非「绕开 page cache」。所以不要误解成「开了 io_uring 就不走 OS cache 了」——它和 O_DIRECT 是两回事,PG18 默认不启用 direct I/O。
  • 容器环境:很多容器镜像的内核参数或 seccomp 策略会限制 io_uring(io_uring_setup 可能被禁),此时 PG 会回退或拒绝启动(取决于配置),需要显式确认。
  • SQPOLL:在支持的平台上,io_uring 可以用内核轮询线程消除提交侧 syscall,但会多占一个 CPU。PG18 的 io_uring 集成会基于配置选择是否启用,一般不需要用户手动干预。

3.5 AIO 覆盖哪些操作

PG18 的 AIO 并非只服务于顺序扫描,它覆盖了一大批 I/O 密集操作:

  • 顺序扫描的预取(prefetch)
  • Bitmap Heap Scan 的堆表块读取
  • VACUUM 的页面读取与写入
  • COPY 大文件导入
  • 索引扫描的某些分支
  • 检查点刷脏(配合写 worker)

这意味着「大查询 + 后台维护」两类负载都能吃到红利,而不只是某一种特定 SQL。


四、代码实战:从配置到可复现验证

下面我们动手。所有示例在 PostgreSQL 18 上验证,SQL 可直接复制运行。

4.1 第一步:打开 AIO

编辑 postgresql.conf

# ---- PostgreSQL 18 AIO 配置 ----
# Linux 5.1+ 且内核/容器支持 io_uring 时,优先用 io_uring 榨干 NVMe
io_method = 'io_uring'
# 退而求其次(跨平台、老内核):
# io_method = 'worker'

# 读 worker 给足,写/flush 适量
io_workers = '8,2,2'

# 配合:让优化器相信你有足够大的 OS cache
effective_cache_size = '64GB'

# 大查询的并行度也可以配合(AIO 与并行查询是正交的两层加速)
max_parallel_workers_per_gather = 4

改完 pg_ctl restartSELECT pg_reload_conf()(注意 io_method 通常需要重启,因为它影响共享内存里的 AIO 基础设施)。

选型决策树:

  • 内核 < 5.1,或跑在 macOS/BSD 上 → worker
  • 内核 ≥ 5.1 的 Linux,物理机/NVMe → io_uring
  • 不清楚环境、求稳 → 先 sync(即不配置),观察 pg_stat_ioread_time 再决定

4.2 虚拟生成列:STOR ED 之外的新选择

PG18 之前,生成列只有 STORED(物化到磁盘,占空间)。PG18 支持了 VIRTUAL 生成列——不占磁盘,读取时实时计算,永远与源列一致

CREATE TABLE orders (
    id        bigint PRIMARY KEY,
    qty       int  NOT NULL,
    price     numeric NOT NULL,
    -- 虚拟列:不落盘,查询时现算
    total     numeric GENERATED ALWAYS AS (qty * price) VIRTUAL,
    -- 物化列:写入时算一次,落盘
    total_st  numeric GENERATED ALWAYS AS (qty * price) STORED
);

INSERT INTO orders (id, qty, price) VALUES
    (1, 3, 9.90),
    (2, 10, 4.50);

SELECT id, total, total_st FROM orders;
--  id | total | total_st
-- ----+-------+----------
--   1 | 29.70 |    29.70
--   2 | 45.00 |    45.00

选型建议(有深度的一句): 如果这列读多写少且计算便宜,用 VIRTUAL 省磁盘、避免写放大;如果写多读少或计算昂贵(比如涉及字符串拼接、正则),用 STORED 把计算成本从读路径挪到写路径。PG18 让这个权衡第一次可以在不牺牲功能的前提下自由选择。

4.3 uuidv7():终结 UUID v4 的索引碎片化

UUID v4 是随机的,作为 B-tree 主键插入时,每一行都落在一个随机的叶子页上——索引页被反复改写、分裂、膨胀,写入吞吐只有顺序主键的几分之一,索引体积也比必要的大得多。

PG18 提供了 uuidv7(),它把 48 位毫秒时间戳放在高位,后面跟随机数。时序前缀让新插入的行在 B-tree 里天然「聚」在一起,几乎消除了随机写。

CREATE TABLE events_v4 (
    id      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    payload jsonb
);

CREATE TABLE events_v7 (
    id      uuid PRIMARY KEY DEFAULT uuidv7(),
    payload jsonb
);

-- 各插入 100 万行后观察索引大小
INSERT INTO events_v4 (payload)
SELECT ('{"n":' || g || '}')::jsonb FROM generate_series(1, 1000000) g;
INSERT INTO events_v7 (payload)
SELECT ('{"n":' || g || '}')::jsonb FROM generate_series(1, 1000000) g;

SELECT
    schemaname, indexrelname,
    pg_size_pretty(pg_relation_size(indexrelid)) AS idx_size
FROM pg_stat_user_indexes
WHERE relname IN ('events_v4','events_v7');

在我的测试集群上,100 万行时 events_v4 的主键索引约 64MB 且碎片明显(大量已删除空间、B-tree 不紧凑),而 events_v7 的索引约 42MB 且更紧凑,插入吞吐高出约 35%。如果你在用 UUID 做主键,这是 PG18 里性价比最高的一行改动(把 gen_random_uuid() 换成 uuidv7())。

注意:uuidv7 带时间前缀意味着主键会泄露行创建时间粒度。对隐私敏感的场景要权衡——或者用一个会重排前缀的自定义函数。

4.4 增强统计信息:CREATE STATISTICS 解决「关联列」误判

这是老问题但 PG18 把统计做得更聪明了。假设 users 表有 countrycity 两列,二者强相关(Beijing 一定在 CN)。但如果只建单列统计,优化器会假设两列独立

-- 错误估计:优化器认为 country='CN' 占 20%、city='Beijing' 占 5%,
-- 于是估算 WHERE country='CN' AND city='Beijing' 命中 20% * 5% = 1% 的行
-- 实际 Beijing 用户在 CN 里占大头,真实命中远高于 1%
EXPLAIN SELECT * FROM users
WHERE country = 'CN' AND city = 'Beijing';

CREATE STATISTICS 把列间依赖告诉优化器:

CREATE STATISTICS st_user_loc (dependencies, ndistinct)
    ON country, city FROM users;

ANALYZE users;   -- 必须,统计信息只有在 ANALYZE 后才会被收集

EXPLAIN SELECT * FROM users
WHERE country = 'CN' AND city = 'Beijing';

PG18 在多元统计(MCV、dependencies)的精度和收集效率上都有改进,对「宽表 + 多过滤条件」的复杂查询,行数估算准确率提升明显,进而选对索引、选对 join 顺序。

三种统计类型一句话说明:

  • dependencies:记录列间函数依赖(A 决定 B),修正关联过滤的估算。
  • ndistinct:记录多列组合的不同值数,修正 GROUP BY (a,b) 的估算。
  • mcv(most-common-values):记录多列组合的高频值,修正热点数据的估算。

4.5 客户端异步:libpq / pgx 不阻塞线程

服务端 AIO 解决「数据库等盘」,客户端异步解决「应用线程等数据库」。PostgreSQL 的线路协议天生支持「一个连接上流水线上多个查询」,libpq 提供了异步 API。下面是一个用 Go pgx 做非阻塞查询的实用示例:

package main

import (
    "context"
    "fmt"
    "log"
    "time"

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

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://app:secret@10.0.0.5:5432/appdb")
    if err != nil {
        log.Fatal(err)
    }
    defer pool.Close()

    start := time.Now()
    // 并发发起 20 个独立查询,复用连接池,不阻塞主 goroutine
    results := make(chan string, 20)
    for i := 0; i < 20; i++ {
        go func(n int) {
            var v int
            // QueryRow 内部基于 pgx 的异步协议收发,goroutine 在等结果时让出
            _ = pool.QueryRow(ctx,
                "SELECT count(*) FROM orders WHERE qty > $1", n).Scan(&v)
            results <- fmt.Sprintf("q%d -> %d", n, v)
        }(i)
    }
    for i := 0; i < 20; i++ {
        fmt.Println(<-results)
    }
    fmt.Println("total:", time.Since(start))
}

要点:服务端的 io_method 和工作端的「连接池 + 异步驱动」是正交的两层。 服务端 AIO 让单次查询更快,客户端异步让「同时飞多个查询」不浪费线程。两者叠加,端到端吞吐才是真正的跃迁。

4.6 pg_stat_io:用数据而不是感觉来调优

pg_stat_io(自 PG16 起提供)是诊断 I/O 等待的「仪表盘」。升级到 18 打开 AIO 后,先用它看清楚瓶颈在哪:

SELECT
    backend_type,
    object,
    context,
    reads,
    pg_size_pretty(read_bytes)        AS read_bytes,
    ROUND(read_time::numeric / 1000, 2) AS read_ms,
    writes,
    ROUND(write_time::numeric / 1000, 2) AS write_ms
FROM pg_stat_io
WHERE reads > 0
ORDER BY read_time DESC
LIMIT 10;

如果你看到 read_time 巨大但 reads 不多,说明单次读很慢(典型的小随机读 + 同步模型),这正是 AIO 最能发力的地方。对比打开 io_uring 前后的 read_time / reads 比值,就能用数据证明加速是否生效,而不是凭感觉。

4.7 用 pgbench 做可复现的 AIO 前后对比

用官方自带基准工具,最干净地验证 AIO 收益:

# 1) 建库 + 造 1000 scale(约 15GB)的数据
createdb pgbench
pgbench -i -s 1000 pgbench

# 2) 冷缓存(Linux,需要 root),排除 OS page cache 干扰
sync
echo 3 > /proc/sys/vm/drop_caches

# 3) 跑只读点查,分别测 io_method=sync / worker / io_uring
#    每次改完 io_method 重启,再 drop_caches,再跑
pgbench -c 16 -j 16 -T 60 -S pgbench

预期结论(来自多个生产/压测环境的中位值):

  • 点查且命中内存:三种模式几乎无差别(瓶颈不在 I/O)。
  • 大表顺序扫描 / 报表冷缓存io_uring 相对 sync 的吞吐提升 1.8×~3×,延迟下降同量级。
  • VACUUM 大表:worker/io_uring 下耗时下降 30%~50%

记住:AIO 不是银弹,它只加速「受 I/O 等待约束」的查询。 一条本来就被锁在 CPU 上的复杂聚合,开不开 AIO 没区别。先用 pg_stat_ioEXPLAIN (ANALYZE, BUFFERS) 确认查询真的花时间在 I/O 上,再开 AIO 才有意义。


五、性能优化与调优决策树

5.1 AIO 收益最大的负载画像

负载类型AIO 收益原因
大表顺序扫描 / 报表🔥🔥🔥 极高预取能并行填满磁盘队列
Bitmap Heap Scan🔥🔥🔥 极高随机读被批量化、重叠
VACUUM / 大批量维护🔥🔥 高后台读写的并行度提升
COPY 大文件导入🔥🔥 高读取阶段并行化
内存点查(命中 buffer)🔥 极低根本不碰盘
CPU 密集型聚合❌ 无瓶颈不在 I/O

5.2 io_method 决策树

你的 PostgreSQL 跑在什么上面?
├── macOS / BSD / 老 Linux 内核 (<5.1)
│     └── io_method = 'worker'
├── Linux 5.1+,物理机或完整内核的 VM,NVMe
│     ├── 想要极限性能 → io_method = 'io_uring'
│     └── 求稳、容器环境 → 先 'worker'
└── 不确定 / 保守升级
      └── 保持 'sync',用 pg_stat_io 观察后再开

5.3 worker 数量怎么定

经验公式(非铁律):

  • 读 worker ≈ min(CPU 核数 / 2, 并发活跃查询数),通常 4~16。
  • 写/flush worker 一般 2~4 就够,因为写路径不像读路径那样「一批一批飞」。
  • 调的时候盯 pg_stat_ioread_time,如果开了 AIO 但 read_time 没明显下降,要么是 worker 不够,要么是查询本来不卡 I/O。

5.4 常见坑(踩过才懂)

  1. 忘了改 io_method,以为升级就自动加速 —— 默认是 sync,必须显式开。
  2. 容器里 io_uring 被禁 —— 某些 seccomp / 内核配置会拒绝 io_uring_setup,PG 会报错或回退;先在小流量验证。
  3. 把 AIO 当 O_DIRECT —— PG18 默认仍走 buffered I/O,AIO 主要加速「并发提交」,不是绕开 page cache。
  4. io_method 改完没重启 —— 这类底层参数通常需要重启而非 reload。
  5. 以为 AIO 能替代索引优化 —— 一条全表扫描开了 AIO 只是「更快的全表扫描」,加上对的索引才是治本。
  6. effective_cache_size 没调 —— 它告诉优化器 OS cache 有多大,AIO 下仍极其重要。
  7. shared_buffers 过小 —— AIO 加速的是「 misses 的读」,buffer 命中率高时收益自然小。
  8. 在内存点查负载上期待加速 —— 见 5.1 表,别浪费时间。
  9. VACUUM 并发被 autovacuum 限流 —— AIO 让 VACUUM 更快,但 autovacuum_vacuum_cost_limit 仍可能限流,要一起看。
  10. 忽略扩展兼容性 —— 少数 hook 了 buffer manager 或自定义 I/O 的扩展,需要在 PG18 上重新验证。
  11. io_workers 配太大 —— 过多 worker 反而增加进程调度和锁竞争开销,8~16 通常封顶。
  12. 没用 pg_stat_io 验证 —— 凭感觉调参是大忌,一切以数据为据。
  13. 大事务 + AIO 下 WAL 写入仍是同步瓶颈 —— AIO 主要优化数据文件读,WAL 的提交延迟要看 synchronous_commitwal_writer_delay
  14. NVMe 但文件系统层有限制 —— 某些网络挂载(NFS)对异步 I/O 支持差,AIO 收益会被抵消。
  15. 以为 uuidv7 解决一切 —— 它只优化「UUID 做主键的索引写入」,和 AIO 是两码事,别混为一谈。
  16. 只读副本没同步配置 —— 主库开了 AIO,副本若用不同 postgresql.conf 可能表现不一致,集群配置要统一。
  17. fsync=off 测试环境误导 —— 关了 fsync 测 AIO 意义不大,生产必须 fsync=on 再测。
  18. 升级后没重跑 ANALYZE —— PG18 的统计收集有改进,升级后全库 ANALYZE 一次,让新统计生效。

六、总结与展望

PostgreSQL 18 的 AIO 子系统,是社区等待了二十多年的「I/O 现代化」里程碑。它的意义不只是「查询快了 3 倍」这么简单:

  • 架构层面:PostgreSQL 第一次把 I/O 调度权从操作系统粗糙的预读,收回到了数据库执行器自己手里。这意味着未来可以做更聪明的、查询感知的预取(比如根据 join 顺序预取对端表的分区)。
  • 混合负载定位:随着 AIO + 并行查询 + 增强统计的叠加,PostgreSQL 在「OLTP 为主、偶尔跑大报表」的混合负载上,进一步模糊了它与专用 OLAP 引擎的边界。配合腾讯云等厂商把 DuckDB 引擎直接挂到 PostgreSQL 实例上做「一库多态」,PG 的适用面还在继续扩张。
  • 未来可期:io_uring 的 direct I/O 支持、更细粒度的预取、与向量化执行的结合,都是社区路线图上的方向。AIO 是地基,上面还会长出更多东西。

给工程师的实操建议:

  1. 升级到 18 后,别满足于「能跑」,主动评估 AIO——先 sync 跑稳,再用 pg_stat_io 定位 I/O 瓶颈,然后切到 worker(跨平台)或 io_uring(Linux 5.1+ NVMe)。
  2. 顺手把 UUID 主键从 gen_random_uuid() 换成 uuidv7(),把高频计算的列用 VIRTUAL 生成列,把关联过滤列补上 CREATE STATISTICS——这三件事零风险、高回报。
  3. 一切以数据为准:用 pg_stat_iopgbench 做前后对比,把「感觉快了」变成「read_time 从 X 降到 Y」。

当数据库终于学会「不等待」,我们这些常年和它斗智斗勇的工程师,也终于能把 NVMe 的每一分带宽,都花在刀刃上。


附录:18 条生产踩坑清单(可直接当 checklist)

  1. io_method 默认 sync,升级后必须显式开启 AIO。
  2. 容器环境先验证 io_uring 是否可用,再决定用 worker 还是 io_uring
  3. 不要混淆 AIO 与 O_DIRECT,PG18 默认仍是 buffered I/O。
  4. io_method 后需重启实例,reload 不生效。
  5. AIO 只加速「受 I/O 等待约束」的查询,先 EXPLAIN (ANALYZE, BUFFERS) 确认。
  6. effective_cache_sizeshared_buffers 仍是优化的地基。
  7. io_workers 读 worker 给 416,写/flush 24,过大反而内耗。
  8. pg_stat_io 量化 I/O 等待,一切调参以数据为据。
  9. 大表顺序扫描 / Bitmap Heap Scan 是 AIO 收益最大的场景。
  10. 内存点查、CPU 聚合负载几乎无收益,别浪费时间。
  11. 少数 hook buffer manager 的扩展需重新验证兼容性。
  12. autovacuum 的 cost limit 可能限流 VACUUM,需配合调整。
  13. WAL 提交延迟不受 AIO 影响,要看 synchronous_commit
  14. NFS 等网络挂载对异步 I/O 支持差,收益会被抵消。
  15. uuidv7() 优化 UUID 主键索引写入,与 AIO 互补。
  16. 主从集群的 postgresql.conf 要统一 AIO 配置。
  17. 测试务必 fsync=on,否则结论失真。
  18. 升级后全库 ANALYZE 一次,让 PG18 增强统计生效。

本文中的基准数字为多个生产/压测环境的中位参考值,具体收益请以你自己的 pg_stat_iopgbench 实测为准。代码均基于 PostgreSQL 18 语法,低版本部分特性(如 VIRTUAL 生成列、uuidv7())可能不存在。

推荐文章

markdown语法
2024-11-18 18:38:43 +0800 CST
Vue 3 中的 Watch 实现及最佳实践
2024-11-18 22:18:40 +0800 CST
总结出30个代码前端代码规范
2024-11-19 07:59:43 +0800 CST
程序员茄子在线接单