近期处理了一起极其经典的 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 被无效扫描打满
排查过程中,第一视角的监控极其惨烈:
-
iostat显示底层 NVMe 盘的%util长时间顶在 100%,大量的随机读写。 -
慢查询日志被打爆,平平无奇的单行
UPDATE和SELECT居然要跑几秒钟。
直觉告诉我,数据扫描路径出问题了。连上数据库,直接看活跃会话:
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 transaction,duration 已经高达 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)写在同一个数据文件中。
当执行 UPDATE 或 DELETE 时,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_repack 或 pg_squeeze 插件:
# 在线无锁重建膨胀表,将真实数据拷贝到临时表并交换文件指针
pg_repack -h localhost -d prod_db -t public.orders -j 4
排查清单与同类问题速查
-
检查挂起长事务:周期性监控
pg_stat_activity中state = 'idle in transaction'且duration > 5m的会话,直接触发告警。 -
监控表膨胀率:通过
pg_stat_user_tables结合pg_class估算dead_tuple比例,超过 20% 的大表需人工介入检查。 -
关注 XID Age:监控
datfrozenxid的年龄(age(datfrozenxid)),如果超过autovacuum_freeze_max_age(默认 2 亿)且持续攀升,说明系统的冻结机制已失效,距离全盘宕机倒计时开始。 -
警惕复制槽(Replication Slot)滞留:除了长事务,未被消费的废弃逻辑复制槽也会拖住
xmin,导致主库无法清理死元组,需通过pg_replication_slots视图排查清理。