前言
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 做了两层优化:
- 共享内存 CLOG Buffer:CommitLog 页被 cache 在
clog buffer中,减少磁盘 IO; - 行级 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起分配。
分配逻辑(简化):
|
|
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,则同一数字同时表示:
- 很多年前某次已提交插入;
- 刚刚启动的当前事务。
若仍按「查 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」:
|
|
3.3 核心机制:FREEZE 不是「标记旧事务已提交」,而是「解除 XID 绑定」
VACUUM FREEZE 扫描足够老的可见元组,对满足条件的行:
- 将
xmin改写为FrozenTransactionId(2),和/或 - 在
t_infomask上设置HEAP_XMIN_FROZEN/HEAP_XMIN_COMMITTED等 hint 位,声明该元组对所有快照永久可见,不再依赖 CLOG 查询「xmin 对应事务是否提交」。
冻结后的语义(与源码/文档一致):
FrozenTransactionId = 2不是某次真实业务事务,永远不会通过nextXid分配给新会话;- 该行不再占用环上某个真实 XID 编号的语义槽位;
- 后续可见性判断走 frozen hint 快速路径,而不是拿
xmin去 CLOG 查一个可能已被新事务复用的编号。
示意:
|
|
对 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 危险区。
|
|
3.5 完整时间线(严格版)
|
|
结论: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 未命中、且元组仍绑定真实 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 更完整):
|
|
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 万事务):
|
|
第二道硬停(距 wraparound 约 100 万事务):
|
|
此时数据库拒绝写入,防止数据可见性错乱。生产环境必须在第一道 WARNING 就接入监控告警,不能等到 ERROR。
4.3 排查长事务阻塞 freeze
|
|
常见根因:长事务未提交、逻辑复制 slot 阻塞 vacuum、autovacuum 被大量 dead tuple 拖慢。
五、故障案例
案例 1:XID wraparound 告警
现象:日志出现 transaction ID wraparound 或上述 WARNING。
处理:
- 立即
VACUUM FREEZE(必要时VACUUM FREEZE VERBOSE观察进度); - 定位并终止/协调超长事务;
- 检查
pg_stat_all_tables中relfrozenxidage; - 调整
autovacuum_vacuum_cost_delay等参数,确保 freeze 跟得上写入速度。
案例 2:CLOG 损坏
现象:库无法启动,报 could not access status of transaction。
恢复思路:
- 优先从备份恢复
pg_xact目录; - 最后手段:
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 位事务 ID(trx_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 STATUS中 History list length 持续飙高;ibdata1或独立 undo 表空间膨胀;- 查询变慢、磁盘 IO 升高。
排查方向:
|
|
类比关系:
- 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
|
|
监控建议:对 age(datfrozenxid) 设阈值告警,远早于 WARNING 日志出现。
MySQL
|
|
监控建议:长事务持续时间、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 最实用的认知是:
- PG 怕 XID 环,盯 freeze 与长事务;
- MySQL 怕 undo 堆,盯 purge 与长事务;
- 两者都不是「提交完就万事大吉」,清理机制是否跟得上写入,才是事务运维的主线。
理解 CommitLog 与 XID 回卷,不是为了背参数,而是为了在告警出现的第一时间知道:该 vacuum freeze,而不是盲目重启或加磁盘。