防事务 ID 回卷 Autovacuum 的业务影响

John Doe 七月 29, 2026

摘要:在本文中,我们将结合一个真实案例,介绍防事务 ID 回卷的 Autovacuum 对业务 SLA 可用性的影响。

目录

事件与业务背景

事故概况:2021 年 11 月 22 日,Duffel 核心航班搜索 API 完全不可用长达 2 小时 17 分钟,直接导致下游商家无法搜索、预订机票,产生业务损失。

业务场景:航班搜索结果有效期仅 30 分钟,高并发下会产生海量过期数据。团队采用 PostgreSQL 表分区方案优化:表按小时分区,通过两个定时任务分别创建新分区、删除旧分区,用 DROP 分区替代批量 DELETE,大幅提升过期数据清理效率。

雪崩效应:故障的完整传导路径

故障的完整传导路径

在 Duffel 的案例中,由于航班搜索结果表的数据量巨大,表年龄触及了 2 亿的红线触发了防事务 ID 回卷的 autovacuum。随后的宕机其实经历了极其标准的 4 个传导步骤:

  1. 紧急清理进程启动且拒绝让步: 持有 SHARE UPDATE EXCLUSIVE 锁. 防事务 ID 回卷的 autovacuum 启动全表扫描,持有常规读写互不干扰的 SHARE UPDATE EXCLUSIVE 锁。但因为它处于“不可中断”模式,它成为了系统中一堵无法被推倒的墙。

  2. 定时 DDL 语句进入等待队列: 请求 SHARE ROW EXCLUSIVE 等高级别锁. 此时,业务系统恰好触发了自动分表、删除旧分区的 DDL 语句(例如 Duffel 定时执行的 CREATE TABLE ... PARTITION OF)。该操作请求获取高级别的表级锁。由于 autovacuum 坚守锁不放,这条 DDL 语句无法执行,只能被挂起并进入数据库的等待队列(Wait Queue)。

  3. 核心灾难:业务读写请求被全面拦截: 请求 ROW EXCLUSIVE 锁. 这是击穿 SLA 的决定性环节。PostgreSQL 的锁队列遵循严格的“先来后到”原则。即使常规的业务写操作(如 INSERT)只需要较弱的锁,但因为队列前方已经卡着一个排他级别很高的 DDL 请求,所有后续的 API 业务请求都被视为与该 DDL 冲突,全部排在它后面干等。

  4. 连接池耗尽,SLA 降为零: 故障全面爆发. 由于读写查询被阻塞,数据库的等待队列迅速变长。应用服务器(API)的数据库连接池在短时间内被彻底耗尽。新的 API 请求无法建立连接,直接导致业务大面积抛出访问错误,可用性 SLA 遭到严重破坏。

核心原理:为什么 autovacuum 会堵死业务?

防事务 ID 回卷 Autovacuum

该问题背后的完整 PostgreSQL 机制为:

  1. 基础:MVCC 与事务 ID 回卷 PostgreSQL 基于 32 位事务 ID(XID)实现 MVCC 多版本并发控制,XID 循环复用会出现「回卷」问题——旧数据的事务 ID 会看起来比新事务更大,导致数据不可见、甚至逻辑上的数据丢失。VACUUM 的核心职责之一就是标记「冻结」旧行,从根本上避免回卷风险。

  2. 常规 Autovacuum 的锁规则 常规 Autovacuum 会在后台默默运行,持有 SHARE UPDATE EXCLUSIVE 锁。如果业务端发起了需要修改表结构的 DDL 语句,常规的清理进程会非常“识趣”地中断自己并释放锁,对业务 SLA 毫无影响

  3. 关键例外:防回卷 autovacuum 不可中断 当表的冻结年龄(relfrozenxid 的年龄)达到 autovacuum_freeze_max_age(默认 2 亿)阈值时,会触发防事务 ID 回卷的强制 VACUUM

    防事务 ID 回卷的 Autovacuum 肩负着防止数据丢失的生死任务,PostgreSQL 在内部将其设定为不可中断(Uninterruptible)。这就意味着,哪怕全表扫描需要耗费好几个小时,它也会死死咬住手里的锁,绝不向任何冲突操作让步。

  4. 锁排队的雪崩效应 由于创建分区的脚本没有加锁超时限制,会无限期等待锁,进而堵死整条等待队列。团队估算,这个问题最晚 30 天内也必然会爆发。最终表现为整张表完全不可读写,引发全站故障。

保障业务 SLA 的最佳实践

要防止类似的底层保护机制变成业务杀手,需要从以下三个层面进行防御:

  1. 为 DDL 加上“安全阀”(防雪崩): 这是 Duffel 踩坑后得出的最痛教训。无论是做 Migration 还是跑定时任务,任何涉及到 DDL 的语句,都必须设置 lock_timeoutstatement_timeout。这样当它拿不到锁时会“快速失败(Fail-fast)”并退出队列,而不是一直卡在那里阻挡后续正常的读写流量。
  2. 监控前置,主动出击(防触发): 不要把希望寄托在系统的紧急防回绕机制上。应当把 pg_class.relfrozenxid 的年龄纳入核心监控大盘。在它逼近 2 亿之前,利用业务流量低谷期(如凌晨)手动执行 VACUUM FREEZE
  3. 理清依赖图(快速恢复): 发生死锁或严重阻塞时,通过 pg_stat_activity 提取正在持有锁的进程和等待队列的日志,快速梳理出锁等待的依赖树,直接使用 pg_terminate_backend() 杀掉那个挂起的 DDL 进程(在 Duffel 案例中是执行建表语句的进程),即可瞬间疏通业务流。

当然,也可以考虑使用 Redrock Postgres,内部采用 64 位事务 ID,修改表元组时记录回滚日志,无需全量冻结。

参考

Duffel Engineering:梳理一次宕机事件:PostgreSQL 的并发控制与清理

PostgreSQL 源代码:storage/lmgr/proc.c:ProcSleep()