排查过程中,我们接手了一个险些造成核心交易系统雪崩的 PostgreSQL 性能故障。表现非常直接:主库 Load Average 突然从 2 飙升到 80+,核心订单表查询的 99 线延迟从 5ms 暴涨至 3s 以上,底层 NVMe SSD 的 I/O 写入利用率打满 100%。
直接抛出结论:下游大数据团队停用了一个 Flink CDC 抽取任务,但未在 PG 主库清理对应的逻辑复制槽(Logical Replication Slot)。这个处于离线(active = false)状态的僵尸复制槽死死拖住了全局的 catalog_xmin 推进,导致主库 MVCC 机制失效,几个核心大表的死元组(Dead Tuples)疯狂膨胀。当某位值班兄弟发现 WAL 目录快满,为了释放空间“急中生智”手动 Drop 掉该复制槽时,被压抑已久的 xmin 水位线瞬间释放,触发了史诗级的 Autovacuum 工作流。几个 Worker 进程以近乎全速的 I/O 扫描并清理上百 GB 的死元组,直接击穿了存储 I/O,引发全站查询阻塞。
盲目 Drop 复制槽而不评估 MVCC 历史债务,是典型的只管表象不顾底层的操作。下面拆解底层的运作机理和防御性配置。
案发现场与指标特征
故障爆发时,首先看 iostat:
iostat -dx 1
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
nvme0n1 0.00 0.00 45200.00 1205.50 361600.00 15600.00 16.25 32.50 0.70 0.65 2.50 0.02 100.00
磁盘 %util 顶在 100%,大量的随机读(r/s 极高),这通常不是正常的业务流量。
切到数据库内部看 pg_stat_activity 的等待事件:
SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE state = 'active' AND wait_event_type = 'IO';
发现几十个业务连接卡在 DataFileRead,更致命的是,出现了几个底层的进程,query 字段显示:
autovacuum: VACUUM public.orders
回头翻看慢查询日志和监控,发现在 Autovacuum 启动前,有人执行了:
SELECT pg_drop_replication_slot('flink_cdc_orders');
这就是血案的导火索。
底层原理解析:为什么 Drop 复制槽会击穿 I/O?
PostgreSQL 的 MVCC 实现与 MySQL/Oracle 不同,它没有独立的 Undo Log 表空间,而是将旧版本的数据(Tuple)直接留在原表的 Heap 文件中。
为了判断哪些旧版本 Tuple 可以被安全清理,PG 维护了一个全局的可见性底线——xmin horizon。如果系统中没有任何活跃事务、预备事务或复制槽需要看到某个旧版本,Autovacuum 就可以将其作为死元组(Dead Tuple)回收。
逻辑复制槽(用于 CDC 日志解析)为了保证在解码 WAL 时能够读取到完整的系统表元数据,会持有一个 catalog_xmin。
只要下游 CDC 断开连接且不推进 LSN,这个复制槽就会变成“僵尸”,其 catalog_xmin 永远停留在断开的那一刻。
-- 查看复制槽状态的致命视角
SELECT slot_name, plugin, active, xmin, catalog_xmin, restart_lsn
FROM pg_replication_slots;
连锁反应如下:
-
垃圾回收停滞:
catalog_xmin停滞,导致全局 xmin horizon 无法推进。 -
表极度膨胀:业务高频执行 UPDATE/DELETE,产生海量 Dead Tuples,但 Autovacuum 扫描后发现这些 Tuple 还处于 xmin horizon 之上,无法物理回收。表体积从 50GB 膨胀到 200GB。
-
WAL 堆积:物理/逻辑复制槽不仅阻塞 xmin,还会通过
restart_lsn阻塞 WAL 日志的清理。PG 的pg_wal目录开始告警。 -
引爆 I/O 风暴:运维强行
pg_drop_replication_slot(),释放了catalog_xmin和 WAL 限制。xmin horizon 瞬间跳跃到当前最新。Autovacuum 再次醒来时,发现有 150GB 的死元组“终于”可以清理了,开始执行极其沉重的索引和堆表扫描。如果没有做好 I/O 成本节流配置,Autovacuum 会消耗掉所有磁盘带宽。
止血与防御性配置落地
1. 现场紧急止血
不能任由 Autovacuum 把主库 I/O 打死,必须先将其阻断,再限流恢复。
第一步,干掉正在作恶的 autovacuum 进程(PG 内部机制会自动重启它,但我们要争取时间修改配置):
-- 找出 autovacuum worker 并 cancel
SELECT pg_cancel_backend(pid)
FROM pg_stat_activity
WHERE query LIKE 'autovacuum: %';
第二步,动态调低 Autovacuum 的 I/O 侵略性:
-- 增加每次清理达到成本限制后的睡眠时间(默认可能是2ms,调大到20ms)
ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '20ms';
-- 减小单次清理的成本限制(让它更快休眠)
ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 200;
-- 重新加载配置
SELECT pg_reload_conf();
2. 长期防御性调优(避坑指南)
别指望所有研发都会记得下线 CDC 时通知 DBA,系统必须具备自我保护能力。
防御手段 A:限制复制槽的 WAL 保留量 (PostgreSQL 13+ 特性)
在 PG 13 之前,一个失联的复制槽可以直接把磁盘撑爆。PG 13 引入了救命参数 max_slot_wal_keep_size。当复制槽保留的 WAL 超过此值时,PG 会强制使该槽失效(Invalidate),宁可让下游 CDC 报错重做,也不能让主库挂掉。
# postgresql.conf
# 允许单个复制槽最多保留 50GB 的 WAL
max_slot_wal_keep_size = 50GB
防御手段 B:杀死长连接与长事务
除了复制槽,长时间 Idle in transaction 的连接同样会死锁 xmin horizon。
# postgresql.conf
# 强制断开闲置超过 10 分钟的事务连接,防止 xmin 冻结
idle_in_transaction_session_timeout = 600000
防御手段 C:精细化 Autovacuum I/O 控制
默认的 Autovacuum 参数在 SSD 时代显得过于保守,但如果是极度膨胀后的突发清理,又会过于激进。建议基于磁盘 I/O 能力计算 cost_limit,不要使用一刀切的默认值。基于 NVMe 盘的基线建议:
autovacuum_max_workers = 5
autovacuum_vacuum_cost_limit = 2000
autovacuum_vacuum_cost_delay = 2ms
保证日常能快读清理完毕,不至于把债务堆积到爆发。
排查清单:PostgreSQL 存储膨胀与 I/O 突增速查
为了便于后续快速定位同类问题,请重点检查以下几项:
-
检查全局 xmin 阻塞源: 执行
SELECT * FROM pg_replication_slots WHERE active = false;查找僵尸复制槽。 -
排查长事务/预备事务: 查询
pg_stat_activity中state = 'idle in transaction'且backend_xid/backend_xmin极老的会话;排查pg_prepared_xacts中未提交的 2PC 事务。 -
确认表膨胀率 (Dead Tuples): 查询
pg_stat_user_tables,对比n_live_tup与n_dead_tup的比例,若n_dead_tup极高且持续不降,说明 MVCC 清理链已断裂。 -
监控 Autovacuum 行为: 观察系统视图
pg_stat_progress_vacuum,检查当前是否有表正在经历阶段耗时极长的vacuum_index或vacuum_heap。 -
WAL 堆积安全阈值验证: 检查当前 PG 版本是否配置了
max_slot_wal_keep_size作为底线防御,否则将随时面临全盘占满的宕机风险。