冻结与事务号回卷
事务号(xid)是 32 位无符号整数,约 42 亿个。一个每秒 1000 个事务的系统,49 天就用完一轮。
这不是一个可以「以后再说」的工程细节——它是 Postgres 架构里唯一一个会导致数据库强制停机的机制。
事务号是环形的
Section titled “事务号是环形的”Postgres 用 xid 判断可见性:「事务 X 比我早还是比我晚」。42 亿个号用完之后要从头开始,那么问题来了——xid 100 和 xid 42 亿零 100,谁更早?
Postgres 的答案是:把 xid 空间看成一个环。对任意一个 xid,前 21 亿个算「过去」,后 21 亿个算「未来」。
这个设计的代价是:如果一个元组的 xmin 落后当前 xid 超过 21 亿,它会突然从「过去」变成「未来」——也就是「尚未提交」,于是这一行凭空消失。
这就是事务号回卷(wraparound)事故。
解决办法:冻结
Section titled “解决办法:冻结”VACUUM 会把足够老的元组标记为已冻结(frozen)——冻结的元组对所有事务永远可见,不再参与 xid 比较,也就不怕回卷。
看一眼实际过程。先造一张表:
CREATE EXTENSION IF NOT EXISTS pageinspect; CREATE EXTENSION IF NOT EXISTS pg_visibility; DROP TABLE IF EXISTS fz; CREATE TABLE fz (id int PRIMARY KEY, v int); INSERT INTO fz SELECT i, i FROM generate_series(1, 100) i;
VACUUM fz;
普通 VACUUM 之后的状态:
SELECT relfrozenxid::text AS 表的冻结水位,
age(relfrozenxid) AS 距今多少个事务
FROM pg_class WHERE relname = 'fz';SELECT * FROM pg_visibility_map('fz', 0);SELECT lp, t_xmin::text,
to_hex(t_infomask) AS infomask十六进制,
(t_infomask & 768) = 768 AS 已冻结
FROM heap_page_items(get_raw_page('fz', 0))
LIMIT 3;all_visible 是 true(页面对所有人可见),但 all_frozen 是 false,元组也没有冻结标记。这两件事是分开的:可见不等于冻结。
现在强制冻结:
VACUUM FREEZE fz;
SELECT * FROM pg_visibility_map('fz', 0);SELECT lp, t_xmin::text,
to_hex(t_infomask) AS infomask十六进制,
(t_infomask & 768) = 768 AS 已冻结
FROM heap_page_items(get_raw_page('fz', 0))
LIMIT 3;三处变化:
all_frozen变成true;infomask从0x900变成0xb00——多出来的是HEAP_XMIN_FROZEN位;t_xmin的值没有变。
最后这点值得强调。9.4 之前,冻结是把 t_xmin 改写成一个特殊的 FrozenTransactionId (2),原始事务号就永久丢失了。现在改用 infomask 标记位,原始 xid 被保留下来,排查问题时还能知道这行是哪个事务写的。
表的冻结水位也被推进了:
SELECT relfrozenxid::text AS 表的冻结水位,
age(relfrozenxid) AS 距今多少个事务
FROM pg_class WHERE relname = 'fz';age() 回到 0,意味着这张表里没有任何比冻结水位更老的元组。
三个关键参数
Section titled “三个关键参数”SELECT name, setting, unit
FROM pg_settings
WHERE name IN ('vacuum_freeze_min_age',
'vacuum_freeze_table_age',
'autovacuum_freeze_max_age')
ORDER BY name;| 参数 | 默认值 | 含义 |
|---|---|---|
vacuum_freeze_min_age |
5000 万 | 元组老过这个岁数,VACUUM 顺手冻结它 |
vacuum_freeze_table_age |
1.5 亿 | 表老过这个岁数,下次 VACUUM 升级为全表扫描(不能只扫 FSM 标记的页) |
autovacuum_freeze_max_age |
2 亿 | 强制触发 autovacuum,哪怕表完全没有更新、哪怕 autovacuum 被关掉 |
注意最后一条:autovacuum_freeze_max_age 触发的 vacuum 是不可关闭的。这是 Postgres 的最后防线。
如果由于某种原因(长事务、复制槽、autovacuum 被人为禁用、磁盘满),表的 age 继续增长:
- 达到 4000 万剩余(即 age ≈ 21 亿):日志开始刷警告
database "x" must be vacuumed within N transactions; - 达到 300 万剩余:数据库拒绝执行任何新的写事务,只能进单用户模式手工
VACUUM。
SELECT datname AS 数据库,
age(datfrozenxid) AS 最老事务年龄,
2000000000 - age(datfrozenxid) AS 距离强制停机还剩
FROM pg_database
WHERE datname = current_database();这条查询应该出现在每一个 Postgres 监控面板上。它是少数几个「不看就可能整站宕机」的指标。
64 位 xid 呢?
Section titled “64 位 xid 呢?”「为什么不直接用 64 位事务号」是个合理的问题。答案是:每个元组头要多存 8 个字节,而元组头本身才 23 字节——所有表的体积会显著增长。
社区讨论这个方案很多年了,一直没有进主干。一些分支(比如 Postgres Pro)实现了 64 位 xid。在此之前,冻结机制就是你必须理解的东西。
先自己回答,再点开对照。
事务号为什么是环形的?这个设计带来了什么风险?
因为 xid 是 32 位无符号整数,约 42 亿个,用完必须从头开始。而 Postgres 判断可见性靠的是「事务 X 比我早还是比我晚」,回到起点之后这个比较就没法用绝对大小做了。于是把 xid 空间看成一个环:对任意一个 xid,前 21 亿个算「过去」,后 21 亿个算「未来」。
风险是:一旦某个元组的 xmin 落后当前 xid 超过 21 亿,它就会从「过去」翻到「未来」——被判成「尚未提交」,这一行凭空消失。
常见错误:以为回卷的后果是「报错」或者「xid 重复导致主键冲突」这类看得见的故障。真正的后果比报错糟糕得多——是静默的数据消失,查询正常返回、不抛任何错,只是少了一批行。正因为这个后果无法接受,Postgres 才选择在 age 逼近阈值时主动拒绝所有写事务:宁可停机,也不让你读到错误的结果。那次停机是防线,不是故障本身——故障是「让 age 涨到那里」的那几周。
「可见」和「已冻结」有什么区别?为什么 all_visible 为真时 all_frozen 仍可能为假?
- 可见是相对的:
all_visible表示「这一页上所有元组对当前所有活跃事务都可见」。它仍然建立在 xid 比较之上,所以仍然怕回卷。 - 已冻结是绝对的:冻结的元组对所有事务永远可见,不再参与 xid 比较,因此彻底退出回卷风险。
实测这个差别:普通 VACUUM fz 之后 all_visible = true 但 all_frozen = false,元组的 infomask 是 0x900,没有冻结位;VACUUM FREEZE 之后 all_frozen 变 true,infomask 变成 0xb00(多出来的是 HEAP_XMIN_FROZEN)。
两者分开是因为它们由不同的规则驱动:all_visible 由「还有没有活跃事务需要旧版本」决定,all_frozen 由元组的年龄决定——普通 VACUUM 只顺手冻结老过 vacuum_freeze_min_age(5000 万)的元组。
常见错误:把这两个位当成「同一件事的强弱两档」,以为 all_visible 到了迟早会自动升级成 all_frozen。不会。一张只插不改的历史表可以长期停在 all_visible = true / all_frozen = false:数据早就对所有人可见,没有任何更新,autovacuum 也没有理由光顾它——而它一直挂在回卷风险名单上。这正是「冷数据表值得主动 VACUUM FREEZE 一次」这条建议的由来,冻完之后后续的 vacuum 可以直接跳过这些页,几乎是免费的。
现代 Postgres 冻结元组时为什么不再改写 t_xmin?保留它有什么好处?
9.4 之前,冻结的做法是把 t_xmin 改写成特殊的 FrozenTransactionId (2),原始事务号就永久丢失了。现在改用 infomask 里的 HEAP_XMIN_FROZEN 标记位来表达同一件事——实测 VACUUM FREEZE 前后 t_xmin 的值完全没变,变的只有 infomask(0x900 → 0xb00)。
好处是排查问题时还能知道这一行是哪个事务写的。
常见错误:既然 t_xmin 保留下来了,就拿它去判断「这行有多老、什么时候写的」。冻结之后 t_xmin 虽然还在,但它已经不参与可见性判断了——标记位一旦置上,这行对谁都可见,t_xmin 是多少都不影响结果。它退化成一条审计线索,不再是一个可以用来推算年龄的量。何况 xid 本身会回卷,跨越一轮之后单看一个绝对数值也无法还原先后顺序。
autovacuum_freeze_max_age 触发的 vacuum 有什么特别之处?
它是不可关闭的,而且是三个参数里唯一会凭空发起一轮 vacuum 的:
| 参数 | 默认值 | 它做什么 |
|---|---|---|
vacuum_freeze_min_age |
5000 万 | 让已经在跑的 VACUUM 顺手冻结老元组 |
vacuum_freeze_table_age |
1.5 亿 | 让下一次 VACUUM 升级成全表扫描 |
autovacuum_freeze_max_age |
2 亿 | 强制发起一轮 autovacuum |
哪怕这张表完全没有更新、哪怕 autovacuum 被人为设成 off,age 越过 2 亿时它照样会启动。这是 Postgres 的最后防线。
常见错误:以为把 autovacuum 关掉就能在业务高峰期彻底躲开 vacuum 的 I/O。防回卷的那一轮关不掉——而且关得越久,它触发时要冻结的量越大、跑得越久,正好落在最不希望它跑的时候,还带着一张已经积压到极限的账单。想控制时机,正确做法是反过来:给大表调小 autovacuum_freeze_max_age,让它更早开始、分批冻结,而不是攒到最后一次性爆发。
写出监控回卷风险的那条 SQL,并说明它为什么必须上监控面板。
SELECT datname, age(datfrozenxid), 2000000000 - age(datfrozenxid) AS 距离强制停机还剩FROM pg_database;必须上面板,是因为这个指标有两个别的指标都没有的性质:
- 后果是整站宕机——剩 4000 万时日志开始刷
must be vacuumed within N transactions,剩 300 万时数据库拒绝所有新的写事务,只能进单用户模式手工VACUUM; - 爆炸前没有任何常规征兆——回卷压力不消耗 CPU、不消耗磁盘、不影响延迟。
常见错误:把「QPS、延迟、磁盘水位全都正常」当成数据库健康的充分证据。正文那个半夜剧本正是这么走的:某个批处理留下一个 idle in transaction 连接 → autovacuum 被钉住,推不动任何表的 relfrozenxid → 系统正常运行几周,监控面板上一切正常 → 某天凌晨 age 越过阈值,写入全停。而且到那一刻再去 VACUUM 已经来不及——大表冻结一遍要几小时。这个指标必须提前几周看,出事那天看它是没有意义的。