news 2026/11/4 12:14:03

面试官:线上 MySQL 的自增 id 用尽怎么办?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
面试官:线上 MySQL 的自增 id 用尽怎么办?

MySQL 的自增 id 都定义了初始值,然后不断加步长。虽然自然数没有上限,但定义了表示这个数的字节长度,计算机存储就有上限。比如,无符号整型 (unsigned int) 是 4 个字节,上限就是 2^32 - 1。那自增 id 用完,会怎么样?

表定义自增值 id

表定义的自增值达到上限后的逻辑是:再申请下一个 id 时,得到的值保持不变。

mysql> create table t(id int unsigned auto_increment primary key) auto_increment=4294967295; Query OK, 0 rows affected (0.01 sec) mysql> insert into t values(null); Query OK, 1 row affected (0.00 sec) mysql> show create table t; +-------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Table | Create Table | +-------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | t | CREATE TABLE `t` ( `id` int unsigned NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=4294967295 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci | +-------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ 1 row in set (0.00 sec) //成功插入一行 4294967295 mysql> insert into t values(null); ERROR 1062 (23000): Duplicate entry '4294967295' for key 't.PRIMARY'

第一个 insert 成功后,该表的 AUTO_INCREMENT 还是 4294967295,导致第二个 insert 又拿到相同自增 id 值,再试图执行插入语句,主键冲突。

2^32 - 1(4294967295)不是一个特别大的数,一个频繁插入删除数据的表是可能用完的。建表时就需要考虑你的表是否有可能达到该上限,若有,就应创建成 8 字节的 bigint unsigned。

InnoDB 系统自增 row_id

若你创建的 InnoDB 表未指定主键,则 InnoDB 会自动创建一个不可见的,6 个字节的 row_id。InnoDB 维护了一个全局的 dict_sys->row_id 值

dict_sys 维护全局 row_id 的代码

所有无主键的 InnoDB 表,每插入一行数据,都将当前的 dict_sys->row_id 作为要插入数据的 row_id,然后把 dict_sys->row_id 加 1。

代码实现时 row_id 是个长度为 8 字节的无符号长整型 (bigint unsigned)。但 InnoDB 在设计时,给 row_id 留的只是 6 个字节的长度,这样写到数据表中时只放了最后 6 个字节,所以 row_id 能写到数据表中的值,就有两个特征:

  1. row_id 写入表中的值范围,是从 0 到 2^48 - 1

  2. 当 dict_sys.row_id=2^48 时,如果再有插入数据的行为要来申请 row_id,拿到以后再取最后 6 个字节的话就是 0

即写入表的 row_id 从 0~2^48 - 1。达到上限后,下个值就是 0,然后继续循环。

2^48 - 1 已经很大,但若一个 MySQL 实例活得久,还是可能达到上限。

InnoDB 里,申请到 row_id=N 后,就将这行数据写入表中;若表中已经存在 row_id=N 的行,新写入的行就会覆盖原有的行。

验证该结论:通过 gdb 修改系统的自增 row_id。用 gdb 是为了便于复现问题,只能在测试环境使用。

  • row_id 用完的验证序列

row_id 用完的验证序列

  • row_id 用完的效果验证

row_id 用完的效果验证

可见,在我用 gdb 将 dict_sys.row_id 设置为 2^48 之后,再插入 a=2 会出现在表 t 的第一行,因为该值的 row_id=0。

之后再插入 a=3,由于 row_id=1,就覆盖了之前 a=1 的行,因为 a=1 这一行的 row_id 也是 1。

所以应该在 InnoDB 表中主动创建自增主键:当表自增 id 到达上限后,再插入数据时会报主键冲突错误。

毕竟覆盖数据,就意味着数据丢失,影响数据可靠性;报主键冲突,插入失败,影响可用性。一般可靠性优于可用性。

Xid

redo log 和 binlog 有个共同字段 Xid,用来对应事务。Xid 在 MySQL 内部是如何生成的呢?

MySQL 内部维护了一个全局变量 global_query_id

global_query_id 相关代码

每次执行语句时,将它赋值给 query_id,然后给该变量 +1:

query_id 赋值与递增代码

若当前语句是该事务执行的第一条语句,则 MySQL 还会同时把 query_id 赋值给该事务的 Xid:

query_id 赋值给 Xid 的代码

而 global_query_id 是一个纯内存变量,重启之后就清零了。所以同一 DB 实例,不同事务的 Xid 可能相同。

但 MySQL 重启之后会重新生成新 binlog 文件,这就保证同一个 binlog 文件里的 Xid 唯一。

虽然 MySQL 重启不会导致同一个 binlog 里面出现两个相同 Xid,但若 global_query_id 达到上限,就会继续从 0 开始计数。理论上还是会出现同一个 binlog 里面出现相同 Xid。

因为 global_query_id 8 字节,上限 2^64 - 1。要出现这种情况,需满足:

  1. 执行一个事务,假设 Xid 是 A

  2. 接下来执行 2^64 次查询语句,让 global_query_id 回到 A 2^64 太大了,这种可能只存在于理论中。

  3. 再启动一个事务,这个事务的 Xid 也是 A

Innodb trx_id

  • Xid 由 server 层维护

InnoDB 内部使用 Xid,为了关联 InnoDB 事务和 server

但 InnoDB 自己的 trx_id,是另外维护的事务 id(transaction id)。

InnoDB 内部维护了一个 max_trx_id 全局变量,每次需要申请一个新的 trx_id 时,就获得 max_trx_id 的当前值,然后并将 max_trx_id 加 1。

InnoDB 数据可见性的核心思想

每一行数据都记录了更新它的 trx_id,当一个事务读到一行数据时,判断该数据是否可见,就是通过事务的一致性视图与这行数据的 trx_id 做对比。

对于正在执行的事务,你可以从 information_schema.innodb_trx 表中看到事务的 trx_id。

看如下案例:事务的 trx_id

事务的 trx_id 查询案例

S2 的执行记录:

mysql> use information_schema; Reading table information for completion of table and column names You can turn off this feature to get a quicker startup with -A Database changed mysql> select trx_id, trx_mysql_thread_id from innodb_trx; +-----------------+---------------------+ | trx_id | trx_mysql_thread_id | +-----------------+---------------------+ | 421972504382792 | 70 | +-----------------+---------------------+ 1 row in set (0.00 sec) mysql> select trx_id, trx_mysql_thread_id from innodb_trx; +---------+---------------------+ | trx_id | trx_mysql_thread_id | +---------+---------------------+ | 1355623 | 70 | +---------+---------------------+ 1 row in set (0.01 sec)

S2 从 innodb_trx 表里查出的这两个字段,第二个字段 trx_mysql_thread_id 就是线程 id。显示线程 id,是为说明这两次查询看到的事务对应的线程 id 都是 5,即 S1 所在线程。

t2 时显示的 trx_id 是一个很大的数;t4 时刻显示的 trx_id 是 1289,看上去是一个比较正常的数字。这是为啥?

t1 时,S1 还未涉及更新,是一个只读事务。对于只读事务,InnoDB 并不会分配 trx_id:

  • t1 时,trx_id 的值就是 0。而这个很大的数,只是显示用

  • 直到 S1 在 t3 时执行 insert,InnoDB 才真正分配 trx_id。所以 t4 时,S2 查到该 trx_id 的值就是 1289。

除了明显的修改类语句,若在 select 语句后面加上 for update,也不是只读事务。

  • update 和 delete 语句除了事务本身,还涉及到标记删除旧数据,即要把数据放到 purge 队列里等待后续物理删除,这个操作也会把 max_trx_id+1,因此在一个事务中至少加 2

  • InnoDB 的后台操作,比如表的索引信息统计这类操作,也是会启动内部事务的,因此你可能看到,trx_id 值并不是按照加 1 递增的。

t2 时查到的很大数字是怎么来的?

每次查询时,由系统临时计算:

当前事务的trx变量的指针地址转成整数,再加上2^48

这样可以保证:

  • 因为同一只读事务在执行期间,它的指针地址不会变,所以无论在 innodb_trx 还是在 innodb_locks 表里,同一个只读事务查出来的 trx_id 就会是一样的

  • 若有并行只读事务,每个事务的 trx 变量的指针地址肯定不同。这样,不同并发只读事务,查出来的 trx_id 就是不同的。

为什么要加 2^48?

保证只读事务显示的 trx_id 值比较大,正常情况下就会区别于读写事务的 id。但 trx_id 跟 row_id 的逻辑类似,定义为 8 个字节。

理论上还是可能出现一个读写事务与一个只读事务显示的 trx_id 相同。不过概率很低,也没有什么实质危害,不管。

为何只读事务不分配 trx_id?

  • 减小事务视图里面活跃事务数组的大小。因为当前正在运行的只读事务,不影响数据的可见性判断。所以,在创建事务的一致性视图时,InnoDB 就只需要拷贝读写事务的 trx_id

  • 减少 trx_id 的申请次数。InnoDB 执行一个普通的 select 语句,也要对应一个只读事务。所以只读事务优化后,普通查询语句无需申请 trx_id,大大减少并发事务申请 trx_id 的锁冲突

由于只读事务不分配 trx_id,显然 trx_id 的增速变慢。

但 max_trx_id 会持久化存储,重启也不会重置为 0。理论上,只要一个 MySQL 实例跑得够久,就可能出现 max_trx_id 达到 2^48 - 1,然后从 0 开始循环。

达到该状态后,MySQL 就会持续出现一个脏读 bug:

首先把当前的 max_trx_id 先修改成 2^48 - 1。这里是可重复读。

  • 复现脏读

  • 因为系统的 max_trx_id 被设置成 2^48 - 1,所以在 session A 启动的事务 TA 的低水位就是 2^48 - 1。

t2 时:

  • session B 执行第一条 update 语句的事务 id=2^48 - 1

  • 第二条事务 id 就是 0 了,这条 update 执行后生成的数据版本上的 trx_id=0

t3 时:

session A 执行 select 的可见性判断:c=3 这个数据版本的 trx_id(0),小于事务 TA 的低水位(2^48 - 1),所以认为该数据可见。

但这是脏读。

由于低水位值会持续增加,而事务 id 从 0 开始计数,导致系统在该时刻后,所有查询都会出现脏读。

并且 MySQL 重启时 max_trx_id 也不会清 0,即重启 MySQL,这个 bug 仍然存在。那这 bug 也是只存在于理论上吗?

假设一个 MySQL 实例的 TPS 是 50w,持续这样,17.8 年后就会出现该情况。但从 MySQL 真正开始流行到现在,恐怕都还没有实例跑到过这个上限。不过,只要 MySQL 实例服务时间够长,就必然会出现该 bug。

这也可以加深对低水位和数据可见性的理解

thread_id

系统保存了一个全局变量 thread_id_counter

thread_id_counter 相关代码

每新建一个连接,就将 thread_id_counter 赋值给这个新连接的线程变量 new_id。

thread_id_counter 定义为 4 个字节,因此达到 2^32 - 1,就会重置为 0,继续增加。

thread_id 重置逻辑代码

但不会在 show processlist 看到两个相同的 thread_id。因为 MySQL 使用了一个唯一数组

MySQL 使用的唯一数组代码

给新线程分配 thread_id 时的逻辑:

给新线程分配 thread_id 的逻辑代码

总结

每种自增 id 有各自的应用场景,在达到上限后的表现也不同:

表的自增 id 达到上限后,再申请时它的值就不会改变,进而导致继续插入数据时报主键冲突错误

row_id 达到上限后,则会归 0 再重新递增,如果出现相同的 row_id,后写的数据会覆盖之前的数据

Xid 只需要不在同一个 binlog 文件中出现重复值即可。虽然理论上会出现重复值,但是概率极小,可以忽略不计

InnoDB 的 max_trx_id 递增值每次 MySQL 重启都会被保存起来,所以我们文章中提到的脏读的例子就是一个必现的 bug,好在留给我们的时间还很充裕

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 12:12:26

大一自学网安第13天

今天轮到html区块的学习。块元素块级元素通常从新行开始,并占据整行宽度,因此在页面上会呈现一块独立的内容块。 可以包含其他块级元素和行内元素。常见块元素:<div><p><h1&#xff…

作者头像 李华
网站建设 2026/11/4 12:11:53

质性资料编码用软件还是手工:什么时候该上软件、什么时候表格就够

访谈做完,三十多份转写稿静静躺在硬盘里,每份两万字上下。编到第五份时,多数人会卡在同一个念头:手上这张表格真撑得住后面的分析吗,还是得先花两周把某款质性分析软件啃熟。这个纠结很少靠「哪款软件更专业」解决&…

作者头像 李华
网站建设 2026/11/4 12:11:25

从肺主皮毛到肾其华在发:掉发的生理逻辑拆解

很多技术同学习惯先搞清楚"原理"再动手。养发也一样,先把三条生理逻辑拆开,才知道自己该补哪块。其一:肺与皮毛传统养生认为"肺主皮毛",肺通过宣发把津液和卫气送到体表,濡润皮肤和毛发。入秋后燥…

作者头像 李华
网站建设 2026/11/4 12:11:11

验布这个岗位,为什么越来越难招人?

听到老板抱怨最多的不是订单,是招人。尤其是验布岗位,年轻人不愿干,老师傅陆续退休,青黄不接。验布为什么难招?原因不复杂。第一,这活费眼睛。一天盯着高速运动的布面,眼睛酸、流泪,…

作者头像 李华
网站建设 2026/11/4 12:10:35

【人工智能】像管理团队一样管理你的 AI Agent

【人工智能】像管理团队一样管理你的 AI Agent摘要:管理 Grok Bot 军团不比带真实团队轻松。本文从传统编程 Agent 与 Personal Agent 的区别切入,给出三条通用规则(教 what 不教 how、先手动跑通再自动化、重要功能人工确认)&…

作者头像 李华
网站建设 2026/11/4 12:10:35

网络安全实战第19期:三重防护失效真相之注入攻击的无限变形之路

转义、黑名单、前端校验:为什么这三样都挡不住注入 网络安全实战 90 天 Week 3 注入风险 | 难度 ★★★ 进阶 | 关键词:输入转义 黑名单缺陷 前端校验 纵深防御 类型约束前面几篇的验证在低防护级别下全部成立。把防护级别调高再试,…

作者头像 李华