PostgreSQL 18 异步 I/O 深度实战:从「同步傻等」到 io_uring,一次全表扫描凭什么提速 3 倍
如果你维护过一个数据量上 TB 的 PostgreSQL 实例,一定经历过这样的深夜:一条看似普通的分析 SQL,全表扫描跑了十几分钟,
iostat里磁盘吞吐却只有物理上限的三分之一,CPU 也闲着。你盯着监控发呆——瓶颈到底在哪?答案往往不是磁盘不够快,而是数据库在「一次读一块、读完再等下一块」地傻等。PostgreSQL 18 用一套全新的异步 I/O 子系统,把这个困扰了社区二十年的老问题,从根上动了刀。
这篇文章不谈发布说明式的功能罗列,而是从一个后端工程师的实用视角,把 PostgreSQL 18 最值得关注的几处硬核改进讲透:异步 I/O(AIO)到底改了什么、io_uring 为什么是关键、Skip Scan 如何救活「建错的索引」、uuidv7() 为什么能让你的主键重新变快,以及原生向量能力落地时的真实边界。每一处都配可复现的验证方法和代码。
一、背景:PostgreSQL 的 I/O 模型欠了二十年的债
要理解 18 的改动有多大,先得看清楚老模型的病灶在哪。
在 PostgreSQL 18 之前,无论你的存储是 NVMe SSD 还是云上高性能云盘,数据库读取数据块(block,默认 8KB)的方式本质上是同步阻塞的。它的执行逻辑简化后是这样:
loop:
发起 read(block_n) # 系统调用
阻塞等待内核返回数据 # ← 关键:进程在这里干等
处理 block_n
n = n + 1
问题就出在「阻塞等待」这一步。现代 NVMe SSD 的特性是高吞吐、深队列——你得同时喂给它几十上百个 I/O 请求,它才能把内部多个闪存通道全部跑满。但同步模型一次只发一个请求,等它回来再发下一个,队列深度永远是 1。这就好比你有一条八车道高速,却只让一辆车上路,等它开到终点再放第二辆。
社区过去用了两个「补丁」来缓解,但都没解决根本问题:
补丁一:操作系统预读(readahead)。 依赖内核检测到顺序读模式后主动预取后续块。但它对随机读几乎无效,对 PostgreSQL 的很多访问模式(比如索引扫描回表、bitmap heap scan)帮助有限,而且数据库无法精确控制。
补丁二:effective_io_concurrency + posix_fadvise。 从 9.x 引入,主要作用于 bitmap heap scan,通过 POSIX_FADV_WILLNEED 提示内核预取。但它是「建议」而非「命令」,覆盖场景窄,很多路径根本用不上。
结果就是:在带宽充足的存储上,PostgreSQL 常常跑不满 I/O。VACUUM、顺序扫描、大表分析——这些吃 I/O 的操作,瓶颈不在硬件,而在数据库自己的读取节奏。这就是 18 要还的债。
二、核心变化:AIO 子系统与三种 I/O 方法
PostgreSQL 18 引入了一个统一的异步 I/O 抽象层。它的核心思想很直接:把「发起 I/O」和「等待 I/O 完成」解耦。数据库可以一口气把几十个读请求塞进队列,然后继续干别的活,等数据陆续回来再处理。队列深度上去了,NVMe 的多通道并行能力才被真正激活。
2.1 io_method 参数:三选一
18 新增了核心参数 io_method,控制底层用哪种机制实现异步:
| io_method | 机制 | 适用场景 |
|---|---|---|
sync | 退化为旧的同步行为 | 兼容 / 排障对照 |
worker | 一组后台 I/O worker 进程代发 I/O | 默认值,跨平台通用 |
io_uring | Linux 内核原生异步接口 | Linux 5.1+,性能天花板 |
先看当前配置:
SHOW io_method;
-- 默认: worker
SHOW io_workers;
-- 默认: 3,控制 worker 模式下的后台 I/O 进程数
worker 模式是默认选择,因为它不依赖特定内核特性,在所有平台都能跑。它的原理是主进程把 I/O 请求丢给一组专门的 I/O worker 进程,这些进程并行地去执行实际的 pread,完成后通过共享内存通知回主进程。相当于用「多进程」模拟了异步。
io_uring 模式才是真正的杀手锏。它直接用 Linux 内核的 io_uring 接口——这是 2019 年进入内核(5.1)的现代异步 I/O 框架,通过一对共享内存的环形队列(提交队列 SQ + 完成队列 CQ)实现用户态与内核态之间近乎零系统调用开销的批量 I/O。它没有 worker 模式的进程间通信开销,队列深度也能压得更满。
要启用它(编译时需带 --with-liburing,主流发行版官方包一般已带):
# postgresql.conf
io_method = io_uring
改完需要重启实例(这是 postmaster 级参数,不能热加载):
sudo systemctl restart postgresql-18
# 验证
psql -c "SHOW io_method;"
2.2 到底快多少?自己动手测
官方宣称顺序读场景最高 3 倍提升。别信宣传,我们自己造数据验证。先建一张足够大、能溢出内存缓存的表:
-- 建一张约 5GB 的表,确保远大于 shared_buffers
CREATE TABLE bench_scan (
id bigint,
payload text
);
INSERT INTO bench_scan
SELECT g, repeat('x', 200)
FROM generate_series(1, 20000000) AS g;
-- 关键:清空 OS page cache 才能测出真实磁盘读
-- (Linux, 需 root)
-- sync && echo 3 > /proc/sys/vm/drop_caches
然后用 EXPLAIN (ANALYZE, BUFFERS) 分别在 io_method = sync 和 io_method = io_uring 下跑同一条全表扫描:
SET max_parallel_workers_per_gather = 0; -- 先关并行,隔离 I/O 变量
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT count(*) FROM bench_scan WHERE payload LIKE '%zzz%';
在一台配 NVMe SSD 的机器上,典型结果对比(数值随硬件浮动,重点看趋势):
-- io_method = sync
Execution Time: 41230.551 ms
Buffers: shared read=641026
实测磁盘吞吐 ~ 130 MB/s (远低于盘的极限)
-- io_method = io_uring
Execution Time: 15840.207 ms
Buffers: shared read=641026
实测磁盘吞吐 ~ 380 MB/s (逼近盘的顺序读极限)
读的数据块数量完全一样(shared read=641026),但执行时间差了 2.6 倍——差距全部来自 I/O 调度效率。这就是异步的价值:同样的活,把硬件喂饱了。
2.3 一个容易被忽略的观测点:新增等待事件
18 还增强了可观测性。异步 I/O 引入了新的等待事件,你可以在 pg_stat_activity 里看到 backend 到底卡在哪:
SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE wait_event_type IS NOT NULL
AND backend_type = 'client backend';
在异步 I/O 密集时,你会观察到 IO / AioIoCompletion 之类的等待事件(表示在等异步读完成),而不再是清一色的同步 DataFileRead。这对定位「到底是磁盘慢还是调度问题」非常关键。
三、VACUUM 与维护操作:沉默的最大受益者
很多人只盯着查询变快,却忽略了 AIO 对后台维护操作的意义,而这往往才是生产环境最疼的地方。
VACUUM 本质上是一次巨大的顺序读:它要扫过表的每个数据页去清理死元组。在旧模型下,一次大表 VACUUM 就是「读一页、等一页」的漫长同步过程,经常拖到几个小时,还容易和业务读写抢 I/O。
18 的 AIO 让 VACUUM 的读取阶段能批量预取。配合 18 里同样得到强化的 vacuum 相关参数,大表维护窗口能明显缩短。一个实用的观测方法是打开 VACUUM 的详细进度:
-- 会话级开启详细输出
VACUUM (VERBOSE, ANALYZE) bench_scan;
-- 或实时观察进度视图
SELECT
p.pid,
p.phase,
p.heap_blks_total,
p.heap_blks_scanned,
round(100.0 * p.heap_blks_scanned / NULLIF(p.heap_blks_total, 0), 1) AS pct
FROM pg_stat_progress_vacuum p;
在开启 io_uring 后对比同一张大表的 VACUUM 耗时,你会发现 heap_blks_scanned 的增长速率明显更快——因为读不再是瓶颈。
给运维的实操建议: 如果你的实例有夜间批量 VACUUM/ANALYZE 任务,升级 18 并切到 io_uring 后,务必重新压测维护窗口,通常可以把窗口收窄,或者把 autovacuum_vacuum_cost_limit 适当调高,让 autovacuum 更激进地利用富余的 I/O 能力:
# 富余 I/O 时让 autovacuum 更快
autovacuum_vacuum_cost_limit = 2000 # 默认 200,视硬件上调
autovacuum_max_workers = 5
四、Skip Scan:救活「前导列选错」的复合索引
I/O 之外,18 在查询规划器上也有一处让人拍大腿的改进:Skip Scan(跳跃扫描)。
4.1 一个经典的「索引白建了」场景
假设你有张千万级的订单表,建了个复合索引:
CREATE TABLE orders (
status text, -- 只有 'paid'/'shipped'/'done' 等少数几种
order_no bigint,
amount numeric,
created timestamptz
);
CREATE INDEX idx_orders ON orders (status, order_no);
复合索引有个铁律:查询必须带上前导列(这里是 status),索引才用得上。如果你的查询是:
SELECT * FROM orders WHERE order_no = 123456;
没带 status,在 18 之前,规划器基本只能全表扫描——哪怕 order_no 就在索引里。很多线上慢查询的根因就是这个:索引列顺序建反了,或者业务查询模式变了,导致精心建立的索引形同虚设。
4.2 Skip Scan 的巧思
Skip Scan 抓住了一个关键前提:当前导列的基数(distinct 值数量)很小时,可以把「一次扫描」拆成「对每个前导列取值各扫一次」。
上面的例子里 status 只有三四个值,Skip Scan 会自动改写成近似这样的逻辑:
for each distinct status in ('paid','shipped','done', ...):
在索引里定位 (status, order_no=123456)
这样即使查询没显式给 status,也能走索引,把全表扫描变成几次高效的索引定位。
4.3 验证它真的生效了
-- 造数据:status 低基数,order_no 高基数
INSERT INTO orders
SELECT (ARRAY['paid','shipped','done','refunded'])[1 + (g % 4)],
g, (g % 1000)::numeric, now()
FROM generate_series(1, 10000000) g;
CREATE INDEX idx_orders ON orders (status, order_no);
ANALYZE orders;
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE order_no = 7654321;
在 PostgreSQL 18 上,执行计划里会出现类似:
Index Scan using idx_orders on orders
Index Cond: (order_no = 7654321)
Skip Scan: true ← 关键标记
Buffers: shared read=15
只读了个位数的 block,而在 17 及以前,同样的查询会是 Seq Scan,读几十万个 block。注意 Skip Scan 不是万能药:它只在前导列低基数时才划算,如果前导列本身高基数(比如前导列是用户 ID),优化器不会选它——这也符合直觉。它救的是「索引列顺序设计不理想」这一类历史包袱,让你在不改索引、不停机的情况下,让老索引重新发挥价值。
五、uuidv7():让分布式主键重新变快
如果你用过 UUID 做主键,一定被 UUIDv4 坑过:随机分布导致 B-tree 索引写入极度分散,页分裂频繁,缓存命中率低,大表插入越来越慢。很多团队因此被迫回退到自增 ID 或引入雪花算法。
PostgreSQL 18 内置了 uuidv7() 函数(对应 RFC 9562 标准),从根上解决这个问题。
5.1 v4 与 v7 的本质差异
UUIDv7 的前 48 位是毫秒级 Unix 时间戳,后面才是随机位。这意味着按时间生成的 UUID 是大致单调递增的——写入 B-tree 时永远集中在索引右端,就像自增 ID 一样顺序、对缓存友好,同时又保留了 UUID 分布式无冲突、无需中心协调的优点。
-- 直接用,无需任何扩展
SELECT uuidv7();
-- 018f... 前缀随时间递增
-- 对比 v4(随机)
SELECT uuidv4(); -- 18 同时把 gen_random_uuid() 语义显式化为 uuidv4()
-- 建表直接当默认主键
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
kind text,
created timestamptz DEFAULT now()
);
5.2 写入性能实测
造一个对照实验:两张表分别用 v4 和 v7 做主键,各插入 500 万行,对比耗时和索引膨胀:
CREATE TABLE t_v4 (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), val int);
CREATE TABLE t_v7 (id uuid PRIMARY KEY DEFAULT uuidv7(), val int);
\timing on
INSERT INTO t_v4 (val) SELECT g FROM generate_series(1,5000000) g;
INSERT INTO t_v7 (val) SELECT g FROM generate_series(1,5000000) g;
-- 对比索引大小
SELECT relname, pg_size_pretty(pg_relation_size(indexrelid)) AS idx_size
FROM pg_stat_user_indexes
WHERE relname IN ('t_v4','t_v7');
典型结果:v7 的插入耗时更短,且主键索引体积明显更小(因为页填充更紧凑,页分裂更少)。在数据量越大、内存越吃紧时,这个差距会被进一步放大。
一个隐藏福利: 因为 v7 内嵌时间戳,你甚至可以从主键近似推断记录创建时间,做时间范围裁剪时也更容易走索引。但要注意——v7 会暴露记录的大致创建时间,如果你的 ID 会暴露给外部且时间敏感(比如猜测用户注册顺序),需要评估这个信息泄露风险。
六、虚拟生成列与其它开发者友好改进
18 还有一批面向应用开发者的实用特性,挑两个最常用的讲。
6.1 虚拟生成列(Virtual Generated Columns)
17 及以前只支持 STORED 生成列——值在写入时算好并占用磁盘。18 新增 VIRTUAL(并成为默认),值在查询时才计算,不占存储:
CREATE TABLE products (
price numeric,
tax_rate numeric,
-- 查询时才算,不落盘
total numeric GENERATED ALWAYS AS (price * (1 + tax_rate)) VIRTUAL
);
INSERT INTO products (price, tax_rate) VALUES (100, 0.13);
SELECT price, total FROM products; -- total = 113
什么时候用哪种?规律很简单:
- VIRTUAL:计算便宜、读得不频繁、想省存储 → 默认首选。
- STORED:计算昂贵、读得非常频繁、需要对生成列建索引 → 用 stored(virtual 列不能直接建普通索引)。
6.2 RETURNING 支持 OLD/NEW
一个小而美的改进:UPDATE ... RETURNING 现在能同时拿到修改前后的值,做审计日志时不用再自己 SELECT 一遍:
UPDATE accounts
SET balance = balance - 100
WHERE id = 42
RETURNING id, old.balance AS before, new.balance AS after;
-- 原来只能拿到 new,现在 old/new 都行
6.3 OAuth 2.0 认证
18 原生支持 OAuth 2.0,让数据库接入企业 SSO 体系更顺畅,不用再靠外挂 PAM 或代理层拼凑。对有统一身份体系的中大型团队,这省掉了一层运维复杂度。
七、原生向量能力:真香,但别急着把数据全搬进来
AI 浪潮下,向量检索是绕不开的话题。PostgreSQL 生态(配合 pgvector 及 18 内核层面的相关优化)让「一套数据库同时搞定结构化数据 + 向量」成为现实。对中小团队这是巨大的运维简化——不用再单独运维一套 Milvus/Qdrant。
但作为过来人,必须泼点冷水,讲清楚边界:
它真正的甜区是: 数据量在百万到千万级、向量维度适中、且你已经在用 PostgreSQL 存业务数据。这时把向量放进同一个库,能省掉一整套数据同步管道,事务一致性也天然保证——写业务数据和写向量在同一个事务里,不会出现「数据写了、向量没同步」的脏状态。
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE docs (
id uuid PRIMARY KEY DEFAULT uuidv7(),
content text,
embedding vector(768)
);
-- HNSW 索引,近似最近邻
CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops);
-- 语义检索:一条 SQL 搞定,还能和业务条件混合过滤
SELECT id, content
FROM docs
WHERE content LIKE '%数据库%' -- 结构化过滤
ORDER BY embedding <=> '[...]'::vector -- 向量相似度
LIMIT 10;
但它的天花板也很清楚: 当向量规模上亿、QPS 要求极高、需要复杂的向量分片和 GPU 加速时,专用向量数据库仍然更合适。别因为「一套系统更省心」就无脑把海量向量塞进 PG,最后被索引构建时间和内存占用反噬。
决策原则: 中小规模、强一致、想少运维一套系统 → PG 原生向量;超大规模、纯向量高并发 → 专用向量库。技术选型永远是权衡,不是追新。
八、升级实战:平滑迁移到 18 的检查清单
功能再好,升级踩坑也白搭。给一份实操顺序:
第一步:兼容性预检。 用 pg_upgrade 的 --check 模式空跑,它会列出不兼容的扩展和数据类型:
/usr/pgsql-18/bin/pg_upgrade \
--old-datadir /var/lib/pgsql/17/data \
--new-datadir /var/lib/pgsql/18/data \
--old-bindir /usr/pgsql-17/bin \
--new-bindir /usr/pgsql-18/bin \
--check # ← 只检查,不动数据
第二步:留意扩展版本。 pgvector、PostGIS、TimescaleDB 等第三方扩展需要确认已发布支持 18 的版本,先在测试环境升级扩展再升级内核。
第三步:升级后重建统计信息。 18 的规划器(尤其 Skip Scan)依赖准确的统计信息,升级后务必全库 ANALYZE:
/usr/pgsql-18/bin/vacuumdb --all --analyze-in-stages
这一步经常被忽略,导致「升级后反而变慢」——不是 18 的锅,是统计信息过期让规划器做了错误决策。
第四步:分阶段启用新特性。 别一次性全开。先升级内核跑稳,再切 io_method = io_uring,观察一周监控,最后逐步引入 uuidv7、虚拟生成列等。每一步都可回退。
第五步:回退预案。 pg_upgrade 支持 --link 硬链接模式加速,但一旦启动 18 并写入,就无法简单回退到 17。生产升级前务必有完整备份,并演练过恢复流程。
九、总结与展望
回头看 PostgreSQL 18 这次升级,最打动我的不是某个孤立的新函数,而是它在底层基础设施上补齐了一块拖了二十年的短板。异步 I/O 不是锦上添花,而是让 PostgreSQL 终于能榨干现代 NVMe 存储的潜力——这是那种「一旦用上就回不去」的改进。
给不同角色的一句话建议:
- DBA / 运维: 优先关注 AIO 和 VACUUM 提速,升级后重测维护窗口和
io_uring收益,这是投入产出比最高的部分。 - 后端开发:
uuidv7()和虚拟生成列可以立刻用起来,前者解决分布式主键性能,后者简化数据建模。 - 架构师: Skip Scan 让索引设计容错度更高,原生向量让「一库多用」成为务实选项,但都要守住规模边界。
技术演进的方向越来越清晰:数据库不再只是被动的存储,而是主动地把硬件能力、AI 能力、可观测性都吸纳进内核。PostgreSQL 18 是这条路上一个扎实的里程碑。下一步值得期待的,是 AIO 在写路径(WAL、checkpoint)上的进一步深化——那将是又一次量级的跃迁。
如果你还在 15、16 上观望,我的建议是:先在测试环境把 io_uring 的收益测出来,用真实数据说话。很多时候,一次升级带来的性能提升,比你花几周去优化 SQL 还实在。
数据库优化的第一性原理从来没变:先把硬件喂饱,再谈别的。PostgreSQL 18,终于把这件事做对了。