MySQL实战45讲学习笔记:第三十九讲

1、本节概况

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;

2、自增值保存在哪儿?

在这个空表 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 对自增值的保存策略之后,咱们再看看自增值修改机制。

3、自增值修改机制

在 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 却不能保证是连续的,这是什么缘由呢?

4、自增值的修改时机

要回答这个问题,咱们就要看一下自增值的修改时机。

一、惟一键冲突是致使自增主键 id 不连续的第一种缘由

一、insert(null, 1,1) 惟一键冲突

假设,表 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 不连续的复现步骤

因此,在这以后,再插入新的数据行时,拿到的自增 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 是递增的,但不保证是连续的。

5、自增锁的优化

能够看到,自增 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?

你必定有两个疑问:为何默认设置下,insert … select 要使用语句级的锁?为何这个参数的默认值不是 2?

答案是,这么设计仍是为了数据的一致性。

咱们一块儿来看一下这个场景:

图 4 批量插入数据的自增锁

在这个例子里,我往表 t1 中插入了 4 行数据,而后建立了一个相同结构的表 t2,而后两个 session 同时执行向表 t2 中插入数据的操做。

你能够设想一下,若是 session B 是申请了自增值之后立刻就释放自增锁,那么就可能出现这样的状况:

  • session B 先插入了两个记录,(1,1,1)、(2,2,2);
  • 而后,session A 来申请自增 id 获得 id=3,插入了(3,5,5);
  • 以后,session B 继续执行,插入两条记录 (4,3,3)、 (5,4,4)。

你可能会说,这也不要紧吧,毕竟 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 的策略:

所以,对于批量插入数据的语句,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 不连续的第三种缘由。

6、小结

今天,咱们从“自增主键为何会出现不连续的值”这个问题开始,首先讨论了自增值的存储。

在 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
的全部记录和间隙加锁。

你以为为何须要这么作呢?

你能够把你的思考和分析写在评论区,我会在下一篇文章和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一块儿阅读。

7、上期问题时间

上期的问题是,若是你维护的 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,主备的内存表都清空了。

相关文章
相关标签/搜索