MySQL实战45讲学习笔记:自增主键为何不是连续的?(第39讲)算法
在第 4 篇文章中,咱们提到过自增主键,因为自增主键可让主键索引尽可能地保持递增顺序插入,避免了页分裂,所以索引更紧凑。sql
以前我见过有的业务设计依赖于自增主键的连续性,也就是说,这个设计假设自增主键是连续的。但实际上,这样的假设是错的,由于自增主键不能保证连续递增。bash
今天这篇文章,咱们就来讲说这个问题,看看什么状况下自增主键会出现 “空洞”?session
为了便于说明,咱们建立一个表 t,其中 id 是自增主键字段、c 是惟一索引。并发
CREATE TABLE `t` ( `id` int(11) NOT NULL AUTO_INCREMENT, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `c` (`c`) ) ENGINE=InnoDB;
在这个空表 t 里面执行 insert into t values(null, 1, 1); 插入一行数据,再执行 show create table 命令,就能够看到以下图所示的结果:工具
图 1 自动生成的 AUTO_INCREMENT 值性能
能够看到,表定义里面出现了一个 AUTO_INCREMENT=2,表示下一次插入数据时,若是须要自动生成自增值,会生成 id=2。学习
其实,这个输出结果容易引发这样的误解:自增值是保存在表结构定义里的。实际上,表
的结构定义存放在后缀名为.frm 的文件中,可是并不会保存自增值。优化
不一样的引擎对于自增值的保存策略不一样spa
MyISAM 引擎的自增值保存在数据文件中。
InnoDB 引擎的自增值,实际上是保存在了内存里,而且到了 MySQL 8.0 版本后,才有了“自增值持久化”的能力,也就是才实现了“若是发生重启,表的自增值能够恢复为
MySQL 重启前的值”,具体状况是:
在 MySQL 5.7 及以前的版本,自增值保存在内存里,并无持久化。每次重启后,第一次打开表的时候,都会去找自增值的最大值 max(id),而后将 max(id)+1 做为这
个表当前的自增值。
举例来讲,若是一个表当前数据行里最大的 id 是 10,AUTO_INCREMENT=11。这时候,咱们删除 id=10 的行,AUTO_INCREMENT 仍是 11。但若是立刻重启实例,
重启后这个表的 AUTO_INCREMENT 就会变成 10。也就是说,MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值。
在 MySQL 8.0 版本,将自增值的变动记录在了 redo log 中,重启的时候依靠 redolog 恢复重启以前的值。
理解了 MySQL 对自增值的保存策略之后,咱们再看看自增值修改机制。
在 MySQL 里面,若是字段 id 被定义为 AUTO_INCREMENT,在插入一行数据的时候,
自增值的行为以下:
1. 若是插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的AUTO_INCREMENT 值填到自增字段;
2. 若是插入数据时 id 字段指定了具体的值,就直接使用语句里指定的值。根据要插入的值和当前自增值的大小关系,自增值的变动结果也会有所不一样。假设,某次
要插入的值是 X,当前的自增值是 Y。
1. 若是 X<Y,那么这个表的自增值不变;
2. 若是 X≥Y,就须要把当前自增值修改成新的自增值。
新的自增值生成算法是:从 auto_increment_offset 开始,以auto_increment_increment 为步长,持续叠加,直到找到第一个大于 X 的值,做为新的自增值。
其中,auto_increment_offset 和 auto_increment_increment 是两个系统参数,分别用来表示自增的初始值和步长,默认值都是 1。
备注:在一些场景下,使用的就不全是默认值。好比,双 M 的主备结构里要求双写的时候,咱们就可能会设置成 auto_increment_increment=2,让一个库的自增 id 都是奇数,另外一个库的自增 id 都是偶数,避免两个库生成的主键发生冲突。
当 auto_increment_offset 和 auto_increment_increment 都是 1 的时候,新的自增值生成逻辑很简单,就是:
1. 若是准备插入的值 >= 当前自增值,新的自增值就是“准备插入的值 +1”;
2. 不然,自增值不变。
这就引入了咱们文章开头提到的问题,在这两个参数都设置为 1 的时候,自增主键 id 却不能保证是连续的,这是什么缘由呢?
要回答这个问题,咱们就要看一下自增值的修改时机。
假设,表 t 里面已经有了 (1,1,1) 这条记录,这时我再执行一条插入数据命令:
insert into t values(null, 1, 1);
这个语句的执行流程就是:
1. 执行器调用 InnoDB 引擎接口写入一行,传入的这一行的值是 (0,1,1);
2. InnoDB 发现用户没有指定自增 id 的值,获取表 t 当前的自增值 2;
3. 将传入的行的值改为 (2,1,1);
4. 将表的自增值改为 3;
5. 继续执行插入数据操做,因为已经存在 c=1 的记录,因此报 Duplicate key error,语句返回。
对应的执行流程图以下:
图 2 insert(null, 1,1) 惟一键冲突
能够看到,这个表的自增值改为 3,是在真正执行插入数据的操做以前。这个语句真正执行的时候,由于碰到惟一键 c 冲突,因此 id=2 这一行并无插入成功,但也没有将自增
值再改回去。
因此,在这以后,再插入新的数据行时,拿到的自增 id 就是 3。也就是说,出现了自增主键不连续的状况
如图 3 所示就是完整的演示结果。
图 3 一个自增主键 id 不连续的复现步骤
能够看到,这个操做序列复现了一个自增主键 id 不连续的现场 (没有 id=2 的行)。可见,惟一键冲突是致使自增主键 id 不连续的第一种缘由。
下面这个语句序列就能够构造不连续的自增 id,你能够本身验证一下。
insert into t values(null,1,1); begin; insert into t values(null,2,2); rollback; insert into t values(null,2,2); //插入的行是(3,2,2)
你可能会问,为何在出现惟一键冲突或者回滚的时候,MySQL 没有把表 t 的自增值改回去呢?若是把表 t 的当前自增值从 3 改回 2,再插入新数据的时候,不就能够生成 id=2
的一行数据了吗?
其实,MySQL 这么设计是为了提高性能。接下来,我就跟你分析一下这个设计思路,看看自增值为何不能回退。
假设有两个并行执行的事务,在申请自增值的时候,为了不两个事务申请到相同的自增id,确定要加锁,而后顺序申请。
1. 假设事务 A 申请到了 id=2, 事务 B 申请到 id=3,那么这时候表 t 的自增值是 4,以后继续执行。
2. 事务 B 正确提交了,但事务 A 出现了惟一键冲突。
3. 若是容许事务 A 把自增 id 回退,也就是把表 t 的当前自增值改回 2,那么就会出现这样的状况:表里面已经有 id=3 的行,而当前的自增 id 值是 2。
4. 接下来,继续执行的其余事务就会申请到 id=2,而后再申请到 id=3。这时,就会出现插入语句报错“主键冲突”。
而为了解决这个主键冲突,有两种方法:
1. 每次申请 id 以前,先判断表里面是否已经存在这个 id。若是存在,就跳过这个 id。可是,这个方法的成本很高。由于,原本申请 id 是一个很快的操做,如今还要再去主键
索引树上判断 id 是否存在。
2. 把自增 id 的锁范围扩大,必须等到一个事务执行完成并提交,下一个事务才能再申请自增 id。这个方法的问题,就是锁的粒度太大,系统并发能力大大降低。
可见,这两个方法都会致使性能问题。形成这些麻烦的罪魁祸首,就是咱们假设的这个“容许自增 id 回退”的前提致使的。
所以,InnoDB 放弃了这个设计,语句执行失败也不回退自增 id。也正是由于这样,因此才只保证了自增 id 是递增的,但不保证是连续的。
能够看到,自增 id 锁并非一个事务锁,而是每次申请完就立刻释放,以便容许别的事务再申请。其实,在 MySQL 5.1 版本以前,并非这样的。
接下来,我会先给你介绍下自增锁设计的历史,这样有助于你分析接下来的一个问题。
在 MySQL 5.0 版本的时候,自增锁的范围是语句级别。也就是说,若是一个语句申请了一个表自增锁,这个锁会等语句执行结束之后才释放。显然,这样设计会影响并发度。
MySQL 5.1.22 版本引入了一个新策略,新增参数 innodb_autoinc_lock_mode,默认值是 1。
1. 这个参数的值被设置为 0 时,表示采用以前 MySQL 5.0 版本的策略,即语句执行结束后才释放锁;
2. 这个参数的值被设置为 1 时:
普通 insert 语句,自增锁在申请以后就立刻释放;
相似 insert … select 这样的批量插入数据的语句,自增锁仍是要等语句结束后才被释放;
3. 这个参数的值被设置为 2 时,全部的申请自增主键的动做都是申请后就释放锁。
你必定有两个疑问:为何默认设置下,insert … select 要使用语句级的锁?为何这个参数的默认值不是 2?
答案是,这么设计仍是为了数据的一致性。
咱们一块儿来看一下这个场景:
图 4 批量插入数据的自增锁
在这个例子里,我往表 t1 中插入了 4 行数据,而后建立了一个相同结构的表 t2,而后两个 session 同时执行向表 t2 中插入数据的操做。
你能够设想一下,若是 session B 是申请了自增值之后立刻就释放自增锁,那么就可能出现这样的状况:
你可能会说,这也不要紧吧,毕竟 session B 的语义自己就没有要求表 t2 的全部行的数据都跟 session A 相同。
是的,从数据逻辑上看是对的。可是,若是咱们如今的 binlog_format=statement,你能够设想下,binlog 会怎么记录呢?
因为两个 session 是同时执行插入数据命令的,因此 binlog 里面对表 t2 的更新日志只有
两种状况:要么先记 session A 的,要么先记 session B 的。
但不管是哪种,这个 binlog 拿去从库执行,或者用来恢复临时实例,备库和临时实例里面,session B 这个语句执行出来,生成的结果里面,id 都是连续的。这时,这个库就
发生了数据不一致。
二、而要解决这个问题,有两种思路:
一、你能够分析一下,出现这个问题的缘由是什么?
其实,这是由于原库 session B 的 insert 语句,生成的 id 不连续。这个不连续的 id,用statement 格式的 binlog 来串行执行,是执行不出来的。
1. 一种思路是,让原库的批量插入数据语句,固定生成连续的 id 值。因此,自增锁直到语句执行结束才释放,就是为了达到这个目的。
2. 另外一种思路是,在 binlog 里面把插入数据的操做都如实记录进来,到备库执行的时候,再也不依赖于自增主键去生成。这种状况,其实就是 innodb_autoinc_lock_mode设置为 2,同时 binlog_format 设置为 row。
所以,在生产上,尤为是有 insert … select 这种批量插入数据的场景时,从并发插入数据性能的角度考虑,我建议你这样设置:innodb_autoinc_lock_mode=2 ,而且binlog_format=row. 这样作,既能提高并发性,又不会出现数据一致性问题。
须要注意的是,我这里说的批量插入数据,包含的语句类型是 insert … select、replace … select 和 load data 语句。
可是,在普通的 insert 语句里面包含多个 value 值的状况下,即便innodb_autoinc_lock_mode 设置为 1,也不会等语句执行完成才释放锁。由于这类语句
在申请自增 id 的时候,是能够精确计算出须要多少个 id 的,而后一次性申请,申请完成后锁就能够释放了。
也就是说,批量插入数据的语句,之因此须要这么设置,是由于“不知道要预先申请多少个 id”。
既然预先不知道要申请多少个自增 id,那么一种直接的想法就是须要一个时申请一个。但若是一个 select … insert 语句要插入 10 万行数据,按照这个逻辑的话就要申请 10 万
次。显然,这种申请自增 id 的策略,在大批量插入数据的状况下,不但速度慢,还会影响并发插入的性能。
所以,对于批量插入数据的语句,MySQL 有一个批量申请自增 id 的策略:
1. 语句执行过程当中,第一次申请自增 id,会分配 1 个;
2. 1 个用完之后,这个语句第二次申请自增 id,会分配 2 个;
3. 2 个用完之后,仍是这个语句,第三次申请自增 id,会分配 4 个;
4. 依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍。
举个例子,咱们一块儿看看下面的这个语句序列:
insert into t values(null, 1,1); insert into t values(null, 2,2); insert into t values(null, 3,3); insert into t values(null, 4,4); create table t2 like t; insert into t2(c,d) select c,d from t; insert into t2 values(null, 5,5);
insert…select,实际上往表 t2 中插入了 4 行数据。可是,这四行数据是分三次申请的自增 id,第一次申请到了 id=1,第二次被分配了 id=2 和 id=3, 第三次被分配到 id=4 到id=7。
因为这条语句实际只用上了 4 个 id,因此 id=5 到 id=7 就被浪费掉了。以后,再执行insert into t2 values(null, 5,5),实际上插入的数据就是(8,5,5)。
这是主键 id 出现自增 id 不连续的第三种缘由。
今天,咱们从“自增主键为何会出现不连续的值”这个问题开始,首先讨论了自增值的存储。
在 MyISAM 引擎里面,自增值是被写在数据文件上的。而在 InnoDB 中,自增值是被记录在内存的。MySQL 直到 8.0 版本,才给 InnoDB 表的自增值加上了持久化的能力,确
保重启先后一个表的自增值不变。
而后,我和你分享了在一个语句执行过程当中,自增值改变的时机,分析了为何 MySQL在事务回滚的时候不能回收自增 id。
MySQL 5.1.22 版本开始引入的参数 innodb_autoinc_lock_mode,控制了自增值申请时的锁范围。从并发性能的角度考虑,我建议你将其设置为 2,同时将 binlog_format 设置
为 row。我在前面的文章中其实屡次提到,binlog_format 设置为 row,是颇有必要的。今天的例子给这个结论多了一个理由。
最后,我给你留一个思考题吧。
在最后一个例子中,执行 insert into t2(c,d) select c,d from t; 这个语句的时候,若是隔离级别是可重复读(repeatable read),binlog_format=statement。这个语句会对表 t
的全部记录和间隙加锁。
你以为为何须要这么作呢?
你能够把你的思考和分析写在评论区,我会在下一篇文章和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一块儿阅读。
上期的问题是,若是你维护的 MySQL 系统里有内存表,怎么避免内存表忽然丢数据,而后致使主备同步中止的状况。
咱们假设的是主库暂时不能修改引擎,那么就把备库的内存表引擎先都改为 InnoDB。对于每一个内存表,执行
set sql_log_bin=off; alter table tbl_name engine=innodb;
这样就能避免备库重启的时候,数据丢失的问题。
因为主库重启后,会往 binlog 里面写“delete from tbl_name”,这个命令传到备库,备库的同名的表数据也会被清空。
所以,就不会出现主备同步中止的问题。
若是因为主库异常重启,触发了 HA,这时候咱们以前修改过引擎的备库变成了主库。而原来的主库变成了新备库,在新备库上把全部的内存表(这时候表里没数据)都改为
InnoDB 表。
因此,若是咱们不能直接修改主库上的表引擎,能够配置一个自动巡检的工具,在备库上发现内存表就把引擎改了。
同时,跟业务开发同窗约定好建表规则,避免建立新的内存表。
你们在春节期间还坚持看专栏,而且深刻地思考和回复,给你们点赞。
@长杰 同窗提到的将数据保存到 InnoDB 表用来持久化,也是一个方法。不过,我仍是建议釜底抽薪,直接修改备库的内存表的引擎。@老杨同志 提到的是主库异常重启的场景,这时候是不会报主备不一致的,由于主库重启的时候写了 delete from tbl_name,主备的内存表都清空了。