PostgreSQL 冻结(Freeze)机制深度解析
一、为什么需要冻结
1.1 事务 ID 的本质
PostgreSQL 用 32 位无符号整数表示事务 ID(XID),范围 0 ~ 2^32-1(约 42 亿)。
其中有三个特殊 XID:
| XID 值 | 名称 | 含义 |
|---|---|---|
| 0 | InvalidTransactionId | 无效事务 |
| 1 | BootstrapTransactionId | 初始化事务 |
| 2 | FrozenTransactionId | 冻结事务(永远可见) |
正常分配从 XID=3 开始。
1.2 XID 比较的模运算陷阱
PostgreSQL 判断事务可见性使用模 2^31 的有符号比较:
XID A 在 XID B 之前(older):(B - A) mod 2^32 < 2^31
这意味着每个 XID 只有"前 2^31 个"是过去,"后 2^31 个"是未来。
回绕场景:
时间轴:... XID=1亿 XID=20亿 XID=40亿 XID=1(回绕)XID=2亿 ...
↑
XID 耗尽后从头分配
此时 XID=1亿 的旧数据,对 XID=2亿 的新事务来说变成了"未来"
→ 旧数据对所有人不可见,相当于数据"消失"
1.3 冻结的本质
将元组的 t_xmin(或 t_xmax)替换为 FrozenTransactionId(= 2),该值在模运算中永远被认为比任何正常 XID 更老,即对所有事务永远可见,彻底脱离 XID 比较逻辑。
二、冻结的两种实现方式
2.1 旧方式:物理写入 FrozenXID(PG 9.4 之前)
直接将 tuple header 中的 t_xmin 改写为 FrozenTransactionId(= 2)。
缺点:每次冻结都要修改 tuple,产生大量 WAL,I/O 开销大。
2.2 新方式:infomask 标志位(PG 9.4+,当前主流)
不修改 t_xmin 的值,而是在 tuple 的 t_infomask 中设置两个标志位:
| 标志位 | 含义 |
|---|---|
HEAP_XMIN_COMMITTED |
xmin 事务已提交 |
HEAP_XMIN_FROZEN |
xmin 已冻结(永远可见) |
当 HEAP_XMIN_FROZEN 被设置时,可见性检查直接返回"可见",无需查 pg_xact(原 pg_clog)。
优点:
- 减少 WAL 量(只需记录 infomask 变更,而非 xmin 值变更)
- 批量冻结整页时可以只更新 VM,不修改每个 tuple
三、冻结触发条件
3.1 元组级冻结条件
当前 XID - tuple.t_xmin > vacuum_freeze_min_age(默认 50,000,000)
满足条件的 tuple 在 VACUUM 扫描时被冻结。
vacuum_freeze_min_age 设置较大:保留旧版本更长时间,减少不必要冻结;
设置较小:更激进冻结,降低回绕风险,但增加 I/O。
3.2 表级全量扫描冻结条件
当前 XID - pg_class.relfrozenxid > vacuum_freeze_table_age(默认 150,000,000)
触发后 VACUUM 忽略 Visibility Map,强制扫描所有页(包括全可见页),确保所有旧 tuple 都被冻结。
3.3 强制 Autovacuum(Aggressive Vacuum)
当前 XID - pg_class.relfrozenxid > autovacuum_freeze_max_age(默认 200,000,000)
即使 autovacuum = off,PostgreSQL 也会强制启动 autovacuum worker 对该表执行冻结。这是最后一道防线。
3.4 三个阈值的关系
|<--freeze_min_age(50M)-->|<--freeze_table_age(150M)-->|<--freeze_max_age(200M)-->|
↑ ↑ ↑
开始全表扫描冻结 强制autovacuum 数据库关闭保护
relfrozenxid (距回绕 1000万 XID 时)
PostgreSQL 在距回绕还剩约 1000 万 XID 时会拒绝新事务(只允许 superuser 连接执行 VACUUM),距回绕还剩约 100 万 XID 时数据库会 PANIC 关闭。
四、relfrozenxid 与 datfrozenxid
4.1 relfrozenxid
存储在 pg_class.relfrozenxid,表示该表中所有 XID 小于此值的 tuple 都已被冻结。
VACUUM 完成后更新此值为:min(当前 XID - vacuum_freeze_min_age, 本次扫描中最老的未冻结 xmin)。
4.2 datfrozenxid
存储在 pg_database.datfrozenxid,是该数据库内所有表的 relfrozenxid 的最小值。
系统级回绕风险以 datfrozenxid 为准。
4.3 查询冻结状态
sql
-- 数据库级别 XID 年龄
SELECT
datname,
age(datfrozenxid) AS xid_age,
2000000000 - age(datfrozenxid) AS xid_remaining,
datfrozenxid
FROM pg_database
ORDER BY xid_age DESC;
-- 表级别 XID 年龄(找出最危险的表)
SELECT
n.nspname AS schema,
c.relname AS table_name,
age(c.relfrozenxid) AS xid_age,
c.relfrozenxid
FROM pg_class c
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE c.relkind = 'r'
ORDER BY xid_age DESC
LIMIT 20;
五、页级冻结优化(PG 14+)
5.1 整页冻结(Page-level Freeze)
PG 14 引入了更激进的整页冻结策略:当一个页内所有 tuple 都满足冻结条件 时,直接在 VM 中设置 ALL_FROZEN bit,后续 VACUUM 完全跳过该页,大幅减少重复扫描开销。
5.2 冻结与 VM 的协作
VACUUM 扫描某页
│
├─ 页内所有 tuple 均已冻结?
│ YES → 设置 VM.ALL_FROZEN bit
│ → 后续 VACUUM 跳过此页(无需再扫描)
│
└─ 页内所有 tuple 对所有事务可见(无死元组)?
YES → 设置 VM.ALL_VISIBLE bit
→ Index-Only Scan 可跳过回表
六、MultiXact 冻结
6.1 什么是 MultiXact
当多个事务同时对同一行加行锁(SELECT FOR SHARE 等),PostgreSQL 用 MultiXactId 替代单个 XID 存储在 t_xmax 中。
MultiXactId 同样是 32 位整数,同样面临回绕问题。
6.2 MultiXact 冻结参数
| 参数 | 默认值 | 说明 |
|---|---|---|
vacuum_multixact_freeze_min_age |
5,000,000 | MultiXact 冻结的最小年龄 |
vacuum_multixact_freeze_table_age |
150,000,000 | 触发全表扫描的 MultiXact 年龄 |
autovacuum_multixact_freeze_max_age |
400,000,000 | 强制 autovacuum 的 MultiXact 年龄 |
七、冻结相关操作
7.1 手动触发冻结
sql
-- 对指定表执行冻结(扫描所有页)
VACUUM FREEZE mytable;
-- 带详细输出
VACUUM FREEZE VERBOSE mytable;
-- 对整个数据库执行冻结
VACUUM FREEZE;
7.2 观察冻结进度
sql
-- 查看 VACUUM FREEZE 进度
SELECT
pid,
datname,
relid::regclass AS table_name,
phase,
heap_blks_total,
heap_blks_scanned,
heap_blks_vacuumed,
num_dead_tuples,
num_frozen_tuples -- PG 16+ 新增字段
FROM pg_stat_progress_vacuum;
7.3 用 pageinspect 验证冻结状态
sql
CREATE EXTENSION pageinspect;
-- 查看 tuple 的冻结标志
SELECT
lp,
t_xmin,
t_xmax,
-- HEAP_XMIN_FROZEN = 0x0200 (infomask bit)
(t_infomask & 512) > 0 AS xmin_frozen,
(t_infomask & 256) > 0 AS xmin_committed
FROM heap_page_items(get_raw_page('mytable', 0));
八、冻结失败的常见原因
| 原因 | 说明 | 解决方法 |
|---|---|---|
| 长事务 | 长事务持有的快照阻止旧 XID 被冻结 | 监控并终止长事务 |
| 长 Replication Slot | 备库 slot 持有的 xmin 阻止冻结推进 |
清理不用的 slot |
vacuum_defer_cleanup_age |
延迟清理导致冻结推迟 | 评估是否需要此参数 |
| 表被锁 | VACUUM 无法获取锁 | 排查锁等待 |
检查阻塞冻结的 Replication Slot
sql
SELECT
slot_name,
slot_type,
active,
age(xmin) AS xmin_age,
age(catalog_xmin) AS catalog_xmin_age,
restart_lsn
FROM pg_replication_slots
ORDER BY age(xmin) DESC NULLS LAST;
九、总结
问题根源:XID 是 32 位整数,约 42 亿后回绕,旧数据变"未来"数据 → 不可见
解决方案:冻结(Freeze)
将 tuple 标记为 HEAP_XMIN_FROZEN
→ 永远可见,脱离 XID 比较
触发层次(由宽松到严格):
- 元组级:xmin 年龄 > vacuum_freeze_min_age(50M)→ 单 tuple 冻结
- 表级:relfrozenxid 年龄 > vacuum_freeze_table_age(150M)→ 全表扫描冻结
- 强制:relfrozenxid 年龄 > autovacuum_freeze_max_age(200M)→ 强制 autovacuum
- 紧急:距回绕 < 1000万 XID → 拒绝新事务
- 崩溃:距回绕 < 100万 XID → 数据库 PANIC
日常运维要点:
✓ 监控 age(datfrozenxid) 和 age(relfrozenxid),保持远低于 200M
✓ 确保 autovacuum 正常运行,不被长事务/长 slot 阻塞
✓ 对写入频繁的大表设置更激进的 autovacuum 参数
✓ 定期检查 pg_replication_slots,清理废弃 slot
✓ 生产环境 XID 年龄超过 150M 时应立即人工介入执行 VACUUM FREEZE
XID 分配是全局的(集群级别)
XID 计数器是整个 PostgreSQL 实例(cluster)共享的,所有数据库的所有事务共用同一个 XID 序列。所以回绕是集群级别的威胁,不是某个库或某张表独有的。
冻结进度是按表跟踪的
每张表有自己的 pg_class.relfrozenxid,表示"这张表里所有 xmin 小于此值的 tuple 都已冻结"。不同表的冻结进度完全独立,一张频繁更新的表可能 relfrozenxid 很老,而另一张只读表可能很新。