被 32 位整数卡了 30 年脖子:PostgreSQL 事务号困局与 V9 的 64 位解法

被 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.hTransactionId 的 uint32 定义
  • PostgreSQL 16 引入的 xid8 数据类型(src/include/catalog/pg_type.dat
  • PG 社区关于 64 位 XID 的邮件列表讨论(2021--2026)
相关推荐
知识的搬运工旺仔2 小时前
CREATE INDEX CONCURRENTLY:线上建索引不阻塞 DML 的代价与坑
数据库·后端·sql
小蒜学长3 小时前
大学生健康饮食的智慧管理系统(代码+数据库+LW)
java·后端·springboot·大学生·健康饮食
小蒜学长3 小时前
基于Java的论坛数据可视化分析系统的设计与实现(代码+数据库+LW)
java·spring boot·后端·数据可视化·论坛系统
青山木3 小时前
RocketMQ 入门到原理(一):整体架构与消息的生命周期
java·分布式·后端·中间件·架构·rocketmq
黑马程序员毕设3 小时前
基于微信小程序的二手交易平台设计与实现报告
前端·人工智能·spring boot·后端·考研
十二同学啊4 小时前
Drools规则引擎:让复杂业务规则从代码中解耦
java·后端·开源
打工仔折腾 AI4 小时前
Pascal Editor 本地部署实战:Bun 启动 WebGPU 3D 编辑器并解决公网访问报错
人工智能·后端·python·性能优化