🤖
AI审核中

别只加索引了:PostgreSQL 18底层性能变革解读

Java 19分钟 116浏览 1评论

过去谈到数据库性能优化,很多人的第一反应通常是:

  • 给查询增加索引;
  • 调整 SQL;
  • 引入 Redis;
  • 增加只读副本;
  • 实在不行就分库分表。

这些方法当然仍然有效,但 PostgreSQL 18 带来的变化更值得关注,因为它开始从数据库底层重新设计数据读取、索引访问和数据建模方式。

PostgreSQL 18 于 2025 年 9 月 25 日正式发布。截至 2026 年 8 月,PostgreSQL 18 已更新至 18.6,PostgreSQL 19 仍处于开发版本阶段,因此本文以 PostgreSQL 18.6 为基准展开讨论。生产环境也应优先选择最新的 18.x 维护版本,而不是停留在最初发布的 18.0。

PostgreSQL 18 的重要变化并不只是多了几个 SQL 函数,而是同时触及了四个层面:

flowchart LR
    A[PostgreSQL 18] --> B[存储访问]
    A --> C[查询优化]
    A --> D[数据建模]
    A --> E[应用交互]

    B --> B1[异步 I/O]
    B --> B2[更完善的 I/O 观测]

    C --> C1[B-Tree Skip Scan]
    C --> C2[Hash Join 与聚合优化]

    D --> D1[UUIDv7]
    D --> D2[虚拟生成列]

    E --> E1[RETURNING OLD / NEW]
    E --> E2[更平滑的版本升级]

其中最值得普通开发者和数据库运维人员关注的,是异步 I/O、Skip Scan、UUIDv7、虚拟生成列以及增强后的 RETURNING

一、异步 I/O:数据库终于不必“读一页、等一页”

1. 同步 I/O 的问题

传统同步读取的执行过程可以简单理解为:

  1. 数据库向操作系统发起读取请求;
  2. 当前执行流程等待数据返回;
  3. 数据进入共享缓冲区;
  4. 查询继续处理下一批数据;
  5. 再次发起读取请求。

当数据已经位于 PostgreSQL 的共享缓冲区或者操作系统页缓存中时,这种等待可能并不明显。

但在以下场景中,I/O 等待会迅速成为瓶颈:

  • 大表顺序扫描;
  • Bitmap Heap Scan;
  • VACUUM
  • 数据量明显大于内存;
  • 使用网络云盘;
  • 存储单次访问延迟较高;
  • 分析型查询需要读取大量数据页。

同步模式下,即使磁盘能够同时处理大量请求,数据库也可能因为逐个等待而无法充分利用存储吞吐能力。

2. PostgreSQL 18 的异步读取方式

PostgreSQL 18 引入了新的异步 I/O 子系统,可以提前提交多个 I/O 请求,让多个读取任务同时处于执行状态,而不是完成一个之后再提交下一个。

sequenceDiagram
    participant Q as 查询执行器
    participant A as AIO 调度器
    participant S as 存储设备

    Q->>A: 提交数据页 1234
    A->>S: 并发发起多个读取请求
    S-->>A: 返回数据页 2
    S-->>A: 返回数据页 1
    A-->>Q: 交付已完成的数据页
    Q->>Q: 继续执行查询
    S-->>A: 返回数据页 34
    A-->>Q: 交付剩余数据页

这并不会让单次磁盘读取本身变快,但能够减少查询执行器空等 I/O 的时间,并提高存储设备的并发利用率。

PostgreSQL 官方说明显示,异步 I/O 首批覆盖了顺序扫描、Bitmap Heap Scan、VACUUM 等路径。在部分存储读取场景中,官方测试观察到了最高约 3 倍的性能提升,但这并不代表所有业务升级后都会自动获得同等收益。

二、三种 I/O 模式应该怎么选

PostgreSQL 18 通过 io_method 参数控制异步 I/O 的实现方式。

模式 实现方式 适用场景
sync 将可异步执行的 I/O 仍按同步方式处理 兼容性验证、问题排查、性能对照
worker 由独立 I/O Worker 进程执行请求 默认模式,适合作为升级后的起点
io_uring 使用 Linux io_uring 接口 Linux 环境下进一步测试低开销异步 I/O

PostgreSQL 18 默认使用 worker,默认启动 3 个 I/O Worker。io_uring 需要 PostgreSQL 在构建时启用 liburing 支持,而且相关参数只能在服务器启动时生效。

可以先查看当前配置:

SHOW io_method;
SHOW io_workers;
SHOW effective_io_concurrency;
SHOW maintenance_io_concurrency;

一个用于测试环境的配置示例如下:

io_method = 'worker'
io_workers = 3

effective_io_concurrency = 16
maintenance_io_concurrency = 16

track_io_timing = on

其中:

  • effective_io_concurrency 控制普通查询期望同时执行的 I/O 数量;
  • maintenance_io_concurrency 面向 VACUUM 等维护任务;
  • track_io_timing 用于记录 I/O 等待时间;
  • io_workers 仅在 io_method = 'worker' 时有效。

PostgreSQL 18 中,effective_io_concurrencymaintenance_io_concurrency 的默认值均为 16。需要注意的是,并发值并不是越高越好。官方文档明确提示,设置过高可能增加整个系统的 I/O 延迟。

因此,不建议看到异步 I/O 后就直接把并发参数调整到几百。更合理的做法是从默认值开始,在接近真实业务的数据量和并发模型下逐步验证。

三、不要只看查询耗时,还要观察 I/O 到底发生了什么

PostgreSQL 18 增加了 pg_aios 系统视图,用于查看当前正在准备、提交、执行或者完成中的异步 I/O Handle。

可以按照状态和操作类型观察当前请求:

SELECT
    state,
    operation,
    count(*) AS handle_count,
    sum(length) AS total_bytes
FROM pg_aios
GROUP BY state, operation
ORDER BY state, operation;

pg_aios 更偏向实时状态查看,可以观察请求是否处于 STAGEDSUBMITTED 或已完成状态。该视图主要面向 PostgreSQL 开发者和性能调优场景,并且默认需要超级用户或者 pg_read_all_stats 权限。

对于累计 I/O 情况,可以使用 pg_stat_io

SELECT
    backend_type,
    object,
    context,
    reads,
    pg_size_pretty(read_bytes::bigint) AS read_size,
    round(read_time::numeric, 2) AS read_time_ms,
    hits,
    evictions
FROM pg_stat_io
WHERE reads IS NOT NULL
ORDER BY read_bytes DESC NULLS LAST;

PostgreSQL 18 的 pg_stat_io 可以统计:

  • 读取次数与读取字节数;
  • 写入次数与写入字节数;
  • 扩展文件次数;
  • 缓冲区命中次数;
  • 缓冲区淘汰次数;
  • I/O 等待时间;
  • bulkreadvacuumnormal 等不同上下文。

其中,read_timewrite_time 等时间字段只有在启用 track_io_timing 后才会产生有效数据。官方也建议将 PostgreSQL 统计视图与操作系统监控工具结合使用,因为 PostgreSQL 无法完全区分数据究竟来自物理磁盘,还是已经存在于操作系统页缓存中。

四、异步 I/O 不是所有查询的“免费加速器”

异步 I/O 更可能改善以下负载:

  • 大表扫描;
  • 数据仓库和报表查询;
  • 大量数据不在共享缓冲区中;
  • 网络附加存储;
  • 存储延迟较高但支持较高并发;
  • VACUUM 读取大量数据页;
  • Bitmap Heap Scan 比较频繁。

以下负载可能变化不明显:

  • 数据基本全部位于内存;
  • 查询主要走高度选择性的索引;
  • 单次只读取极少量数据;
  • 系统瓶颈位于 CPU;
  • 锁竞争才是主要延迟来源;
  • 应用连接池或者网络往返已经成为瓶颈;
  • SQL 本身存在明显的执行计划问题。

因此,升级 PostgreSQL 18 后,仍然需要通过 EXPLAIN ANALYZEBUFFERSpg_stat_io 和操作系统监控确认瓶颈,而不能仅凭版本号推断性能一定会提高。

五、Skip Scan:联合索引不再绝对依赖最左前缀

假设订单表存在以下联合索引:

CREATE INDEX idx_orders_tenant_status_created
ON orders (tenant_id, status, created_at DESC);

传统索引设计经验通常认为,这个索引最适合以下查询:

SELECT *
FROM orders
WHERE tenant_id = 1001
  AND status = 'PAID'
ORDER BY created_at DESC;

因为查询条件包含联合索引最左侧的 tenant_id

但如果查询没有传入租户:

SELECT *
FROM orders
WHERE status = 'PAID'
  AND created_at >= now() - interval '1 day';

过去执行计划很可能无法高效缩小联合索引的扫描范围,因为缺少最左列条件。

PostgreSQL 18 的 B-Tree Skip Scan 可以在一定条件下,根据索引前导列的不同值,动态生成类似下面的查询过程:

tenant_id = 1 AND status = 'PAID'
tenant_id = 2 AND status = 'PAID'
tenant_id = 3 AND status = 'PAID'

数据库并不是真的生成多条 SQL,而是在索引内部反复定位不同前导列分组,从而跳过大量不可能匹配的数据页。

PostgreSQL 官方文档指出,当前导列的不同值较少,而后续列具备有效过滤条件时,优化器可能选择 Skip Scan。如果前导列基数非常高,重复定位的成本可能超过收益,优化器通常仍会选择顺序扫描。

可以通过执行计划观察数据库是否利用了现有索引:

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE status = 'PAID'
  AND created_at >= now() - interval '1 day';

Skip Scan 不代表可以忽略索引顺序

Skip Scan 是优化器增加的一种选择,而不是对最左前缀原则的彻底废除。

以下场景仍然应该考虑创建更匹配的索引:

  • 查询非常高频;
  • 前导列基数很高;
  • 查询延迟要求严格;
  • 后续条件选择性不高;
  • Skip Scan 需要重复进行大量索引定位;
  • 现有联合索引体积非常大。

正确的理解应该是:

PostgreSQL 18 让部分“差一点就能用上联合索引”的查询有了新的执行路径,但索引仍应围绕真实访问模式设计。

六、UUIDv7:兼顾分布式生成与索引局部性

很多系统会使用 UUID 作为主键。

传统 UUIDv4 具有很好的随机性,但随机值写入 B-Tree 索引时,插入位置也会比较分散。随着数据增长,可能带来更多随机页访问、缓存抖动和页面分裂。

PostgreSQL 18 原生提供了 uuidv7()

CREATE TABLE orders (
    id uuid PRIMARY KEY DEFAULT uuidv7(),
    tenant_id bigint NOT NULL,
    status varchar(20) NOT NULL,
    amount numeric(12, 2) NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

UUIDv7 在 UUID 中加入时间信息。PostgreSQL 的实现使用毫秒级 Unix 时间戳、亚毫秒时间信息以及随机数据构造 UUID,因此同一时间段生成的值通常具有更好的排序局部性。

可以直接提取版本和时间:

WITH generated AS (
    SELECT uuidv7() AS id
)
SELECT
    id,
    uuid_extract_version(id) AS uuid_version,
    uuid_extract_timestamp(id) AS generated_time
FROM generated;

PostgreSQL 官方将 UUIDv7 定位为更适合数据库索引和缓存访问的时间有序 UUID。与完全随机的 UUIDv4 相比,UUIDv7 通常能够让新数据更集中地写入索引尾部附近。

UUIDv7 仍然不是自增 ID

UUIDv7 虽然大致按照时间排序,但不能把它当成严格连续的业务序号。

它不能保证:

  • 不同服务器之间绝对按生成先后排序;
  • 同一毫秒内严格递增;
  • UUID 顺序等于事务提交顺序;
  • UUID 顺序等于消息处理顺序。

订单编号、支付流水号等需要严格业务语义的字段,仍然应该单独设计。

时间信息可能被外部观察

由于 UUIDv7 的时间戳可以被提取,如果将其直接暴露在公开接口中,调用方可能推断出记录的大致生成时间。

这不一定是安全漏洞,但属于数据设计时需要考虑的信息暴露问题。对于对时间敏感的业务,可以继续使用随机外部标识,将 UUIDv7 仅作为内部主键。

七、虚拟生成列:把确定性计算收回数据库

假设订单明细中存在数量、单价和总金额:

CREATE TABLE order_item (
    id uuid PRIMARY KEY DEFAULT uuidv7(),
    order_id uuid NOT NULL,
    quantity integer NOT NULL CHECK (quantity > 0),
    unit_price numeric(12, 2) NOT NULL CHECK (unit_price >= 0),
    amount numeric(14, 2)
        GENERATED ALWAYS AS (quantity * unit_price) VIRTUAL
);

应用只需要写入 quantityunit_priceamount 由数据库计算。

PostgreSQL 18 支持两种生成列:

类型 计算时机 是否占用表存储
VIRTUAL 查询读取时计算 不存储计算结果
STORED 插入或更新时计算 存储计算结果

PostgreSQL 18 默认使用虚拟生成列。虚拟列更接近普通视图中的表达式,而存储生成列更接近自动维护的物化结果。

生成列适合承载以下逻辑:

  • 金额计算;
  • 单位换算;
  • 字符串标准化;
  • 状态映射;
  • 日期拆分;
  • 多字段组合;
  • 确定性业务派生值。

这样可以避免多个应用分别实现相同计算规则,减少不同服务之间出现结果不一致的概率。

但生成表达式存在明确限制:

  • 只能使用不可变函数;
  • 不能包含子查询;
  • 不能引用其他生成列;
  • 不能直接引用当前行之外的数据;
  • 不能同时定义默认值;
  • 不能作为分区键;
  • 虚拟生成列对用户自定义函数和类型还有额外限制。

这些限制意味着生成列适合处理“当前行内部的确定性计算”,不适合处理库存查询、汇率转换、跨表统计等动态业务逻辑。

八、RETURNING OLD / NEW:一次 SQL 同时拿到修改前后数据

过去执行更新时,如果应用需要知道数据修改前后的差异,常见做法是:

  1. 查询旧数据;
  2. 在应用中计算新数据;
  3. 执行更新;
  4. 再次查询更新结果;
  5. 写入审计记录。

这种方式不仅增加数据库往返次数,还容易在高并发下出现查询结果与实际更新数据不一致的问题。

PostgreSQL 18 可以在 RETURNING 中同时访问 OLDNEW

UPDATE account
SET balance = balance - 100
WHERE id = 42
  AND balance >= 100
RETURNING
    id,
    old.balance AS balance_before,
    new.balance AS balance_after,
    old.balance - new.balance AS deducted_amount;

还可以结合数据修改 CTE,在同一条 SQL 中写入审计记录:

WITH changed AS (
    UPDATE product
    SET price = round(price * 1.05, 2)
    WHERE category_id = 10
    RETURNING
        id,
        old.price AS old_price,
        new.price AS new_price,
        clock_timestamp() AS changed_at
)
INSERT INTO product_price_audit (
    product_id,
    old_price,
    new_price,
    changed_at
)
SELECT
    id,
    old_price,
    new_price,
    changed_at
FROM changed
RETURNING *;

INSERTUPDATEDELETEMERGE 均支持 RETURNING。PostgreSQL 18 新增了对修改前后数据的显式访问,可以避免为了获取修改结果而额外执行一次查询。对于 INSERT,旧值通常为空;对于 DELETE,新值通常为空。

不过,RETURNING OLD / NEW 并不能替代完整的审计体系。

如果系统要求长期保留变更历史,仍然应该使用:

  • 审计表;
  • 数据库触发器;
  • CDC;
  • 逻辑复制;
  • 领域事件;
  • 不可变操作日志。

RETURNING 解决的是单次修改过程中的数据获取问题,而不是长期历史追踪问题。

九、PostgreSQL 18 升级后的统计信息恢复更快

数据库大版本升级后,一个常见问题是执行计划突然变差。

原因并不一定是新版本优化器存在问题,而可能是旧集群中的优化器统计信息没有被保留下来。新集群在完成大规模 ANALYZE 之前,对数据分布了解不足,可能生成不理想的执行计划。

PostgreSQL 18 的 pg_upgrade 可以迁移旧集群中的大部分优化器统计信息,从而减少升级后等待统计信息重新生成的时间。

但并不是所有统计信息都会迁移,例如:

  • 显式创建的扩展统计信息;
  • 扩展提供的自定义统计信息;
  • 累计统计系统中的部分数据。

官方仍然建议升级后补充执行分阶段统计收集和完整分析。

vacuumdb --all \
  --analyze-in-stages \
  --missing-stats-only \
  --jobs=4

vacuumdb --all \
  --analyze-only \
  --jobs=4

更稳妥的升级流程如下:

flowchart LR
    A[采集旧版本性能基线] --> B[复制生产数据进行预演]
    B --> C[执行 pg_upgrade --check]
    C --> D[完成测试升级]
    D --> E[恢复缺失统计信息]
    E --> F[回放真实业务负载]
    F --> G{性能和兼容性达标}
    G -- 否 --> H[调参或回滚]
    H --> B
    G -- 是 --> I[灰度升级生产环境]
    I --> J[持续观察执行计划与 I/O]

十、升级 PostgreSQL 18 前需要注意什么

1. 不要在生产环境直接验证异步 I/O

应该先复制接近生产规模的数据,回放真实 SQL,再比较:

  • P50、P95、P99 延迟;
  • 查询吞吐量;
  • CPU 使用率;
  • I/O 等待;
  • 缓存命中率;
  • VACUUM 时间;
  • 临时文件写入量;
  • 执行计划变化。

2. 不要只测试平均响应时间

异步 I/O 可能提高整体吞吐量,但如果并发参数配置过高,也可能增加部分查询的尾延迟。因此必须同时观察 P95 和 P99。

3. 检查认证配置

PostgreSQL 18 已将 MD5 密码认证标记为弃用,并计划在未来版本移除。仍然使用 MD5 的系统,应逐步迁移到 SCRAM。

4. 注意新集群默认启用数据页校验和

PostgreSQL 18 新建集群时默认启用数据页校验和。使用 pg_upgrade 时,新旧集群的校验和配置需要匹配,因此升级前应先检查现有集群状态。

5. 检查扩展、驱动和连接池

数据库内核升级成功,不代表周边组件一定兼容。需要验证:

  • 数据库扩展;
  • ORM 和数据库驱动;
  • 连接池;
  • 数据同步工具;
  • 备份工具;
  • 数据库代理;
  • 监控采集器;
  • CDC 组件。

6. 保留完整回滚路径

升级前至少应该具备:

  • 可验证的全量备份;
  • 恢复演练记录;
  • 旧版本数据目录保护方案;
  • 应用版本回滚方案;
  • 数据双写或停机窗口策略;
  • 明确的回滚触发指标。

十一、哪些功能最值得优先采用

对于不同类型的项目,可以采用不同的优先级。

新项目

优先考虑:

  1. 使用 uuidv7() 作为内部 UUID 主键;
  2. 使用 RETURNING OLD / NEW 简化更新结果获取;
  3. 将确定性行内计算放入生成列;
  4. 从一开始建立 pg_stat_io 监控。

已有 OLTP 系统

优先考虑:

  1. 升级到最新 PostgreSQL 18.x 维护版本;
  2. 检查现有 UUIDv4 主键带来的索引写入情况;
  3. 分析联合索引是否可能受益于 Skip Scan;
  4. 验证驱动、连接池和扩展兼容性;
  5. 不要因为异步 I/O 而盲目提高并发参数。

报表和分析型系统

优先考虑:

  1. 对大表顺序扫描进行基准测试;
  2. 比较 workerio_uring
  3. 观察 bulkread 上下文的 I/O 数据;
  4. 验证 VACUUM 和批量任务耗时;
  5. 同时监控存储吞吐和尾延迟。

十二、结语

PostgreSQL 18 最值得关注的地方,不是简单增加了几个新语法,而是数据库正在从“等待操作系统替自己猜测读取需求”,转向主动调度多个 I/O 请求;从严格依赖联合索引前导列,转向在合适的数据分布下尝试 Skip Scan;从随机 UUID,转向更适合索引写入的时间有序 UUID;从应用层重复计算,转向数据库统一维护确定性派生字段。

但这些能力也传递出一个非常重要的信息:

数据库版本升级不会自动替代性能工程,它只是给优化器和开发者提供了更多可选路径。

异步 I/O 是否有效,要看存储延迟和访问模式;Skip Scan 是否执行,要看列基数和统计信息;UUIDv7 是否适合,要看业务是否允许暴露时间特征;虚拟生成列是否划算,要看读取频率与计算成本。

真正可靠的升级方式,仍然是基于真实数据、真实 SQL 和真实业务流量完成测试。

PostgreSQL 18 不是让所有查询突然变快,而是让数据库在面对现代硬件、云存储和复杂业务访问模式时,拥有了更合理的底层能力。

1 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  1 条评论
召田最帥boy   湖南省长沙市

这是真看不懂