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 是实打实的性能天花板:
- 大表顺序扫描 / 报表查询:扫一张 50GB 的表,块一个接一个地同步读,backend 在「发请求→等盘→处理→发下一个请求」的循环里把大量时间耗在
io_wait上,CPU 反而在旁边晒太阳。 - Bitmap Heap Scan:先扫索引得到一堆脏块,再去堆表随机读这些块。随机读本来就慢,再加上同步阻塞,I/O 等待占比经常超过 70%。
- 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走 Linuxlibaio),配合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 内核旁路接口,真正的异步 syscall | Linux 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 时几乎不需要再等(数据已经在了)。
这带来两个收益:
- 并行度:多个 worker 可以同时向磁盘发起请求,把 NVMe 的队列深度填起来。
- 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 restart 或 SELECT pg_reload_conf()(注意 io_method 通常需要重启,因为它影响共享内存里的 AIO 基础设施)。
选型决策树:
- 内核 < 5.1,或跑在 macOS/BSD 上 →
worker - 内核 ≥ 5.1 的 Linux,物理机/NVMe →
io_uring - 不清楚环境、求稳 → 先
sync(即不配置),观察pg_stat_io的read_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 表有 country 和 city 两列,二者强相关(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_io 和 EXPLAIN (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_io的read_time,如果开了 AIO 但read_time没明显下降,要么是 worker 不够,要么是查询本来不卡 I/O。
5.4 常见坑(踩过才懂)
- 忘了改
io_method,以为升级就自动加速 —— 默认是sync,必须显式开。 - 容器里 io_uring 被禁 —— 某些 seccomp / 内核配置会拒绝
io_uring_setup,PG 会报错或回退;先在小流量验证。 - 把 AIO 当 O_DIRECT —— PG18 默认仍走 buffered I/O,AIO 主要加速「并发提交」,不是绕开 page cache。
io_method改完没重启 —— 这类底层参数通常需要重启而非 reload。- 以为 AIO 能替代索引优化 —— 一条全表扫描开了 AIO 只是「更快的全表扫描」,加上对的索引才是治本。
effective_cache_size没调 —— 它告诉优化器 OS cache 有多大,AIO 下仍极其重要。shared_buffers过小 —— AIO 加速的是「 misses 的读」,buffer 命中率高时收益自然小。- 在内存点查负载上期待加速 —— 见 5.1 表,别浪费时间。
- VACUUM 并发被 autovacuum 限流 —— AIO 让 VACUUM 更快,但
autovacuum_vacuum_cost_limit仍可能限流,要一起看。 - 忽略扩展兼容性 —— 少数 hook 了 buffer manager 或自定义 I/O 的扩展,需要在 PG18 上重新验证。
io_workers配太大 —— 过多 worker 反而增加进程调度和锁竞争开销,8~16 通常封顶。- 没用
pg_stat_io验证 —— 凭感觉调参是大忌,一切以数据为据。 - 大事务 + AIO 下 WAL 写入仍是同步瓶颈 —— AIO 主要优化数据文件读,WAL 的提交延迟要看
synchronous_commit与wal_writer_delay。 - NVMe 但文件系统层有限制 —— 某些网络挂载(NFS)对异步 I/O 支持差,AIO 收益会被抵消。
- 以为 uuidv7 解决一切 —— 它只优化「UUID 做主键的索引写入」,和 AIO 是两码事,别混为一谈。
- 只读副本没同步配置 —— 主库开了 AIO,副本若用不同
postgresql.conf可能表现不一致,集群配置要统一。 fsync=off测试环境误导 —— 关了 fsync 测 AIO 意义不大,生产必须fsync=on再测。- 升级后没重跑 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 是地基,上面还会长出更多东西。
给工程师的实操建议:
- 升级到 18 后,别满足于「能跑」,主动评估 AIO——先
sync跑稳,再用pg_stat_io定位 I/O 瓶颈,然后切到worker(跨平台)或io_uring(Linux 5.1+ NVMe)。 - 顺手把 UUID 主键从
gen_random_uuid()换成uuidv7(),把高频计算的列用VIRTUAL生成列,把关联过滤列补上CREATE STATISTICS——这三件事零风险、高回报。 - 一切以数据为准:用
pg_stat_io和pgbench做前后对比,把「感觉快了」变成「read_time 从 X 降到 Y」。
当数据库终于学会「不等待」,我们这些常年和它斗智斗勇的工程师,也终于能把 NVMe 的每一分带宽,都花在刀刃上。
附录:18 条生产踩坑清单(可直接当 checklist)
io_method默认sync,升级后必须显式开启 AIO。- 容器环境先验证 io_uring 是否可用,再决定用
worker还是io_uring。 - 不要混淆 AIO 与 O_DIRECT,PG18 默认仍是 buffered I/O。
- 改
io_method后需重启实例,reload 不生效。 - AIO 只加速「受 I/O 等待约束」的查询,先
EXPLAIN (ANALYZE, BUFFERS)确认。 effective_cache_size、shared_buffers仍是优化的地基。io_workers读 worker 给 416,写/flush 24,过大反而内耗。- 用
pg_stat_io量化 I/O 等待,一切调参以数据为据。 - 大表顺序扫描 / Bitmap Heap Scan 是 AIO 收益最大的场景。
- 内存点查、CPU 聚合负载几乎无收益,别浪费时间。
- 少数 hook buffer manager 的扩展需重新验证兼容性。
- autovacuum 的 cost limit 可能限流 VACUUM,需配合调整。
- WAL 提交延迟不受 AIO 影响,要看
synchronous_commit。 - NFS 等网络挂载对异步 I/O 支持差,收益会被抵消。
uuidv7()优化 UUID 主键索引写入,与 AIO 互补。- 主从集群的
postgresql.conf要统一 AIO 配置。 - 测试务必
fsync=on,否则结论失真。 - 升级后全库
ANALYZE一次,让 PG18 增强统计生效。
本文中的基准数字为多个生产/压测环境的中位参考值,具体收益请以你自己的
pg_stat_io与pgbench实测为准。代码均基于 PostgreSQL 18 语法,低版本部分特性(如VIRTUAL生成列、uuidv7())可能不存在。