Skip to content

冻结事务 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³² 的环形距离:

text
XID 空间是一个环(不是一条直线)

                    XID 当前值
                        │
        ┌───────────────┼───────────────┐
        │               │               │
   过去 20 亿  ←────── 现在 ──────→  未来 20 亿
        │                               │
        └───────────────────────────────┘

每个 XID 都恰好有 20 亿个「更老」的 XID 和 20 亿个「更新」的 XID。

这就是问题的根源:假设某个行版本的插入 XID 是 100。对 XID=1000 的事务来说,100 是「过去」;但当 XID 计数器一路增长到 2,000,000,100 之后,100 就落到了环的另一侧——它看起来变成了「未来」。

text
行版本插入时 xmin = 100

当前 XID = 1000           → 100 在环的「过去」一侧 → 可见 ✅
当前 XID = 1000000        → 100 仍在「过去」一侧 → 可见 ✅
当前 XID = 2000000100     → 100 变成了「未来」   → 不可见 ❌(数据"消失")

也就是说,一个行版本创建后最多只能"安全存在" 20 亿个事务。

官方文档的表述是:必须每 20 亿个事务,对每个数据库中的每张表至少执行一次 VACUUM。注意这里用的是「必须」,因为超过这个界限的后果不是变慢,而是灾难性的数据不可见。

冻结做了什么 ​

要打破上面那个 20 亿的期限,就必须让老元组不再参与 XID 比较规则。PostgreSQL 为此保留了一个特殊 XID:

text
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 都老。

冻结不是一次性动作,而是「四个年龄」的协同 ​

新手常以为「冻结」只有一个开关。实际上它由一组年龄参数协同控制,每个参数负责不同的判断:

text
四个年龄参数的关系(默认值,单位:事务数)

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
  └─ "多老时必须放弃一切避让、全速抢救"(最后保险丝)

四者的关系可以这样记:

text
50,000,000   ──→  150,000,000  ──→  200,000,000        ──→  1,600,000,000
 值不值得冻         要不要全扫          防回卷强制启动            拼命抢救
 min_age           table_age          freeze_max_age          failsafe_age

表级年龄与库级年龄 ​

冻结相关的年龄有两个观察层级,很容易混:

层级字段视图含义
表级relfrozenxidpg_class该表中最老的、尚未被冻结的 XID
表级relminmxidpg_class该表中最老的、尚未处理的多事务 ID
库级datfrozenxidpg_database该数据库中所有表 relfrozenxid 的最小值
库级datminmxidpg_database该数据库中所有表 relminmxid 的最小值

我们真正关心的是它们的年龄(age),即「距离当前 XID 已经过去了多少个事务」:

sql
-- 表级年龄:某张表有多老
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 位,同样会回卷,而且它由独立的参数控制:

sql
-- 多事务 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」,但判据不同。只调其中一个不会影响另一个。

术语表 ​

概念术语说明
事务 IDXID(Transaction ID)32 位,约 40 亿容量
回卷WraparoundXID 计数器绕回 0,导致老事务「变成未来」
冻结Freeze让元组不再参与 XID 比较规则
冻结事务 IDFrozenTransactionId特殊值 2,永远被视为最老
引导事务 IDBootstrapTransactionId特殊值 1,initdb 阶段插入的系统行
多事务 IDMultiXact ID记录「多个事务同时锁行」的 32 位标识,同样会回卷
表冻结年龄age(relfrozenxid)表中最老未冻结 XID 距当前的距离
库冻结年龄age(datfrozenxid)库中所有表冻结年龄的最小值
防回卷清理Anti-wraparound Vacuum无视阈值与关闭状态、强制启动的 autovacuum
失效保护Failsafe年龄逼近极限时放弃一切节流的全速抢救模式

语法与用法 ​

手工冻结 ​

VACUUM 的 FREEZE 选项可以直接要求冻结元组:

sql
-- 语法
VACUUM (FREEZE) table_name;

它的语义是:把 vacuum_freeze_min_age 临时当作 0,即「所有比当前事务老的元组都冻」。因此 VACUUM (FREEZE) 比普通 VACUUM 更激进、写 WAL 更多、更慢,但能立即降低表的冻结年龄。

sql
-- 一次性、彻底地冻结一张表
VACUUM (FREEZE, VERBOSE) orders;
text
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_age200000000100,000 ~ 2,000,000,000否(需重启)表年龄达到此值即强制启动防回卷 autovacuum
autovacuum_multixact_freeze_max_age40000000010,000 ~ 2,000,000,000否(需重启)MultiXact 版本的同款阈值
vacuum_freeze_min_age500000000 ~ 1,000,000,000是元组多老才值得冻结
vacuum_freeze_table_age1500000000 ~ 2,000,000,000是表多老才必须做「全表扫描式」清理
vacuum_multixact_freeze_min_age50000000 ~ 1,000,000,000是MultiXact 的对应门槛
vacuum_multixact_freeze_table_age1500000000 ~ 2,000,000,000是MultiXact 的全表清理门槛
vacuum_failsafe_age16000000000 ~ 2,100,000,000是触发全速抢救的年龄
vacuum_multixact_failsafe_age16000000000 ~ 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 复核实际生效值。

查看当前生效值 ​

sql
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;
text
            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 跳页,把每一页都读一遍以确保老元组被冻结。

text
两种 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

详细说明 ​

四个年龄参数如何协同工作 ​

把一次清理的完整决策链条画出来:

text
一次 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 认为「再不冻就真回卷了」,于是进入失效保护模式,主动放弃一切为了减少业务影响而设计的避让机制:

text
失效保护(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 到真正冻完,可能还要很久」。

sql
-- 放宽/收紧 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 完全平行:

text
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,000

MultiXact 什么时候会累积?主要是行级共享锁场景:

场景为什么会消耗 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) 也纳入监控。

sql
-- 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:

sql
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;
text
  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 后需要同步修改。

实战示例 ​

场景一:巡检全库冻结年龄并输出告警 ​

背景:库里有几十个数据库、上千张表,需要每天巡检一次,按年龄分档告警。

第一步:表级年龄巡检(当前库)

sql
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;
text
        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 | 正常

第二步:库级年龄巡检

sql
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;
text
    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

库级年龄是「全库所有表里最老的那个」,所以它总是等于上面表级巡检的第一名。两者结合看:表级用来看「该处理谁」,库级用来看「整体离红线还有多远」。

第三步:找出「年龄高且体积大」的优先处理对象

单纯年龄高的小表处理起来很快,真正危险的是「又老又大」的表——它的全表扫描式清理会最慢。

sql
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 设阈值告警:

text
推荐告警阈值(表级 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%),并且日志里开始出现防回卷清理的记录。

第一步:确认现状

sql
-- 看年龄
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';
text
 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 冲击:

sql
-- 会话级:拿不到锁就快速失败,避免长时间挂起
SET lock_timeout = '5s';
SET statement_timeout = '4h';

-- 逐表冻结(示例:先处理最老的)
VACUUM (FREEZE, VERBOSE) audit_log;

RESET statement_timeout;
RESET lock_timeout;
text
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

第四步:核实年龄已回落

sql
SELECT c.oid::regclass, age(c.relfrozenxid) AS xid_age
FROM pg_class c WHERE c.oid = 'audit_log'::regclass;
text
   oid    | xid_age
----------+---------
 audit_log|       0

年龄归零意味着「表内所有元组都已冻结,relfrozenxid 被推进到当前 XID 附近」。这是理想状态。

第五步:根治——为什么它会变老

一次冻结只解决当下。要避免复发,得找出根因:

sql
-- 检查是否有什么在长期压制清理
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 仍会强行启动一次清理。

sql
-- 你以为关掉了自动清理就没事了……
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 没动,实际生效值可能不是你以为的那个。

sql
-- 设完后必须复核实际生效值
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。

sql
-- 不推荐:为了冻结而长时间锁表
VACUUM FULL audit_log;

-- 推荐:只冻结,不长时间阻塞
VACUUM (FREEZE) audit_log;

4. 冷冻年龄不能靠 TRUNCATE 或 DELETE 绕过

「把老表删了重建」确实能让年龄归零,但这是靠换了一张新表做到的。如果只是 DELETE FROM t 再 VACUUM,元组虽然被移除,relfrozenxid 的推进仍需一次「全表扫描式清理」,并不能免掉这次扫描的成本。

5. 只监控 age(relfrozenxid) 会漏掉 MultiXact 回卷

外键多、FOR SHARE 多的系统,MultiXact 可能先于 XID 出问题。两套指标都要采。

sql
-- 两个都要看
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 目录可能快速增长,尤其在开启流复制时。

sql
-- 冻结前看一眼 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) 单位是事务数,不是秒/天。一个写入极慢的库,年龄可能几个月都不涨;一个每秒几千事务的库,年龄一天能涨几亿。阈值要结合事务速率来解读。

sql
-- 结合事务速率估算"还能撑多久"
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 亿事务对每张表清理一次」这条底线

这是官方给出的硬性要求。工程上把它转换成一个可执行的检查:

sql
-- 有没有表已经逼近这条底线?
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 来「减少干扰」,改为精细化调参

关闭它既不能阻止防回卷清理,又会让你失去常规清理能力,是最差的选择。正确做法是给大表设置更激进的表级参数:

sql
-- 大表:降低 scale_factor,让清理更及时,避免单次清理量过大
ALTER TABLE audit_log SET (
  autovacuum_vacuum_scale_factor = 0.01,
  autovacuum_vacuum_threshold = 1000
);

4. 让作业完成后主动冻结,避免年龄「坐电梯」

批量导入、数据迁移、大版本升级之后,主动做一次带 FREEZE 的清理,把年龄打回安全区,比等它慢慢涨上去再抢救划算得多。

sql
-- 迁移/导入收尾
VACUUM (FREEZE, ANALYZE) orders;
VACUUM (FREEZE, ANALYZE) order_items;
ANALYZE customers;

5. 大表冻结要分批、限速、设超时

一次全库 VACUUM FREEZE 会让 I/O 瞬间打满,且可能长时间等锁。给每个清理动作加超时保护,把它拆成多个可中断的小任务。

sql
-- 每个表都这样包一层,避免单个动作无限挂起
SET lock_timeout = '5s';
SET statement_timeout = '4h';
VACUUM (FREEZE, VERBOSE) big_table_2026_08;
RESET statement_timeout;
RESET lock_timeout;

6. 定期排查「压制清理推进」的根因

年龄失控很少是「清理不给力」,更多是有东西挡着清理前进。每季度查一遍:

text
清理推进受阻的常见根因(季度检查清单)
├── 长时间运行的事务(pg_stat_activity.xact_start 很早)
├── idle in transaction 的连接(应用未提交/未回滚)
├── inactive 的复制槽(pg_replication_slots.active = false)
├── 长跑的两阶段提交事务(pg_prepared_xacts)
└── 备库长期停摆(hot_standby_feedback 导致主库无法清理)
sql
-- 一次性排查:谁在压制清理(三个来源分别看)
-- 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. 升级/迁移后做一次全库冻结检查

大版本切换、物理迁移、逻辑复制初始化之后,元组的冻结状态可能不理想,容易在之后某天突然触发激进的防回卷清理。收尾时统一处理:

sql
-- 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. 把年龄阈值与容量规划一起做

年龄增长的速率 ≈ 事务速率。在设计阶段就能估算「多久必须清理一次」:

text
理论最大无清理窗口 = 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 统计收集

参考链接 ​