|
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?用户注册
×
PostgreSQL 的 autovacuum,很多 DBA 平时对它的印象就是"后台清理进程,偶尔占点 IO"。可一旦它失守,问题不是慢一点,而是整个库会逼近一个截止期限:事务 ID 回卷(XID wraparound)。真到那一步,PostgreSQL 会强制停止接收写入、进入单用户模式做 anti-wraparound vacuum,业务直接停摆。这次讲一次真实场景下的排查与治理:告警显示 age(datfrozenxid) 超过 8 亿,离 21.4 亿的回卷边界只差三分之一的余量,最后靠梳理冻结机制、揪出阻塞 vacuum 的长事务和泄漏复制槽、调整 autovacuum 吞吐,把年龄压回 4000 万以内。
一、具体的问题
某分析平台的一套 PostgreSQL 12 主库,凌晨收到监控告警:
- database pg_waring: age(datfrozenxid) = 812,345,678, threshold 500,000,000
复制代码
起初值班同学没当回事,觉得"不就是个后台任务没跑完"。结果接下来三天年龄不降反升,日志里开始出现:
- WARNING: database "appdb" must be vacuumed within 1093865 transactions
- HINT: To avoid a database shutdown, execute a database-wide VACUUM...
复制代码
同期症状还包括:
- autovacuum worker 在几张写入最热的表上反复出现,一跑就是几个小时,pg_stat_activity 里全是 autovacuum: VACUUM public.access_log (to prevent wraparound);
- 值班尝试 SELECT pg_cancel_backend(pid) 取消,进程原地不动——anti-wraparound vacuum 不接受取消;
- 磁盘 IO 高峰被 vacuum 吃掉近四成,业务批量任务开始排队。
也就是说,这不是"清理没跟上"的小事,而是冻结进度被什么东西卡住了。要治本,得先弄清 PostgreSQL 为什么要冻结。
二、核心原理
PostgreSQL 的 MVCC 靠每行头部的 xmin(插入它的事务 ID)和 xmax(删除/更新它的事务 ID)来判断行版本可见性。事务 ID 是 32 位无符号整数,取值上限约 21.4 亿(2^31 个用于比较的差值空间),用完会"回卷"——从最大值绕回最小值。如果直接拿回卷后的 ID 做新旧比较,几亿行老数据会被瞬间误判成"来自未来的事务",可见性判断全部错乱。
PostgreSQL 的解法是 冻结(freeze):vacuum 在扫描时,把那些"已经足够老、不会再被未完成事务看到"的行版本标记为 frozen。frozen 行在可见性判断里视作"比任何事务都老",从此不再依赖原始 xmin 参与比较。每一层对象都记录着"最老的未冻结事务 ID":
- pg_class.relfrozenxid:一张表里最老的未冻结事务;
- pg_database.datfrozenxid:一个库里最老的未冻结事务。
age(x) 返回当前事务 ID 与 x 的差值,也就是"距离回卷还剩多少额度"。autovacuum_freeze_max_age(默认 2 亿)触发的是预防性冻结扫描;而 age(datfrozenxid) 一旦逼近 21.4 亿,PostgreSQL 会不惜代价强推 anti-wraparound vacuum,甚至强制关库。
所以 autovacuum 的首要使命不是"回收死元组空间",而是推进 relfrozenxid/datfrozenxid。凡是能让 vacuum 无法推进冻结进度的东西,都在透支回卷余量:
- 长事务 / 空闲事务:vacuum 判断一行能不能冻结,要看它是否可能还被"比最老活跃事务更晚开启的快照"需要。一个挂了一周的 idle in transaction 会话,把整个库的最老 xmin 钉死在原地,vacuum 只能绕着走。
- prepared xacts(两阶段事务):pg_prepared_xacts 里的孤儿预备事务,同样持有全局最老事务号,而且往往一挂就是几个月,最容易被忽略。
- 复制槽泄漏:物理槽持有 xmin,逻辑槽还额外持有 catalog_xmin。下游宕机或废弃后槽没删,等于永久钉住一个历史时间点,vacuum 永远无法推进。
- 冻结扫描吞吐不足:vacuum_cost_delay 的限流默认很保守,写入大的库上,冻结速度赶不上事务消耗速度,年龄自然一路爬升。
三、实例参考(动手步骤)
步骤 0:确认现状基线
先量化"离悬崖多远",把库级和表级的年龄都摸一遍:
- -- 库级年龄,全实例排序
- SELECT datname, age(datfrozenxid) AS age
- FROM pg_database
- ORDER BY 2 DESC;
- -- 表级年龄 TOP 10(只看普通表)
- SELECT c.oid::regclass AS table_name,
- age(c.relfrozenxid) AS xid_age,
- pg_size_pretty(pg_total_relation_size(c.oid)) AS size
- FROM pg_class c
- JOIN pg_namespace n ON n.oid = c.relnamespace
- WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog','information_schema')
- ORDER BY age(c.relfrozenxid) DESC
- LIMIT 10;
复制代码
这次排查里,库级年龄 8.1 亿,表级最大的 public.access_log 达到 7.9 亿——问题集中在一张日志大表,其余表都在 2 亿以内,说明冻结缺口高度局部化,不是全库失控。
步骤 1:找到钉住 vacuum 的元凶
冻结进度卡住的三大嫌疑人逐一过堂:
- -- 1) 长事务与空闲事务
- SELECT pid, usename, state, xact_start, now() - xact_start AS xact_age, query
- FROM pg_stat_activity
- WHERE xact_start IS NOT NULL
- ORDER BY xact_start;
- -- 2) 预备事务(两阶段)
- SELECT gid, prepared, owner, database FROM pg_prepared_xacts ORDER BY prepared;
- -- 3) 复制槽持有的 xmin
- SELECT slot_name, slot_type, active, xmin, catalog_xmin,
- age(xmin) AS xmin_age, age(catalog_xmin) AS cat_age
- FROM pg_replication_slots;
复制代码
结果三条全中:一个报表程序挂了 6 天的 idle in transaction(连接池泄漏的僵尸会话);一个 4 个月前的 pg_prepared_xacts 孤儿条目,来自某次中断的 ETL;还有一个废弃逻辑复制槽 slot_etl_old,catalog_xmin 年龄 6.7 亿,下游环境早已下线。
先解除钉子(在测试环境验证过、确认无人使用后):
- -- 终结僵尸空闲事务
- SELECT pg_terminate_backend(pid) WHERE pid = 18432; -- 按实际 pid
- -- 回滚孤儿预备事务
- SELECT pg_rollback_prepared('etl_20260610_batch_07');
- -- 删除废弃复制槽
- SELECT pg_drop_replication_slot('slot_etl_old');
复制代码
步骤 2:提高 vacuum 冻结吞吐再补扫
钉子拔掉后,先临时放大 autovacuum 的成本预算,让它跑得动,而不是干着急:
- ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 4000; -- 默认 200
- ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '1ms'; -- 默认 2ms
- SELECT pg_reload_conf();
复制代码
然后对目标表主动补一次带 FREEZE 的 vacuum(黄金时段之后执行,注意 IO 影响):
- VACUUM (VERBOSE, FREEZE) public.access_log;
复制代码
VERBOSE 的输出盯两个数:new relfrozenxid 与 tuples: ... removed。这条命令跑完后 access_log 的表级年龄从 7.9 亿降到 3800 万。
步骤 3:固化参数与回卷监控
治理不能靠一次手工补扫。把预防参数落到配置,把回卷年龄做成独立告警项:
- # postgresql.conf
- autovacuum_freeze_max_age = 150000000 # 默认 2 亿,提前触发
- vacuum_freeze_min_age = 50000000 # 行版本够老就及时冻
- autovacuum_vacuum_cost_limit = 2000 # 常态吞吐,比默认激进一档
复制代码
监控 SQL(建议 5 分钟采样一次,阈值分两档:5 亿告警、10 亿严重):
- SELECT max(age(datfrozenxid)) AS max_db_age FROM pg_database;
复制代码
治理前后对比
| 指标 | 治理前 | 治理后(60 天后复查) | | max(age(datfrozenxid)) | 812,345,678 | 41,650,000 | | 回卷 WARNING 日志 | 每小时数条 | 0 条 | | anti-wraparound vacuum 出现频次 | 每天数小时 | 每周一次、分钟级 | | vacuum 占用的 IO 高峰占比 | 约 40% | < 5% | | 最老长事务存活时长 | 6 天 | < 10 分钟(连接池强制 idle_timeout) |
四、实操检查清单
- [ ] 每日采集 age(datfrozenxid),阈值:5 亿告警、10 亿严重、15 亿启动应急预案;
- [ ] 周期巡检 pg_stat_activity 中 xact_age 超过 1 小时的会话,连接池开启 idle_in_transaction_session_timeout;
- [ ] 每周检查 pg_prepared_xacts,prepared 超过 1 天且无归属的按流程回滚;
- [ ] 每周核对 pg_replication_slots:active = false 超过 7 天的槽逐个确认归属,废弃即删;
- [ ] 表级年龄 TOP 10 纳入巡检,单表年龄接近库级年龄时优先安排 VACUUM (FREEZE);
- [ ] 写入量大的库调高 autovacuum_vacuum_cost_limit,确认冻结速度大于日均事务消耗速度;
- [ ] 升级路径核对:PG 13+ 部分场景可开 autovacuum_vacuum_insert_threshold,减少纯插入表的冻结扫描;
- [ ] 所有参数变更先在测试库验证,保留变更记录与回滚方法。
几个容易踩的坑
坑一:把 freeze 当成缩表手段。 VACUUM (FREEZE) 只改行版本标记,不回收空间、不降低表体积。看到"vacuum 了几十 G"就以为表会变小,是常见误解。治膨胀要走 VACUUM FULL 或 pg_repack,那是另一个独立议题,别和回卷治理混为一谈。
坑二:只盯长事务,漏了复制槽。 长事务好查,pg_replication_slots 的 catalog_xmin 静默钉住 vacuum 的案例在实际环境里更多,尤其是逻辑复制和 CDC 链路下线后槽没清理。巡检清单里必须给它留一行。
坑三:anti-wraparound vacuum 被取消不了就慌。 它不接受 pg_cancel_backend,这是设计如此。此时该做的是确认它没被限流卡死(看 pg_stat_progress_vacuum 的 phase 和已扫描页数在动),必要时临时调大 cost_limit 帮它提速,而不是反复尝试杀进程。
坑四:被迫关库时才第一次接触单用户模式。 真到 age 逼近 21.4 亿,PG 会拒绝写入并要求单用户 anti-wraparound vacuum:postgres --single -D $PGDATA appdb,进去后执行 VACUUM FREEZE;。这个流程应当写成预案演练过一次,而不是出事当天现场学。
坑五:vacuum_freeze_table_age 大于 autovacuum_freeze_max_age。 前者决定 vacuum 扫表时是否走"激进冻结扫描",后者决定 autovacuum 是否强制触发。前者设得比后者还大,等于配置自相矛盾——per-table 触发了,扫描却还是保守模式。 |
|