PostgreSQL 冻结(Freeze)机制深度解析

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 比较

触发层次(由宽松到严格):

  1. 元组级:xmin 年龄 > vacuum_freeze_min_age(50M)→ 单 tuple 冻结
  2. 表级:relfrozenxid 年龄 > vacuum_freeze_table_age(150M)→ 全表扫描冻结
  3. 强制:relfrozenxid 年龄 > autovacuum_freeze_max_age(200M)→ 强制 autovacuum
  4. 紧急:距回绕 < 1000万 XID → 拒绝新事务
  5. 崩溃:距回绕 < 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 很老,而另一张只读表可能很新。

相关推荐
赵渝强老师2 小时前
【赵渝强老师】Redis中的字符串
数据库·redis·nosql
天空属于哈夫克32 小时前
告别重复粘贴:如何利用 API 实现企业微信群公告自动更新
数据库·自动化·企业微信·rpa
梦想的旅途22 小时前
企业微信自动发送文本消息的实现与配置
数据库·企业微信
有味道的男人2 小时前
小红书视频比较详情API在线调用数据帮助你更快解决数据抓取
数据库·音视频
米粒12 小时前
力扣算法刷题 Day23
数据库·算法·leetcode
2401_8747325310 小时前
为你的Python脚本添加图形界面(GUI)
jvm·数据库·python
Chengbei1111 小时前
Redis 图形化综合检测工具:redis_tools_GUI,一键探测 + 利用
数据库·redis·web安全·网络安全·缓存·系统安全
hutengyi11 小时前
PostgreSQL的备份方式
数据库·postgresql