五年前我吐槽 PG 的 32 位 XID 是狗皮膏药,今天它被治好了

五年前在博客上吐槽 PostgreSQL 的 32 位事务号是"狗皮膏药"。 五年后,一家国产数据库把这个吐槽实现了。

这篇文章同时写给两种人:

  • 刚入门 PG 的同学:我尽量把"XID 到底是什么、freeze 到底在 freeze 什么"讲到不需要翻内核代码也能懂;
  • PG 内核开发者和资深 DBA:我会尽力还原金仓 V009R002C016 到底改了什么、没改什么、还有什么坑,方便你拿来做对照参考。

一、为什么说 PG 的 XID 是"狗皮膏药"

先讲一个容易被忽略的常识:PostgreSQL 的事务号是 32 位的。

TransactionId 这个类型在 src/include/c.h 里的真实定义就是一个 uint32。这意味着什么?意味着一个集群在同一时刻,最多只能同时存在 2³² ≈ 43 亿个事务号。

43 亿这个数字,写在纸上看着挺大。放到真实业务里,根本不经花:

  • 一个中等规模的电商库,一天的写入事务量就能跑到 5 亿到 10 亿
  • 一个 IoT 接入网关、一个高频支付流水库,一天吃掉十几亿事务号很正常。

也就是说,几天到一两周,43 亿的号段就能被走完一遍。而事务号一旦耗尽,问题不是"稍微慢一点",而是:

PG 的 MVCC 可见性判断依赖 t_xmin < current_xid 这类比较。XID 用完了,新事务没法分配号,整个库直接拒绝服务。

那怎么办?PG 的选择是------事务号循环复用

但循环复用立刻带出一个死结:在一个 32 位的环形空间里,你怎么让"老 XID"和"新 XID"不撞车?毕竟老数据还在表里躺着,新事务又必须拿到号。

PG 的解法叫 frozen xid

1.1 把 XID 空间想象成一个圆

把 32 位的 XID 空间看成一个圆:

  • 圆上的某一个点,是 frozen xid
  • 从 frozen xid 顺时针往前的部分,是**"过去"**------已经分配过、但现在被判定为"老得不能再老"的事务号;
  • 逆时针方向,是**"未来"**------即将被分配的新事务号。
scss 复制代码
              (未来可用)
                  ▲
                  │
                  │   20 亿
                  │
     ←────────────┤────────────→
   (过去已分配)   │   (frozen xid)
                  │
                  │   20 亿
                  │
                  ▼
              (未来可用)

于是可见性判断的逻辑大致是:

c 复制代码
if (t_xmin == frozen_xid) {
    /* 永远可见,不需要再看 commit log */
} else if (t_xmin 在 frozen_xid 的顺时针一侧) {
    /* 属于"过去",通常不可见 */
} else {
    /* 属于"未来",去 commit log 查它提交了没有 */
}

每消耗 20 亿 个事务号,frozen xid 就必须往前挪动一次。挪动的方式,是 autovacuum 扫全表 :把所有 t_xmin < frozen_xid 的行头信息里的 xmin 直接标记成 frozen(一个特殊 bit 位)。被标记的行"永远可见",从此不再参与可见性判断,它占用的那个事务号也就可以被安全回收、重新分配了。

机制上很巧妙,运维上很痛苦。

1.2 这套机制的三笔代价

第一笔:冻结风暴。

freeze 是全表扫描级的操作。一张大表冻结一遍,IO 暴增、WAL 暴涨、主从延迟飙升。半夜被 freeze 拖垮的监控告警,PG 的 DBA 基本都见。

第二笔:长事务场景下必须停库 freeze。

autovacuum 会跳过正在被事务占用的 page。只要有一个长事务挂着不回滚、不提交,freeze 就一天推一天地延后。极端情况下,pg_xact 里剩余 slot 掉到 100 万以下,PG 会强制进入单用户模式,拒绝所有新事务,只为了把全库的 freeze 跑完。这已经不是"性能问题",是"业务中断"。

第三笔:运维心智负担。

部署一套 PG 之前,DBA 得先算三道题:

  1. 我一天大概产生多少事务?
  2. freeze 相关阈值应该配多少?
  3. 我的 SSD 能不能扛得住 freeze 时的 IO 峰值?

这三道题任何一道算错,都可能变成生产事故。一个数据库系统的"安全边界"要由使用者的算术能力来保证,这件事本身就挺不合理的。

1.3 我在 2021 年写过什么

2021 年 8 月 24 日,在博客《DB 吐槽大会第 2 期 - PG 32 位 xid》里写过:

"事务号 xid 是 uint32 类型,最多只能存储 40 亿个事务号,事务号必须循环使用。"

并给出了一条相当直接的修复建议:

"未来产品迭代如何修复这个坑:64 位 xid,例如 zedstore、zheap、postgrespro 等引擎。"

五年过去了,PostgreSQL 社区至今没有合并 64 位 XID 方案 。引用 2026 年 PG 邮件列表上的共识:xid8 这个数据类型虽然早就存在,但内部的 TransactionId 仍然是 uint32

社区的理由很务实,也很能理解:

  • 32 位 + freeze 的组合虽然丑,但确实能跑;
  • 改成 64 位要动 tuple header、CLOG、vacuum、复制协议、子事务、prepared transaction、hash join 里所有依赖 xid 的地方;
  • 影响面太大,收益/风险比不划算。

2026 年 8 月 28 日 ,金仓发布的 V009R002C016 版本说明书里明确写着:

"支持 64 位事务 ID(XID),避免因 32 位事务 ID 回收不及时或长事务阻塞导致的业务中断问题。"

这是国产数据库第一次把"64 位事务号"做成生产可用特性。在我看来,这个改动对 PG 生态的意义,不亚于 PG 15 把 logical replication 做到跨大版本兼容。


二、64 位 XID 到底难在哪

想理解金仓做了什么,得先理解为什么 PG 社区一直没做

64 位 XID 从来不是"把 uint32 改成 uint64"这么轻松。它牵动的面极广,至少有五个硬骨头:

2.1 Tuple header 直接胖一圈

PG 现在的元组头(简化版)长这样:

c 复制代码
typedef struct HeapTupleHeaderData
{
    t_choice       t_choice;      /* 哪种 t_xmin */
    TransactionId  t_xmin;        /* 32 位 */
    TransactionId  t_xmax;        /* 32 位 */
    CommandId      t_cid;
    TransactionId  t_xvac;        /* 32 位,vacuum 锁存,PG 17 已移除 */
    TransactionId  t_multi;       /* multixact id,32 位 */
    ItemPointerData t_ctid;
    /* ... */
} HeapTupleHeaderData;

如果 xminxmaxmulti 全部改成 64 位,每个 tuple 的头部就要多出 16 字节

16 字节听着不多,但一张 10 亿行的表,光元组头就要多占 16 GB。这是 PG 社区犹豫的第一个点,也是最难绕开的一个点:磁盘、内存、备份、复制,全都要跟着变。

2.2 CLOG 不能再靠 SLRU 撑

PG 的 CLOG(Commit Log) 用 8KB 页存事务提交状态,每页存 8192 / 32 × 2 = 512 个事务号的状态,外面套一层 SLRU 两级缓存。

问题在于:CLOG 的大小和事务号空间是绑定的。32 位只对应 512MB 量级,64 位则完全不是一个量级。

所以在 64 位 XID 下,CLOG 必须换存储结构。社区讨论过的思路有两种:

  • 改成 ring buffer
  • 改成基于 segment 的 mmap 文件

但两种思路都牵动 vacuum 的可见性判断路径,所以讨论了很多次,始终没人真正动手。

2.3 Vacuum 的"半个圆"语义失效了

32 位 XID 下,vacuum 的核心判断是:

复制代码
relfrozenxid < oldestXid - 2000000000

这句话的本质是:"事务号落在 freeze 点之前的,都该被冻结。"

64 位 XID 下,这个机制根本不需要了。 因为你这辈子也写不到 2⁶³ 个事务。

于是 freeze 的核心逻辑必须被重新设计:从"对抗循环"变成"无限寿命 + 普通 cleanup"。这不是改代码,这是改语义------而语义一变,所有依赖它的调优经验、监控指标、运维手册都要跟着变。

2.4 子事务与 prepared transaction

PG 的子事务号是 uint32,分配时复用父事务的 XID 命名空间。64 位下,得给子事务单独一个命名空间(比如高 32 位用进程号、低 32 位用子事务号),pg_subtrans 也要重写

prepared transaction 更麻烦:2PC 里持久化的 xid 要跨库跨时间存活,必须保证宽度足够。这块改动会改到 pg_prepared_xacts 系统表的 schema------也就是说,会影响用户在 SQL 层能看见的东西。

2.5 复制协议是最大的门槛

流复制协议里的 WalDataMessage 包含 xl_xid。一旦 64 位化,主从必须同时升级,跨版本复制会直接断。

这是 PG 社区最忌讳的升级门槛。一个不能灰度升级的内核改动,在没有极强理由的情况下很难被接受。


三、金仓 V009R002C016 实际改了什么

读金仓 V009R002C016 的版本说明书(PDF 的 L903、L1012-1075),我的结论是:

这不是一次全栈改造,而是一次有取舍的实用方案。

3.1 公开承认的范围

说明书里有两条关键原文。

L903:

"支持 64 位事务 ID(XID),避免因 32 位事务 ID 回收不及时或长事务阻塞导致的业务中断问题。"

L1012:

"为适配 64 位事务 ID,以下 VACUUM 及自动清理(Autovacuum)相关参数的数据类型与取值范围已同步升级。"

3.2 实际改动:VACUUM 参数 int64 化

说明书明确列了 4 个关键参数的升级:

参数 变更前 变更后
vacuum_freeze_min_age integer,最大 1000000000 int64,最大 2⁶⁴-1 ≈ 1.15×10¹⁹
vacuum_freeze_table_age integer,最大 2000000000 int64,最大 2⁶⁴-1
vacuum_multixact_freeze_min_age integer,最大 1000000000 int64,最大 2⁶⁴-1
vacuum_multixact_freeze_table_age integer,最大 2000000000 int64,最大 2⁶⁴-1

注意一个非常关键的设计细节:这些参数的默认值没有变。

仍是 200000000 / 400000000 / 10000000000 / 20000000000,只扩大了上限。

这意味着金仓保留了一个**"32 位 XID 语义仿真层"**:老的 PG 业务脚本、老的调优模板、老的运维手册,几乎不需要做任何改动------因为默认行为跟 PG 完全一致。

这个细节也释放了一个重要的工程信号:金仓没有把 tuple header 里的 xmin 改成 64 位。

理由很直接:如果 tuple header 变了,freeze 参数的语义就完全不同了,默认值不可能保持对齐。所以他们的 64 位改造,最可能是**"扩展分配号空间 + 让 XID 不再循环"**,而 tuple header 里的 t_xmin 仍是 32 位,仍然需要 freeze 才能继续用。

3.3 配套改造:实例级动态内存管控

L903 紧接着还有一句:

"全面支持 64 位事务 ID 与实例级动态内存管控,新增 ...... max_dynamic_memory,可全局限制实例内存占用,超限时触发明确告警,降低 OOM 风险。"

这条不是随手加的功能,而是 64 位 XID 改造的必要配套

逻辑是这样的:64 位 XID 让事务号生命周期变长、不再循环,freeze 频率降低、后台 autovacuum 工作量减少;但反过来,长事务持有的 xid 缓存可能更大,单次事务的内存占用有上升趋势。把一个"会被拖垮"的压力源消掉,就必须防住它转化成的另一个压力源------OOM。

所以同步加一个内存上限控制,很合理。

3.4 与 FastPath 锁改造的协同

L1299 提到:

"新增 FastPath 锁机制,并提供 FastPath 槽位数量、CLOG 轻量锁分区数、CSNLOG 轻量锁分区数的配置能力。"

CSNLOG(Commit Sequence Number Log) 记录事务提交顺序。这里有个因果关系容易被忽略:

64 位 XID 改造必然伴随 CSN 调整,因为 CSN 之前是 32 位、跟随 XID 一起循环的。

顺带把 CSNLOG 的并发从"单锁"改成"轻量锁分区数可配",这是高并发场景下的必须优化。三个改动串在一条线上,说明金仓这次是有系统设计的,不是打补丁。


四、入门者能看懂的 5 个核心要点

如果你是 PG 入门者,这一节帮你把心智模型立起来。

4.1 32 位 XID 是"半圆循环",64 位 XID 是"直线"

32 位下,事务号空间是一个圆,frozen xid 是圆上的一个点,可见性判断要处理"顺时针 / 逆时针"的方向问题。

64 位下,这个圆变成了一条直线

复制代码
t_xmin < 当前事务快照 → 直接判可见

不再有"循环"概念,freeze 也从"对抗循环"变成"普通 cleanup"。

4.2 freeze 到底在 freeze 什么

很多人以为 freeze 是"删除老数据"------不是 。freeze 是标记老数据。

PG 在 heap tuple header 里预留了 2 个 bit 位,t_infomask 字段里放着:

  • HEAP_XMIN_FROZEN
  • HEAP_XMIN_INVALID

freeze 做的事情,本质上是把 t_xmin 替换成 FrozenTransactionId = 2,让所有事务都"看到"这条记录是 frozen 的(等于它早于任何事务)。于是 t_xmin 这个字段就被释放出来,可以循环复用了。

64 位 XID 下这个机制仍然存在 (因为 tuple header 字段宽度没变),但触发频率大幅下降。原因很简单:XID 不再循环了,frozen 边界不再是"半个圆之前的数据",而变成"应用层主动想清理的超长历史数据"。

4.3 64 位 XID 的真实收益

对高写入业务,64 位 XID 直接消除的是四件事:

  1. freeze 风暴的根源------不再有"半个圆快耗尽了"的焦虑;
  2. autovacuum 必须成功的强约束------XID 不循环,vacuum 慢一点不致命;
  3. 长事务导致停库的极端情况------长事务不再"挤占 freeze 空间";
  4. 从库延迟与 freeze 的耦合------freeze 不再高频,从库不再因为主库 freeze 而大幅延迟。

但它不直接解决另外两件事,这点必须说清楚:

  • 表膨胀(bloat):死元组还是得靠 vacuum / autovacuum 清;
  • 长事务持有的锁:仍然可能阻塞 DDL。

别指望 64 位 XID 是万能药,它治的是"事务号循环"这一种病。

4.4 autovacuum 的调优逻辑变了

32 位 XID 下,autovacuum_vacuum_insert_scale_factorvacuum_freeze_table_agevacuum_freeze_min_age 这几个参数,本质都是硬卡时间窗口的逻辑------你必须在 20 亿事务之内完成 freeze,否则就出事。

64 位 XID 下,这些参数变成普通 cleanup 调优。你可以让 autovacuum 更激进(比如把 freeze_age 调小),因为即使 freeze 不及时,业务也不会立刻崩。

金仓把默认值保持不变,实际上是给了 DBA 一层"老 PG 运维经验继续管用"的兼容垫。

4.5 与主从复制、备份恢复的关系

32 位 XID 下,流复制的 standby 也会有 freeze 压力(因为 standby 也要复算 xmin)。64 位 XID 下这个压力大幅降低。

standby 的 promote / switchover 仍然需要确认主从 XID 兼容性

金仓这次没有在版本说明里提这一点,有两种可能:这块还没改完,或者金仓选择了"主从必须同版本"的限制(类似 PG 大版本升级策略)。如果是后者,迁移规划时就要提前排期。


五、专家关心的实现细节

这一节写给 PG 内核开发者和资深 DBA,相当于金仓这次改造的"实现笔记"。

5.1 数据类型层面:可能是混合方案

基于 PDF 内容反推,金仓的内部定义大概是这样的:

c 复制代码
/* 推测的金仓 XID 内部定义(基于 PDF 反推) */
typedef uint64 kingbase_xid;        /* 64 位事务号分配 */
typedef uint32 kingbase_tup_xmin;   /* tuple header 仍 32 位 */
typedef uint32 kingbase_tup_xmax;   /* tuple header 仍 32 位 */

也就是 "分配空间 64 位,tuple header 仍 32 位"的混合方案

这跟 PG 社区讨论过的 xid8 思路是一致的:xid8 用于系统表、事务快照、WAL,但 tuple header 保留 32 位(靠 frozen bit 标识已冻结的 xmin)。

好处:

  • 不破坏 tuple header 大小,旧 page 兼容;
  • 不破坏 CLOG 结构,升级路径平滑;
  • 主从可以不同时升级;
  • 运维心智兼容(默认值不变)。

代价:

  • tuple header 仍然要 freeze,只是频率大幅降低;
  • 业务层仍受 32 位 tuple header 的"半圆"约束------单表 43 亿事务上限不变

最后这条,是我认为最需要在选型和容量规划里说清楚的一点。

5.2 过渡期的 autovacuum 调优建议

对已经在用金仓 V009R002C016 的 DBA,我给一个三阶段建议。

第一阶段(迁移期,1-3 个月):保留 PG 经验值

sql 复制代码
-- 不改 freeze 参数,让系统按默认值跑
-- 重点:监控 freeze 频率

先建立基线,不要急着调。没有基线的调优都是猜。

第二阶段(优化期,3-6 个月):开始利用 64 位 XID 的优势

sql 复制代码
ALTER SYSTEM SET vacuum_freeze_min_age = 50000000;      -- 从 2 亿改 5 千万,更激进
ALTER SYSTEM SET autovacuum_freeze_max_age = 1000000000; -- 从 4 亿改 10 亿,延长 freeze 周期

第三阶段(平稳期,6 个月以后):根据实际负载调优

sql 复制代码
-- 此时 freeze 已不是高频操作,可以放心把 freeze_age 调大
ALTER SYSTEM SET vacuum_freeze_table_age = 5000000000;   -- 50 亿

5.3 与 PG 16/17/18 的对比

版本 做了什么 与核心 XID 的关系
PG 16 引入 xid8 数据类型 只解决"用户能用 64 位 xid 字段",没解决内部事务号分配
PG 17 transaction_id 改进 修的是 multixact,不修主 XID
PG 18 邮件列表讨论过"全 64 位 XID" 牵动太大,目前没有 RFC

金仓 V9 的意义在于:它是第一个把"全 64 位 XID 分配"做成生产可用特性的 PG 系国产数据库。

5.4 与其他数据库的横向对比

数据库 事务号宽度 freeze 机制 备注
PostgreSQL 32 位 freeze + autovacuum 32 位 XID 仍循环
Oracle SCN 是数字,内部 6 字节 undo + 多版本 不依赖 XID 循环
MySQL / InnoDB 6 字节 TRX_ID undo log 不依赖 XID 循环
KingbaseES V9 64 位分配(推测)+ 32 位 tuple header freeze 频率大幅降低 国产首个 64 位 XID 生产可用
zheap / zedstore 64 位 TID 不需要 freeze 实验项目,未生产化

从这个对比能看清金仓的路线:"PG 兼容 + XID 空间扩展"的中间路径,不是 zheap 那种完全改造 tuple header 的激进方案。

这跟金仓一贯的"Oracle 兼容"路线是一致的------不破已有生态,只在关键节点做扩展


六、对 PG 社区的三个启示

金仓这次改造,我认为给 PG 社区提供了三个可借鉴的方向。

6.1 渐进式 XID 扩展方案

PG 社区讨论"全 64 位 XID"时,最大的顾虑就是破坏 tuple header 兼容性。

金仓的方案是"分配层 64 位 + tuple header 仍 32 位"。这个分层设计值得 PG 学习------它能在不破坏 page 格式的前提下,把事务号分配空间从 43 亿扩到 2⁶⁴,代价仅仅是引入一个"分配号 → tuple xmin"的映射。

6.2 "默认值不变"的兼容性设计

升级 freeze 参数类型时,金仓把默认值保持得跟 PG 完全一致,只扩展上限。

这个细节看起来不起眼,实际上是降低 DBA 迁移心智成本的关键 。PG 社区如果要做类似改造,应该学这种"默认值不变"的兼容性设计思路------能改的地方改,不该变的地方一点都不动

6.3 freeze 的价值被重新定义了

  • 32 位 XID 下,freeze 是"对抗循环的紧急任务";
  • 64 位 XID 下,freeze 是"普通 cleanup"。

这个语义转变对 autovacuum 调优哲学有深远影响:DBA 不再需要"算 freeze 窗口",而是回归"按业务需要清理死元组"这件事本身。


七、对应用开发者的三条建议

无论你是入门者还是专家,64 位 XID 改造之后,应用层的实践应该有这些调整。

第一,不要在应用层做"事务号运算"。

PG 系的事务号本来就不连续(committed 事务会跳号),在应用层算 xid + 1 从来就没有意义。金仓 64 位之后,这一点仍然成立,甚至更该强调。

第二,freeze 不再是"紧急任务",告警阈值可以放宽。

监控告警的阈值可以从"剩余 1000 万事务"放宽到"剩余 100 万事务",给 DBA 留出更多运维空间。前提是你确认自己跑的是真正的 64 位分配版本。

第三,跨版本迁移测试更复杂了。

金仓 64 位 XID 之后,从老版本升级时,主从 XID 兼容性需要专门测试。这一项建议写进上线的 checklist。


八、给金仓的两点期待

写完这篇,我作为 PG 老用户,提两个期待。

第一,配套最佳实践。

在运维管理层面,以往哪些需要特殊注意的配置,现在不需要再纠结了?另外有哪些新增的注意事项?这类问题最好是官方出一份《64 位 XID 迁移与运维最佳实践》,比让 DBA 自己摸索强得多。

第二,配套监控指标。

max_dynamic_memory 已经加入告警了,那"事务平均寿命"这类指标也应该补上------让 DBA 能直观看到 64 位 XID 升级之后,freeze 频率到底降了多少、收益到底有多大。能被度量的改进,才是可验证的改进。


九、写在最后

五年前我吐槽 PG 的 32 位 XID 是一块"狗皮膏药",当时没人接这个吐槽------PG 社区认为"freeze + autovacuum 已经够用",国产数据库当时也没人敢碰这块内核。

今天,金仓 V009R002C016 把 64 位 XID 做成了生产可用,做到了 PG 社区十几年没做到的事。

这件事值得 PG 社区反思:国产数据库不是只在做 Oracle 兼容,也在做 PG 自己也解决不了的内核改造。

金仓的"32 → 64 位 XID 渐进改造",值得 PG 17/18/19 认真抄作业。

五年的吐槽,一个版本号,问题被国产数据库治好了。


相关推荐
DBA_G1 小时前
从湖仓一体到AI原生:GBase数据库的金融全栈技术实践
数据库
ChenLuck1 小时前
32 位 XID 的“狗皮膏药”被撕掉了:金仓 V9 的 64 位事务号实测与底层拆解
数据库
旺仔不是程序员1 小时前
判断存在用 LIMIT 1:PostgreSQL 五种 count 计数方式与 NULL 语义
数据库·后端·sql
旺仔不是程序员1 小时前
IN 操作规范:PostgreSQL 元素数量、EXISTS 替代与 = ANY 写法
数据库·后端·sql
这个DBA有点耶1 小时前
异构数据集成方案:跨平台同步的完整指南与工具选型(含实测)
数据库·sql·程序人生·架构·数据库架构·dba
蓝速科技2 小时前
会议室门牌显示模板选型与品牌视觉适配落地指南丨蓝速科技
大数据·运维·数据库·人工智能·科技
jaysee-sjc2 小时前
【苍穹外卖】Day01:从零认识企业级项目开发
java·开发语言·数据库·mysql·spring·intellij-idea·mybatis
智购科技自动贩卖机2 小时前
自动售货机嵌入式系统时钟同步与时间管理实战:从RTC校准到断网时间保持的工程实践
大数据·linux·数据库·人工智能·yolo
Y3815326622 小时前
SEO 周报自动化:从采集数据到一页报告
数据库·搜索引擎