由 John Doe 七月 29, 2026
摘要:在本文中,我们将结合一个真实案例,介绍防事务 ID 回卷的 Autovacuum 对业务 SLA 可用性的影响。
目录
事件与业务背景
事故概况:2021 年 11 月 22 日,Duffel 核心航班搜索 API 完全不可用长达 2 小时 17 分钟,直接导致下游商家无法搜索、预订机票,产生业务损失。
业务场景:航班搜索结果有效期仅 30 分钟,高并发下会产生海量过期数据。团队采用 PostgreSQL 表分区方案优化:表按小时分区,通过两个定时任务分别创建新分区、删除旧分区,用 DROP 分区替代批量 DELETE,大幅提升过期数据清理效率。
雪崩效应:故障的完整传导路径

在 Duffel 的案例中,由于航班搜索结果表的数据量巨大,表年龄触及了 2 亿的红线触发了防事务 ID 回卷的 autovacuum。随后的宕机其实经历了极其标准的 4 个传导步骤:
-
紧急清理进程启动且拒绝让步: 持有 SHARE UPDATE EXCLUSIVE 锁. 防事务 ID 回卷的 autovacuum 启动全表扫描,持有常规读写互不干扰的
SHARE UPDATE EXCLUSIVE锁。但因为它处于“不可中断”模式,它成为了系统中一堵无法被推倒的墙。 -
定时 DDL 语句进入等待队列: 请求 SHARE ROW EXCLUSIVE 等高级别锁. 此时,业务系统恰好触发了自动分表、删除旧分区的 DDL 语句(例如 Duffel 定时执行的
CREATE TABLE ... PARTITION OF)。该操作请求获取高级别的表级锁。由于 autovacuum 坚守锁不放,这条 DDL 语句无法执行,只能被挂起并进入数据库的等待队列(Wait Queue)。 -
核心灾难:业务读写请求被全面拦截: 请求 ROW EXCLUSIVE 锁. 这是击穿 SLA 的决定性环节。PostgreSQL 的锁队列遵循严格的“先来后到”原则。即使常规的业务写操作(如
INSERT)只需要较弱的锁,但因为队列前方已经卡着一个排他级别很高的 DDL 请求,所有后续的 API 业务请求都被视为与该 DDL 冲突,全部排在它后面干等。 -
连接池耗尽,SLA 降为零: 故障全面爆发. 由于读写查询被阻塞,数据库的等待队列迅速变长。应用服务器(API)的数据库连接池在短时间内被彻底耗尽。新的 API 请求无法建立连接,直接导致业务大面积抛出访问错误,可用性 SLA 遭到严重破坏。
核心原理:为什么 autovacuum 会堵死业务?

该问题背后的完整 PostgreSQL 机制为:
-
基础:MVCC 与事务 ID 回卷 PostgreSQL 基于 32 位事务 ID(XID)实现 MVCC 多版本并发控制,XID 循环复用会出现「回卷」问题——旧数据的事务 ID 会看起来比新事务更大,导致数据不可见、甚至逻辑上的数据丢失。
VACUUM的核心职责之一就是标记「冻结」旧行,从根本上避免回卷风险。 -
常规 Autovacuum 的锁规则 常规 Autovacuum 会在后台默默运行,持有
SHARE UPDATE EXCLUSIVE锁。如果业务端发起了需要修改表结构的 DDL 语句,常规的清理进程会非常“识趣”地中断自己并释放锁,对业务 SLA 毫无影响。 -
关键例外:防回卷 autovacuum 不可中断 当表的冻结年龄(
relfrozenxid的年龄)达到autovacuum_freeze_max_age(默认 2 亿)阈值时,会触发防事务 ID 回卷的强制 VACUUM。防事务 ID 回卷的 Autovacuum 肩负着防止数据丢失的生死任务,PostgreSQL 在内部将其设定为不可中断(Uninterruptible)。这就意味着,哪怕全表扫描需要耗费好几个小时,它也会死死咬住手里的锁,绝不向任何冲突操作让步。
-
锁排队的雪崩效应 由于创建分区的脚本没有加锁超时限制,会无限期等待锁,进而堵死整条等待队列。团队估算,这个问题最晚 30 天内也必然会爆发。最终表现为整张表完全不可读写,引发全站故障。
保障业务 SLA 的最佳实践
要防止类似的底层保护机制变成业务杀手,需要从以下三个层面进行防御:
- 为 DDL 加上“安全阀”(防雪崩): 这是 Duffel 踩坑后得出的最痛教训。无论是做 Migration 还是跑定时任务,任何涉及到 DDL 的语句,都必须设置
lock_timeout和statement_timeout。这样当它拿不到锁时会“快速失败(Fail-fast)”并退出队列,而不是一直卡在那里阻挡后续正常的读写流量。 - 监控前置,主动出击(防触发): 不要把希望寄托在系统的紧急防回绕机制上。应当把
pg_class.relfrozenxid的年龄纳入核心监控大盘。在它逼近 2 亿之前,利用业务流量低谷期(如凌晨)手动执行VACUUM FREEZE。 - 理清依赖图(快速恢复): 发生死锁或严重阻塞时,通过
pg_stat_activity提取正在持有锁的进程和等待队列的日志,快速梳理出锁等待的依赖树,直接使用pg_terminate_backend()杀掉那个挂起的 DDL 进程(在 Duffel 案例中是执行建表语句的进程),即可瞬间疏通业务流。
当然,也可以考虑使用 Redrock Postgres,内部采用 64 位事务 ID,修改表元组时记录回滚日志,无需全量冻结。
参考
Duffel Engineering:梳理一次宕机事件:PostgreSQL 的并发控制与清理
PostgreSQL 源代码:storage/lmgr/proc.c:ProcSleep()