作为 Java 开发,我们在日常项目里和 MySQL 打交道是非常频繁的,尤其是各种基于自增主键的设计,不管是电商订单号、用户ID还是某个配置表的记录索引,几乎都绕不开这个逻辑。而今天我们要聊的,就是一个看上去很“遥远”、但却实实在在存在的问题:MySQL 的自增 ID 用完了怎么办?
这不是个段子,也不是危言耸听。
自增 ID 真有上限吗?
很多刚入行的小伙伴听到这个问题,第一反应可能是:“自然数不是无限的吗?哪来的用完一说?”
别忘了,这可是在计算机里,MySQL 又不是玄学数据库,它定义的是一个
定长整数类型。比如 int unsigned
是 4 字节,那上限就是 2^32 - 1
,也就是 4294967295
。再插,就报错。
举个栗子:
CREATE TABLE t (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
) AUTO_INCREMENT = 4294967295;
你插一条数据:
INSERT INTO t VALUES(NULL); -- 成功,id=4294967295
再插:
INSERT INTO t VALUES(NULL); -- 报错:Duplicate entry '4294967295' for key 'PRIMARY'
你没看错,它还真不是给你从头开始,而是原地打转。下一条 insert 仍然试图拿 4294967295
,结果直接和上一次冲突了,主键重复。
作为 Java 开发,这种问题你遇到一次,代价可能就是线上故障、业务挂掉、甚至数据丢失。
所以建表时一定不能“图省事”用 int
或 int unsigned
,能用 BIGINT UNSIGNED
就别省字节了。
那我建了 BIGINT
就万无一失了吗?
大概率是了,BIGINT UNSIGNED
是 8 字节,上限 2^64 - 1
,这辈子别说你,可能你孙子都插不完。
但你得注意,MySQL 不光一个地方在用“自增”,除了我们建表时自定义的自增 ID,还有 InnoDB 内部自动生成的 row_id、Xid、事务ID 等等,它们也有上限,也会“归零”。
我们一个一个讲。
如果你没定义主键,那 InnoDB 会替你造一个
是的,InnoDB 会给每行数据生成一个内部的 row_id
。但这个 row_id
并不是用 BIGINT
写入数据页的,而是只用了 6 字节。
也就是说,虽然 row_id 的变量是 BIGINT
类型,但它真正落盘写入表里的 ID,只能表示从 0
到 2^48 - 1
。
那如果某一天用完了呢?
答:
它会归零,然后从 0 开始再循环一次。
我当时看到这里是真的头皮一紧。归零这词在技术里,一般就不是个好兆头……
更可怕的是,InnoDB 不是检查这个 row_id 有没有重复,而是直接拿来用。所以新的数据会把之前那个 row_id 相同的老数据覆盖掉,数据直接丢了。
row_id 用完 = 数据丢失
怎么验证这个问题?用 gdb
去改内存里 InnoDB 的 row_id 值,比如直接把它改成 2^48
,再往表里插一条数据,row_id 会变成 0。再插一条,row_id 变成 1。如果你的表里原来就有 row_id=1 的老数据,那对不起,它就这样被覆盖了……
这也是为啥我们一直强调:不要让 InnoDB 替你生成主键,你得自己建一个明确的 BIGINT UNSIGNED AUTO_INCREMENT
主键,这样才能在 ID 用完时明确抛错(主键冲突),而不是“神不知鬼不觉”地覆盖原有数据。
在数据安全性这事上,宁可不可用,也不能不可靠。插入失败还能重试,数据丢了就是永久损失。
MySQL 的事务 ID(trx_id)也不是无限的
InnoDB 的每条数据都会记录“我是谁写的”——也就是它被哪个事务更新过,这个事务编号就是 trx_id
。这个 trx_id 用来做可见性判断,决定你这个事务看到的是哪一版的数据。
那这个 trx_id 是怎么生成的?
也是个全局变量:max_trx_id
。每次事务更新数据,它就 +1,发一个新的给你。
但你注意,它的存储空间也是 6 字节。
又是熟悉的 2^48
,也就是说 trx_id 能表示的范围也是 0 到 2^48 - 1
。
问题来了,如果 trx_id 也到达上限了,怎么办?
答:继续从 0 开始。
听着耳熟吧?和 row_id 一模一样的逻辑。
脏读 bug:trx_id 重置后的连锁反应
你要是事务 ID 重置了,也许你看一眼觉得没啥,但实际上可能会引发严重的脏读。
简单解释一下:
每个事务启动的时候,InnoDB 会记录一个“低水位线”,就是它当前能看到最旧的数据版本(也就是那一刻活跃事务的最小 trx_id)。如果你启动事务时,max_trx_id 正好是 2^48 - 1
,你拿到的事务 ID 就是这个极限值。
然后你有个并发事务继续干活,新插入的数据版本,它的 ID 是 0(因为归零了)。你这个老事务去读的时候一看,诶,0 2^48 - 1
,那是“之前的数据”,于是就给你读出来了。
可问题是,这数据明明是刚插的。你没 commit,你照样能读。这就是脏读。
而且只要你一旦回到了 0,这事就天天都在发生。你 MySQL 重启都没用,因为 max_trx_id
是持久化保存的,重启不会清零。
虽然这个 bug 是理论上的,得 TPS 到百万级、连续运行 17 年左右才能触发……但只要时间够长,它就是必然会发生的事。是不是突然觉得冷汗直冒?
事务 Xid 也是递增的,它的 ID 会不会重复?
MySQL 的 Server 层会给每个事务分配一个 Xid
(事务 ID),用于 binlog 和 redo log 的标识。
这个 Xid 是从 global_query_id
来的,而 global_query_id
是个内存变量,重启就清零。
那是不是重启后就可能有重复的 Xid?是的,但影响不大。
因为每次重启 MySQL,都会生成新的 binlog 文件,而只要同一个 binlog 文件里没有重复 Xid,就不会有问题。
而且 global_query_id
是 8 字节的,想让它自增满一次达到 2^64
的上限,要执行 1.84 * 10^19
条语句……理论上确实可能,但现实中几乎不可能。
所以这块你可以放心。
线程 ID(thread_id)也不是无限的
每个 MySQL 连接都分配一个线程 ID,也是自增的。这玩意是 int
类型,4 字节,最多 2^32 - 1
,也会归零。
但你 show processlist 的时候不会看到重复的线程 ID,因为系统里用了一张唯一表来记录线程,如果之前那个线程还没退出,新线程是拿不到重复 ID 的,MySQL 会继续找空位。
所以这块比较安全。
所以,作为开发我该注意什么?
所有自增 ID,建表的时候都用 BIGINT UNSIGNED
,不要省事用 INT
。
一定要明确声明主键,别让 InnoDB 自动帮你生成 row_id
。
如果你做的是高并发、写入密集的系统(比如交易系统、日志系统),还得考虑将来是不是可能触发这些上限。
-
某些只读事务不会申请 trx_id(比如普通 SELECT
),但是带 FOR UPDATE
的查询是会申请的,要注意性能和锁冲突。
永远不要把“我们现在数据量不大”当作建表偷懒的理由。数据库是活的,用几年之后它会长成你无法控制的样子。
最后
MySQL 是靠自增 ID 在维持数据秩序,但你不能指望它“自个长命百岁不出错”。任何设计都必须考虑“最坏情况”,特别是这些“归零”带来的潜在 bug,虽然离我们现在还有点距离,但你一旦踩到,就是线上直接拉闸的节奏。
写代码的时候,别总想着“先用着再说”,这世上没有免费的懒惰,后面你会用加倍的代价来还。