PostgreSQL 数据库 CPU 异常升高问题分析

在生产环境中数据库 CPU 利用率在某个时间点突然异常增高,几分钟后恢复正常。这种情况导致在这几分钟内,服务可用性下降。由于数据库使用的是 AWS RDS(postgresql),通过 Database Insights 查看异常时间点的 Top SQL,发现存在 autovacuum 事件。最终分析到原因是:在高事务推进速度下,常规 vacuum 无法把业务表的冻结年龄持续压低,最终周期性逼近防回卷区间,触发 anti-wraparound autovacuum,从而引起短时 CPU/WAL/IO 峰值

PostgreSQL 面临"事务ID回卷"的风险,需要主动进行"冻结"来防范;而 MySQL (InnoDB) 则没有这个问题。这种差异源于两者在实现 MVCC 时不同的核心架构选择。

下面是具体的分析过程。

XID 回卷

PostgreSQL 的 MVCC(Multi-Version Concurrency Control) 依赖 XID,即事务 ID。

举例说明:

假设 user 表中有一行数据,其由第 50 个事务创建。

复制代码
id    name
1     Tom

现在第 100 个事务修改了这条记录,将 name 改成了 Jerry。在 PostgreSQL 中并不会直接覆盖原纪录,而是会有两条记录同时存在:

复制代码
id    name    xmin   xmax
1     Tom     50     100
1     Jerry   100    NULL

其中,xmin 表示创建该行数据的事务 ID,xmax 为删除(失效)该行数据的事务 ID。换句话说,第一行数据对事务 id 在 [50,100)区间的事务可见,第二行数据对事务 id 在 [100, NULL) 区间的事务可见。

事务 ID 回卷

在 PostgreSQL 中,事务 ID 用 32 位整数表示(约 42 亿)。当达到上限后,事务 ID 会回卷至 0。一旦回卷,新事务的 ID 就可能比旧事务的 ID 更小,从而导致在数据可见性方面产生问题。

为了解决这一问题,PostgreSQL 通过 VACUUM 机制,将那些足够老的事务 ID 标记为一个特殊的、对所有事务都可见的 "冻结 ID"(relfrozenxid)。PostgreSQL 为每个表都维护了一个 relfrozenxid,用来表示这个表中所有事务 ID 小于 relfrozenxid 的记录都已经被冻结。那如何断定什么是足够老的事务 ID 呢?

Vacuum 分为两种,一种是常规的,一种是强制的( anti-wraparound VACUUM)。

常规 vaccum 是动态的,其选择冻结的事务 id 会结合不同的参数和当前系统状态算出来。常规 vacuum 消耗的资源少一些,对数据库影响相对较小。

而强制 vacuum 则是有固定规则的:表中当前活跃的最老的事务 id 的年龄接近 autovacuum_freeze_max_age 时,会触发强制 vacuum,以保证事务安全。强制 vacuum 会占用大量资源,导致 CPU、网络、读写延迟都会增加。

autovacuum_freeze_max_age 理论上可以是最大事务 id 的一半,即 21 亿。默认情况下,该值为 2 亿,通过 sql 语句可以查询:

sql 复制代码
SHOW autovacuum_freeze_max_age;

这个值表示,当前数据库中活跃的事务 id 的范围不能超过 2 亿。假设某个表中最老的活跃事务 id 是 1 kw,当前数据库中最大事务 id 是 2.1 亿,则这个表中这条最老的事务年龄就达到了 autovacuum_freeze_max_age 阈值,会触发 anti-wraparound VACUUM

autovacuum_freeze_max_age 最大值

理论上来说,只要允许活跃的事务 id 范围不超过最大值的一半,就可以达到循环利用事务 id 的效果。

举个例子说明,假设数据库事务 id 最大值为 12,允许活跃的事务为 6 个。如果当前活跃的事务 id 最大值为 9,最小值为 2,则 xid = 2 的活跃事务必然是比 9 更新的事务。因为只有按照 9 -> 10 -> 11 -> 12 -> 1 -> 2 的方向演进,活跃的事务范围才不超过 6。反过来如果是按照 2 到 9 的顺序演进,事务范围就大于 6 了,不满足条件。

如果允许活跃的事务 > 6 呢?假设为 7。当前活跃的事务 id 最大为 9,最小为 3。如果按照 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 的顺序演进,满足条件,因此认为 xid = 9 的事务是新的事务。反过来按照 9 -> 10 -> 11 -> 12 -> 1 -> 2 -> 3 的顺序演进,同样满足条件,因此认为 xid = 3 的事务是新的事务。这就产生矛盾了。

如果允许活跃的事务数超过事务 id 最大值的一半,就无法判断两个事务的新旧,所以 autovacuum_freeze_max_age 的值最大可以为 2^31。

Trouble-Shooting

想要判断是否是 anti-wraparound VACUUM 导致数据库 CPU 飙升,可以通过一些直观的手段来查看是否有 Vacuum 事件,比如 AWS Database Insights 监控。如果不能直观的看出,也可以通过 sql 语句来判断。

查询活跃事务 id 最老的表:

复制代码
SELECT
  c.oid::regclass AS table_name,
  greatest(age(c.relfrozenxid), age(t.relfrozenxid)) AS xid_age,
  pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class c
LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relkind IN ('r','m')
ORDER BY xid_age DESC
LIMIT 10;

如果某个表中 xid_age 接近 2 亿,说明即将或者已经触发强制 vacuum。如果还差很多,但是 xid_age 增长速度很快(间隔几分钟后再次查看),那也有可能在不久的将来会触发强制 vacuum。当然也可能触发普通 vacuum 将老龄的事务 id 给冻结了。通过以下语句可以查看该表上次普通 vacuum 的时间:

复制代码
SELECT relname, n_tup_ins, n_tup_upd, n_tup_del, n_dead_tup,
       last_autovacuum, autovacuum_count
FROM pg_stat_user_tables
WHERE relname = 'xxx';

结果示例如下,说明普通 vaccum 确实有触发:

复制代码
relname	n_tup_ins  n_tup_upd  n_tup_del  n_dead_tup  last_autovacuum             autovacuum_count
xxx	     1675710	11324652   30733      9366       2026-06-25 10:37:58.7963+00  52

如果写少( upd/del 低)同时 autovacuum_count 低,说明这张表不容易被普通 vacuum 清理。与此同时如果全局 xid 推进速度快,那这张表就很容易被动变老。

再看看全库 xid 推进速度:

复制代码
SELECT datname, xact_commit, xact_rollback, blks_read, blks_hit, tup_inserted, tup_updated, tup_deleted
FROM pg_stat_database
ORDER BY (xact_commit + xact_rollback) DESC;

结果如下:

复制代码
datname    xact_commit   xact_rollback     blks_read    blks_hit     tup_inserted    tup_updated  tup_deleted
"rds"      2646817444     1329881          12193124     37985177484  121269311       15274081      1423400

xact_commitxact_rollback 高说明全库事务推进快。tup_insertedtup_updatedtup_deleted 高说明全局写活动频繁。

结果分析

根据以上结果可得结论如下:

业务表 xid_age 高且增长速度快说明该表确实是高龄表,已经进入中高风险区间,具备触发防回卷清理的前提。

业务表写多(相对其他表来说),autovacuum_count 多,last_autovacuum 时间近,说明不是没有触发 autovacuum,而是触发了很多次但仍然老化偏高。

原因是全库事务推进速度太快,即使业务表在持续 vacuum,也可能很快再次变老。随着 age(relfrozenxid) 接近 autovacuum_freeze_max_age 阈值,系统触发 anti-wraparound autovacuum。

要验证也很容易,只需要查看数据库 events 或者 logs,比如 AWS Log Insights 可以搜索:

复制代码
fields @timestamp, @message
| filter @message like /wraparound/
# | filter @message like /autovacuum/
| sort @timestamp desc
| limit 100

看看是否有防回卷清理或普通清理。

优化措施

事实上不管是普通 vacuum,还是强制 vacuum,都会导致 CPU 升高,只不过强制 vacuum 更严重一些。

可以通过调整 vacuum 相关的 RDS 参数来优化资源占用:

参数 含义 默认值 调整效果
autovacuum_vacuum_scale_factor 触发 vacuum 阈值的比例项 0.1 vacuum 阈值 = autovacuum_vacuum_threshold + scale_factor × 表行数,scale_factor 越小越早触发 vacuum,每次 vacuum 就更平滑
autovacuum_vacuum_threshold 触发 vacuum 阈值基础项 PostgreSQL 默认 50 正常无需调
autovacuum_vacuum_cost_limit vacuum 的成本值 GREATEST({log(DBInstanceClassMemory/21474836480)*600},200) 这个值本身是根据实例内存来计算的,内存越大,允许 vacuum 占用资源就越多,这是一个经验值,不建议调整
autovacuum_vacuum_cost_delay vacuum 的成本值达到后暂停时间 继承 PostgreSQL 的 2ms autovacuum_vacuum_cost_limit 配合作用,本次 vacuum 消耗达到 cost_limit 后暂停 cost_delay 时间后再继续,越大越平滑,但清理更慢
autovacuum_work_mem 每个 autovacuum worker 的工作内存 默认与 maintenance_work_mem 一样 如果太小,autovacuum 可能需要多次扫描索引,导致执行时间变长和 I/O 增加

优化思路是提高 vacuum 频次,降低单次 vacuum 体量。因此考虑如下调整:

  • 对热点大表降低 autovacuum_vacuum_scale_factor,增加 vacuum 频率以减少单次 vacuum 体量。
  • 适当降低 autovacuum_vacuum_cost_limit 并提高 autovacuum_vacuum_cost_delay ,降低瞬时 CPU/IO 冲击。