PostgreSQL 18 异步 I/O 深度实战:从同步阻塞到 3 倍吞吐,一次真正的架构级跃迁
数据库的性能瓶颈,十有八九卡在 I/O 上。PostgreSQL 用了近三十年才在核心层引入异步 I/O,这一步走得晚,但走得实在。这篇文章不谈发布通稿式的"六大亮点罗列",而是从一个后端工程师的视角,把 PostgreSQL 18 里最值得较真的几个改动——异步 I/O 子系统、UUIDv7、Skip Scan、虚拟生成列——掰开揉碎,配上可以直接复制的配置和 SQL,讲清楚它们到底改变了什么、什么场景该用、什么坑不能踩。
一、为什么 PostgreSQL 18 是一个"架构级"版本
先说结论:PostgreSQL 18 不是一个"堆特性"的常规迭代,它在存储读取路径上动了地基。
过去很多年,PostgreSQL 的 I/O 模型本质上是同步阻塞的。当一个后端进程(backend)需要从磁盘读取一个数据块时,它调用 pread(),然后就地等待,直到操作系统把数据搬进共享缓冲区(shared buffers),才继续往下走。在本地 NVMe 上,这个等待可能只有几十微秒,你感觉不到;但一旦你的数据库跑在云存储(EBS、云盘、网络块存储)上,单次阻塞读的延迟可能是本地盘的 5~10 倍,顺序扫描一张大表时,CPU 大量时间浪费在"干等 I/O 返回"上。
在 18 之前,社区的缓解手段是 posix_fadvise()——一种"建议性预读"。它告诉内核"我等会儿可能要读这些块,你先帮我预热到 page cache"。但这只是建议,内核可以不理你,而且数据只是进了 page cache,还得再拷贝一次进共享缓冲区。治标不治本。
PostgreSQL 18 正式引入了异步 I/O(Asynchronous I/O,AIO)子系统。核心思路很简单也很经典:后端进程发起 I/O 请求后不再等待,而是继续推进查询逻辑,等数据真正就绪时再回来处理。官方基准测试显示,在读取密集型场景下,性能提升最高可达 3 倍。
这是一次典型的"晚到但正确"的工程决策。下面我们逐层拆。
二、核心概念:AIO 到底改了什么
2.1 ReadStream:异步预读的载体
理解 AIO,绕不开一个新设施叫 ReadStream。
传统同步模型下的读取流程是这样的:
backend 需要 block N
→ 发起 pread(block N)
→ 阻塞等待
→ 数据进共享缓冲区
→ 处理 block N
→ 需要 block N+1
→ 再发起 pread(block N+1)
→ 再阻塞...
每一次读都要"发起—等待—处理"串行走完。而 ReadStream 机制下:
backend 声明"我要顺序读 block N, N+1, N+2..."
→ ReadStream 异步批量发起多个预读请求
→ backend 处理 block N 的同时,N+1、N+2 已经在后台被读取
→ 处理完 N 直接拿 N+1,几乎无等待
关键差异在于 I/O 与 CPU 计算重叠。当你的查询在处理已经到手的数据块时,后续的数据块正在被并行地读进来。这就是吞吐量提升的根本来源。
目前(18.0)AIO 的异步读已经覆盖三类场景:
- 顺序扫描(Seq Scan):全表扫描类查询,收益最直接
- 位图堆扫描(Bitmap Heap Scan):走位图索引后回表读堆
- VACUUM:清理死元组时的大量顺序读
需要特别强调一个当前限制:PostgreSQL 18 只实现了异步读,没有实现异步写。写路径(WAL 刷盘、checkpoint 写脏页)依然是同步的。所以如果你的负载是写密集型(大量 INSERT/UPDATE),18 的 AIO 帮不上太多忙,别抱错误预期。这只是异步化的第一步,未来版本才会向写路径推进。
2.2 io_method:三种 I/O 调度方式
AIO 引入了一个核心参数 io_method,决定"由谁、以什么方式"来执行实际的 I/O:
-- 查看当前配置
SHOW io_method;
-- 可选值:sync | worker | io_uring
三种模式的本质区别:
sync(向后兼容)
不是真正的异步。在支持的平台上继续用 posix_fadvise 做同步预读,数据落到 page cache 而非共享缓冲区。这是"关掉 AIO"的选项,行为等同于旧版本。当你怀疑 AIO 引入了问题、需要排除变量时,可以临时切回它。
worker(默认值)
创建一个"I/O 工作进程池"。当某个 backend 需要读块时,它把请求塞进共享内存里的一个队列,某个 I/O worker 被唤醒,执行 pread,把数据放进共享缓冲区,再通知原 backend。这是跨平台的默认方案,Linux/macOS/其他 Unix 都能用,不依赖特定内核特性。
io_uring(Linux 专属高性能方案)
直接使用 Linux 内核 5.1+ 的 io_uring 接口,由发起 I/O 的 backend 进程自己异步提交和收割 I/O,省掉了 worker 进程间的队列传递和上下文切换开销。在高并发、高 IOPS 场景下,io_uring 的效率明显优于 worker,但它有平台和运行时限制(后面容器化那节会详谈)。
2.3 io_workers:worker 模式下的并行度
如果你用 worker 模式,io_workers 决定进程池大小:
-- 默认值为 3
SHOW io_workers;
-- 调整(不需要重启,但改 io_method 需要重启)
ALTER SYSTEM SET io_workers = 8;
SELECT pg_reload_conf();
经验值:vCPU 数量的 25%~50% 是一个合理的起点。太少了并行度不够,跑不满存储带宽;太多了 worker 之间抢 CPU 和锁,反而降低效率。8 核机器给 34 个 worker,16 核给 68 个,然后压测调整。
三、架构分析:AIO 相关参数的完整调优
AIO 不是打开就万事大吉,它牵动一组相互关联的参数。这里给一份可以直接落地的配置模板,再逐条解释权衡。
# ===== postgresql.conf =====
# --- I/O 方式选择 ---
# Linux 5.1+ 且非受限容器环境,优先 io_uring
io_method = io_uring # 或 worker(跨平台默认)
io_workers = 4 # 仅 worker 模式生效,建议 vCPU 的 25%-50%
# --- 并发预读深度 ---
# 18 版本默认值从历史的 1 大幅提高
effective_io_concurrency = 16 # 普通读并发,SSD/云盘可拉到 32-256
maintenance_io_concurrency = 16 # VACUUM/CREATE INDEX 等维护操作的并发
# --- I/O 合并 ---
io_combine_limit = 128kB # 相邻块合并成一次大 I/O 的上限
io_max_combine_limit = 256kB # 硬上限(改这个需要重启)
# --- 共享缓冲区(和 AIO 协同)---
shared_buffers = 8GB # 通常为物理内存的 25%
effective_io_concurrency:这次真的有用了
在旧版本里,effective_io_concurrency 的默认值是 1,很多人根本没调过,因为它只影响 posix_fadvise 的预读深度,效果有限。PostgreSQL 18 里它成了 AIO 的核心旋钮——它直接决定 ReadStream 能同时在途(in-flight)多少个 I/O 请求。
- 本地 SATA SSD:16~32
- 本地 NVMe:64~128
- 高性能云盘(高 IOPS 配置):128~256
值越大,越能榨干存储的并行能力,但也占用更多 I/O 队列资源。云盘场景收益尤其明显,因为云存储的单次延迟高,靠"多请求并发在途"来摊薄延迟正是 AIO 的强项。
io_combine_limit:把碎读合并成大读
当 ReadStream 发现要读的多个块在物理上相邻,它会把它们合并成一次更大的 I/O 系统调用,减少调用次数和内核开销。io_combine_limit(默认 128kB)控制单次合并的上限。对顺序扫描这种"读的块天然连续"的场景,合并的收益很大。
四、代码实战:验证与压测 AIO
光配置不验证等于没配。下面是一套完整的验证流程。
4.1 确认 AIO 生效
-- 1. 确认 io_method
SHOW io_method;
-- 2. 查看 AIO 的实时统计(18 新增系统视图)
SELECT * FROM pg_aios;
-- 这个视图列出当前在途的异步 I/O 操作,
-- 能看到 io_method、operation、state 等,
-- 跑一个大表扫描时观察它会有一批 in-flight 的读
4.2 造数据 + 对比扫描
-- 建一张足够大的表(约 1000 万行,撑到几百 MB 以上,
-- 确保数据不会全命中缓存)
CREATE TABLE bench_scan AS
SELECT
g AS id,
md5(g::text) AS payload,
(random() * 100000)::int AS category,
now() - (random() * interval '365 days') AS created_at
FROM generate_series(1, 10000000) g;
-- 清空 OS cache 影响:重启实例或用足够大的表
-- 强制走顺序扫描做对比
SET max_parallel_workers_per_gather = 0; -- 先排除并行干扰
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT count(*) FROM bench_scan WHERE payload LIKE 'a%';
分别在 io_method = sync 和 io_method = io_uring 下跑(改完 io_method 要重启),对比 EXPLAIN ANALYZE 输出里的执行时间。在云盘环境下,你通常能看到 io_uring 模式下顺序扫描的耗时明显下降。注意用 BUFFERS 选项观察实际读了多少块,确保是真的在读盘而不是命中缓存。
4.3 观测 I/O 全景:pg_stat_io
PostgreSQL 18 强化了 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_bytes DESC;
这是判断"到底是不是 I/O 瓶颈、AIO 有没有起作用"最直接的依据。配合新增的 vacuum/analyze 耗时统计一起看:
SELECT relname,
last_vacuum,
vacuum_time, -- 18 新增:累计 vacuum 耗时
last_analyze,
analyze_time -- 18 新增:累计 analyze 耗时
FROM pg_stat_all_tables
WHERE schemaname = 'public'
ORDER BY vacuum_time DESC NULLS LAST;
五、UUIDv7:别再用 UUIDv4 当主键了
这是 18 里对应用开发者最"无脑收益"的一个特性。
5.1 UUIDv4 的性能原罪
分布式系统里大家爱用 UUID 当主键,因为可以客户端生成、不依赖数据库自增、天然避免冲突。但 UUIDv4 是完全随机的,这带来一个致命问题:索引写放大。
PostgreSQL 的主键走 B-tree 索引。当你插入一个随机 UUID,它会落在 B-tree 的随机位置。连续插入 100 万行随机 UUID,等于在整棵索引树上到处"插队",导致:
- 频繁的页分裂(page split)
- 缓冲区命中率暴跌(每次插入都可能碰一个冷页)
- WAL 写入量激增(页分裂要记 WAL)
- 索引膨胀严重
在写密集场景,随机 UUID 主键的插入吞吐可能只有自增整数的几分之一。
5.2 UUIDv7:时间有序的 UUID
UUIDv7 的结构是:高位是毫秒级 Unix 时间戳 + 低位随机数。这意味着按时间先后生成的 UUID 在数值上天然有序。插入 B-tree 时永远追加在"右侧",几乎不产生页分裂,索引局部性极好。
它既保留了 UUID 的分布式友好(客户端可生成、全局唯一),又拿回了自增主键的写入性能。
-- 18 内置函数,无需扩展
SELECT uuidv7();
-- 例如:0198f3a2-...(前段随时间递增)
-- 连续调用,你会看到生成的值是递增的
SELECT uuidv7() FROM generate_series(1, 5);
-- 建表直接用作主键
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
user_id bigint NOT NULL,
amount numeric(12, 2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 对比:旧版要装扩展才能有 uuid 生成能力
-- CREATE EXTENSION "uuid-ossp"; -- 然后 uuid_generate_v4()
-- 18 里 uuidv7() 是核心内置函数
顺带一提,18 也提供了 uuidv4() 作为内置别名,你不再需要为了生成 UUID 去装 uuid-ossp 扩展。
5.3 什么时候仍然该谨慎
UUIDv7 唯一的"代价"是它泄露了创建时间信息(高位就是时间戳)。如果你的主键会暴露给外部、而记录的创建时间是敏感信息(比如不希望别人从订单 ID 推断出下单先后和时段),那要么继续用 v4,要么在对外暴露时做一层映射。绝大多数内部系统不需要担心这个。
六、Skip Scan:复合索引的"起死回生"
6.1 一个经典的索引痛点
假设你有一张表,建了复合索引 (status, created_at):
CREATE INDEX idx_orders ON orders (status, created_at);
按照 B-tree 的规则,这个索引只在查询带上前导列 status 时才高效。如果你的查询是:
SELECT * FROM orders WHERE created_at > '2026-01-01';
没有 status 条件,前导列缺失——在 18 之前,优化器基本只能选择全表扫描或者全索引扫描,性能很差。这逼得很多人为了这种查询再单独建一个 (created_at) 索引,多占空间、多拖慢写入。
6.2 Skip Scan 的原理
PostgreSQL 18 引入了 Skip Scan(跳跃扫描)。当前导列的基数较小(distinct 值不多,比如 status 只有 'pending'、'paid'、'shipped'、'cancelled' 四种)时,优化器不再放弃索引,而是:
对 status 的每一个 distinct 值:
在索引中定位 (该status, created_at > '2026-01-01') 的区间
扫描该区间
把所有区间结果合并
相当于把"一次做不到的扫描"拆成"对每个前导值各做一次小范围索引扫描"。只要前导列 distinct 值不多,这个开销远小于全表扫描。
-- 前导列基数低,Skip Scan 收益大
-- status 只有几种取值时,下面这个查询在 18 里可以走索引
EXPLAIN (ANALYZE)
SELECT * FROM orders
WHERE created_at BETWEEN '2026-01-01' AND '2026-02-01';
-- 在执行计划里你会看到 "Index Scan ... (skip scan)" 相关标识
实战建议:Skip Scan 让你在前导列基数低时可以少建一些冗余索引,但它不是万金油——如果前导列基数很高(比如前导列是 user_id 有几百万个不同值),跳跃扫描要处理海量区间,还不如全表扫描,优化器一般也不会选它。理解"低基数前导列"这个前提是关键。
七、虚拟生成列:查询时计算,不占存储
生成列(Generated Columns)不是新概念,PostgreSQL 12 就有了,但那时只支持 STORED(存储型)——值在写入时算好,物理落盘,占存储空间。
PostgreSQL 18 补上了 VIRTUAL(虚拟型),而且虚拟成了默认:值在查询时动态计算,完全不占存储。
CREATE TABLE products (
id uuid PRIMARY KEY DEFAULT uuidv7(),
price numeric(10, 2) NOT NULL,
tax_rate numeric(4, 3) NOT NULL DEFAULT 0.13,
-- 虚拟生成列:查询时才计算,不占磁盘
price_with_tax numeric GENERATED ALWAYS AS (price * (1 + tax_rate)) VIRTUAL,
-- 存储生成列:写入时算好落盘(需要显式 STORED)
price_cents bigint GENERATED ALWAYS AS ((price * 100)::bigint) STORED
);
INSERT INTO products (price) VALUES (99.00);
SELECT id, price, price_with_tax, price_cents FROM products;
-- price_with_tax 是查询时现算的,price_cents 是写入时存好的
怎么选 VIRTUAL 还是 STORED?
- 用 VIRTUAL:计算便宜、读得不频繁、想省存储。省的是磁盘和写入开销,代价是每次读都要重算。
- 用 STORED:计算昂贵、读得很频繁、或者需要在这个列上建索引(虚拟列不能直接建索引)。用空间换读取速度。
这个取舍本质上就是经典的"时间换空间 vs 空间换时间",放到列级别让你精细控制。
八、其他值得关注的改进
8.1 更省心的大版本升级
以前 pg_upgrade 之后最头疼的是:规划器统计信息(planner statistics)会丢,升级完必须重跑 ANALYZE,在这之前查询计划可能一塌糊涂,出现"升级后性能不如升级前"的尴尬窗口期。18 改进了升级流程,能保留统计信息,大幅缩短"升级后达到预期性能"的时间,让大版本升级的中断和风险都更小。
8.2 OAuth 2.0 认证
18 内置了对 OAuth 2.0 的支持,和企业 SSO(单点登录)体系对接更顺畅,不用再靠外部代理或插件硬凑。对有统一身份治理需求的团队是实打实的便利。
8.3 优化器:HashRightSemiJoin 与 Self-Join Elimination
- HashRightSemiJoin:支持并行执行,处理大表的
IN子查询时,优化器可以选择基于小表构建哈希,大幅降低资源和耗时。 - Self-Join Elimination:当一张表和自己做内连接、且能证明这个连接对结果没有实际作用时,优化器直接把它消除,换成更简单的扫描。对分区表尤其有价值。
8.4 可观测性增强
除了前面提到的 vacuum/analyze 耗时,18 还在 pg_stat_checkpointer 加了 num_done 跟踪已完成检查点,在 pg_stat_database 加了 parallel_workers_to_launch / parallel_workers_launched 观察并行执行的实际使用情况。这些都是排障时能救命的细节。
九、生产落地:容器化环境的 io_uring 大坑
这是最容易踩、也最坑的一个点,单独拎出来讲。
你在 postgresql.conf 里设了 io_method = io_uring,满心欢喜以为拿到了最高性能,结果实际悄悄回退到了低效模式——因为容器运行时(containerd / Docker)出于安全考虑,默认禁用了 io_uring 系统调用(它历史上出过一些内核安全漏洞,很多安全基线会封掉它)。
9.1 先确认宿主机内核支持
# 返回值 > 0 说明内核支持 io_uring
grep io_uring /proc/kallsyms | wc -l
# 返回 0 → 内核不支持或被禁,io_uring 模式会回退
9.2 容器里放行 io_uring
Docker:
docker run -d \
--cap-add=SYS_IOURING \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:18 \
-c io_method=io_uring \
-c effective_io_concurrency=64
Kubernetes(securityContext):
apiVersion: v1
kind: Pod
metadata:
name: pg18
spec:
containers:
- name: postgres
image: postgres:18
securityContext:
capabilities:
add: ["SYS_IOURING"]
args:
- "-c"
- "io_method=io_uring"
- "-c"
- "effective_io_concurrency=64"
注意:有些托管 K8s 平台的 seccomp/AppArmor 策略会更严格,即使加了 capability 也可能被拦。这种情况别硬刚,退回到 worker 模式是最稳妥的选择:
ALTER SYSTEM SET io_method = 'worker';
ALTER SYSTEM SET io_workers = 8; -- 建议 vCPU 的 25%-50%
-- io_method 改动需要重启实例
worker 模式在绝大多数场景下已经能拿到 AIO 的大部分收益,且没有平台兼容性风险。不要为了追 io_uring 的那点极限性能,去和平台安全策略死磕——这是很多团队实际落地时最该记住的一条。
十、迁移到 PostgreSQL 18 的实操 Checklist
给一个可执行的升级前后核对清单:
升级前:
- 确认所有依赖的扩展都有 18 兼容版本(尤其 PostGIS、pgvector 这类)
- 全量备份 + 演练回滚流程
- 在测试环境用
pg_upgrade --check做预检
升级后:
- 虽然 18 保留了统计信息,仍建议观察后跑一次
ANALYZE兜底 - 逐步调
io_method:先worker稳住,再评估是否上io_uring - 用
pg_stat_io和pg_aios观察 AIO 是否真的生效 - 压测对比升级前后的关键查询耗时,别只信通稿的"3 倍",以你自己的负载为准
- 评估把随机 UUID 主键迁移到
uuidv7()(新表直接用,老表按需) - 排查是否有可以靠 Skip Scan 省掉的冗余索引
# Docker Compose 快速起一个 18 实例试水
cat > docker-compose.yml <<'EOF'
services:
postgres18:
image: postgres:18
container_name: postgres18
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- "-c"
- "io_method=worker"
- "-c"
- "io_workers=4"
- "-c"
- "effective_io_concurrency=32"
ports:
- "5432:5432"
volumes:
- pg18data:/var/lib/postgresql/data
volumes:
pg18data:
EOF
docker compose up -d
docker exec -it postgres18 psql -U postgres -c "SHOW io_method;"
十一、总结与展望
回头看 PostgreSQL 18,它给我的感受是"务实的地基工程":
- 异步 I/O 是主角,但要清醒认识到它目前只覆盖读路径、只对 Seq Scan/Bitmap Heap Scan/VACUUM 生效、云盘场景收益最大、写密集负载暂时无感。这是一个"开始",不是"完成"。
- UUIDv7 是对应用开发者最友好的礼物,几乎无脑替换 UUIDv4 就能改善写入和索引性能,除非你在意时间信息泄露。
- Skip Scan 让复合索引更"宽容",但前提是低基数前导列,别指望它救高基数场景。
- 虚拟生成列把时间/空间的取舍下放到列级别,用对了很省心。
- 升级体验、OAuth、优化器、可观测性这些改进虽不抢眼,但都是生产环境里真正会让你少加班的东西。
从工程哲学上看,PostgreSQL 社区在异步 I/O 这件事上表现出典型的"慢就是快"——它没有为了赶时髦一次性把读写全异步化,而是先把读路径这个收益最确定、风险最可控的部分做扎实,把架构骨架搭好,再在后续版本逐步推进。对一个承载着无数关键业务、以稳健著称近三十年的数据库来说,这种克制反而是最负责任的选择。
如果你的数据库跑在云上、以读为主、且饱受 I/O 等待之苦,PostgreSQL 18 值得你认真排一次升级。但记住:先用你自己的真实负载压测,再决定 io_method 怎么配。通稿里的"3 倍"是别人的场景,你的数字要自己跑出来。
数据库这行没有银弹,只有一个个把地基夯实的版本。PostgreSQL 18,就是这样一块夯得很实的砖。