深入 PostgreSQL 生产表膨胀雪崩:长事务挂起引发的 autovacuum 失效与 XID Wraparound 宕机危机

近期处理了一起极其经典的 PostgreSQL 数据库性能雪崩事故。核心表现为核心集群 CPU Load 飙升至 100+,读写 P99 延迟从 5ms 暴增到 3000ms,同时监控面板上的磁盘利用率以肉眼可见的速度疯狂攀升(每小时吃掉数十 GB)。

结论先行:业务服务因某个非预期的异常退出,留下了一个长达数天的 idle in transaction(事务空闲)会话。这个幽灵会话死死按住了全局的 xmin 水位线,导致底层的 autovacuum 进程虽然疯狂拉起扫表,却无法清理任何死元组(Dead Tuples),最终引发海量表膨胀,并险些触发 PG 核心的 XID Wraparound(事务 ID 环绕)强制只读宕机保护。

解决方法极其简单粗暴:pg_terminate_backend(pid) 杀掉僵尸进程,并在全局强制开启 idle_in_transaction_session_timeout 防御性配置。随后通过 pg_repack 无锁重建膨胀表。

现场还原:当磁盘 I/O 被无效扫描打满

排查过程中,第一视角的监控极其惨烈:

  1. iostat 显示底层 NVMe 盘的 %util 长时间顶在 100%,大量的随机读写。

  2. 慢查询日志被打爆,平平无奇的单行 UPDATESELECT 居然要跑几秒钟。

直觉告诉我,数据扫描路径出问题了。连上数据库,直接看活跃会话:

SELECT pid, usename, state, backend_xid, backend_xmin, duration 
FROM (
    SELECT pid, usename, state, backend_xid, backend_xmin, 
           now() - xact_start AS duration 
    FROM pg_stat_activity 
    WHERE state != 'idle'
) sq 
ORDER BY duration DESC LIMIT 5;

结果极其刺眼:排名第一的会话状态是 idle in transactionduration 已经高达 96:12:45(整整四天!)。

再看系统视图里的表膨胀情况:

SELECT relname, n_live_tup, n_dead_tup, 
       round(n_dead_tup::numeric / (n_live_tup + n_dead_tup + 0.01) * 100, 2) AS dead_ratio
FROM pg_stat_user_tables 
ORDER BY n_dead_tup DESC LIMIT 5;

核心订单表的 n_dead_tup 高达数亿,dead_ratio 超过 70%。这意味着业务每次查询,PG 都要在磁盘上额外扫描 70% 的废弃数据,I/O 不炸才是见鬼了。

底层原理:为什么一个 idle 会话能拖垮整个集群?

很多人从 MySQL 迁移到 PostgreSQL 时,最不适应的就是它的 MVCC(多版本并发控制)实现。

MySQL 把旧版本数据存放在独立的 Undo Log 里,而 PG 的设计更为激进——直接把新老版本(Tuples)写在同一个数据文件中。 当执行 UPDATEDELETE 时,PG 只是在老元组的头部打上过期标记(xmax),然后插入一个新元组。这些被打上标记的老旧死元组,全靠后台的 autovacuum 进程来回收空间。

autovacuum 清理死元组有一个铁律:必须保证当前系统中没有任何活跃事务可能再访问到这些元组

这里就涉及全局最小活跃事务 ID(xmin)。 如果系统中存在一个事务 A(比如我们抓到的那个僵尸会话),它在 4 天前开启(执行了 BEGIN 并且做过查询),那么 PG 必须为事务 A 保留它开启那个时间点的所有数据快照。 在事务 A 提交或回滚之前,全局的 xmin 水位永远无法向前推进。

这就是最致命的地方:即便这 4 天里产生了上亿个死元组,autovacuum 正常按计划被唤醒,它扫描了整个表,发现这些死元组的 xid 都比那个僵尸事务 A 的 xid 要大,于是它一个字节都不能删,只能无奈地退出。循环往复,白白消耗大量 I/O 去扫表,却做着无用功。

致命一击:XID Wraparound 保护

更可怕的还在日志里。查看 postgresql.log,发现大量类似这样的告警:

WARNING:  database "prod_db" must be vacuumed within 10000000 transactions
HINT:  To avoid a database shutdown, execute a database-wide VACUUM in that database.

PG 的事务 ID(XID)是一个 32 位的无符号整数,最大约 42 亿。为了处理环绕(即 XID 耗尽后从头开始),PG 把 XID 空间一分为二,过去 21 亿是“过去”,未来 21 亿是“未来”。 为了防止极其古老的事务 ID 变成“未来”导致数据不可见,PG 强制要求在 XID 跨度达到 20 亿之前,必须通过 VACUUM 冻结(Freeze)旧事务。

因为那个 4 天前的僵尸事务拦住了 autovacuum 的清理与冻结逻辑,XID 正在逼近环绕红线。一旦触发 autovacuum (to prevent wraparound),这是最高优先级的强制清理操作,它会无视常规调度并疯狂吃光 I/O。如果最后还没清理完,PG 会为了保护数据不损坏,强行将整个数据库锁死进入只读模式(shutdown)

防御性落地:如何给系统系上安全带

一个开发连直连线上 DB 手敲 BEGIN 忘了 COMMIT 去喝咖啡,或者微服务里一个没有设置 Timeout 的 HTTP 请求持有了 DB 链接挂死,就能让整个集群陪葬。这种架构容错率极低,必须从配置层面进行防御性斩断。

1. 止血操作: 立刻执行斩首,将该 PID 强杀:

SELECT pg_terminate_backend(pid);

杀掉之后,autovacuum 终于能工作了,观察磁盘 I/O 依然很高,但那是正在真正清理死元组。

2. 核心防御配置(必须写进 postgresql.conf):

# 强制终止空闲在事务中的会话(救命配置,单位毫秒)
idle_in_transaction_session_timeout = 600000  # 10分钟

# 强制终止超长查询(防止烂SQL打满CPU)
statement_timeout = 30000  # 30秒

# 开启 autovacuum 慢执行日志,增强可观测性
log_autovacuum_min_duration = 1000 # 超过1秒的清理记录到日志

3. 空间回收: autovacuum 只能把死元组标记为可复用,它不会把磁盘空间还给操作系统(除非死元组刚好在文件的最后)。 对于已经严重膨胀的表,直接执行 VACUUM FULL 会获取最高级别的排他锁(AccessExclusiveLock),直接导致业务阻塞报错。 生产环境的唯一正解是使用 pg_repackpg_squeeze 插件:

# 在线无锁重建膨胀表,将真实数据拷贝到临时表并交换文件指针
pg_repack -h localhost -d prod_db -t public.orders -j 4

排查清单与同类问题速查

  1. 检查挂起长事务:周期性监控 pg_stat_activitystate = 'idle in transaction'duration > 5m 的会话,直接触发告警。

  2. 监控表膨胀率:通过 pg_stat_user_tables 结合 pg_class 估算 dead_tuple 比例,超过 20% 的大表需人工介入检查。

  3. 关注 XID Age:监控 datfrozenxid 的年龄(age(datfrozenxid)),如果超过 autovacuum_freeze_max_age(默认 2 亿)且持续攀升,说明系统的冻结机制已失效,距离全盘宕机倒计时开始。

  4. 警惕复制槽(Replication Slot)滞留:除了长事务,未被消费的废弃逻辑复制槽也会拖住 xmin,导致主库无法清理死元组,需通过 pg_replication_slots 视图排查清理。