☰
面试官:MySQL自增主键为什么不是连续的?
2026/10/3 13:56:11 网站建设 项目流程

一、从一个面试问题说起

不知道你有没有遇到过这样的场景:你正信心满满地面试一个后端开发岗位,前面几轮关于 JVM、Redis、分布式锁的问题都回答得还算流畅。轮到数据库环节,面试官看似不经意地问了一句:"你们项目里主键一般怎么生成?"你回答:"MySQL 自增主键比较多。"面试官点点头,紧接着追问:"那你知道 MySQL 的自增主键为什么经常不是连续的吗?"

很多同学听到这个问题会愣一下。因为在直觉里,AUTO_INCREMENT既然号称"自增",那么 1、2、3、4、5 一路排下去似乎是天经地义的事。但实际生产中你会发现,表里的主键经常出现 1、2、4、7、9 这样的空洞,甚至隔几个数就跳一次号。这到底是 MySQL 的 Bug,还是它刻意为之的设计?

这篇文章,我们就从现象到原理,从源码到实验,把"自增主键为什么不连续"这个问题彻底讲透。全文会覆盖以下几个方面:

  • 自增主键的基础机制与内部计数器

  • 自增锁与innodb_autoinc_lock_mode三种模式

  • 造成主键不连续的六大核心原因

  • 不同插入方式的底层差异

  • MySQL 8.0 对自增值持久化的改进

  • 可以亲手复现空洞的实验步骤

  • 自增值耗尽、面试追问与生产实践

读完本文,你不仅能从容应对这个问题,还能从"自增值分配"这条主线出发,串起事务、锁、回滚、并发控制、持久化等一系列 MySQL 核心知识。

二、自增主键的基础知识

2.1 什么是 AUTO_INCREMENT

在 MySQL 中,给某一列加上AUTO_INCREMENT属性后,当插入一行数据却没有显式指定该列的值时,MySQL 会自动为它分配一个递增的数值。最典型的用法是把它加在整数类型的主键上:

sql

CREATE TABLE `t_user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

上面的建表语句中,id列就是自增主键。执行INSERT INTO t_user(name) VALUES ('张三')时,我们并没有给 id 赋值,MySQL 会自动生成一个值。这个值由表内部维护的一个"自增计数器"决定,而不是简单地在当前最大值基础上加一这么简单。

需要注意的是,AUTO_INCREMENT 只能用于整数类型的列,包括TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT。如果你试图把它用在CHAR、VARCHAR、DECIMAL或者浮点类型上,MySQL 会直接报错。这个限制本身就提醒我们:自增值是一个"计数"语义,而不是"业务编号"语义。

一个很重要的认知是:自增主键保证的是"唯一且单调递增的一般趋势",它从来没有承诺过"一定连续"。官方文档对 AUTO_INCREMENT 的描述里,只强调了自动生成值的唯一性,并没有任何一条保证生成的值是连续的。理解了这一点,本文后续的所有"不连续"现象就都顺理成章了。

2.2 为什么大家都爱用自增主键

在深入不连续问题之前,我们先搞清楚一个问题:为什么自增主键在业务中这么流行?答案主要来自 InnoDB 存储引擎的物理结构。

InnoDB 默认使用聚簇索引来组织数据。所谓聚簇索引,就是数据行本身和主键索引存储在一起,叶子节点里直接存放整行记录。这意味着:

  • 如果主键是顺序递增的,新插入的数据会追加到 B+ 树的"最右侧"叶子页。

  • 只有当右侧叶子页写满时,才会发生一次新的页分配。

  • 页分裂的概率极低,写入效率高,磁盘空间利用率也高。

反过来,如果主键是随机值,比如 UUID,那么每次插入都可能落在 B+ 树的中间某个位置。一旦目标叶子页已经写满,就会触发页分裂。页分裂会带来额外的数据搬运、索引节点调整,还可能留下大量"页内空洞",最终导致写入性能下降、碎片增多。

所以,自增主键之所以流行,根本原因是它和聚簇索引的追加写特性天然契合。它是为了"写入性能"和"索引紧凑性"服务的,而不是为了"号码连续"服务的。这也解释了为什么数据库设计者宁可让主键出现空洞,也要优先保证分配机制的简单和高效。

2.3 两个容易混淆的概念:自增列与自增计数器

很多同学觉得"自增主键就是上一个值加一",这个理解有两个隐藏的误区:

第一个误区是把"下一个值"简单地等同于"max(id) + 1"。实际上,MySQL 为每张带自增列的表维护了一个独立的计数器,这个计数器的当前值并不总是等于表中的最大 id。计数器有自己的生命周期和持久化策略,它可能比 max(id) 大,也可能在极端情况下(如 MySQL 8.0 之前的重启)出现回退。

第二个误区是把"自增列的值"和"物理行号"混为一谈。自增列只是一个普通的数值列,只是因为带了 AUTO_INCREMENT 属性,MySQL 会在你没有指定值时帮你填。它并不代表数据在磁盘上的物理顺序,更不承担任何"行号必须连续"的责任。

在 InnoDB 内部,自增计数器被保存在数据字典结构dict_table_t的autoinc字段中。每次需要生成自增值时,InnoDB 会读取并推进这个计数器。计数器在内存中的推进非常快,但它和实际写入磁盘的数据并不完全同步,这正是诸多"跳号"现象的根源之一。

2.4 LAST_INSERT_ID() 与自增值的可见性

在讲计数器之前,还需要掌握一个非常实用的函数:LAST_INSERT_ID()。当你执行完一条插入语句后,可以通过SELECT LAST_INSERT_ID()拿到本次连接刚刚生成的自增值。

sql

INSERT INTO t_user(name) VALUES ('李四'); SELECT LAST_INSERT_ID();

这里有几个值得注意的细节:

  • LAST_INSERT_ID()是会话级的,不同连接之间互相隔离。A 连接拿到的是 A 自己插入生成的值,不会被 B 连接的插入干扰。

  • 如果一条语句一次插入了多行,LAST_INSERT_ID()返回的是第一条记录被分配的自增值,而不是最后一条。例如一次插入 3 行,生成 10、11、12,函数返回 10。

  • 如果插入失败或者被回滚,已经生成的自增值不会退回计数器。即使你拿不到结果,这个"号"也已经消耗掉了。

  • 通过 JDBC 等驱动,执行插入后调用getGeneratedKeys(),底层其实就是向 MySQL 协议发起LAST_INSERT_ID请求。

理解LAST_INSERT_ID()的会话隔离性,有助于后面理解:为什么并发插入时 A 和 B 拿到的号可能是交错的,但各自又能准确地取得自己的值。这个特性是"连续"和"唯一"能够同时被权衡处理的重要前提。

三、自增值的生成机制

3.1 自增计数器如何推进

要理解不连续,必须先理解连续是怎么来的。MySQL 中,自增值的分配并不直接发生在 SQL 层的INSERT解析之后,而是发生在 InnoDB 存储引擎内部。当一个插入请求进入 InnoDB 时,大致会经历这样的流程:

  1. SQL 层解析插入语句,判断哪些行需要自动生成主键。

  2. InnoDB 根据插入类型向自增计数器申请一段连续的值。

  3. 根据innodb_autoinc_lock_mode的配置,选择是否加自增锁、锁的粒度如何。

  4. 计数器向前推进,申请到的值被分配给各条待插入记录。

  5. 记录写入聚簇索引,事务提交或回滚。

从这个流程可以清楚地看到:自增值的分配发生在写入之前的"申请"阶段,而不是写入成功之后的"结算"阶段。也就是说,一个自增值一旦从计数器里被拿走,不管这条记录最终是否真的成功落库,它都不会再被还回去。这是自增值不连续的第一个,也是最根本的机制基础。

我们可以把计数器想象成一个只能前进、不能后退的"发号机"。你今天去银行取号排队,拿到的号是 57 号。即使你拿完号之后突然有事走了,没有真正办理业务,也不会有人把你的 57 号回收再发给下一个人。下一个人拿到的仍然是 58 号。数据库的自增值分配,本质上就是这样一个发号逻辑。

3.2 自增锁:AUTO-INC Lock

在并发场景下,如果多个事务同时申请自增值,计数器就必须加锁保护,否则会出现两个事务拿到同一个号的情况。InnoDB 为此设计了一种特殊的锁,叫AUTO-INC 锁,也叫自增锁。

自增锁有几个非常鲜明的特点:

  • 它是一种表级锁,而不是行锁。加锁对象是整张表,而不是某一行。

  • 它的生命周期很短,通常在"申请完自增值"之后就会释放,而不是像普通行锁那样直到事务结束才释放。

  • 它存在的核心目的,是为了保证"一次插入语句生成的自增值是连续的",以及兼容基于语句的复制。

为什么要引入表级锁?考虑这样一种情况:事务 A 执行一条普通插入,生成 id=10;事务 B 几乎同时执行插入,生成 id=11。对于实际业务来说,这完全没问题,因为主键 10 和 11 谁先拿到并不重要。但对于基于语句的主从复制来说,问题就大了。

在 statement 格式的 binlog 中,主库执行的每条 SQL 会被原样记录,从库再原样执行一遍。如果主库上两条插入语句并发执行,它们的自增值分配顺序是 10、11,但从库是单线程回放,执行顺序可能变成先 11、后 10。这样主从两边的数据就错位了。因此,在早期版本中,为了保证 statement 复制的正确性,自增锁必须把并发插入串行化,让每次插入拿到的号在语句级别是连续的。

3.3 innodb_autoinc_lock_mode 三种模式

随着 MySQL 的发展,尤其是基于行复制(ROW 格式 binlog)成为主流之后,语句级连续的必要性下降了。为了让并发插入性能更好,MySQL 引入了innodb_autoinc_lock_mode参数。它有三种取值:

取值名称主要行为优点缺点
0传统锁定模式所有插入都使用 AUTO-INC 表级锁,语句执行完才释放复制最安全,兼容 statement binlog并发性能差
1连续锁定模式简单插入不加表锁,用轻量级互斥量;批量插入仍加表级锁兼顾性能与复制安全批量插入仍可能阻塞
2交错锁定模式所有插入都不加 AUTO-INC 表锁,自增值可能交错并发性能最好不兼容 statement 复制下的不连续场景

这里需要对"简单插入"和"批量插入"做一下区分。MySQL 官方把插入分成三类:

  • 简单插入(Simple inserts):插入前能够预先确定行数的语句,比如单行INSERT INTO ... VALUES (...)。这种语句生成的自增值个数是明确的。

  • 批量插入(Bulk inserts):插入前无法预先确定行数的语句,比如INSERT INTO ... SELECT ...、LOAD DATA。这类语句可能要插入很多行,且行数事先未知。

  • 混合模式插入(Mixed-mode inserts):一条语句中,部分行指定了自增列的值,部分行没有指定。比如INSERT INTO t(id, name) VALUES (1, 'a'), (NULL, 'b'), (NULL, 'c')。

在innodb_autoinc_lock_mode=1的连续锁定模式下:

  • 对于简单插入,行数已知,MySQL 可以预先用非常轻量的互斥量一次性分配足够数量的自增值,不需要加表级 AUTO-INC 锁。

  • 对于批量插入,因为行数不确定,为了保证语句内部的连续性和复制安全,仍然会使用表级 AUTO-INC 锁,并且锁会持续到语句执行结束。

  • 对于混合模式插入,由于部分行指定了值,分配逻辑更复杂,MySQL 的处理也要更谨慎。

在innodb_autoinc_lock_mode=2的交错锁定模式下,所有插入都不再用表级锁,多个事务的自增值可以互相交错。举例来说,事务 A 一次插入 3 行,事务 B 一次插入 2 行,最终分配结果可能是 A 拿到 1、3、5,B 拿到 2、4。这在 ROW 格式 binlog 下是安全的,因为从库回放的是具体行数据,不依赖自增值的语句级连续性。

不同 MySQL 版本的默认值不一样:MySQL 5.7 及更早版本默认innodb_autoinc_lock_mode=1;MySQL 8.0 默认值调整为 2。主从复制的 binlog 格式也相应推荐使用 ROW 格式。

3.4 预分配与预留机制

除了锁模式,自增值的"预分配"也是造成跳号的重要机制。在innodb_autoinc_lock_mode=1下,对于批量插入,InnoDB 不知道要插入多少行,于是会按照一定策略一次向计数器申请一段值。比如它可能一次性申请 8 个号,然后从这段号里依次分配给实际插入的行。如果最终只插入了 5 行,那么剩下 3 个号就被浪费掉了。

这种浪费在LOAD DATA导入大量数据时尤其明显。假设 InnoDB 每次预申请 8 个号,而你的数据行数不是 8 的整数倍,那么每一批的最后都会有若干个号被空出来。数据量越大,累积起来的空洞就越多。

预分配机制体现了 MySQL 的一个核心权衡:用"号码的浪费"换取"并发和批量写入的效率"。与其在每插入一行时都去争抢一次计数器锁,不如一次申请一批,让后续的行在无锁或少锁的情况下快速写入。号码的连续性和写入的吞吐量,在这里形成了一对取舍。

四、导致自增主键不连续的六大核心原因

铺垫了这么多,接下来进入本文最核心的部分。我们把自增主键不连续的原因归纳为六大类,每一类都从"为什么"讲到"怎么复现"。这六类原因分别是:

  • 事务回滚导致的自增值消耗

  • DELETE 删除数据留下的空洞

  • 主键或唯一键冲突

  • INSERT ... ON DUPLICATE KEY UPDATE 的多余消耗

  • 批量插入的预分配浪费

  • MySQL 8.0 之前服务重启导致的自增值回退

4.1 原因一:事务回滚

这是最常见、也是面试中最常被问到的一个原因。请看下面这个例子:

sql

-- 假设当前表里 max(id) = 9 START TRANSACTION; INSERT INTO t_user(name) VALUES ('王五'); -- 生成了 id = 10 ROLLBACK;

执行完 ROLLBACK 后,我们再查询这张表,会发现 id 为 10 的记录并不存在。但不妨接着执行下一条插入:

sql

INSERT INTO t_user(name) VALUES ('赵六'); -- 生成 id = 11 SELECT * FROM t_user ORDER BY id DESC LIMIT 3;

你会发现赵六拿到的是 11,而不是 10。也就是说,虽然王五那行被回滚了,但它占用的 10 号没有被回收。原因就是前面反复强调的:自增值是在事务执行过程中从计数器里申请走的,回滚只会撤销数据行,不会撤销计数器的推进。

这里还可以再深挖一层。InnoDB 并不会为"回滚后归还自增值"设计一套复杂的回收机制。如果真的归还,就需要考虑并发事务可能已经按 10、11、12 的顺序拿走了后续号码,归还 10 就可能和后续已分配的号冲突,甚至危害主键唯一性。因此,从实现成本和数据安全两个角度看,让自增值"只进不退"都是更合理的选择。

4.2 原因二:DELETE 删除数据留下的空洞

第二种情况更加直观。假设表里已经有 id 为 1 到 10 的十条数据,某一天业务删除了一些老数据:

sql

DELETE FROM t_user WHERE id IN (3, 5, 8);

删除之后,表里剩下 1、2、4、6、7、9、10。此时继续插入一条新数据,你会拿到 11,而不是 3、5 或 8。原因很简单:普通 DELETE 只是把数据行标记删除,并不会重置自增计数器。计数器的当前值是 10,下一次自然继续分配 11。

很多同学会想:"那我把表清空,号会不会从 1 重新开始?"这里要区分两个操作:

  • DELETE FROM t_user;只删除数据,不重置计数器。下一次插入仍然从 11 开始。

  • TRUNCATE TABLE t_user;相当于删除表后重建,会重置计数器。下一次插入从 1 开始。

所以,如果你只是用 DELETE 清空数据,主键空洞仍然会持续存在。只有 TRUNCATE 或 DROP TABLE 后重建,才会真正让自增值重新开始。

4.3 原因三:主键或唯一键冲突

第三种情况是插入时发生了主键冲突。看下面的例子:

sql

-- 假设当前表里已经有 id = 1 的记录 INSERT INTO t_user(id, name) VALUES (1, '孙七'); -- 报错:Duplicate entry '1' for key 'PRIMARY'

这条插入因为主键冲突失败了,但你可能想不到,如果表中还有其他列是唯一键,冲突也同样会消耗自增值:

sql

-- 假设 mobile 列上建立了唯一索引 INSERT INTO t_user(id, name, mobile) VALUES (NULL, '周八', '13800000000'); -- 如果 mobile 已存在,同样报 Duplicate entry

关键是,在 MySQL 的插入流程里,自增值的申请往往发生在唯一性检查之前。InnoDB 要先拿到一个候选主键值,才能构建整条记录,之后再检查唯一索引是否冲突。即使最终因为冲突插入失败,刚才申请的那个自增值也已经消耗掉了。

可以理解为:去餐厅取号,不管最后有没有吃上饭,号都已经叫过了。下一次取号一定是下一个号码。

4.4 原因四:INSERT ... ON DUPLICATE KEY UPDATE 的多余消耗

这是生产中极容易被忽略的一个"坑"。INSERT ... ON DUPLICATE KEY UPDATE的语义是:先尝试插入,如果冲突就转成更新。问题在于,即使最终走了更新分支,MySQL 也已经在插入阶段申请并消耗了一个自增值。

sql

-- 假设表里已经有 id = 1 的记录 INSERT INTO t_user(id, name) VALUES (1, '吴九') ON DUPLICATE KEY UPDATE name = VALUES(name);

这条语句最终执行的是 UPDATE,并没有新增行。但如果你紧接着再插入一条不冲突的记录,会发现主键跳过了好几个号。因为在内部,MySQL 先尝试用自增值生成一条新记录,失败后才改为更新。这个"尝试"的动作,已经把号拿走了。

这类语句在"存在即更新、不存在即插入"的业务场景里非常常见,比如用户签到、订单状态兜底、幂等写入。如果 QPS 很高,而冲突比例也比较高,自增主键会以肉眼可见的速度跳号。虽然这通常不影响功能,但如果你给主键选了偏小的类型,比如 INT,就需要留意号段消耗速度了。

4.5 原因五:批量插入的预分配浪费

这个原因在前面 3.4 节已经详细讲过,这里再从"不连续"的角度收一下尾。对于INSERT INTO ... SELECT ...、LOAD DATA这类插入前无法确定行数的语句,在innodb_autoinc_lock_mode=1下,InnoDB 会一次向计数器申请一批号码,而不是一条一条申请。

比如某个内部批次一次预留 8 个号。如果这一批实际只插入了 5 行,剩下的 3 个号不会再分配给下一批。连续的大批量导入之后,主键序列里就会留下一串"规律性的空洞"。这些空洞的本质,是用一部分号码空间换批量分配效率。

在innodb_autoinc_lock_mode=2下,由于并发事务的号码会交错分配,虽然单个事务内部不再预留一长段,但从整张表的视角看,号码仍然会呈现交错跳跃。只要 ROW 格式 binlog 下主从能正确回放,这种跳号就是可接受的。

4.6 原因六:MySQL 8.0 之前服务重启导致的自增值回退

最后一个原因是版本相关的,也是很多同学在低版本 MySQL 里偶然踩到的"灵异事件"。在 MySQL 8.0 之前,InnoDB 的自增计数器主要保存在内存中,服务运行期间它不断增长。但如果 MySQL 突然重启,计数器不一定能恢复到重启前的值。

sql

-- 假设当前 max(id) = 100 INSERT INTO t_user(name) VALUES ('郑十'); -- 拿到 101 DELETE FROM t_user WHERE id = 101; -- 删掉刚插入的行 -- 此时 MySQL 重启 INSERT INTO t_user(name) VALUES ('冯十一'); -- 可能又拿到 101

为什么?因为重启后,MySQL 需要重新计算自增计数器的起点。在没有持久化的版本里,它会扫描表里的最大值 max(id),然后把计数器设置为 max(id) + 1。既然 id=101 的那行已经被删除,那么 max(id) 就是 100,重启后计数器退回 101。

这样一来,如果删除的数据已经被其他表外键引用,或者删除的流水号曾被下游系统消费,自增值回退就可能带来主键重复、上下游对账不准等问题。这是早期版本一个非常隐蔽的坑。MySQL 8.0 通过把自增值的修改写入 redo log,解决了重启丢失计数状态的问题,我们在第六章会展开讲。

五、不同插入方式的底层差异

讲完六大原因,我们再横向对比一下不同插入方式对自增值的影响。面试官如果继续追问,通常会从这里切入。

5.1 普通 INSERT VALUES

普通单行插入是最简单的场景。因为没有显式指定自增列的值,MySQL 会申请 1 个自增值。高峰并发下,这种场景由锁模式决定是否加 AUTO-INC 表锁。MySQL 8.0 默认innodb_autoinc_lock_mode=2,多事务可以交错拿号,吞吐很高。

5.2 一条语句插入多行

sql

INSERT INTO t_user(name) VALUES ('A'), ('B'), ('C');

一条语句多行 VALUES,行数在解析阶段就能确定,属于简单插入。MySQL 会一次性申请 3 个连续号,比如 11、12、13。如果这条语句本身失败了,这 3 个号同样会被消耗掉。

5.3 INSERT ... SELECT

sql

INSERT INTO t_user(name) SELECT name FROM t_user_bak;

这类语句的行数在插入前不确定,属于批量插入。在锁模式 1 下,会申请表级 AUTO-INC 锁并持续到语句结束,同时可能按批次预留号码,产生号段浪费。在锁模式 2 下,不再全程持有表锁,但逐段分配的方式仍可能带来交错和跳跃。

5.4 REPLACE INTO

sql

REPLACE INTO t_user(id, name) VALUES (1, '王替换');

REPLACE INTO 会先尝试插入,如果主键或唯一键冲突,就删除冲突行,再插入新行。这个"先插后删再插"的过程,会消耗额外的自增号。如果冲突频繁,号段消耗会比想象中更快。

5.5 INSERT ... ON DUPLICATE KEY UPDATE

前面 4.4 已经分析过:它可能不新增行,却仍然消耗自增值。这里再强调一句:如果你希望用自增主键精确反映插入次数,就不要依赖它,因为它连"尝试插入"都算号。

5.6 LOAD DATA

sql

LOAD DATA LOCAL INFILE '/tmp/user.csv' INTO TABLE t_user FIELDS TERMINATED BY ',' LINES TERMINATED BY '\n' (name);

LOAD DATA 是典型的批量插入,行数未知。它的自增值分配和INSERT ... SELECT类似,容易产生预分配空洞。数据导入量越大,空洞往往越多。大量导入后如果发现主键跳跃严重,不必惊慌,多数情况下这是正常现象。

六、MySQL 8.0 对自增值持久化的改进

4.6 节提到,MySQL 8.0 之前自增计数器主要保存在内存里,重启后通过扫描 max(id) 来重建,这就会导致回退。MySQL 8.0 的一个重要改进,就是让自增值具备了持久化能力。

6.1 8.0 之前为什么不持久化

在那个阶段,InnoDB 认为自增值本质上是一个"可以重新推导的状态"。重启后算出max(id) + 1就足够了。但这个推导忽略了"已分配但已删除"的情况,一旦发生,就会出现号段回退。

6.2 8.0 的持久化机制

MySQL 8.0 开始,InnoDB 每次修改自增计数器的当前值时,会把这次变更写入 redo log。即使服务器突然宕机,重启后也能从 redo log 中恢复出正确的计数器位置,从而避免回退。

sql

-- 查看当前自增值 SHOW CREATE TABLE t_user\G -- 输出中的 AUTO_INCREMENT=xxx 表示下一个可分配值

SHOW CREATE TABLE展示的AUTO_INCREMENT=xxx就是当前计数器的下一个可用值。在 MySQL 8.0 中,它会随着 redo log 一起被更可靠地恢复。

6.3 实际使用中还要注意的边界

即使 MySQL 8.0 解决了自增值回退,也不代表主键就连续了。回滚、DELETE、冲突、预分配这些因素依然存在。持久化改进解决的是"重启不回退",而不是"分配不跳号"。理解这一点,能帮你更准确地向面试官表达你对版本差异的认知。

七、可以亲手复现空洞的实验步骤

光看理论容易忘记,建议你用本机 MySQL 把下面几个实验跑一遍。建议使用 MySQL 5.7 和 8.0 各准备一套环境,有些差异只有亲手验证过才会有感觉。

7.1 环境准备

sql

CREATE DATABASE IF NOT EXISTS demo; USE demo; CREATE TABLE t_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(64) NOT NULL, mobile VARCHAR(20) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_mobile (mobile) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

7.2 复现回滚空洞

sql

START TRANSACTION; INSERT INTO t_user(name) VALUES ('a'); ROLLBACK; INSERT INTO t_user(name) VALUES ('b'); SELECT * FROM t_user;

预期结果:只有一条记录,且 id 为 2。说明 id=1 已经被回滚事务消耗。

7.3 复现唯一键冲突

sql

INSERT INTO t_user(name, mobile) VALUES ('c', '13800000000'); -- 下面这条会因为 mobile 冲突而失败 INSERT INTO t_user(name, mobile) VALUES ('d', '13800000000'); INSERT INTO t_user(name, mobile) VALUES ('e', '13900000000'); SELECT * FROM t_user;

预期结果:c 的 id 为 3,d 插入失败但号被消耗,e 的 id 跳到 5。

7.4 复现 ON DUPLICATE KEY UPDATE 跳号

sql

INSERT INTO t_user(name, mobile) VALUES ('f', '13800000000') ON DUPLICATE KEY UPDATE name = VALUES(name); INSERT INTO t_user(name, mobile) VALUES ('g', '13700000000'); SELECT * FROM t_user;

预期结果:f 最终走了更新,没有新增行;g 的 id 却比前一次分配又跳了号,说明更新路径仍然消耗了自增值。

7.5 复现 DELETE 与 TRUNCATE 的区别

sql

DELETE FROM t_user; INSERT INTO t_user(name) VALUES ('h'); SELECT * FROM t_user; -- id 不会从 1 开始 TRUNCATE TABLE t_user; INSERT INTO t_user(name) VALUES ('i'); SELECT * FROM t_user; -- id 从 1 重新开始

7.6 观察批量插入的号段跨度

sql

-- 造一张测试源表 CREATE TABLE t_src AS SELECT CONCAT('name_', n) AS name FROM ( SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 ) tmp; INSERT INTO t_user(name) SELECT name FROM t_src; SHOW CREATE TABLE t_user\G

观察SHOW CREATE TABLE中的AUTO_INCREMENT值与实际插入行数之间的差异,再结合innodb_autoinc_lock_mode的值理解分配行为。

八、自增值耗尽、面试追问与生产实践

8.1 自增值耗尽会发生什么

自增列既然是一个整数,就有最大值。以 INT 为例,无符号最大值是 4294967295。一旦计数器到达上限,继续插入就会失败:

sql

-- 当 AUTO_INCREMENT 到达 INT UNSIGNED 上限后 INSERT INTO t_user(name) VALUES ('x'); -- 报错:Duplicate entry '4294967295' for key 'PRIMARY' 或自增值耗尽相关错误

虽然普通业务很难把 INT 用到上限,但一些高频日志、流水、埋点表完全有可能。尤其是前面提到的ON DUPLICATE KEY UPDATE和回滚会加速号段消耗,所以这类表建议直接使用BIGINT UNSIGNED,或者评估是否需要自增主键。

8.2 面试高频追问

追问一:自增值为什么不能简单归还?

因为归还后可能与其他已分配的号码冲突。例如事务 A 拿了 10 但回滚,事务 B 已经拿了 11、12,如果归还 10,下一次分配又给 10,那么 10 和 11 的相对顺序就被打乱,客户端也无法保证有序。此外,实现"归还并保证唯一"的成本远高于直接浪费一个号码。

追问二:为什么 MySQL 8.0 之后还要用 BIGINT?

8.0 虽然解决了自增值重启回退,但号段仍然会因为回滚、冲突、预分配而消耗。高频表在几年内消耗数十亿号并不罕见,INT UNSIGNED 上限只有 42 亿,稍有不慎就会撞线。所以生产环境的高频表推荐 BIGINT UNSIGNED。

追问三:能不能关闭自增锁提升并发?

innodb_autoinc_lock_mode=2已经默认不再使用表级 AUTO-INC 锁,主从复制使用 ROW 格式时完全安全。但要注意两个前提:binlog 格式必须是 ROW;不依赖语句级的自增连续。如果你的场景恰好依赖INSERT ... SELECT里行的顺序与自增值顺序严格一致,就应当避免使用模式 2。

追问四:如果就是要主键连续怎么办?

可以自己维护一个序列表,在同一个事务里通过SELECT ... FOR UPDATE加锁取号,再写入业务表。但这会引入额外的写操作和锁竞争,性能远不如原生自增。除非业务必须要求连续编号(比如发票号、订单号),否则不建议这样做。

8.3 生产实践建议

建议一:主键类型优先 BIGINT UNSIGNED。尤其在日志、流水、埋点、消息表这类高频写入场景,INT 的号段消耗速度远超想象。

建议二:不要试图用自增主键表示业务连续性。自增主键只保证唯一和趋势递增,它不承担业务含义。发票号、订单号、账单号这类需要连续的业务编号,应该单独生成并持久化。

建议三:避免在高频表上滥用 ON DUPLICATE KEY UPDATE。如果冲突比例很高,号段消耗速度会被放大。对于幂等写入场景,可以考虑先查后写、唯一索引冲突重试等方式。

建议四:主从复制优先使用 ROW 格式。ROW 格式下,自增值的分配顺序不再影响主从一致性,可以放心使用innodb_autoinc_lock_mode=2获得更高并发。

建议五:不要依赖主键顺序推断写入时间。由于事务回滚、并发交错,主键大小与插入时间并不严格对应。如果业务需要精确时间,应显式记录 create_time。

九、总结

"MySQL 自增主键为什么不连续"看似只是一个面试问题,实际上它串起了 InnoDB 的多个核心机制:自增计数器的生命周期、AUTO-INC 锁与锁模式、聚簇索引的追加写特性、事务回滚与持久化、复制格式与主从一致。掌握这条主线之后,你会发现很多看似孤立的知识点都连成了一张网。

把要点再回顾一遍:

  • 自增主键只保证唯一和趋势递增,不保证连续,这是它的设计前提,而不是 Bug。

  • 自增值的分配发生在写入之前,一旦分配就不会因为回滚而归还,这是不连续最根本的原因。

  • AUTO-INC 锁与 innodb_autoinc_lock_mode决定并发插入时的分配粒度,模式 2 是 MySQL 8.0 的默认选择,也是现代高并发场景的推荐。

  • 事务回滚、DELETE、唯一键冲突、ON DUPLICATE KEY UPDATE、批量预分配、8.0 之前重启是六大不连续来源,掌握每一类背后的事务与锁机制,是面试中的加分项。

  • MySQL 8.0 的自增值持久化解决了重启回退问题,但没有解决分配跳号问题,两个问题要分开理解。

  • 生产实践中优先使用 BIGINT UNSIGNED,不要用自增主键承担业务连续性,遇到高频写入表要提前规划号段容量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询