过去谈到数据库性能优化,很多人的第一反应通常是:
- 给查询增加索引;
- 调整 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 的问题
传统同步读取的执行过程可以简单理解为:
- 数据库向操作系统发起读取请求;
- 当前执行流程等待数据返回;
- 数据进入共享缓冲区;
- 查询继续处理下一批数据;
- 再次发起读取请求。
当数据已经位于 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: 提交数据页 1、2、3、4
A->>S: 并发发起多个读取请求
S-->>A: 返回数据页 2
S-->>A: 返回数据页 1
A-->>Q: 交付已完成的数据页
Q->>Q: 继续执行查询
S-->>A: 返回数据页 3、4
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_concurrency 和 maintenance_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 更偏向实时状态查看,可以观察请求是否处于 STAGED、SUBMITTED 或已完成状态。该视图主要面向 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 等待时间;
bulkread、vacuum、normal等不同上下文。
其中,read_time、write_time 等时间字段只有在启用 track_io_timing 后才会产生有效数据。官方也建议将 PostgreSQL 统计视图与操作系统监控工具结合使用,因为 PostgreSQL 无法完全区分数据究竟来自物理磁盘,还是已经存在于操作系统页缓存中。
四、异步 I/O 不是所有查询的“免费加速器”
异步 I/O 更可能改善以下负载:
- 大表扫描;
- 数据仓库和报表查询;
- 大量数据不在共享缓冲区中;
- 网络附加存储;
- 存储延迟较高但支持较高并发;
VACUUM读取大量数据页;- Bitmap Heap Scan 比较频繁。
以下负载可能变化不明显:
- 数据基本全部位于内存;
- 查询主要走高度选择性的索引;
- 单次只读取极少量数据;
- 系统瓶颈位于 CPU;
- 锁竞争才是主要延迟来源;
- 应用连接池或者网络往返已经成为瓶颈;
- SQL 本身存在明显的执行计划问题。
因此,升级 PostgreSQL 18 后,仍然需要通过 EXPLAIN ANALYZE、BUFFERS、pg_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
);
应用只需要写入 quantity 和 unit_price,amount 由数据库计算。
PostgreSQL 18 支持两种生成列:
| 类型 | 计算时机 | 是否占用表存储 |
|---|---|---|
VIRTUAL |
查询读取时计算 | 不存储计算结果 |
STORED |
插入或更新时计算 | 存储计算结果 |
PostgreSQL 18 默认使用虚拟生成列。虚拟列更接近普通视图中的表达式,而存储生成列更接近自动维护的物化结果。
生成列适合承载以下逻辑:
- 金额计算;
- 单位换算;
- 字符串标准化;
- 状态映射;
- 日期拆分;
- 多字段组合;
- 确定性业务派生值。
这样可以避免多个应用分别实现相同计算规则,减少不同服务之间出现结果不一致的概率。
但生成表达式存在明确限制:
- 只能使用不可变函数;
- 不能包含子查询;
- 不能引用其他生成列;
- 不能直接引用当前行之外的数据;
- 不能同时定义默认值;
- 不能作为分区键;
- 虚拟生成列对用户自定义函数和类型还有额外限制。
这些限制意味着生成列适合处理“当前行内部的确定性计算”,不适合处理库存查询、汇率转换、跨表统计等动态业务逻辑。
八、RETURNING OLD / NEW:一次 SQL 同时拿到修改前后数据
过去执行更新时,如果应用需要知道数据修改前后的差异,常见做法是:
- 查询旧数据;
- 在应用中计算新数据;
- 执行更新;
- 再次查询更新结果;
- 写入审计记录。
这种方式不仅增加数据库往返次数,还容易在高并发下出现查询结果与实际更新数据不一致的问题。
PostgreSQL 18 可以在 RETURNING 中同时访问 OLD 和 NEW:
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 *;
INSERT、UPDATE、DELETE 和 MERGE 均支持 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. 保留完整回滚路径
升级前至少应该具备:
- 可验证的全量备份;
- 恢复演练记录;
- 旧版本数据目录保护方案;
- 应用版本回滚方案;
- 数据双写或停机窗口策略;
- 明确的回滚触发指标。
十一、哪些功能最值得优先采用
对于不同类型的项目,可以采用不同的优先级。
新项目
优先考虑:
- 使用
uuidv7()作为内部 UUID 主键; - 使用
RETURNING OLD / NEW简化更新结果获取; - 将确定性行内计算放入生成列;
- 从一开始建立
pg_stat_io监控。
已有 OLTP 系统
优先考虑:
- 升级到最新 PostgreSQL 18.x 维护版本;
- 检查现有 UUIDv4 主键带来的索引写入情况;
- 分析联合索引是否可能受益于 Skip Scan;
- 验证驱动、连接池和扩展兼容性;
- 不要因为异步 I/O 而盲目提高并发参数。
报表和分析型系统
优先考虑:
- 对大表顺序扫描进行基准测试;
- 比较
worker与io_uring; - 观察
bulkread上下文的 I/O 数据; - 验证
VACUUM和批量任务耗时; - 同时监控存储吞吐和尾延迟。
十二、结语
PostgreSQL 18 最值得关注的地方,不是简单增加了几个新语法,而是数据库正在从“等待操作系统替自己猜测读取需求”,转向主动调度多个 I/O 请求;从严格依赖联合索引前导列,转向在合适的数据分布下尝试 Skip Scan;从随机 UUID,转向更适合索引写入的时间有序 UUID;从应用层重复计算,转向数据库统一维护确定性派生字段。
但这些能力也传递出一个非常重要的信息:
数据库版本升级不会自动替代性能工程,它只是给优化器和开发者提供了更多可选路径。
异步 I/O 是否有效,要看存储延迟和访问模式;Skip Scan 是否执行,要看列基数和统计信息;UUIDv7 是否适合,要看业务是否允许暴露时间特征;虚拟生成列是否划算,要看读取频率与计算成本。
真正可靠的升级方式,仍然是基于真实数据、真实 SQL 和真实业务流量完成测试。
PostgreSQL 18 不是让所有查询突然变快,而是让数据库在面对现代硬件、云存储和复杂业务访问模式时,拥有了更合理的底层能力。

这是真看不懂