编程 PostgreSQL 18 异步 I/O 深度实战:从同步阻塞到 3 倍吞吐,一次真正的架构级跃迁

2026-07-23 13:44:25 +0800 CST views 13

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 = syncio_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

给一个可执行的升级前后核对清单:

升级前:

  1. 确认所有依赖的扩展都有 18 兼容版本(尤其 PostGIS、pgvector 这类)
  2. 全量备份 + 演练回滚流程
  3. 在测试环境用 pg_upgrade --check 做预检

升级后:

  1. 虽然 18 保留了统计信息,仍建议观察后跑一次 ANALYZE 兜底
  2. 逐步调 io_method:先 worker 稳住,再评估是否上 io_uring
  3. pg_stat_iopg_aios 观察 AIO 是否真的生效
  4. 压测对比升级前后的关键查询耗时,别只信通稿的"3 倍",以你自己的负载为准
  5. 评估把随机 UUID 主键迁移到 uuidv7()(新表直接用,老表按需)
  6. 排查是否有可以靠 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,就是这样一块夯得很实的砖。

推荐文章

404错误页面的HTML代码
2024-11-19 06:55:51 +0800 CST
JavaScript设计模式:发布订阅模式
2024-11-18 01:52:39 +0800 CST
php 连接mssql数据库
2024-11-17 05:01:41 +0800 CST
一键配置本地yum源
2024-11-18 14:45:15 +0800 CST
利用Python构建语音助手
2024-11-19 04:24:50 +0800 CST
程序员茄子在线接单