
五年前在博客上吐槽 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 得先算三道题:
- 我一天大概产生多少事务?
- freeze 相关阈值应该配多少?
- 我的 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;
如果 xmin、xmax、multi 全部改成 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_FROZENHEAP_XMIN_INVALID
freeze 做的事情,本质上是把 t_xmin 替换成 FrozenTransactionId = 2,让所有事务都"看到"这条记录是 frozen 的(等于它早于任何事务)。于是 t_xmin 这个字段就被释放出来,可以循环复用了。
64 位 XID 下这个机制仍然存在 (因为 tuple header 字段宽度没变),但触发频率大幅下降。原因很简单:XID 不再循环了,frozen 边界不再是"半个圆之前的数据",而变成"应用层主动想清理的超长历史数据"。
4.3 64 位 XID 的真实收益
对高写入业务,64 位 XID 直接消除的是四件事:
- freeze 风暴的根源------不再有"半个圆快耗尽了"的焦虑;
- autovacuum 必须成功的强约束------XID 不循环,vacuum 慢一点不致命;
- 长事务导致停库的极端情况------长事务不再"挤占 freeze 空间";
- 从库延迟与 freeze 的耦合------freeze 不再高频,从库不再因为主库 freeze 而大幅延迟。
但它不直接解决另外两件事,这点必须说清楚:
- 表膨胀(bloat):死元组还是得靠 vacuum / autovacuum 清;
- 长事务持有的锁:仍然可能阻塞 DDL。
别指望 64 位 XID 是万能药,它治的是"事务号循环"这一种病。
4.4 autovacuum 的调优逻辑变了
32 位 XID 下,autovacuum_vacuum_insert_scale_factor、vacuum_freeze_table_age、vacuum_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 认真抄作业。
五年的吐槽,一个版本号,问题被国产数据库治好了。