被 32 位整数卡了 30 年脖子:PostgreSQL 事务号困局与 V9 的 64 位解法
一句话导读:PostgreSQL 用一个 32 位整数管理全库事务,这个三十年前埋下的设计,让无数 DBA 在深夜处理"冻结风暴"和强制停库。2026 年 8 月,金仓 V9 把 64 位事务 ID 做成了生产可用特性。这篇文章从原理到实战,把这件事讲透。
一、从一张凌晨两点的告警单说起
做过 PostgreSQL 生态运维的人,大概率经历过这样的夜晚:监控系统突然弹出血红告警,pg_xact 里剩余的事务槽位逼近百万大关,autovacuum 疯狂扫表,磁盘 IO 打满,WAL 生成速度翻了几倍,从库延迟曲线像悬崖一样抬升。你翻遍所有慢查询日志,一无所获------因为问题的根源不在任何一条 SQL 上,而在 PostgreSQL 内核深处一个只有 4 个字节的字段上。
这个字段叫 TransactionId,类型是无符号 32 位整数。它是 PostgreSQL 多版本并发控制(MVCC)体系的基石,也是整个数据库生命周期里最紧的那根弦。
要理解为什么一个整数能把数据库逼到墙角,得先从它是怎么被消耗的说起。
二、一个 32 位整数,如何掐住 PG 的命门
PostgreSQL 的 MVCC 实现方式是:每一行数据的头部(tuple header)都记录着"我是被哪个事务插入的"(t_xmin)和"我是被哪个事务删除或更新的"(t_xmax)。当你的查询执行时,数据库拿当前事务号和行头上的事务号做比较,判断这行数据对你是否可见。
这套机制运转的前提,是事务号必须单调递增且永不重复 。问题来了:TransactionId 的本质是 uint32,能表达的上限是 2 的 32 次方,约 43 亿。
值得补充的是事务号的消耗口径:它不只是"你显式开启的写事务"才消耗,大批量 INSERT/UPDATE/DELETE 中的每条语句、后台的统计信息更新、autovacuum 自己跑的清理工作,都在分食这个号池。所以实际消耗速度往往比业务方报的 QPS 更快。
43 亿听起来是个大数,但放到高写入业务里完全是另一回事。一个典型电商数据库,一天就能烧掉 5 到 10 亿个事务号------算下来,号池撑不过一周。
号池耗尽之后怎么办?只能循环复用。但循环复用立刻引出一个致命问题:上一轮用过的 100 号和这一轮新分配的 100 号,在 32 位空间里是同一个数字,数据库怎么知道某行数据头上的"100 号"到底是哪个年代的事务?
把场景再具象化一点:假设你的订单表里有一行数据,它的 t_xmin 记录着事务号 100000000(1 亿)。号池循环若干轮之后,当前活跃事务也拿到了 100000000 这个号。不做任何处理的话,数据库会把这行数据判断为"当前事务刚刚插入的"------可见性完全错乱。frozen xid 机制存在的意义,就是在每一轮循环之前,把所有"上一代"的痕迹提前抹掉。
三、续命术:把号池掰成一个圆
PostgreSQL 的答案是 frozen xid 机制 。它的思路可以概括为:把 43 亿个事务号首尾相接,掰成一个圆环,frozen_xid 是圆环上的一个分界点------
markdown
未来
│
20 亿 │ 20 亿
│
过去 ─────── frozen_xid ─────── 未来
│
20 亿 │ 20 亿
│
未来
分界点的顺时针方向是"已分配的过去",逆时针方向是"可分配的未来",各占约 20 亿的弧长。可见性判断的伪代码本质上是这样的:
c
if (t_xmin 在 frozen_xid 顺时针半圆内) {
// 属于"过去"的事务, 通常不可见
} else {
// 属于"未来"的事务, 查 CLOG 看是否已提交
}
每消耗掉约 20 亿个事务号,frozen_xid 就必须沿圆环往前挪一次。而"挪动"的实现方式,是 autovacuum 把整张表扫一遍,把所有 t_xmin 落在过期弧段里的行打上冻结标记(t_infomask 里的 HEAP_XMIN_FROZEN 位,等价于把 xmin 置为 FrozenTransactionId = 2,即"早于一切事务")。被打标的行永久可见,从此退出可见性运算,它们头上那 4 个字节的 xmin 也就被释放出来,可以安全进入下一轮循环。
触发链条上有两个关键参数值得记住。vacuum_freeze_table_age(默认 4 亿)决定"表里最老未冻结事务号距离分界点多远时,autovacuum 开始做全表扫描式冻结";autovacuum_freeze_max_age 则是回卷防线------一旦表的年龄逼近它,autovacuum 会不惜代价强制发起冻结。换句话说,这就是那个"再不冻结就要出可见性事故"的红色警报线,也是为什么监控里"表年龄"指标一旦飙升,DBA 的血压会跟着飙升。
机制本身是精巧的。但精巧的续命术,往往意味着昂贵的账单。
四、续命的账单:DBA 的三重折磨
第一重:冻结风暴。 freeze 需要全表扫描。表越大,扫描时间越长,IO 压力越大,WAL 写入越猛,主从延迟越深。对于 TB 级大表,一次 freeze 足以让业务侧感知到明显的性能塌陷。
第二重:长事务能把你逼到停库。 autovacuum 会跳过正在被活跃事务占用的数据页。一个忘关的长事务挂在那里,freeze 就一天天往后推。极端情况下,pg_xact 里剩余槽位跌破 100 万,PostgreSQL 会启动最后的自保动作:强制进入单用户模式,拒绝一切新事务,把全库 freeze 跑完再说。在生产环境里,这和停机没有区别。
第三重:永远做不完的算术题。 用 PG 的系统上线之前,DBA 必须算清楚三件事:业务一天产生多少事务、freeze 相关阈值配多少、存储层扛得住多高的扫描 IO。任何一项算错,就是生产事故。这不是运维,是走钢丝。
把三重折磨连起来算一笔时间账更直观:一天 5 亿事务的库,20 亿的安全窗口理论上只有 4 天;而一张 TB 级大表的全表冻结扫描可能要跑十几个小时,如果磁盘是机械盘或者云盘限流,时间还要翻倍;扫描期间又恰好挂着一个跑批的长事务,冻结进度停滞,窗口进一步收窄------三个因素互相咬合,就构成了经典的"冻结风暴"死亡螺旋。这也是为什么有经验的 DBA 看到"表年龄"曲线陡升时,宁可主动在业务低峰期手工触发 VACUUM FREEZE,也不愿等 autovacuum 在错误的时间自己动手。
这个设计缺陷并非无人知晓。早在 2021 年,PG 社区的资深开发者就在公开博客里点名吐槽过"事务号是 32 位、必须循环使用"这个坑,并明确建议未来用 64 位方案(当时提到的方向包括 zedstore、zheap 等实验引擎)。
五年多过去了,PostgreSQL 主线依然没有合并 64 位事务号。社区不做的理由非常务实:牵一发动全身。
五、社区为什么 30 年不敢动手
把事务号从 32 位扩到 64 位,表面上是改一个类型定义,实际上是给 PG 内核做心脏搭桥手术。难点至少有五处:
1. Tuple header 会发胖。 现在的 HeapTupleHeaderData 长这样(简化):
c
typedef struct HeapTupleHeaderData {
t_choice t_choice;
TransactionId t_xmin; /* 32 位 */
TransactionId t_xmax; /* 32 位 */
CommandId t_cid;
TransactionId t_xvac; /* 32 位, PG 17 已移除 */
TransactionId t_multi; /* 32 位 multixact id */
ItemPointerData t_ctid;
/* ... */
};
如果 xmin、xmax、multi 全部 64 位化,每行要多出约 16 字节。一张 10 亿行的表,光行头就膨胀 16 GB。这是社区踌躇的第一个原因。
2. CLOG 的存储结构撑不住。 PG 的提交日志(CLOG)用 8KB 的 SLRU 页存事务提交状态,每页只能装约 512 个事务号。号段拉长到 64 位后,这套两级缓存结构必须推倒重来------社区讨论过环形缓冲区、基于段的 mmap 文件等方案,但没人动手,因为它直接牵动可见性判断这条命脉。
3. Vacuum 的"半圆"语义整个作废。 32 位体系下,vacuum 的 freeze 触发逻辑(relfrozenxid 与最老 XID 的差值超过 20 亿)本质是在对抗"圆环快转满"。64 位之下,事务号一辈子用不到 2 的 63 次方,循环不复存在,freeze 的核心逻辑必须从"对抗回卷"重新设计为"无限寿命下的普通清理"。
4. 子事务和两阶段提交跟着遭殃。 PG 的子事务号复用父事务的 32 位命名空间,pg_subtrans 子事务映射表要重写;prepared transaction 在两阶段提交中持久化的 xid 需要保证宽度,pg_prepared_xacts 系统表的 schema 都得改。
5. 复制协议的兼容性悬崖。 流复制协议的消息体里携带事务号,64 位化之后主从必须同时升级,跨版本复制直接断裂。这是社区最忌讳触碰的升级红线。
五个难点叠在一起,让"64 位 XID"在 PG 邮件列表里沉浮多年,始终停留在 xid8 这个用户可见的数据类型层面(PG 16 引入,仅解决用户表存事务号的需求),内部的事务号分配,依然是那个 32 位的循环圆环。
六、2026 年 8 月,金仓出手了
2026 年 8 月 28 日,电科金仓发布 KingbaseES V009R002C016 版本。版本说明书中白纸黑字写着:
"支持 64 位事务 ID(XID),避免因 32 位事务 ID 回收不及时或长事务阻塞导致的业务中断问题。"
这是 PG 系国产数据库里,第一个把 64 位事务号做成生产可用特性的版本。
真正值得细读的是参数表。版本说明书明确列出,四个 VACUUM 及自动清理相关参数的数据类型与取值范围同步升级:
| 参数 | 升级前 | 升级后 |
|---|---|---|
vacuum_freeze_min_age |
integer,上限 10 亿 | int64,上限 2⁶⁴-1(约 1.15×10¹⁹) |
vacuum_freeze_table_age |
integer,上限 20 亿 | int64,上限 2⁶⁴-1 |
vacuum_multixact_freeze_min_age |
integer,上限 10 亿 | int64,上限 2⁶⁴-1 |
vacuum_multixact_freeze_table_age |
integer,上限 20 亿 | int64,上限 2⁶⁴-1 |
注意一个容易忽略的细节:四个参数的默认值一个都没动(依然是 2 亿、4 亿、1 亿、2 亿),只放大了天花板。
这个小细节的信息量极大。它意味着金仓保留了一层"32 位语义兼容层"------从 PG 迁移过来的业务脚本、DBA 积累多年的 freeze 阈值经验,全部原样有效。
七、拆解金仓的"混合方案":分配层 64 位,行头仍是 32 位
从参数语义可以反推出金仓的改造路线。一个简单的逻辑链:如果金仓把 tuple header 里的 xmin 改成了 64 位,freeze 参数的语义会发生根本变化,默认值不可能和 PG 保持一致。默认值原样保留,恰恰说明行头里的 t_xmin 依然是 32 位。
由此可以推断出金仓的内部结构(基于版本说明书反推):
c
/* 推断的金仓内部定义 */
typedef uint64 kingbase_xid; /* 事务号分配空间: 64 位 */
typedef uint32 kingbase_tup_xmin; /* tuple header 的 xmin: 仍是 32 位 */
typedef uint32 kingbase_tup_xmax; /* tuple header 的 xmax: 仍是 32 位 */
也就是说,金仓走的是一条**"分配层 64 位 + 行头 32 位"的分层混合方案**:事务号的分配号池从 43 亿扩展到 2 的 64 次方,不再循环;而行头里的事务号字段维持原宽度,通过既有的冻结标记机制继续工作。
这条路线的收益清单:
- 不破坏 tuple header 尺寸,旧数据页格式完全兼容;
- 不动 CLOG 的存储结构;
- 升级路径平滑,主从无需强制同时升级;
- 运维心智兼容,默认参数与 PG 完全一致。
代价同样要看得清楚:
- 行头仍需 freeze,只是触发频率断崖式下降(frozen 边界不再追赶圆环,只剩应用层主动清理超长历史数据一种场景);
- 单表 43 亿事务的行头约束依然存在------这是 32 位行头字段决定的天花板,与分配层无关。
这套思路与 PG 社区讨论过的 xid8 方向一脉相承:系统表、事务快照、WAL 使用宽事务号,行头保留窄字段加冻结位。区别在于,社区停在了讨论,金仓把它做进了产品。
八、藏在参数表背后的配套工程
64 位化不是孤立的一步棋。金仓版本说明书里的另外几处改动,拼出了完整的工程版图。
实例级动态内存管控。 版本说明在 64 位 XID 同一段落里提到新增 max_dynamic_memory 参数,可全局限制实例内存占用,超限触发明确告警,降低 OOM 风险。这是必要的配套:事务号不再循环意味着单个事务的生命周期可以更长,长事务持有的相关缓存随之变大,必须同步加内存上限兜底。
FastPath 锁与日志分区锁。 版本说明的另一处提到新增 FastPath 锁机制,并提供 FastPath 槽位数量、CLOG 轻量锁分区数、CSNLOG 轻量锁分区数的配置能力。
CSNLOG(Commit Sequence Number Log)记录事务提交顺序号。在 32 位体系下,CSN 跟随 XID 循环;事务号 64 位化之后,CSN 必须同步调整。金仓顺手把 CLOG 和 CSNLOG 的并发控制从单锁改成了可配置的轻量锁分区------高并发场景下,这是绕不开的优化,否则新号池的能力会被日志锁的争用吃掉。
九、DBA 视角:新世界的三阶段调优路线
升级到金仓 V009R002C016 之后,freeze 相关的调优哲学要从根上换掉:过去是"必须在 20 亿事务窗口内完成的紧急任务",现在变成"普通的死元组清理节奏"。给已经在用这个版本的 DBA 一条可落地的路线:
sql
-- 阶段一(迁移期, 1~3 个月): 一切保持默认
-- 不动任何 freeze 参数, 让系统按 PG 默认值跑
-- 重点工作: 建立基线, 监控 freeze 触发频率
-- 阶段二(优化期, 3~6 个月): 开始兑现 64 位红利
ALTER SYSTEM SET vacuum_freeze_min_age = 50000000;
-- min_age 从 2 亿收紧到 5 千万, 让 freeze 更激进、更碎、更无感
ALTER SYSTEM SET autovacuum_freeze_max_age = 1000000000;
-- max_age 从 4 亿放宽到 10 亿, 拉长 freeze 周期
-- 阶段三(平稳期, 6 个月以上): 按实际负载放开手脚
ALTER SYSTEM SET vacuum_freeze_table_age = 5000000000;
-- table_age 提到 50 亿, 此时 freeze 已是低频操作
核心判断依据:64 位号池下,freeze 哪怕慢半拍也不致命。调优目标从"抢在圆环转满之前完成"变成了"把 IO 波峰摊平在日常"。
不管在哪个阶段,两条监控 SQL 应该常备。第一条盯"表年龄",确认冻结推进是否健康:
sql
SELECT c.oid::regclass AS 表名,
age(c.relfrozenxid) AS 年龄
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 2 DESC
LIMIT 10;
第二条盯死元组堆积和 autovacuum 的实际执行情况:
sql
SELECT relname,
n_dead_tup,
last_autovacuum,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
在 32 位时代,第一条 SQL 的"年龄"列是生死线,逼近 20 亿就要拉群报警;在 64 位号池下,它的意义退化成"冻结清理的推进节奏"------数值波动依然值得关注,但不再是倒计时炸弹。
十、放进数据库江湖里看这张牌
把各家数据库的事务标识设计摆在一起,金仓这次改造的位置会更清晰:
| 数据库 | 事务号宽度 | 回收机制 | 特点 |
|---|---|---|---|
| PostgreSQL | 32 位 | freeze + autovacuum | 循环复用, 有冻结风暴 |
| Oracle | SCN, 内部 6 字节 | undo + 多版本 | 天然不依赖循环 |
| MySQL/InnoDB | TRX_ID, 6 字节 | undo log | 天然不依赖循环 |
| KingbaseES V9 | 64 位分配 + 32 位行头(推断) | freeze 频率大幅降低 | PG 系首个生产可用的 64 位分配 |
| zheap / zedstore | 64 位 TID | 无需 freeze | 实验项目, 未生产化 |
Oracle 和 InnoDB 因为事务标识天生够宽,从来不知道冻结风暴为何物;zheap、zedstore 们想从根上重构行存储,但至今停留在实验阶段。金仓选择的是中间道路------不推翻 PG 的页格式,只在"事务号分配"这个关键节点上扩容。这条路线和金仓一贯的"PG 兼容 + 关键节点扩展"产品哲学完全一致:不破生态存量,解决增量痛点。
还有一个容易被忽略的时间维度。PG 社区并非不想解决,而是每次讨论都会卡在"升级兼容性"这堵墙上------全 64 位方案意味着所有存量集群必须经历一次断裂式升级,这对社区生态是不可承受之重。金仓作为单一厂商,发行版版本节奏自己说了算,反而有条件把这个社区层面难以协调的改造先落地。换句话说,这不完全是技术能力的差距,也有生态治理模式的差异------但无论原因是什么,结果是明确的:先跑出来的方案,会先积累生产数据和踩坑经验,这在数据库市场里是真金白银的先发优势。
十一、冷静清单:治好了什么,没治什么
任何工程方案都有边界,64 位事务号也不例外。
彻底解决的:
- 冻结风暴的根源------号池不再循环,"半圆快耗尽"的焦虑从机制上消失;
- autovacuum "必须成功"的强约束------freeze 慢一点也不致命,vacuum 调度获得前所未有的弹性;
- 长事务逼停库的极端场景------长事务不再挤占冻结窗口,单用户模式自保的逻辑失去触发条件;
- 主从延迟与 freeze 的耦合------freeze 低频化之后,从库不再周期性被主库的扫描拖垮。
依然存在的:
- 表膨胀------死元组清理依然要靠 vacuum/autovacuum,一个字节都没省;
- 长事务持锁------该阻塞 DDL 还是阻塞 DDL,与事务号宽度无关;
- 单表 43 亿事务的行头上限------32 位行头字段决定了这个天花板还在原地。
遗留的悬念: 版本说明没有提及主从切换(promote/switchover)场景下新旧版本 XID 的兼容策略。这意味着要么这部分改造尚未完成,要么金仓选择了"主从必须同版本"的限制(类似 PG 大版本升级的既有策略)。跨版本混搭的环境,升级前务必实测。
升级前的检查清单: 把上面的分析收敛成四条可执行动作。第一,确认所有业务脚本里没有硬编码"事务号最大 20 亿"的假设(比如自己写的巡检阈值);第二,在测试环境跑一轮主从切换,验证 XID 相关的复制与恢复行为;第三,盘点库里的长事务来源(跑批、报表、忘关的客户端),64 位化解决的是冻结窗口被挤占的问题,但长事务持有的锁还在;第四,升级后第一周保持 freeze 参数全默认,用上面两条 SQL 建立新基线,再谈调优。
十二、写给三类人,和一个值得记住的转折
写给应用开发者: 三件事。第一,永远不要在应用层做事务号算术------PG 系的事务号因为回滚会跳号,xid + 1 这类运算没有意义,64 位化之后依然如此。第二,freeze 相关的监控告警阈值可以放宽了,从"剩余 1000 万事务号"降到"剩余 100 万"是安全的,给运维留出缓冲。第三,从旧版本迁移上来时,主从 XID 兼容性要纳入测试用例。
写给 DBA: 两个期待。一是官方的配套最佳实践文档------哪些过去的"注意事项"可以从此扔掉,哪些是新引入的;二是监控体系的补位------max_dynamic_memory 已进入告警体系,"事务平均寿命"应该成为新指标,它是衡量 64 位改造实际收益最直观的标尺。
写给 PG 内核社区: 金仓这次改造给出了三个可抄的作业。其一,分层设计------分配层扩宽、行头保窄,在不破坏页格式的前提下解决循环问题,比"全 64 位"的一揽子方案现实得多。其二,默认值不变的兼容哲学------只放大参数上限、不动默认值,把迁移心智成本压到最低。其三,freeze 的语义重定义------从"对抗循环的紧急任务"回归"普通的清理动作",这会改变整个 autovacuum 调优的理论基础。
回到开头那张凌晨两点的告警单。那个折磨了 PG 生态三十年的 32 位整数,那个让无数 DBA 在上线前反复验算、在深夜盯着冻结进度条的圆环,终于有了一个生产级的解法------而且率先交卷的,是一家国产数据库厂商。
值得说明的是,这不是简单的"做了个 Oracle 兼容",而是动到了 PostgreSQL 社区自己讨论多年都没敢动手的内核地带。五年多前被公开吐槽的那个设计缺陷,五年后由国产数据库率先补上。这件事本身,比 64 位这个数字更值得记住。
补充:三个大概率会被问到的问题
问题一:64 位 XID 会让日常性能变慢吗?
从事务号分配本身看,64 位整数和 32 位整数的分配开销没有量级差异。真正影响性能的是配套改造:金仓把 CLOG/CSNLOG 的并发控制改成可配置的轻量锁分区,高并发写入场景下日志访问的锁争用反而会下降。当然,行头仍是 32 位意味着每次可见性判断的比较成本不变------它是零差异,不是变慢。
问题二:存量老库升级,数据要重写吗?
从"分配层 64 位 + 行头 32 位"的分层设计看,数据页格式没有被破坏,理论上存量数据无需重写、原地升级即可。但"理论上"三个字在数据库领域从来不够用------升级前在测试环境完整跑一遍主从切换、崩溃恢复、逻辑复制,是唯一正确的姿势。
问题三:本文对金仓内部实现的分析,可信度有多高?
分层混合方案是从版本说明书的参数语义(默认值不变、仅上限扩大)反推出来的,属于有依据的工程推断而非官方确认。参数升级、内存管控、FastPath 锁这些是版本说明书的明文内容;tuple header 仍为 32 位则是推断。等更详细的官方技术文档或源码披露后,这部分结论值得复核。把"已知"和"推断"分开写,是这类分析文章应有的诚实。
参考资料:
- KingbaseES V009R002C016 版本说明书(2026-08-28 发布)
- PostgreSQL 源码
src/include/c.h中TransactionId的 uint32 定义 - PostgreSQL 16 引入的
xid8数据类型(src/include/catalog/pg_type.dat) - PG 社区关于 64 位 XID 的邮件列表讨论(2021--2026)