大家好,我是 JiekeXu,江湖人称“强哥”,青学会MOP技术社区主席,荣获Oracle ACE Pro称号,OpenTenBase ACE,金仓社区最具价值倡导者KVA,崖山最具价值专家YVP,IvorySQL开源社区专家顾问委员会成员,KWDB社区MVP,墨天轮MVP,墨天轮连续多年度“墨力之星”,拥有Oracle OCP/OCM认证,MySQL 5.7/8.0 OCP认证以及金仓KCA、KCP、KCM、KCSM证书,TiDB PCTA/PCTP证书、PCA、OBCA、OGCA等众多国产数据库认证证书,专注于数据库技术、系统架构及大数据运维,致力于分享最纯粹、最接地气的 DBA 实战与前沿技术洞察。如果你也对数据技术充满热忱,欢迎关注我的微信公众号“JiekeXu DBA之路”点赞、转发与评论,谢谢!

标签:#PostgreSQL #数据库 #Postgres #XID #Freeze #autovacuum
一、32 位 XID 的本质
PostgreSQL 给每个事务分配一个 32 位无符号整数作为事务 ID(XID)。它沿一个环状空间从 0 一直推进到 2^32 - 1 = 4,294,967,295,再绕回 0。每开一个新事务,系统把"当前 XID + 1"分配给它。
判断一个 tuple 是否对当前事务可见,PG 用的是 mod-2³² 比较:
- 把目标 XID 与当前 XID 相减(mod 2³²)
- 结果 < 2³¹(约 21.5 亿),目标在"过去窗口"——已提交则可见
- 结果 ≥ 2³¹,目标在"未来窗口"——不可见
这意味着环上始终存在两个各占半圈的"过去/未来"窗口,随当前 XID 的推进同步向前滚动。
下面这张图把环上空间、当前 XID、过去/未来窗口画在一起。

理解这张图的关键点:当前 XID 把环切成两半。后半个圈(≤ 2³¹ 距离内)是"过去",前半个圈是"未来"。PG 只能用 mod-2³² 减法判断 XID 在哪一半,环上没有任何绝对原点——所谓"过去/未来"是相对当前 XID 而言的。
二、隐患:XID Wraparound
隐患就藏在这个"绕回"机制里。每个 tuple 的头部记着自己被哪个事务插入(xmin)和更新(xmax)。Tuple 的可见性判断依赖 xmin 与当前 XID 在环上的距离比较。
设想一个具体场景:
- 一行数据在 XID = 100 时插入,
xmin = 100 - 这行 tuple 永不被 freeze(即其
xmin字段始终是 100) - 时间推移,事务一直开,XID 一路增长:100 → 10 亿 → 20 亿 → 绕回 0 → 再涨到 21.5 亿 - 100
- 此时再看
xmin = 100这行:mod-2³² 比较会把它判定为"距离当前 XID 几乎一整圈"——被算作"未来窗口"中的未提交事务
结果:已提交的数据凭空消失,老 tuple 在新事务眼里变得不可见。这就是 PostgreSQL 社区著名的 XID wraparound 问题。整个库的可见性语义会被打破。
PG 还有一个二级隐患:所有系统目录表、用户表共享同一个 XID 计数器,意味着即使一张表的事务很少,只要别处一直在产生事务(COPY、批量 INSERT、长跑分析),它的 XID 也会跟着环往前推进。任何表都可能踩雷,无关它自己的写量。
更严重的是,PG 一旦发现某个表的 age(= 当前 XID − relfrozenxid)即将撞到 2³¹,会强制进入只读保护模式:拒绝任何写事务,数据库事实上停止服务,必须由管理员 single-user mode 启动并手动 VACUUM FREEZE 才能救活。这是社区为了避免数据丢失做的"丢车保帅"。
三、社区怎么解决:Freeze + autovacuum 阶梯式防御
核心思路:把"老 tuple 的 xmin 改写成特殊值 FrozenXID (=2)。这个值在 mod-2³² 比较中永远被视为"过去已提交可见",因此 freeze 后的 tuple 不再随 XID 环循环而失效。表只记一个 relfrozenxid 字段表示"我已 freeze 到此 XID 为止"——只要它跟着时间推进,表的"安全 age"就有保证。
下面这张图展示了 freeze 改写 xmin 的过程:

社区并不指望管理员手动 freeze 整库,而是用 autovacuum 自动按阈值触发 freeze。三个核心 GUC 参数构成一条阶梯式防御:
| 参数 | 默认值 | 触发动作 |
|---|---|---|
vacuum_freeze_min_age |
5 千万 | 任何 VACUUM 时 freeze 比这个 age 更老的 XID |
vacuum_freeze_table_age |
1.5 亿 | 表 age 达到时, 下一次 vacuum 切换为"全表扫描 freeze" |
autovacuum_freeze_max_age |
2 亿 | 表 age 达到时, 强制启动 anti-wraparound vacuum (即使 autovacuum=off) |
2^31 (硬上限) |
21.5 亿 | 数据库进入只读保护模式, 拒绝新写事务 |
下面这张阶梯图直观展示从"正常 vacuum"到"强制 anti-wraparound"再到"保护停机"的递进过程:

四、监控与运维实操
下面这几条 SQL 是生产环境监控 XID wraparound 风险的"必备武器"。
1. 查每张表的 age,按风险排序:
SELECT c.oid::regclass AS rel,
age(c.relfrozenxid) AS xid_age,
to_char(age(c.relfrozenxid), 'FM999,999,999,999') AS xid_age_str,
current_setting('autovacuum_freeze_max_age')::int AS freeze_max_age,
round(100.0 * age(c.relfrozenxid)
/ current_setting('autovacuum_freeze_max_age')::int, 1) AS pct_of_max
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm', 't')
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY 2 DESC
LIMIT 20;
xid_age 越接近 autovacuum_freeze_max_age(默认 2 亿),离强制 anti-wraparound 越近;当 xid_age 接近 21.5 亿(2³¹),数据库会进入只读保护模式。
2. 查整库 age(数据库级 datfrozenxid):
SELECT datname,
age(datfrozenxid) AS age,
to_char(age(datfrozenxid), 'FM999,999,999,999') AS age_str
FROM pg_database
ORDER BY age DESC;
3. 查当前是否有 anti-wraparound vacuum 在跑:
SELECT pid,
datname,
state,
query,
now() - query_start AS runtime
FROM pg_stat_activity
WHERE query ILIKE '%autovacuum%to prevent wraparound%'
OR backend_type = 'autovacuum worker'
ORDER BY runtime DESC NULLS LAST;
4. 强制 freeze 单表(维护窗口用):
VACUUM (FREEZE, ANALYZE) my_big_table;
-- 对超大表分区逐分区执行,避免一次扫全表打爆 IO
VACUUM (FREEZE, ANALYZE) my_partitioned_table PARTITION p_2024_09;
5. 关键 GUC 调参(postgresql.conf):
autovacuum = on
autovacuum_freeze_max_age = 200000000 # 默认 2 亿; 大表可降到 1 亿更早触发
vacuum_freeze_min_age = 50000000 # 默认 5 千万
vacuum_freeze_table_age = 150000000 # 默认 1.5 亿
log_autovacuum_min_duration = 0 # 把所有 anti-wraparound 行为都记日志
五、反模式与踩坑警示
按"先测量后优化、规划回滚"的原则,列几条生产血泪教训:
- 别关 autovacuum。PG 10+ 即使
autovacuum = off,anti-wraparound vacuum 仍会被强制启动;但 9.x 老版本不一定可靠。关 autovacuum 是社区第一反模式。 - 别在大表上等 emergency vacuum。一旦
age > autovacuum_freeze_max_age才触发 freeze,autovacuum 会全表扫描,几百 GB 的表会把 IO 打爆几天。生产应该在低峰期手动VACUUM FREEZE分区跑。 - 别用
pg_resetwal --next-transaction跳 XID。它跳过的同时不会推进 freeze,下次 vacuum 反而更容易 wraparound。 - 大表分区是好朋友。每个分区有自己的
relfrozenxid,autovacuum 可以独立 freeze 单个分区而不是整张大表。 SELECT * FROM pg_stat_user_tables里n_dead_tup飙升但 autovacuum 没跑,先看pg_stat_activity是否有 anti-wraparound worker 卡住——典型是某个长跑事务阻塞 vacuum 推进relfrozenxid。- 升级到 PG 14+。PG 14 起 vacuum freeze 默认利用 visibility map 的
all-frozen位跳过已冻结页,大表 freeze 的 IO 开销大幅下降;这是社区至今仍在持续优化的点。 - 不要在生产上跑
TRUNCATE去规避 wraparound——TRUNCATE会重置 relfrozenxid 但破坏快照语义,正在跑的长查询会报错。
总结
PostgreSQL 32 位 XID 是一个绕回环(2³² = 42.9 亿槽位),PG 用 mod-2³² 比较把环切成"过去/未来"两个半圈(各 2³¹ ≈ 21.5 亿)来判断 tuple 可见性。
机制:每个事务拿一个 32 位 XID;tuple 头部记录插入它的 xmin;可见性 = 当前 XID 与 xmin 在环上的距离比较。
隐患:如果表的 relfrozenxid 不推进,XID 绕一圈回到老位置,老 tuple 会被判定为"未来未提交",已提交的数据凭空消失;PG 会在 age 接近 2³¹ 时强制进入只读保护模式救命。
社区方案:autovacuum 按 vacuum_freeze_min_age(5 千万)→ vacuum_freeze_table_age(1.5 亿全表扫描)→ autovacuum_freeze_max_age(2 亿强制 anti-wraparound, 即使 autovacuum=off 仍启动)→ 2³¹ 只读保护,阶梯式触发 freeze。freeze 的本质是把 tuple 的 xmin 改写为 FrozenXID (=2),永远被视为"过去已提交可见"。PG 14+ 还利用 visibility map 的 all-frozen 位跳过已冻结页,让大表 freeze 的 IO 显著下降。
生产运维关键:监控 pg_class.relfrozenxid 的 age、把 log_autovacuum_min_duration = 0、对大表在低峰期手动 VACUUM FREEZE 分区、永远不要关 autovacuum。三张配图分别画了 XID 循环空间、freeze 改写 xmin 的过程、autovacuum 阈值阶梯——已记录在本次工作日志里。
全文完,希望可以帮到正在阅读的你,如果觉得有帮助,可以分享给你身边的朋友,同事,你关心谁就分享给谁,一起学习共同进步~~~
欢迎关注我的公众号【JiekeXu DBA之路】,一起学习新知识!
——————————————————————————
公众号:JiekeXu DBA之路
墨天轮:https://www.modb.pro/u/4347
CSDN :https://blog.csdn.net/JiekeXu
ITPUB:https://blog.itpub.net/69968215
腾讯云:https://cloud.tencent.com/developer/user/5645107
——————————————————————————





