冻结事务 ID(FREEZE)
前面几节讲的都是「空间」问题:死元组、膨胀、VACUUM FULL。这一节讲的是另一个更致命的维度——事务 ID(XID)。
PostgreSQL 的 MVCC 依赖「比较事务 ID 的新旧」来判断一个行版本该不该被当前事务看到。而 XID 是 32 位整数,容量只有约 40 亿。一个跑了很久的集群,如果从不冻结老元组,XID 计数器会绕回 0,于是过去的事务突然「变成未来」,数据看起来全部消失——这是 PostgreSQL 最严重的一类事故。
VACUUM 的第四个、也是最不可替代的职责,就是把足够老的元组冻结(freeze)。本节讲清冻结的原理、五个年龄参数的相互关系、多事务 ID(MultiXact)回卷这个更隐蔽的坑,以及怎么监控与应急。
前置知识
阅读本节前,建议先了解:表膨胀检测与处理
基础概念
为什么事务 ID 会「绕回来」
XID 是 32 位无符号整数,取值范围 0 ~ 4,294,967,295。PostgreSQL 并不用「绝对大小」比较两个 XID,而是用模 2³² 的环形距离:
XID 空间是一个环(不是一条直线)
XID 当前值
│
┌───────────────┼───────────────┐
│ │ │
过去 20 亿 ←────── 现在 ──────→ 未来 20 亿
│ │
└───────────────────────────────┘
每个 XID 都恰好有 20 亿个「更老」的 XID 和 20 亿个「更新」的 XID。这就是问题的根源:假设某个行版本的插入 XID 是 100。对 XID=1000 的事务来说,100 是「过去」;但当 XID 计数器一路增长到 2,000,000,100 之后,100 就落到了环的另一侧——它看起来变成了「未来」。
行版本插入时 xmin = 100
当前 XID = 1000 → 100 在环的「过去」一侧 → 可见 ✅
当前 XID = 1000000 → 100 仍在「过去」一侧 → 可见 ✅
当前 XID = 2000000100 → 100 变成了「未来」 → 不可见 ❌(数据"消失")
也就是说,一个行版本创建后最多只能"安全存在" 20 亿个事务。官方文档的表述是:必须每 20 亿个事务,对每个数据库中的每张表至少执行一次 VACUUM。注意这里用的是「必须」,因为超过这个界限的后果不是变慢,而是灾难性的数据不可见。
冻结做了什么
要打破上面那个 20 亿的期限,就必须让老元组不再参与 XID 比较规则。PostgreSQL 为此保留了一个特殊 XID:
FrozenTransactionId = 2
它是一个「永远比任何普通 XID 都老」的特殊值,
不遵循模 2³² 的比较规则,因此永远不会因为回卷而"变成未来"。被冻结的元组,「插入 XID」被当成 2,于是对任何普通事务来说永远是「过去」的,只要不被删除就一直有效。
冻结的物理实现(9.4 分水岭)
- PostgreSQL 9.4 之前:冻结是真的把元组的
xmin字段改写为 2。你会在xmin这种系统列里直接看到 2。 - PostgreSQL 9.4 及以后:冻结只是在元组头部设置一个标志位,保留原始
xmin以便事后追溯。xmin列里看到的仍然是原始值。 - 因此,在从 9.4 以前
pg_upgrade升级过来的库里,仍然可能看到xmin = 2的元组。这是历史遗留,不要试图「修复」它。 - 系统目录中可能出现
xmin = 1的行,这是BootstrapTransactionId,表示该行在initdb的第一阶段插入。它和 2 一样,被视为比任何普通 XID 都老。
冻结不是一次性动作,而是「四个年龄」的协同
新手常以为「冻结」只有一个开关。实际上它由一组年龄参数协同控制,每个参数负责不同的判断:
四个年龄参数的关系(默认值,单位:事务数)
vacuum_freeze_min_age 50,000,000
└─ 元组"多老才值得冻"(单次 VACUUM 的冷冻门槛)
vacuum_freeze_table_age 150,000,000
└─ 表"多老才必须做全表扫描式清理"(而不是只看 VM 标记的页)
autovacuum_freeze_max_age 200,000,000
└─ 表"多老就必须强制启动防回卷 autovacuum"(无视 autovacuum 是否关闭)
vacuum_failsafe_age 1,600,000,000
└─ "多老时必须放弃一切避让、全速抢救"(最后保险丝)四者的关系可以这样记:
50,000,000 ──→ 150,000,000 ──→ 200,000,000 ──→ 1,600,000,000
值不值得冻 要不要全扫 防回卷强制启动 拼命抢救
min_age table_age freeze_max_age failsafe_age表级年龄与库级年龄
冻结相关的年龄有两个观察层级,很容易混:
| 层级 | 字段 | 视图 | 含义 |
|---|---|---|---|
| 表级 | relfrozenxid | pg_class | 该表中最老的、尚未被冻结的 XID |
| 表级 | relminmxid | pg_class | 该表中最老的、尚未处理的多事务 ID |
| 库级 | datfrozenxid | pg_database | 该数据库中所有表 relfrozenxid 的最小值 |
| 库级 | datminmxid | pg_database | 该数据库中所有表 relminmxid 的最小值 |
我们真正关心的是它们的年龄(age),即「距离当前 XID 已经过去了多少个事务」:
-- 表级年龄:某张表有多老
SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class
WHERE relkind = 'r';
-- 库级年龄:整个库有多老(取所有表里最老的那个)
SELECT datname, age(datfrozenxid) AS xid_age
FROM pg_database;age(relfrozenxid) 越大,说明这张表越久没被有效冻结,回卷风险越高。
多事务 ID(MultiXact)回卷
除了 XID,还有一个几乎被忽略的计数器:MultiXact ID。当多个事务同时锁住同一行(典型场景是 SELECT ... FOR SHARE、外键引用检查)时,PostgreSQL 无法只用一个 XID 记录这件事,于是引入 MultiXact ID。
MultiXact ID 同样是 32 位,同样会回卷,而且它由独立的参数控制:
-- 多事务 ID 的年龄(注意用 mxid_age,不是 age)
SELECT relname, mxid_age(relminmxid) AS mxid_age
FROM pg_class
WHERE relkind = 'r'
ORDER BY mxid_age(relminmxid) DESC
LIMIT 10;MultiXact 回卷比 XID 回卷更隐蔽:很多团队监控了 age(relfrozenxid) 却忘了 mxid_age(relminmxid),结果在 XID 一切正常的情况下突然收到多事务回卷告警。
MultiXact 与 XID 是两套独立机制
autovacuum_multixact_freeze_max_age(默认 4 亿)与 autovacuum_freeze_max_age(默认 2 亿)是两个独立参数,触发的是同一种「防回卷 autovacuum」,但判据不同。只调其中一个不会影响另一个。
术语表
| 概念 | 术语 | 说明 |
|---|---|---|
| 事务 ID | XID(Transaction ID) | 32 位,约 40 亿容量 |
| 回卷 | Wraparound | XID 计数器绕回 0,导致老事务「变成未来」 |
| 冻结 | Freeze | 让元组不再参与 XID 比较规则 |
| 冻结事务 ID | FrozenTransactionId | 特殊值 2,永远被视为最老 |
| 引导事务 ID | BootstrapTransactionId | 特殊值 1,initdb 阶段插入的系统行 |
| 多事务 ID | MultiXact ID | 记录「多个事务同时锁行」的 32 位标识,同样会回卷 |
| 表冻结年龄 | age(relfrozenxid) | 表中最老未冻结 XID 距当前的距离 |
| 库冻结年龄 | age(datfrozenxid) | 库中所有表冻结年龄的最小值 |
| 防回卷清理 | Anti-wraparound Vacuum | 无视阈值与关闭状态、强制启动的 autovacuum |
| 失效保护 | Failsafe | 年龄逼近极限时放弃一切节流的全速抢救模式 |
语法与用法
手工冻结
VACUUM 的 FREEZE 选项可以直接要求冻结元组:
-- 语法
VACUUM (FREEZE) table_name;它的语义是:把 vacuum_freeze_min_age 临时当作 0,即「所有比当前事务老的元组都冻」。因此 VACUUM (FREEZE) 比普通 VACUUM 更激进、写 WAL 更多、更慢,但能立即降低表的冻结年龄。
-- 一次性、彻底地冻结一张表
VACUUM (FREEZE, VERBOSE) orders;INFO: vacuuming "public.orders"
INFO: "orders": found 0 removable, 3004271 nonremovable row versions in 528420 pages
DETAIL: 0 dead row versions cannot be removed yet, oldest xmin: 841233911
VACUUM冻结相关参数总表
| 参数 | 默认值 | 取值范围 | 可否 reload | 作用 |
|---|---|---|---|---|
autovacuum_freeze_max_age | 200000000 | 100,000 ~ 2,000,000,000 | 否(需重启) | 表年龄达到此值即强制启动防回卷 autovacuum |
autovacuum_multixact_freeze_max_age | 400000000 | 10,000 ~ 2,000,000,000 | 否(需重启) | MultiXact 版本的同款阈值 |
vacuum_freeze_min_age | 50000000 | 0 ~ 1,000,000,000 | 是 | 元组多老才值得冻结 |
vacuum_freeze_table_age | 150000000 | 0 ~ 2,000,000,000 | 是 | 表多老才必须做「全表扫描式」清理 |
vacuum_multixact_freeze_min_age | 5000000 | 0 ~ 1,000,000,000 | 是 | MultiXact 的对应门槛 |
vacuum_multixact_freeze_table_age | 150000000 | 0 ~ 2,000,000,000 | 是 | MultiXact 的全表清理门槛 |
vacuum_failsafe_age | 1600000000 | 0 ~ 2,100,000,000 | 是 | 触发全速抢救的年龄 |
vacuum_multixact_failsafe_age | 1600000000 | 0 ~ 2,100,000,000 | 是 | MultiXact 的抢救阈值 |
参数之间存在「静默限制」
官方文档用「silently limited」描述了这些上限。也就是说,如果你把参数设得自相矛盾,PostgreSQL 不会报错,而是悄悄改掉:
vacuum_freeze_min_age被静默限制为autovacuum_freeze_max_age的一半。vacuum_freeze_table_age被静默限制为autovacuum_freeze_max_age的 95%。vacuum_multixact_freeze_table_age被静默限制为autovacuum_multixact_freeze_max_age的 95%。vacuum_failsafe_age被静默调整为不低于autovacuum_freeze_max_age的 105%。
所以调整时应成组地调,而不是只改一个数字。改完用 SHOW 复核实际生效值。
查看当前生效值
SHOW autovacuum_freeze_max_age;
SHOW vacuum_freeze_min_age;
SHOW vacuum_freeze_table_age;
SHOW vacuum_failsafe_age;
-- 一次性列出全部冻结相关参数(注意参数名近似,用正则筛更省事)
SELECT name, setting, unit
FROM pg_settings
WHERE name ~ 'freeze|failsafe'
ORDER BY name; name | setting | unit
--------------------------------+-----------+------
autovacuum_freeze_max_age | 200000000 |
autovacuum_multixact_freeze_max_age | 400000000 |
vacuum_failsafe_age | 1600000000 |
vacuum_freeze_min_age | 50000000 |
vacuum_freeze_table_age | 150000000 |
vacuum_multixact_failsafe_age | 1600000000 |
vacuum_multixact_freeze_min_age | 5000000 |
vacuum_multixact_freeze_table_age | 150000000 |标准清理与「全表扫描式」清理
这是理解 vacuum_freeze_table_age 的关键。普通 VACUUM 为了省 I/O,会跳过可见性映射(VM)标记为 all-visible 的页——那些页既然全可见,就没有死元组要清理。
但这带来一个副作用:冻结信息也一起被跳过了。所以当表的年龄逼近 vacuum_freeze_table_age 时,VACUUM 会自动切换成「全表扫描模式」(aggressive vacuum),不再依赖 VM 跳页,把每一页都读一遍以确保老元组被冻结。
两种 VACUUM 模式
常规模式(表不算老时)
读 VM → 只扫「有死元组」的页 → 快
但不会去更新已 all-visible 页里的元组冻结状态
全表扫描模式(age(relfrozenxid) > vacuum_freeze_table_age 时自动切换)
忽略 VM,逐页扫描全表 → 慢,且产生大量 WAL
确保所有足够老的元组都被冻结
安全区 table_age 门槛 max_age 门槛
──┼──────────────────────┼───────────────────────────┼──────→ age
│ 常规 VACUUM │ 全表扫描式 VACUUM │ 防回卷强制清理
│ (可能跳过冻结) │ (保证冻结) │ (无视一切节流)
0 150,000,000 200,000,000详细说明
四个年龄参数如何协同工作
把一次清理的完整决策链条画出来:
一次 VACUUM(或 autovacuum)对某张表的决策流程
① 这张表有多老? a = age(relfrozenxid)
② a > autovacuum_freeze_max_age(2 亿)?
├─ 是 → 强制启动防回卷 autovacuum(即使 autovacuum = off 也会跑!)
└─ 否 → 按常规阈值判断是否要清理
③ 本次清理用哪种模式?
├─ a > vacuum_freeze_table_age(1.5 亿)→ 全表扫描模式
└─ 否则 → 常规模式(可跳 VM 页)
④ 单个元组要不要冻?
└─ 元组年龄 > vacuum_freeze_min_age(5000 万)→ 冻结
⑤ 极端情况:a > vacuum_failsafe_age(16 亿)?
└─ 进入失效保护:放弃成本延迟、跳过非必需维护,全速抢救注意 ② 这一条:防回卷 autovacuum 会无视 autovacuum = off。很多人为了「减少后台干扰」把 autovacuum 关掉,以为万事大吉,结果被 2 亿阈值触发的强制清理打了个措手不及——它会在业务高峰期启动,并且比常规清理更重。
防回卷清理的「失控」特征
普通 autovacuum 受基于成本的清理延迟(cost-based vacuum delay)制约,会主动为业务让路。但防回卷清理的优先级完全不同:
| 维度 | 常规 autovacuum | 防回卷 autovacuum |
|---|---|---|
| 触发条件 | 死元组/插入量超过阈值 | age(relfrozenxid) 超过 2 亿 |
| 可否被关闭 | 可以(autovacuum = off) | 不可以 |
| 是否受 cost delay 限制 | 是 | 是(但见下方 failsafe) |
| 扫描范围 | 可跳过 VM 页 | 全表扫描模式 |
| 单次时长 | 通常短 | 可能很长 |
| 对业务影响 | 温和 | 可能造成明显 I/O 冲击 |
之所以说防回卷清理容易「失控」,是因为一旦表已经膨胀很严重,全表扫描要读的页数极多,而 autovacuum_freeze_max_age 又在不停催它——于是后台清理与业务查询抢 I/O,形成恶性循环。
正确的做法不是等它触发,而是在年龄到 1.5 亿左右就主动干预(手工 VACUUM FREEZE,或在低峰期分批处理)。
失效保护(Failsafe)
当表的年龄继续逼近 16 亿(vacuum_failsafe_age 默认值),PostgreSQL 认为「再不冻就真回卷了」,于是进入失效保护模式,主动放弃一切为了减少业务影响而设计的避让机制:
失效保护(Failsafe)触发后的三个放弃
1. 取消基于成本的清理延迟(cost-based delay)
不再主动 sleep 让路,全速磁盘 I/O
2. 跳过非必需的维护动作
例如索引中的死项清理(index cleanup)——那是"优化",
而冻结局元组是"保命",优先保命
3. 禁用缓冲区访问策略(Buffer Access Strategy)
不再限制自己只用小缓冲区,避免污染 shared_buffers;
改为可以自由使用共享缓冲区,尽快读完vacuum_failsafe_age 默认 1,600,000,000(16 亿),距离 32 位上限(约 42.9 亿)只剩约 27 亿的余量。之所以留这么大的安全边际,是因为「从触发 failsafe 到真正冻完,可能还要很久」。
-- 放宽/收紧 failsafe(注意它会被静默调整为不低于 freeze_max_age 的 105%)
ALTER SYSTEM SET vacuum_failsafe_age = 1600000000;
SELECT pg_reload_conf();不要把 failsafe 当成"最后手段"来依赖
failsafe 是保险丝,不是清理策略。它触发时意味着你已经错过了一到两次正常的冻结窗口。等到 failsafe 启动,全速 I/O 会对线上业务造成明显抖动。正确的运维目标是:让 age(relfrozenxid) 从不超过 autovacuum_freeze_max_age 的一半。
多事务 ID 的完整链条
MultiXact 有自己独立的一套参数,链条与 XID 完全平行:
MultiXact 版本的"四个年龄"
vacuum_multixact_freeze_min_age 5,000,000 (默认只有 500 万,比 XID 门槛小一个量级)
vacuum_multixact_freeze_table_age 150,000,000
autovacuum_multixact_freeze_max_age 400,000,000 (默认 4 亿,是 XID 阈值的 2 倍)
vacuum_multixact_failsafe_age 1,600,000,000MultiXact 什么时候会累积?主要是行级共享锁场景:
| 场景 | 为什么会消耗 MultiXact |
|---|---|
SELECT ... FOR SHARE / FOR NO KEY UPDATE | 多个事务同时持有同一行的共享锁 |
| 外键约束检查 | 插入子行时要对父行加 FOR KEY SHARE 锁 |
FOR UPDATE 与 FOR SHARE 并发 | 锁模式冲突时用 MultiXact 记录多个持有者 |
大量并发的 ON CONFLICT DO NOTHING | 涉及行锁判断 |
因此,外键多、并发写多、用了 FOR SHARE 的系统,MultiXact 增长会更快。这类系统必须把 mxid_age(relminmxid) 也纳入监控。
-- MultiXact 年龄巡检:找出最老的表
SELECT c.oid::regclass AS table_name,
mxid_age(c.relminmxid) AS mxid_age,
age(c.relfrozenxid) AS xid_age
FROM pg_class c
WHERE c.relkind = 'r'
AND c.relnamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
ORDER BY mxid_age(c.relminmxid) DESC
LIMIT 20;冻结与 WAL 的关系
这是运维中一个容易被忽视的代价:冻结会产生 WAL。
常规 VACUUM 只是标记空间可复用,元组本身没有被修改,WAL 量很小。但冻结要修改元组头部的标志位(以及更新 FSM/VM),这些都是页级修改,必须写 WAL。所以:
- 一次全表扫描式的
VACUUM(age > vacuum_freeze_table_age)会为大量页写 WAL; VACUUM FREEZE(把min_age当 0)写得更多;- 在流复制的从库上,这些 WAL 会原样重放。
大表在低峰期做 VACUUM FREEZE 时,务必关注 WAL 目录的磁盘占用。相关机制见 WAL 与检查点优化。
观察正在进行中的清理
冻结属于 VACUUM 的一部分,因此没有独立的进度视图,用 pg_stat_progress_vacuum:
SELECT pid, datname, relid::regclass AS table_name, phase,
heap_blks_total, heap_blks_scanned, heap_blks_vacuumed,
indexes_total, indexes_processed
FROM pg_stat_progress_vacuum; pid | datname | table_name | phase | heap_blks_total | heap_blks_scanned | indexes_total | indexes_processed
-------+---------+--------------------------+-----------------+-----------------+-------------------+---------------+-------------------
28417 | mydb | public.orders | vacuuming heap | 528420 | 310022 | 3 | 0
28433 | mydb | public.sessions | vacuuming heap | 384120 | 384120 | 2 | 1判断「是不是在做全表扫描式清理」的一个实用技巧:看 heap_blks_scanned 是否逼近 heap_blks_total。常规模式会跳页,扫描量远小于总量;全表扫描模式会一路扫到尾。
PG 18 的列名变化
在 PostgreSQL 18 中,pg_stat_progress_vacuum 的列名做了调整:旧版的 max_dead_tuples / num_dead_tuples 已改为 max_dead_tuple_bytes / dead_tuple_bytes,并新增 num_dead_item_ids。如果你的监控脚本是在旧版本上写的,升级到 18 后需要同步修改。
实战示例
场景一:巡检全库冻结年龄并输出告警
背景:库里有几十个数据库、上千张表,需要每天巡检一次,按年龄分档告警。
第一步:表级年龄巡检(当前库)
SELECT
c.oid::regclass AS table_name,
age(c.relfrozenxid) AS xid_age,
mxid_age(c.relminmxid) AS mxid_age,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
round(age(c.relfrozenxid) * 100.0 / 200000000, 1) AS pct_of_limit,
CASE
WHEN age(c.relfrozenxid) > 180000000 THEN '严重'
WHEN age(c.relfrozenxid) > 150000000 THEN '警告'
WHEN age(c.relfrozenxid) > 100000000 THEN '关注'
ELSE '正常'
END AS level
FROM pg_class c
WHERE c.relkind = 'r'
AND c.relnamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
ORDER BY age(c.relfrozenxid) DESC
LIMIT 20; table_name | xid_age | mxid_age | total_size | pct_of_limit | level
---------------------------+-----------+----------+------------+--------------+--------
public.audit_log | 162340911 | 4210000 | 42 GB | 81.2 | 警告
public.counters | 118220145 | 980000 | 769 MB | 59.1 | 关注
public.orders | 42108833 | 120000 | 4128 MB | 21.1 | 正常
public.sessions | 18022931 | 1204000 | 3001 MB | 9.0 | 正常第二步:库级年龄巡检
SELECT
datname,
age(datfrozenxid) AS db_xid_age,
mxid_age(datminmxid) AS db_mxid_age,
round(age(datfrozenxid) * 100.0 / 2000000000, 1) AS pct_of_hard_limit
FROM pg_database
ORDER BY age(datfrozenxid) DESC; datname | db_xid_age | db_mxid_age | pct_of_hard_limit
---------------+------------+-------------+-------------------
mydb | 162340911 | 4210000 | 8.1
postgres | 18022931 | 120400 | 0.9
template0 | 1 | 1 | 0.0库级年龄是「全库所有表里最老的那个」,所以它总是等于上面表级巡检的第一名。两者结合看:表级用来看「该处理谁」,库级用来看「整体离红线还有多远」。
第三步:找出「年龄高且体积大」的优先处理对象
单纯年龄高的小表处理起来很快,真正危险的是「又老又大」的表——它的全表扫描式清理会最慢。
SELECT
c.oid::regclass AS table_name,
age(c.relfrozenxid) AS xid_age,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
pg_total_relation_size(c.oid) AS size_bytes
FROM pg_class c
WHERE c.relkind = 'r'
AND age(c.relfrozenxid) > 100000000
ORDER BY pg_total_relation_size(c.oid) DESC;第四步:把巡检固化到监控系统
把第一步的 SQL 交给监控(Prometheus + postgres_exporter,或定时脚本),并对 pct_of_limit 设阈值告警:
推荐告警阈值(表级 age(relfrozenxid))
> 100,000,000 (pct ≥ 50%) 关注:开始规划处理
> 150,000,000 (pct ≥ 75%) 警告:本周内必须处理
> 180,000,000 (pct ≥ 90%) 严重:立即处理,随时可能被强制清理
推荐告警阈值(库级 age(datfrozenxid))
> 1,500,000,000 (15 亿) 警告
> 1,800,000,000 (18 亿) 严重:安排维护窗口全库 VACUUM场景二:一张大表的冻结年龄失控,如何处置
背景:audit_log 表 42 GB,age(relfrozenxid) 已达 1.62 亿(81%),并且日志里开始出现防回卷清理的记录。
第一步:确认现状
-- 看年龄
SELECT c.oid::regclass, age(c.relfrozenxid) AS xid_age, relminmxid, relfrozenxid
FROM pg_class c WHERE c.oid = 'audit_log'::regclass;
-- 看这张表有没有在自动清理/设置过特殊参数
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, last_vacuum,
autovacuum_count, reloptions
FROM pg_stat_user_tables
WHERE relname = 'audit_log'; relname | n_live_tup | n_dead_tup | last_autovacuum | reloptions
-----------+------------+------------+-------------------------------+------------
audit_log | 88420013 | 21400 | 2026-09-28 03:12:44.201+08 | {autovacuum_vacuum_scale_factor=0.01}第二步:判断是否需要立即处理
年龄 1.62 亿 < 2 亿,还没到强制触发点,但已经进入全表扫描模式区间(> 1.5 亿),每次清理都会很重。建议在低峰期主动做一次 VACUUM FREEZE,把年龄打回安全区。
第三步:低峰期分批处理
对大表不要一次性全库 VACUUM FREEZE,而是按分区或按表逐个做,控制 I/O 冲击:
-- 会话级:拿不到锁就快速失败,避免长时间挂起
SET lock_timeout = '5s';
SET statement_timeout = '4h';
-- 逐表冻结(示例:先处理最老的)
VACUUM (FREEZE, VERBOSE) audit_log;
RESET statement_timeout;
RESET lock_timeout;INFO: vacuuming "public.audit_log"
INFO: "audit_log": found 21400 removable, 88420013 nonremovable row versions in 5512840 pages
DETAIL: 0 dead row versions cannot be removed yet, oldest xmin: 841233911
INFO: "audit_log": truncated 0 pages
INFO: "audit_log": removed 21400 dead row versions in 2104 pages
VACUUM第四步:核实年龄已回落
SELECT c.oid::regclass, age(c.relfrozenxid) AS xid_age
FROM pg_class c WHERE c.oid = 'audit_log'::regclass; oid | xid_age
----------+---------
audit_log| 0年龄归零意味着「表内所有元组都已冻结,relfrozenxid 被推进到当前 XID 附近」。这是理想状态。
第五步:根治——为什么它会变老
一次冻结只解决当下。要避免复发,得找出根因:
-- 检查是否有什么在长期压制清理
SELECT pid, state, xact_start, now() - xact_start AS xact_age,
left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 5;
-- 检查复制槽(inactive 槽会阻止清理推进)
SELECT slot_name, slot_type, active, restart_lsn
FROM pg_replication_slots;常见根因:长时间不结束的事务、idle in transaction 连接、inactive 的复制槽。这些会让「最老活跃事务」卡住不前进,清理就无法安全回收或推进冻结。修掉根因,年龄才不会反复失控。
注意事项
1. 防回卷清理无视 autovacuum = off
这是最反直觉的一点:即使你关闭了 autovacuum,只要某表 age(relfrozenxid) 超过 autovacuum_freeze_max_age,PostgreSQL 仍会强行启动一次清理。
-- 你以为关掉了自动清理就没事了……
ALTER SYSTEM SET autovacuum = off;
-- ……但年龄一旦超 2 亿,系统照样强制清理(且更重)
SELECT c.oid::regclass, age(c.relfrozenxid)
FROM pg_class c
WHERE c.relkind = 'r' AND age(c.relfrozenxid) > 190000000;2. 不要同时只调 vacuum_freeze_min_age 和 autovacuum_freeze_max_age 中的一个
两者存在静默限制关系(min_age ≤ freeze_max_age 的一半)。单独调大 min_age 时,如果 freeze_max_age 没动,实际生效值可能不是你以为的那个。
-- 设完后必须复核实际生效值
ALTER SYSTEM SET vacuum_freeze_min_age = 80000000; -- 期望值 8000 万
SELECT pg_reload_conf();
SHOW vacuum_freeze_min_age; -- 若 freeze_max_age = 2 亿,这里最多只到 1 亿3. VACUUM FULL 也会顺带冻结,但不该为了冻结而用它
VACUUM FULL 重写整表,新写入的元组自然都是「新」的,也顺带把老元组冻结掉。但它取 ACCESS EXCLUSIVE 锁,代价远高于 VACUUM FREEZE。为了降年龄,用 VACUUM FREEZE,不要用 VACUUM FULL。
-- 不推荐:为了冻结而长时间锁表
VACUUM FULL audit_log;
-- 推荐:只冻结,不长时间阻塞
VACUUM (FREEZE) audit_log;4. 冷冻年龄不能靠 TRUNCATE 或 DELETE 绕过
「把老表删了重建」确实能让年龄归零,但这是靠换了一张新表做到的。如果只是 DELETE FROM t 再 VACUUM,元组虽然被移除,relfrozenxid 的推进仍需一次「全表扫描式清理」,并不能免掉这次扫描的成本。
5. 只监控 age(relfrozenxid) 会漏掉 MultiXact 回卷
外键多、FOR SHARE 多的系统,MultiXact 可能先于 XID 出问题。两套指标都要采。
-- 两个都要看
SELECT datname, age(datfrozenxid) AS xid_age, mxid_age(datminmxid) AS mxid_age
FROM pg_database ORDER BY 2 DESC;6. 冻结会产生 WAL,大表冻结要预留 WAL 空间
冻结会修改元组头标志位并更新 FSM/VM,都是页级修改。对大表做 VACUUM FREEZE 时,pg_wal 目录可能快速增长,尤其在开启流复制时。
-- 冻结前看一眼 WAL 目录空间是否充裕
SELECT pg_size_pretty(sum(size)) AS wal_total
FROM pg_ls_waldir();7. 从 9.4 之前升级过来的库里出现 xmin = 2 是正常的
不要把这些元组当成「数据异常」去处理。同理,系统目录里 xmin = 1 的行是 BootstrapTransactionId,也不要动。
8. age() 的语义是「距今多少个事务」,不是时间
age(relfrozenxid) 单位是事务数,不是秒/天。一个写入极慢的库,年龄可能几个月都不涨;一个每秒几千事务的库,年龄一天能涨几亿。阈值要结合事务速率来解读。
-- 结合事务速率估算"还能撑多久"
SELECT datname, age(datfrozenxid) AS xid_age,
xact_commit + xact_rollback AS total_xacts
FROM pg_stat_database d
JOIN pg_database ON datname = d.datname
ORDER BY age(datfrozenxid) DESC;最佳实践
1. 把冻结年龄当作一等监控指标,而不是事故后才查
XID 回卷是那种「平时不报错、一旦发生就无法用普通手段恢复」的故障。它必须在监控层面拦截。最低配置:
| 指标 | 数据来源 | 告警线 |
|---|---|---|
age(relfrozenxid)(最大表) | pg_class.age() | 1.5 亿警告 / 1.8 亿严重 |
age(datfrozenxid)(每库) | pg_database.age() | 15 亿警告 / 18 亿严重 |
mxid_age(relminmxid)(最大表) | pg_class.mxid_age() | 3 亿警告 / 3.5 亿严重 |
mxid_age(datminmxid)(每库) | pg_database.mxid_age() | 30 亿警告 / 35 亿严重 |
2. 保证「每 20 亿事务对每张表清理一次」这条底线
这是官方给出的硬性要求。工程上把它转换成一个可执行的检查:
-- 有没有表已经逼近这条底线?
SELECT c.oid::regclass AS table_name, age(c.relfrozenxid) AS xid_age
FROM pg_class c
WHERE c.relkind = 'r'
AND age(c.relfrozenxid) > 100000000
ORDER BY age(c.relfrozenxid) DESC;3. 不要关闭 autovacuum 来「减少干扰」,改为精细化调参
关闭它既不能阻止防回卷清理,又会让你失去常规清理能力,是最差的选择。正确做法是给大表设置更激进的表级参数:
-- 大表:降低 scale_factor,让清理更及时,避免单次清理量过大
ALTER TABLE audit_log SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 1000
);4. 让作业完成后主动冻结,避免年龄「坐电梯」
批量导入、数据迁移、大版本升级之后,主动做一次带 FREEZE 的清理,把年龄打回安全区,比等它慢慢涨上去再抢救划算得多。
-- 迁移/导入收尾
VACUUM (FREEZE, ANALYZE) orders;
VACUUM (FREEZE, ANALYZE) order_items;
ANALYZE customers;5. 大表冻结要分批、限速、设超时
一次全库 VACUUM FREEZE 会让 I/O 瞬间打满,且可能长时间等锁。给每个清理动作加超时保护,把它拆成多个可中断的小任务。
-- 每个表都这样包一层,避免单个动作无限挂起
SET lock_timeout = '5s';
SET statement_timeout = '4h';
VACUUM (FREEZE, VERBOSE) big_table_2026_08;
RESET statement_timeout;
RESET lock_timeout;6. 定期排查「压制清理推进」的根因
年龄失控很少是「清理不给力」,更多是有东西挡着清理前进。每季度查一遍:
清理推进受阻的常见根因(季度检查清单)
├── 长时间运行的事务(pg_stat_activity.xact_start 很早)
├── idle in transaction 的连接(应用未提交/未回滚)
├── inactive 的复制槽(pg_replication_slots.active = false)
├── 长跑的两阶段提交事务(pg_prepared_xacts)
└── 备库长期停摆(hot_standby_feedback 导致主库无法清理)-- 一次性排查:谁在压制清理(三个来源分别看)
-- 1) 长事务
SELECT pid, state, xact_start, now() - xact_start AS duration, left(query, 60)
FROM pg_stat_activity
WHERE xact_start < now() - interval '10 minutes'
ORDER BY xact_start;
-- 2) idle in transaction(最容易忽视)
SELECT pid, state, state_change, now() - state_change AS idle_for, left(query, 60)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY state_change;
-- 3) inactive 复制槽(会阻止主库推进冻结年龄)
SELECT slot_name, slot_type, active, restart_lsn, confirmed_flush_lsn
FROM pg_replication_slots
WHERE NOT active;7. 升级/迁移后做一次全库冻结检查
大版本切换、物理迁移、逻辑复制初始化之后,元组的冻结状态可能不理想,容易在之后某天突然触发激进的防回卷清理。收尾时统一处理:
-- 1) 先看全库年龄分布
SELECT c.oid::regclass, age(c.relfrozenxid) AS xid_age
FROM pg_class c
WHERE c.relkind = 'r' AND age(c.relfrozenxid) > 50000000
ORDER BY age(c.relfrozenxid) DESC;
-- 2) 对年龄偏高的表逐个 FREEZE
VACUUM (FREEZE) table_name;8. 把年龄阈值与容量规划一起做
年龄增长的速率 ≈ 事务速率。在设计阶段就能估算「多久必须清理一次」:
理论最大无清理窗口 = 2,000,000,000 / 事务速率(TPS)
例:TPS = 2,000 → 约 11.5 天必须清理一遍
TPS = 20,000 → 约 1.2 天必须清理一遍
TPS = 100,000 → 约 5.6 小时必须清理一遍高 TPS 系统如果清理跟不上,就必须调低 autovacuum_freeze_max_age(例如降到 1 亿),让强制清理更早、更温和地介入。
下一节
继续学习:ANALYZE 统计收集