盛世宏图 发表于 2024-11-29 22:50:20

第三十八讲:自增主键为什么不是一连的

第三十八讲:自增主键为什么不是一连的

简概

https://images.cnblogs.com/cnblogs_com/blogs/827070/galleries/2419425/o_241129122936_QQ_1732883361124.png
引言

​        在第 4 篇文章中,我们提到过自增主键,由于自增主键可以让主键索引只管地保持递增顺序插入,避免了页分裂,因此索引更紧凑。
​        之前我见过有的业务设计依靠于自增主键的一连性,也就是说,这个设计假设自增主键是一连的。
​        但实际上,如许的假设是错的,因为自增主键不能保证一连递增。今天这篇文章,我们就来说说这个问题,看看什么情况下自增主键会出现 “空洞”?
​        为了便于阐明,我们创建一个表 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;记住这边的unique,反面要考
自增值生存在哪儿?

​        在这个空表 t 内里实行 insert into t values(null, 1, 1); 插入一行数据,再实行 show create table 命令,就可以看到如下图所示的结果:
    https://static001.geekbang.org/resource/image/cb/ff/cb2637cada0201b18650f56875e94fff.png?wh=1430*542   
          图 1 主动天生的 AUTO_INCREMENT 值        ​        可以看到,表界说内里出现了一个 AUTO_INCREMENT=2,表示下一次插入数据时,如果需要主动天生自增值,会天生 id=2。
​        其实,这个输出结果容易引起如许的误解:自增值是生存在表布局界说里的。实际上,表的布局界说存放在后缀名为.frm 的文件中,但是并不会生存自增值。
如何查看当前表的下一个自增主键值是多少? Show create table查看表信息,内里有个AUTO_INCREMENT=某个值,这个值就是下一个自增主键的值。MySQL自增主键的存放计谋根据存储引擎的差异而差异
​        差异的引擎对于自增值的生存计谋差异。

[*]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 中,重启的时间依靠 redo log 规复重启之前的值。

​        理解了 MySQL 对自增值的生存计谋以后,我们再看看自增值修改机制。
自增值修改机制

​        在 MySQL 内里,如果字段 id 被界说为 AUTO_INCREMENT,在插入一行数据的时间,自增值的行为如下:

[*]如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段;
[*]如果插入数据时 id 字段指定了具体的值,就直接利用语句里指定的值。
​        根据要插入的值和当前自增值的大小关系,自增值的变动结果也会有所差异。假设,某次要插入的值是 X,当前的自增值是 Y。
<ul>
如果 X= 当前自增值,新的自增值就是“准备插入的值 +1”;
否则,自增值不变。
</ol>​        这就引入了我们文章开头提到的问题,在这两个参数都设置为 1 的时间,自增主键 id 却不能保证是一连的,这是什么原因呢?
自增值的修改时机

​        要回答这个问题,我们就要看一下自增值的修改时机。假设,表 t 内里已经有了 (1,1,1) 这条记录,这时我再实行一条插入数据命令:
insert into t values(null, 1, 1); 这个语句的实行流程就是:

[*]实行器调用 InnoDB 引擎接口写入一行,传入的这一行的值是 (0,1,1);
[*]InnoDB 发现用户没有指定自增 id 的值,获取表 t 当前的自增值 2;
[*]将传入的行的值改成 (2,1,1);
[*]将表的自增值改成 3;
[*]继续实行插入数据操作,由于已经存在 c=1 的记录,以是报 Duplicate key error,语句返回。
对应的实行流程图如下:
    https://static001.geekbang.org/resource/image/f1/d3/f16d89a6e7ad6e2cde13b32bb2292dd3.jpg?wh=1142*1522   
          图 2 insert(null, 1,1) 唯一键辩论        ​        可以看到,这个表的自增值改成 3,是在真正实行插入数据的操作之前。这个语句真正实行的时间,因为遇到唯一键 c 辩论,以是 id=2 这一行并没有插入成功,但也没有将自增值再改回去。
​        以是,在这之后,再插入新的数据行时,拿到的自增 id 就是 3。
​        也就是说,出现了自增主键不一连的情况。如图 3 所示就是完备的演示结果。
    https://static001.geekbang.org/resource/image/77/26/77b87820b649692a555f19b562d5d926.png?wh=659*641   
          图 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,肯定要加锁,然后顺序申请。

[*]假设事件 A 申请到了 id=2, 事件 B 申请到 id=3,那么这时间表 t 的自增值是 4,之后继续实行。
[*]事件 B 精确提交了,但事件 A 出现了唯一键辩论。
[*]如果答应事件 A 把自增 id 回退,也就是把表 t 的当前自增值改回 2,那么就会出现如许的情况:表内里已经有 id=3 的行,而当前的自增 id 值是 2。
[*]接下来,继续实行的其他事件就会申请到 id=2,然后再申请到 id=3。这时,就会出现插入语句报错“主键辩论”。
​        而为了解决这个主键辩论,有两种方法:

[*]每次申请 id 之前,先判断表内里是否已经存在这个 id。如果存在,就跳过这个 id。但是,这个方法的本钱很高。因为,本来申请 id 是一个很快的操作,如今还要再去主键索引树上判断 id 是否存在。
[*]把自增 id 的锁范围扩大,必须等到一个事件实行完成并提交,下一个事件才气再申请自增 id。这个方法的问题,就是锁的粒度太大,系统并发能力大大下降。
​        可见,这两个方法都会导致性能问题。造成这些麻烦的罪魁罪魁,就是我们假设的这个“答应自增 id 回退”的前提导致的。因此,InnoDB 放弃了这个设计,语句实行失败也不回退自增 id。也正是因为如许,以是才只保证了自增 id 是递增的,但不保证是一连的。
自增锁的优化

​        可以看到,自增 id 锁并不是一个事件锁,而是每次申请完就立刻开释,以便答应别的事件再申请。其实,在 MySQL 5.1 版本之前,并不是如许的。
​        接下来,我会先给你介绍下自增锁设计的汗青,如许有助于你分析接下来的一个问题。
​        在 MySQL 5.0 版本的时间,自增锁的范围是语句级别。也就是说,如果一个语句申请了一个表自增锁,这个锁会等语句实行结束以后才开释。显然,如许设计会影响并发度。MySQL 5.1.22 版本引入了一个新计谋,新增参数 innodb_autoinc_lock_mode,默认值是 1。

[*]这个参数的值被设置为 0 时,表示采用之前 MySQL 5.0 版本的计谋,即语句实行结束后才开释锁;
[*]这个参数的值被设置为 1 时:

[*]普通 insert 语句,自增锁在申请之后就立刻开释;
[*]类似 insert … select 如许的批量插入数据的语句,自增锁还是要等语句结束后才被开释;

[*]这个参数的值被设置为 2 时,所有的申请自增主键的动作都是申请后就开释锁。
​        你肯定有两个疑问:为什么默认设置下,insert … select 要利用语句级的锁?为什么这个参数的默认值不是 2?
​        答案是,这么设计还是为了数据的一致性。我们一起来看一下这个场景:
    https://static001.geekbang.org/resource/image/e0/df/e0a69e151277de54a8262657e4ec89df.png?wh=933*278   
          图 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 来串行实行,是实行不出来的。
​        而要解决这个问题,有两种思路:

[*]一种思路是,让原库的批量插入数据语句,固定天生一连的 id 值。以是,自增锁直到语句实行结束才开释,就是为了到达这个目的。
[*]另一种思路是,在 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 的计谋:

[*]语句实行过程中,第一次申请自增 id,会分配 1 个;
[*]1 个用完以后,这个语句第二次申请自增 id,会分配 2 个;
[*]2 个用完以后,还是这个语句,第三次申请自增 id,会分配 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 的所有记录和间隙加锁。你觉得为什么需要这么做呢?
答案

见下期

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: 第三十八讲:自增主键为什么不是一连的