PostgreSQL 的另一个隐形炸弹:MultiXact ID 回卷

2026-05-18 32 预计阅读时间:1 分钟
来源:postgr.es AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:9 分钟

你精心调好了 autovacuum,监控面板上 age(datfrozenxid) 稳稳地在安全线以内,觉得自己已经把 XID 回卷这头猛兽驯服了——然后某天凌晨,数据库突然拒绝写入,报错信息指向一个你从未关注过的名字:MultiXact ID wraparound

这不是理论推演。Richard Yen 在文章中提到,他见过不止一位资深 DBA 在这个坑里栽倒。原因很简单:所有人都盯着 XID,没人看 MultiXact。而它的破坏力和 XID 回卷完全一样——数据库停止接受某些写操作,直到你跑完 vacuum。

一行锁,多个持锁者

PostgreSQL 每行数据都有一个系统列 xmax,正常情况下它存的是删除或更新该行的事务 ID(XID)。但问题来了:当多个事务同时对同一行持有锁时,xmax 只有 32 位,塞不下多个 XID。

典型场景:

  • SELECT ... FOR SHARE——多个事务可以同时持有共享行锁。
  • SELECT ... FOR KEY SHARE——外键检查隐式使用的锁模式。每次往子表插入或更新一条引用父表的记录,Postgres 都会在父表对应行上悄悄加一个 FOR KEY SHARE 锁。
  • 组合锁——一个事务持有 FOR KEY SHARE,另一个持有 FOR NO KEY UPDATE,两者不冲突,共存于同一行。

解决方案就是 MultiXact 机制:分配一个 MultiXact ID(本质上是一个指针),指向 pg_multixact/ 目录下的文件结构,里面记录了所有参与的事务 ID 和锁类型。行的 xmax 字段存这个 MultiXact ID,同时在 tuple header 的 infomask 中设置 HEAP_XMAX_IS_MULTI 标志位,告诉 Postgres "这不是普通 XID,去查 MultiXact 表"。

xmax 仍然是 32 位固定宽度,但通过间接引用,它能表达任意数量的并发行锁。

外键:看不见的 MultiXact 工厂

最值得警惕的场景是外键。你的应用代码里没有任何 FOR SHARE 语句,但只要 schema 里存在外键引用,Postgres 就在背后默默产生 MultiXact ID。

想象一个典型的业务结构:一张 users 表被几十张子表引用。每秒数百条子表插入,每条插入都要在 users 的对应行上加 FOR KEY SHARE 锁。如果多个子表事务同时引用同一个用户行,MultiXact ID 就会快速累积。你完全感知不到这个过程,直到监控报警——或者没有报警,直到数据库停写。

32 位计数器的宿命

和 XID 一样,MultiXact ID 是 32 位计数器,回卷是必然的。Postgres 只能看到约 20 亿个 MultiXact ID 的历史窗口。如果某行还引用着一个即将滑出可见范围的旧 MultiXact ID,Postgres 就无法判断那些锁是否还有意义——这是灾难的起点。

防回卷的手段也是冻结(freeze)。冻结一个 MultiXact 意味着把行 xmax 中的 MultiXact 引用替换为零值、单个 XID,或一个更新的 MultiXact ID,取决于锁信息是否还有意义。

对应的参数和 XID 冻结参数一一映射:

XID 参数 MultiXact 对应参数
vacuum_freeze_min_age vacuum_multixact_freeze_min_age
vacuum_freeze_table_age vacuum_multixact_freeze_table_age
autovacuum_freeze_max_age autovacuum_multixact_freeze_max_age

当表的 MultiXact age 超过 autovacuum_multixact_freeze_max_age,autovacuum 会触发一次全表扫描式的激进 vacuum 来冻结旧 MultiXact ID——即使该表没有任何 dead tuple、本来不会触发常规 autovacuum。

这就是为什么你有时会看到 autovacuum 在一张"干净"的表上跑:它不是在浪费资源,而是在救你的命。

监控:把 MultiXact age 加进仪表盘

数据库级别的全局视图:

SELECT datname,
       age(datfrozenxid)    AS xid_age,
       mxid_age(datminmxid) AS mxid_age
FROM pg_database
ORDER BY mxid_age DESC;

按表细查,找出最危险的表:

SELECT c.oid::regclass AS table_name,
       age(c.relfrozenxid)    AS xid_age,
       mxid_age(c.relminmxid) AS mxid_age
FROM pg_class c
WHERE c.relkind IN ('r', 't', 'm')
ORDER BY mxid_age DESC
LIMIT 20;

重点关注 mxid_age 接近 autovacuum_multixact_freeze_max_age(默认 4 亿)的表。如果接近阈值,autovacuum 应该会自动介入,但在大表或 autovacuum worker 不足的系统上,它可能来不及完成。

建立完整的防线

第一步:补齐告警。 如果你已经在 XID age 达到 5 亿时触发告警,就给 MultiXact age 加一条同等级别的规则。两条线缺一不可。

第二步:盯住外键父表。 那张被全库引用的 accountsusers 表,几乎一定是 MultiXact 累积最快的表。单独给它配置更激进的 autovacuum 参数:

-- 对高热度父表降低 MultiXact freeze 阈值,让 autovacuum 更早介入
ALTER TABLE users SET (
    autovacuum_multixact_freeze_max_age = 200000000,  -- 2 亿,比默认 4 亿更早触发
    autovacuum_multixact_freeze_table_age = 150000000
);

调整后,autovacuum 在 MultiXact age 达到 2 亿时就会启动全表冻结 vacuum,给你更多缓冲空间。代价是这张表的 vacuum 频率会更高,但对于被大量外键引用的热表,这个代价远比停写事故小。

第三步:不要关掉"看起来无用"的 autovacuum。 看到 autovacuum 在零 dead tuple 的表上运行时,先查 mxid_age——它大概率在做 MultiXact 冻结。如果你把它关掉或延迟,就是在亲手拆除防线。

第四步:确认 autovacuum worker 充足。 默认只有 3 个 worker。在高并发外键写入的系统中,冻结 vacuum 可能和大表常规 vacuum 排队争抢 worker。适当调高 autovacuum_max_workers,并配合 autovacuum_naptime 降低间隔,确保冻结任务不被长期阻塞:

# postgresql.conf
autovacuum_max_workers = 6          # 从默认 3 提高到 6
autovacuum_naptime = 30s            # 从默认 1min 缩短到 30s

回卷前的紧急抢救

如果 mxid_age 已经逼近 20 亿(2³¹ − 1),autovacuum 来不及完成,你需要手动介入:

# 找出 mxid_age 最高的表
psql -c "
  SELECT c.oid::regclass, mxid_age(c.relminmxid)
  FROM pg_class c
  WHERE c.relkind = 'r'
  ORDER BY mxid_age(c.relminmxid) DESC
  LIMIT 5;
"

# 对最危险的表立即执行全表 vacuum
psql -c "VACUUM FREEZE ANALYZE users;"

VACUUM FREEZE 会扫描全表并冻结所有旧 XID 和 MultiXact ID。对于大表这会很慢,但这是唯一能阻止回卷的操作。如果表太大,考虑在业务低峰期提前安排,而不是等到紧急时刻。


XID 回卷是明面上的威胁,所有人都知道要防。MultiXact 回卷是暗处的同卵双生——同样的机制、同样的后果、同样的修复手段,只是触发路径更隐蔽。如果你今天只做一件事:登录你的数据库,跑一遍上面那条 mxid_age 查询。从未看过这个数字的人,现在就是最好的起点。


相关推荐