You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

301 lines
17 KiB
Markdown

2 years ago
# 39 | 自增主键为什么不是连续的?
在[第4篇文章](https://time.geekbang.org/column/article/69236)中,我们提到过自增主键,由于自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,因此索引更紧凑。
之前我见过有的业务设计依赖于自增主键的连续性,也就是说,这个设计假设自增主键是连续的。但实际上,这样的假设是错的,因为自增主键不能保证连续递增。
今天这篇文章,我们就来说说这个问题,看看什么情况下自增主键会出现 “空洞”?
为了便于说明我们创建一个表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命令就可以看到如下图所示的结果
![](https://static001.geekbang.org/resource/image/cb/ff/cb2637cada0201b18650f56875e94fff.png)
图1 自动生成的AUTO\_INCREMENT值
可以看到表定义里面出现了一个AUTO\_INCREMENT=2表示下一次插入数据时如果需要自动生成自增值会生成id=2。
其实,这个输出结果容易引起这样的误解:自增值是保存在表结构定义里的。实际上,**表的结构定义存放在后缀名为.frm的文件中但是并不会保存自增值。**
不同的引擎对于自增值的保存策略不同。
* MyISAM引擎的自增值保存在数据文件中。
* InnoDB引擎的自增值其实是保存在了内存里并且到了MySQL 8.0版本后才有了“自增值持久化”的能力也就是才实现了“如果发生重启表的自增值可以恢复为MySQL重启前的值”具体情况是
* 在MySQL 5.7及之前的版本自增值保存在内存里并没有持久化。每次重启后第一次打开表的时候都会去找自增值的最大值max(id)然后将max(id)+1作为这个表当前的自增值。
举例来说如果一个表当前数据行里最大的id是10AUTO\_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在插入一行数据的时候自增值的行为如下
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语句返回。
对应的执行流程图如下:
![](https://static001.geekbang.org/resource/image/f1/d3/f16d89a6e7ad6e2cde13b32bb2292dd3.jpg)
图2 insert(null, 1,1)唯一键冲突
可以看到这个表的自增值改成3是在真正执行插入数据的操作之前。这个语句真正执行的时候因为碰到唯一键c冲突所以id=2这一行并没有插入成功但也没有将自增值再改回去。
所以在这之后再插入新的数据行时拿到的自增id就是3。也就是说出现了自增主键不连续的情况。
如图3所示就是完整的演示结果。
![](https://static001.geekbang.org/resource/image/77/26/77b87820b649692a555f19b562d5d926.png)
图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**
答案是,这么设计还是为了数据的一致性。
我们一起来看一下这个场景:
![](https://static001.geekbang.org/resource/image/e0/df/e0a69e151277de54a8262657e4ec89df.png)
图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的策略
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 readbinlog\_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主备的内存表都清空了。