在生产环境中数据库 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_commit 和 xact_rollback 高说明全库事务推进快。tup_inserted、tup_updated、tup_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 冲击。