跳转到内容

冻结与事务号回卷

准备中…

事务号(xid)是 32 位无符号整数,约 42 亿个。一个每秒 1000 个事务的系统,49 天就用完一轮。

这不是一个可以「以后再说」的工程细节——它是 Postgres 架构里唯一一个会导致数据库强制停机的机制。

Postgres 用 xid 判断可见性:「事务 X 比我早还是比我晚」。42 亿个号用完之后要从头开始,那么问题来了——xid 100 和 xid 42 亿零 100,谁更早?

Postgres 的答案是:把 xid 空间看成一个。对任意一个 xid,前 21 亿个算「过去」,后 21 亿个算「未来」。

这个设计的代价是:如果一个元组的 xmin 落后当前 xid 超过 21 亿,它会突然从「过去」变成「未来」——也就是「尚未提交」,于是这一行凭空消失。

这就是事务号回卷(wraparound)事故。

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;
⌘/Ctrl + Enter
VACUUM fz;
⌘/Ctrl + Enter

普通 VACUUM 之后的状态:

SELECT relfrozenxid::text AS 表的冻结水位,
     age(relfrozenxid)  AS 距今多少个事务
FROM pg_class WHERE relname = 'fz';
⌘/Ctrl + Enter
SELECT * FROM pg_visibility_map('fz', 0);
⌘/Ctrl + Enter
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;
⌘/Ctrl + Enter

all_visibletrue(页面对所有人可见),但 all_frozenfalse,元组也没有冻结标记。这两件事是分开的:可见不等于冻结。

现在强制冻结:

VACUUM FREEZE fz;
⌘/Ctrl + Enter
SELECT * FROM pg_visibility_map('fz', 0);
⌘/Ctrl + Enter
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;
⌘/Ctrl + Enter

三处变化:

  • all_frozen 变成 true
  • infomask0x900 变成 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';
⌘/Ctrl + Enter

age() 回到 0,意味着这张表里没有任何比冻结水位更老的元组。

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;
⌘/Ctrl + Enter
参数 默认值 含义
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();
⌘/Ctrl + Enter

这条查询应该出现在每一个 Postgres 监控面板上。它是少数几个「不看就可能整站宕机」的指标。

「为什么不直接用 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 = trueall_frozen = false,元组的 infomask0x900,没有冻结位;VACUUM FREEZE 之后 all_frozentrueinfomask 变成 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 的值完全没变,变的只有 infomask0x9000xb00)。

好处是排查问题时还能知道这一行是哪个事务写的

常见错误:既然 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;

必须上面板,是因为这个指标有两个别的指标都没有的性质:

  1. 后果是整站宕机——剩 4000 万时日志开始刷 must be vacuumed within N transactions,剩 300 万时数据库拒绝所有新的写事务,只能进单用户模式手工 VACUUM
  2. 爆炸前没有任何常规征兆——回卷压力不消耗 CPU、不消耗磁盘、不影响延迟。

常见错误:把「QPS、延迟、磁盘水位全都正常」当成数据库健康的充分证据。正文那个半夜剧本正是这么走的:某个批处理留下一个 idle in transaction 连接 → autovacuum 被钉住,推不动任何表的 relfrozenxid → 系统正常运行几周,监控面板上一切正常 → 某天凌晨 age 越过阈值,写入全停。而且到那一刻再去 VACUUM 已经来不及——大表冻结一遍要几小时。这个指标必须提前几周看,出事那天看它是没有意义的。