前言

PostgreSQL 的事务系统对用户基本透明:你执行 BEGIN / COMMIT,很少直接碰到底层的 CommitLog(CLOG)32 位事务 ID(XID)。但一旦遇到 长事务、autovacuum 不及时、XID wraparound 告警,如果不理解这些机制,很容易把问题当成「磁盘满了」或「连接数爆了」去排查,白白浪费时间。

本文基于 PostgreSQL 的 CommitLog 与 XID 原理,系统梳理其工作机制、性能优化、回卷防护与故障案例,并与 MySQL InnoDB 做对照——帮助已经熟悉 MySQL MVCC 的 DBA,快速建立 PostgreSQL 事务运维的心智模型。


一、CommitLog(CLOG)是做什么的

PostgreSQL 把事务的最终状态记录在 CommitLog 中。从 PostgreSQL 10 起,对应文件位于数据目录的 pg_xact 子目录(早期版本叫 pg_clog)。

每条事务有四种状态,用 2 bit 编码:

状态值 宏名 含义
0x00 TRANSACTION_STATUS_IN_PROGRESS 进行中
0x01 TRANSACTION_STATUS_COMMITTED 已提交
0x02 TRANSACTION_STATUS_ABORTED 已回滚
0x03 TRANSACTION_STATUS_SUB_COMMITTED 子事务已提交

CommitLog 本质上是一个位图文件,因此具备以下特点:

  • 32 位 XID 空间下,理论上最多记录约 20 亿 个事务状态;
  • 整个 CLOG 最多占用约 512 MB 空间;
  • CLOG 也会随 VACUUM / FREEZE 被清理,触发阈值与 autovacuum_freeze_max_age(默认 2 亿)相关。

1.1 读每一行都要查 CLOG 吗?

Heap 表每一行都有 xmin(插入事务 ID)和 xmax(删除/更新事务 ID)。如果每次判断可见性都去读 CLOG 文件,性能显然无法接受。

PostgreSQL 做了两层优化:

  1. 共享内存 CLOG Buffer:CommitLog 页被 cache 在 clog buffer 中,减少磁盘 IO;
  2. 行级 hint bit(t_infomask:若 HEAP_XMIN_COMMITTED 等标志位已设置,说明对应事务状态已知,无需再查 CLOG

这是典型的「热路径缓存 + 行内 hint」设计,与 InnoDB 在 undo 链上走版本、用 ReadView 判可见性的思路不同,但目标一致:把事务状态查询从磁盘路径挪到内存 fast path


二、XID 基本原理

2.1 分配规则

  • 每个需要 XID 的普通事务,从共享变量 nextXid 领取一个 32 位无符号整数,分配后 nextXid++
  • 尚未绕回复用的一段区间内,XID 单调递增;绕回后数值会再次从低端开始,但语义上已是新一代事务
  • 永久保留、不会分配给普通事务的特殊值(源码宏名):
    • InvalidTransactionId = 0:无效 XID;
    • BootstrapTransactionId = 1:初始化库时使用;
    • FrozenTransactionId = 2:冻结标记,表示元组不再绑定某个真实历史 XID
  • 普通事务从 FirstNormalTransactionId = 3 起分配。

分配逻辑(简化):

1
2
3
4
XidGenLock->lock();
nextXid = ShmemVariableCache->nextXid;
ShmemVariableCache->nextXid++;
XidGenLock->unlock();

2.2 为什么会「回卷」

32 位 XID 空间大小为 2³² ≈ 42.9 亿,其中有效普通事务 ID 约为 2³² − 3(扣除 0/1/2)。计数器递增到上限后会绕回 FirstNormalTransactionId(3) 继续分配。

每一行 heap 元组在物理上持久保存:

  • xmin:插入/最后一次重写该版本的事务 XID;
  • xmax:删除或更新该版本的事务 XID(0 表示未被删改)。

因此,若系统在「二十亿次事务之后」再次分配 XID = 100000,而某张表上仍有一行 xmin = 100000 且未被 freeze,则同一数字同时表示:

  1. 很多年前某次已提交插入;
  2. 刚刚启动的当前事务。

若仍按「查 CLOG + 比较 XID 先后」判可见性,结论必然错乱。这就是 XID wraparound(回卷) 问题的严格定义:不是计数器溢出本身,而是 XID 编号复用与旧元组上未清理的 xmin/xmax 发生语义冲突


三、XID 复用冲突:PostgreSQL 如何避免「撞号」

本节回答一个常被问到的问题:新分配的事务 XID,若与极老数据行上的 xmin/xmax 数值相同,PostgreSQL 如何区分、如何避免冲突?

3.1 先澄清误区:分配 XID 时不会「扫全库查重」

PostgreSQL 不会nextXid++ 时去全库搜索「有没有行的 xmin 等于即将分配的号码」。若这样做,每次事务开始都要扫 petabyte 级堆表,不可接受。

实际策略是维护一个全局不变式(invariant)

nextXid 绕回并复用某个编号 N 之前,所有仍存活的 heap 元组中,不得再存在「未冻结且 xmin 或 xmax = N」的引用。

维护手段是 VACUUM(FREEZE)两道 wraparound 红线(告警 / 停写),而不是运行时分配时的冲突检测。

3.2 XID 比较:环而非直线

Wraparound 之后,不能用普通整数大小比较 XID 先后。PostgreSQL 在内部把 XID 看作 2³² 个点的圆环,用 TransactionIdPrecedes(a, b) / TransactionIdFollows(a, b) 判断相对先后:以某一参考点为界,环的一半视为「过去」,另一半视为「未来」。

这对尚未 freeze、仍绑定真实 XID 的元组正常运维区间内有效。
一旦「最老未冻结 xmin」与 nextXid 在环上靠得太近,连环比较也无法区分「三十年前的 100000」与「刚分配的 100000」——系统必须在此之前完成 freeze,否则只能停写。

SQL 中的 age(xid) 函数即表达「从 xid 沿环走到当前 nextXid 经过了多少个 XID」:

1
2
-- age(xid) = 当前计数器相对 xid 的「环上距离」
SELECT age('100000'::xid), pg_current_xact_id();

3.3 核心机制:FREEZE 不是「标记旧事务已提交」,而是「解除 XID 绑定」

VACUUM FREEZE 扫描足够老的可见元组,对满足条件的行:

  1. xmin 改写为 FrozenTransactionId(2),和/或
  2. t_infomask 上设置 HEAP_XMIN_FROZEN / HEAP_XMIN_COMMITTED 等 hint 位,声明该元组对所有快照永久可见,不再依赖 CLOG 查询「xmin 对应事务是否提交」。

冻结后的语义(与源码/文档一致):

  • FrozenTransactionId = 2 不是某次真实业务事务,永远不会通过 nextXid 分配给新会话;
  • 该行不再占用环上某个真实 XID 编号的语义槽位;
  • 后续可见性判断走 frozen hint 快速路径,而不是拿 xmin 去 CLOG 查一个可能已被新事务复用的编号。

示意:

1
2
3
4
5
6
【冻结前】  元组 T:  xmin = 100000
            → 环上编号 100000 仍被 T 引用,不可安全复用

【VACUUM FREEZE 后】  元组 T:  xmin = 2 (Frozen),HEAP_XMIN_FROZEN 置位
            → 100000 这一「槽位」在存活元组中已无引用
            → 未来 nextXid 再次等于 100000 时,不会与 T 冲突

xmax:若元组已被删除标记,通常由普通 VACUUM 回收 dead tuple;对仍存活但需要 freeze 的 xmax,同样有 HEAP_XMAX_FROZEN 等处理,避免旧 xmax 编号在回卷后产生同类歧义。多事务 ID(MultixactId)另有 vacuum_multixact_freeze_* 参数,原理类似,本文不展开。

3.4 何时 freeze:三层阈值

参数 默认值(PG 15 常见默认) 含义
vacuum_freeze_min_age 50,000,000 单次 VACUUM 中,元组 xmin 的 age 超过此值即可被 freeze
vacuum_freeze_table_age 150,000,000 表级 relfrozenxid age 超过此值,普通 VACUUM 也会积极 freeze
autovacuum_freeze_max_age 200,000,000 表/库 age 超过此值,autovacuum 强制 anti-wraparound 冻结

数据库级 pg_database.datfrozenxid 与表级 pg_class.relfrozenxid 记录「该库/表已确认 freeze 到的最老 XID 边界」。
age(datfrozenxid) 越大,说明越接近 wraparound 危险区。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
-- 库级 wraparound 压力
SELECT datname,
       datfrozenxid,
       age(datfrozenxid) AS db_frozen_age
FROM pg_database
WHERE datname = current_database();

-- 拖后腿的表
SELECT n.nspname, c.relname,
       c.relfrozenxid,
       age(c.relfrozenxid) AS tbl_frozen_age
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 10;

3.5 完整时间线(严格版)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
T0   事务 XID=100000 提交,行 R 写入堆:R.xmin=100000,CLOG[100000]=COMMITTED

T1   … 历经大量事务,ShmemVariableCache->nextXid 持续递增 …

T2   autovacuum 对 R 所在表执行 VACUUM FREEZE
     条件:age(R.xmin) > vacuum_freeze_min_age
     结果:R.xmin := 2,设置 HEAP_XMIN_FROZEN
     更新:relfrozenxid / datfrozenxid 推进

T3   计数器继续绕回,某新事务分配 XID=100000
     读 R:见 xmin=2(Frozen)→ 对所有事务可见,与「当前 100000 号事务」无歧义
     读新事务写入的行:xmin=100000 → 对应当前 CLOG[100000],语义正确

T_fail  若 T2 长期未发生,且 age(最老 relfrozenxid) 逼近环上极限
     → 先 WARNING(距强制停写约 1000 万 XID)
     → 再 ERROR,拒绝写命令,防止 T3 在 T2 缺失时发生

结论:PostgreSQL 避免冲突的方式,是 在复用编号之前,用 FREEZE 消除存活元组对旧编号的引用;不是分配后靠运行时逻辑「猜」新旧两次 100000。

3.6 CLOG 与 freeze 的分工

  • CLOG(pg_xact):记录某一 XID 是否提交/中止,供未 freeze 元组在可见性判断时使用;
  • FREEZE 之后:元组不再依赖该 XID 在 CLOG 中的条目;
  • CLOG 段截断:当一段 XID 范围内的所有元组均已 freeze 或 dead 被清理,对应 CLOG 页可被回收,这与「XID 编号可安全复用」是同一套生命周期管理。

因此:CLOG 解决「这个 XID 提交了吗」;FREEZE 解决「这个 XID 还能不能继续挂在行上」——二者缺一不可。

3.7 可见性判断路径(与冲突避免相关)

对 heap 元组,简化决策顺序如下:

1
2
3
4
5
1. xmin 为 FrozenTransactionId(2) 或 HEAP_XMIN_FROZEN 已置位?
   → 是:插入版本对所有读者可见(在 xmax 检查之前即成立)
2. 否则:根据 snapshot 与 TransactionIdPrecedes/Follows 比较 xmin 与 snapshot.xmax / xip[]
3. 若 hint 位未设置:查 CLOG 确认 xmin 事务是否 COMMITTED / ABORTED
4. 对 xmax 做对称检查(删除/更新版本是否对已提交事务可见)

只有在步骤 1 未命中、且元组仍绑定真实 XID 时,复用同一 XID 编号才会产生灾难性歧义——这正是 FREEZE 必须提前完成的原因。

3.8 谁阻止 FREEZE:长事务与复制槽

以下情况会导致 relfrozenxid 长期推不动,最终触发 wraparound 告警:

因素 机制
长事务未结束 会话的 backend_xmin(快照最老 XID)阻止 vacuum 清理其可见性所需的旧版本;持 backend_xid 的事务直接占有一个 XID
逻辑复制 slot slot 的 catalog_xmin / restart_lsn 约束 vacuum 不能移除仍需复制的行版本
大表 + 慢 autovacuum dead tuple 过多、autovacuum_vacuum_cost_delay 限流,freeze 扫描跟不上写入
全库 freeze 未覆盖 个别表长期未 autovacuum,成为最老 relfrozenxid 短板

排查 SQL(比仅查 backend_xid 更完整):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- 阻塞 vacuum 的 xmin(含只读长查询)
SELECT pid, datname, state, backend_xmin, backend_xid,
       xact_start, query_start, left(query, 120) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL OR backend_xid IS NOT NULL
ORDER BY age(backend_xmin) DESC NULLS LAST;

-- 复制槽是否拖住 catalog_xmin
SELECT slot_name, plugin, slot_type, active,
       xmin, catalog_xmin, restart_lsn, confirmed_flush_lsn
FROM pg_replication_slots;

3.9 与 MySQL 的对比(针对「撞号」问题)

问题 PostgreSQL MySQL InnoDB
行上是否持久保存「创建事务号」 是,xmin / xmax(32 位) 是,隐藏列 DB_TRX_ID(64 位)
事务号是否会复用 (32 位回卷) 实质上不会(64 位,耗尽不现实)
如何避免「旧行 trx_id 与新事务同号」 FREEZE 解除绑定 + 停写保护 无需此机制
长事务后果 freeze 滞后 → wraparound purge 滞后 → undo 膨胀

InnoDB 的一致性读沿 undo 版本链 找历史版本,用 ReadView 判断各 trx_id 是否可见;旧版本的 DB_TRX_ID 是历史事实,不需要因为 trx_id 分配器绕回而改写行上的 ID。这是两种 MVCC 架构在「事务 ID 生命周期」上最根本的差异。


四、XID 回卷防护机制

4.1 正常防护:VACUUM FREEZE

  • VACUUM 扫描旧行,将 xmin 足够老的元组 freeze
  • autovacuum_freeze_max_age 默认 2 亿:当某库最老未冻结 XID 的 age 超过该值,autovacuum 会 aggressively 做 freeze。

4.2 告警与停机:两道红线

PostgreSQL 把 XID 空间想象成一个。当「最新分配的 XID」与「环上最老仍在使用的 XID」之间的距离过近时:

第一道告警(距 wraparound 约 1000 万事务):

1
2
WARNING: database "XXX" must be vacuumed within 177009986 transactions
HINT: To avoid a database shutdown, execute a database-wide VACUUM in "XXX".

第二道硬停(距 wraparound 约 100 万事务):

1
ERROR: database is not accepting commands to avoid wraparound data loss in database "XXXX"

此时数据库拒绝写入,防止数据可见性错乱。生产环境必须在第一道 WARNING 就接入监控告警,不能等到 ERROR。

4.3 排查长事务阻塞 freeze

1
2
3
4
5
6
7
8
-- 紧急冻结(业务低峰执行)
VACUUM FREEZE VERBOSE;

-- 找出持有 XID 最久的会话
SELECT pid, query_start, query
FROM pg_stat_activity
WHERE backend_xid IS NOT NULL
ORDER BY age(backend_xid) DESC;

常见根因:长事务未提交逻辑复制 slot 阻塞 vacuumautovacuum 被大量 dead tuple 拖慢


五、故障案例

案例 1:XID wraparound 告警

现象:日志出现 transaction ID wraparound 或上述 WARNING。

处理

  1. 立即 VACUUM FREEZE(必要时 VACUUM FREEZE VERBOSE 观察进度);
  2. 定位并终止/协调超长事务;
  3. 检查 pg_stat_all_tablesrelfrozenxid age;
  4. 调整 autovacuum_vacuum_cost_delay 等参数,确保 freeze 跟得上写入速度。

案例 2:CLOG 损坏

现象:库无法启动,报 could not access status of transaction

恢复思路

  1. 优先从备份恢复 pg_xact 目录;
  2. 最后手段:pg_resetwal 重建 WAL/CLOG(有数据风险,需 Oracle 官方文档级谨慎评估)。

六、与 MySQL InnoDB 的对照

很多从 MySQL 转 PostgreSQL 的 DBA 会问:MySQL 会不会也有 XID 回卷?

结论:InnoDB 不会出现 PostgreSQL 这种 32 位 XID wraparound 导致库停写的经典问题。 但两者在「事务状态如何记录、可见性如何判断、长事务如何拖垮系统」上,有清晰的可比与不可比之处。

6.1 核心差异一览

维度 PostgreSQL MySQL InnoDB
事务 ID 宽度 32 位,会循环 64 位 trx_id,工程上可视为不回卷
事务状态存储 CLOG 位图(pg_xact) undo log + 事务系统(trx sys)
行上事务标记 xmin / xmax 隐藏列 DB_TRX_ID / DB_ROLL_PTR
可见性判断 XID 比较 + CLOG 状态 + hint bit ReadView + undo 版本链
「回卷」类运维灾难 ,需 VACUUM FREEZE 同类 XID wraparound
长事务典型后果 freeze 滞后 → XID 告警/停库 purge 滞后 → undo 膨胀、表空间涨、性能下降

6.2 MySQL 侧:事务 ID 与 MVCC

InnoDB 为每个读写事务分配递增的 64 位事务 IDtrx_id)。一致性读通过 ReadView 判断 undo 链上某一版本是否可见:

  • m_low_limit_id:大于等于此 id 的版本不可见;
  • m_up_limit_id / 活跃事务列表:未提交事务的版本不可见;
  • 本事务自己修改的行走特殊 fast path。

这与 PostgreSQL「拿行上 XID 去 CLOG 查提交状态」是两套架构,但解决的仍是 MVCC 同一类问题。

更完整的 ReadView 与 undo 链讲解,可参考本站文章《MVCC 与 ReadView:InnoDB 可见性判断与事务 id 分配》。

6.3 MySQL 侧:最接近 PG freeze 压力的场景

InnoDB 没有 XID wraparound,但 purge 线程 若因长事务无法清理旧 undo,会出现:

  • SHOW ENGINE INNODB STATUSHistory list length 持续飙高;
  • ibdata1 或独立 undo 表空间膨胀;
  • 查询变慢、磁盘 IO 升高。

排查方向:

1
2
3
4
5
6
7
-- 长事务
SELECT * FROM information_schema.innodb_trx
ORDER BY trx_started;

-- 活跃事务
SELECT * FROM performance_schema.events_transactions_current
WHERE STATE = 'ACTIVE';

类比关系

  • PG 的 VACUUM FREEZE ≈ 防止 XID 环空间耗尽;
  • MySQL 的 purge undo ≈ 防止版本链与 undo 历史无限堆积。

症状不同,根因都常指向:长事务 + 清理机制跟不上

6.4 不要混淆的其他「回卷」

概念 数据库 说明
XID wraparound PostgreSQL 本文主题,可致停库
AUTO_INCREMENT 溢出 MySQL 整型列写满,与事务 XID 无关
Binlog 轮转 MySQL 日志文件切换,非事务 ID 回卷
GTID 集合管理 MySQL 复制拓扑问题,非 InnoDB trx_id 回卷

七、DBA 运维清单

PostgreSQL

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- 各库最老 XID age
SELECT datname, age(datfrozenxid) AS frozen_age
FROM pg_database
ORDER BY frozen_age DESC;

-- 各表 freeze 进度
SELECT relname, age(relfrozenxid) AS table_frozen_age
FROM pg_stat_all_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_frozen_age DESC
LIMIT 20;

监控建议:对 age(datfrozenxid) 设阈值告警,远早于 WARNING 日志出现。

MySQL

1
2
3
SHOW ENGINE INNODB STATUS\G   -- 关注 History list length
SELECT * FROM sys.innodb_lock_waits;
SELECT * FROM information_schema.innodb_trx;

监控建议:长事务持续时间、undo 表空间使用率、History list length 趋势。


八、总结

PostgreSQL 的 CLOG + 32 位 XID 是一套紧凑、高效、但对运维有硬性约束的设计:

  • CLOG 回答「某个 XID 提交了吗」;
  • FREEZE 回答「某个 XID 还能不能继续挂在存活元组的 xmin/xmax 上」;
  • wraparound 告警/停写 保证「在 XID 编号被复用之前,旧引用必须已解除」——不是分配时扫全库查重,而是靠 VACUUM FREEZE 维护全局不变式

MySQL InnoDB 用 64 位 trx_id + undo 版本链 + ReadView 避开了 XID 回卷,却把压力转移到了 purge 与 undo 空间管理

跨库 DBA 最实用的认知是:

  1. PG 怕 XID 环,盯 freeze 与长事务;
  2. MySQL 怕 undo 堆,盯 purge 与长事务;
  3. 两者都不是「提交完就万事大吉」,清理机制是否跟得上写入,才是事务运维的主线。

理解 CommitLog 与 XID 回卷,不是为了背参数,而是为了在告警出现的第一时间知道:该 vacuum freeze,而不是盲目重启或加磁盘