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.

226 lines
15 KiB
Markdown

2 years ago
# 45 | 自增id用完怎么办
MySQL里有很多自增的id每个自增id都是定义了初始值然后不停地往上加步长。虽然自然数是没有上限的但是在计算机里只要定义了表示这个数的字节长度那它就有上限。比如无符号整型(unsigned int)是4个字节上限就是232\-1。
既然自增id有上限就有可能被用完。但是自增id用完了会怎么样呢
今天这篇文章我们就来看看MySQL里面的几种自增id一起分析一下它们的值达到上限以后会出现什么情况。
# 表定义自增值id
说到自增id你第一个想到的应该就是表结构定义里的自增字段也就是我在第39篇文章[《自增主键为什么不是连续的?》](https://time.geekbang.org/column/article/80531)中和你介绍过的自增主键id。
表定义的自增值达到上限后的逻辑是再申请下一个id时得到的值保持不变。
我们可以通过下面这个语句序列验证一下:
```
create table t(id int unsigned auto_increment primary key) auto_increment=4294967295;
insert into t values(null);
//成功插入一行 4294967295
show create table t;
/* CREATE TABLE `t` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4294967295;
*/
insert into t values(null);
//Duplicate entry '4294967295' for key 'PRIMARY'
```
可以看到第一个insert语句插入数据成功后这个表的AUTO\_INCREMENT没有改变还是4294967295就导致了第二个insert语句又拿到相同的自增id值再试图执行插入语句报主键冲突错误。
232\-14294967295不是一个特别大的数对于一个频繁插入删除数据的表来说是可能会被用完的。因此在建表的时候你需要考察你的表是否有可能达到这个上限如果有可能就应该创建成8个字节的bigint unsigned。
# InnoDB系统自增row\_id
如果你创建的InnoDB表没有指定主键那么InnoDB会给你创建一个不可见的长度为6个字节的row\_id。InnoDB维护了一个全局的dict\_sys.row\_id值所有无主键的InnoDB表每插入一行数据都将当前的dict\_sys.row\_id值作为要插入数据的row\_id然后把dict\_sys.row\_id的值加1。
实际上在代码实现时row\_id是一个长度为8字节的无符号长整型(bigint unsigned)。但是InnoDB在设计时给row\_id留的只是6个字节的长度这样写到数据表中时只放了最后6个字节所以row\_id能写到数据表中的值就有两个特征
1. row\_id写入表中的值范围是从0到248\-1
2. 当dict\_sys.row\_id=248时如果再有插入数据的行为要来申请row\_id拿到以后再取最后6个字节的话就是0。
也就是说写入表的row\_id是从0开始到248\-1。达到上限后下一个值就是0然后继续循环。
当然248\-1这个值本身已经很大了但是如果一个MySQL实例跑得足够久的话还是可能达到这个上限的。在InnoDB逻辑里申请到row\_id=N后就将这行数据写入表中如果表中已经存在row\_id=N的行新写入的行就会覆盖原有的行。
要验证这个结论的话你可以通过gdb修改系统的自增row\_id来实现。注意用gdb改变量这个操作是为了便于我们复现问题只能在测试环境使用。
![](https://static001.geekbang.org/resource/image/6a/9a/6a7bfd460f9e75afcfcfc4a963339a9a.png)
图1 row\_id用完的验证序列
![](https://static001.geekbang.org/resource/image/5a/5c/5ad1fff81bda3a6b00ec84e84753fa5c.png)
图2 row\_id用完的效果验证
可以看到在我用gdb将dict\_sys.row\_id设置为248之后再插入的a=2的行会出现在表t的第一行因为这个值的row\_id=0。之后再插入的a=3的行由于row\_id=1就覆盖了之前a=1的行因为a=1这一行的row\_id也是1。
从这个角度看我们还是应该在InnoDB表中主动创建自增主键。因为表自增id到达上限后再插入数据时报主键冲突错误是更能被接受的。
毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性;报主键冲突,是插入失败,影响的是可用性。而一般情况下,可靠性优先于可用性。
# Xid
在第15篇文章[《答疑文章(一):日志和索引相关问题》](https://time.geekbang.org/column/article/73161)中我和你介绍redo log和binlog相配合的时候提到了它们有一个共同的字段叫作Xid。它在MySQL中是用来对应事务的。
那么Xid在MySQL内部是怎么生成的呢
MySQL内部维护了一个全局变量global\_query\_id每次执行语句的时候将它赋值给Query\_id然后给这个变量加1。如果当前语句是这个事务执行的第一条语句那么MySQL还会同时把Query\_id赋值给这个事务的Xid。
而global\_query\_id是一个纯内存变量重启之后就清零了。所以你就知道了在同一个数据库实例中不同事务的Xid也是有可能相同的。
但是MySQL重启之后会重新生成新的binlog文件这就保证了同一个binlog文件里Xid一定是惟一的。
虽然MySQL重启不会导致同一个binlog里面出现两个相同的Xid但是如果global\_query\_id达到上限后就会继续从0开始计数。从理论上讲还是就会出现同一个binlog里面出现相同Xid的场景。
因为global\_query\_id定义的长度是8个字节这个自增值的上限是264\-1。要出现这种情况必须是下面这样的过程
1. 执行一个事务假设Xid是A
2. 接下来执行264次查询语句让global\_query\_id回到A
3. 再启动一个事务这个事务的Xid也是A。
不过264这个值太大了大到你可以认为这个可能性只会存在于理论上。
# Innodb trx\_id
Xid和InnoDB的trx\_id是两个容易混淆的概念。
Xid是由server层维护的。InnoDB内部使用Xid就是为了能够在InnoDB事务和server之间做关联。但是InnoDB自己的trx\_id是另外维护的。
其实你应该非常熟悉这个trx\_id。它就是在我们在第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562)中讲事务可见性时用到的事务idtransaction id
InnoDB内部维护了一个max\_trx\_id全局变量每次需要申请一个新的trx\_id时就获得max\_trx\_id的当前值然后并将max\_trx\_id加1。
InnoDB数据可见性的核心思想是每一行数据都记录了更新它的trx\_id当一个事务读到一行数据的时候判断这个数据是否可见的方法就是通过事务的一致性视图与这行数据的trx\_id做对比。
对于正在执行的事务你可以从information\_schema.innodb\_trx表中看到事务的trx\_id。
我在上一篇文章的末尾留给你的思考题就是关于从innodb\_trx表里面查到的trx\_id的。现在我们一起来看一个事务现场
![](https://static001.geekbang.org/resource/image/94/7c/94c704190f7609b3e6443688368cd97c.png)
图3 事务的trx\_id
session B里我从innodb\_trx表里查出的这两个字段第二个字段trx\_mysql\_thread\_id就是线程id。显示线程id是为了说明这两次查询看到的事务对应的线程id都是5也就是session A所在的线程。
可以看到T2时刻显示的trx\_id是一个很大的数T4时刻显示的trx\_id是1289看上去是一个比较正常的数字。这是什么原因呢
实际上在T1时刻session A还没有涉及到更新是一个只读事务。而对于只读事务InnoDB并不会分配trx\_id。也就是说
1. 在T1时刻trx\_id的值其实就是0。而这个很大的数只是显示用的。一会儿我会再和你说说这个数据的生成逻辑。
2. 直到session A 在T3时刻执行insert语句的时候InnoDB才真正分配了trx\_id。所以T4时刻session B查到的这个trx\_id的值就是1289。
需要注意的是除了显而易见的修改类语句外如果在select 语句后面加上for update这个事务也不是只读事务。
在上一篇文章的评论区有同学提出实验的时候发现不止加1。这是因为
1. update 和 delete语句除了事务本身还涉及到标记删除旧数据也就是要把数据放到purge队列里等待后续物理删除这个操作也会把max\_trx\_id+1 因此在一个事务中至少加2
2. InnoDB的后台操作比如表的索引信息统计这类操作也是会启动内部事务的因此你可能看到trx\_id值并不是按照加1递增的。
那么,**T2时刻查到的这个很大的数字是怎么来的呢**
其实这个数字是每次查询的时候由系统临时计算出来的。它的算法是把当前事务的trx变量的指针地址转成整数再加上248。使用这个算法就可以保证以下两点
1. 因为同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在 innodb\_trx还是在innodb\_locks表里同一个只读事务查出来的trx\_id就会是一样的。
2. 如果有并行的多个只读事务每个事务的trx变量的指针地址肯定不同。这样不同的并发只读事务查出来的trx\_id就是不同的。
那么,**为什么还要再加上248呢**
在显示值里面加上248目的是要保证只读事务显示的trx\_id值比较大正常情况下就会区别于读写事务的id。但是trx\_id跟row\_id的逻辑类似定义长度也是8个字节。因此在理论上还是可能出现一个读写事务与一个只读事务显示的trx\_id相同的情况。不过这个概率很低并且也没有什么实质危害可以不管它。
另一个问题是,**只读事务不分配trx\_id有什么好处呢**
* 一个好处是这样做可以减小事务视图里面活跃事务数组的大小。因为当前正在运行的只读事务是不影响数据的可见性判断的。所以在创建事务的一致性视图时InnoDB就只需要拷贝读写事务的trx\_id。
* 另一个好处是可以减少trx\_id的申请次数。在InnoDB里即使你只是执行一个普通的select语句在执行过程中也是要对应一个只读事务的。所以只读事务优化后普通的查询语句不需要申请trx\_id就大大减少了并发事务申请trx\_id的锁冲突。
由于只读事务不分配trx\_id一个自然而然的结果就是trx\_id的增加速度变慢了。
但是max\_trx\_id会持久化存储重启也不会重置为0那么从理论上讲只要一个MySQL服务跑得足够久就可能出现max\_trx\_id达到248\-1的上限然后从0开始的情况。
当达到这个状态后MySQL就会持续出现一个脏读的bug我们来复现一下这个bug。
首先我们需要把当前的max\_trx\_id先修改成248\-1。注意这个case里使用的是可重复读隔离级别。具体的操作流程如下
![](https://static001.geekbang.org/resource/image/13/c0/13735f955a437a848895787bf9c723c0.png)
图 4 复现脏读
由于我们已经把系统的max\_trx\_id设置成了248\-1所以在session A启动的事务TA的低水位就是248\-1。
在T2时刻session B执行第一条update语句的事务id就是248\-1而第二条update语句的事务id就是0了这条update语句执行后生成的数据版本上的trx\_id就是0。
在T3时刻session A执行select语句的时候判断可见性发现c=3这个数据版本的trx\_id小于事务TA的低水位因此认为这个数据可见。
但,这个是脏读。
由于低水位值会持续增加而事务id从0开始计数就导致了系统在这个时刻之后所有的查询都会出现脏读的。
并且MySQL重启时max\_trx\_id也不会清0也就是说重启MySQL这个bug仍然存在。
那么,**这个bug也是只存在于理论上吗**
假设一个MySQL实例的TPS是每秒50万持续这个压力的话在17.8年后就会出现这个情况。如果TPS更高这个年限自然也就更短了。但是从MySQL的真正开始流行到现在恐怕都还没有实例跑到过这个上限。不过这个bug是只要MySQL实例服务时间够长就会必然出现的。
当然这个例子更现实的意义是可以加深我们对低水位和数据可见性的理解。你也可以借此机会再回顾下第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562)中的相关内容。
# thread\_id
接下来我们再看看线程idthread\_id。其实线程id才是MySQL中最常见的一种自增id。平时我们在查各种现场的时候show processlist里面的第一列就是thread\_id。
thread\_id的逻辑很好理解系统保存了一个全局变量thread\_id\_counter每新建一个连接就将thread\_id\_counter赋值给这个新连接的线程变量。
thread\_id\_counter定义的大小是4个字节因此达到232\-1后它就会重置为0然后继续增加。但是你不会在show processlist里看到两个相同的thread\_id。
是因为MySQL设计了一个唯一数组的逻辑给新线程分配thread\_id的时候逻辑代码是这样的
```
do {
new_id= thread_id_counter++;
} while (!thread_ids.insert_unique(new_id).second);
```
这个代码逻辑简单而且实现优雅,相信你一看就能明白。
# 小结
今天这篇文章我给你介绍了MySQL不同的自增id达到上限以后的行为。数据库系统作为一个可能需要7\*24小时全年无休的服务考虑这些边界是非常有必要的。
每种自增id有各自的应用场景在达到上限后的表现也不同
1. 表的自增id达到上限后再申请时它的值就不会改变进而导致继续插入数据时报主键冲突的错误。
2. row\_id达到上限后则会归0再重新递增如果出现相同的row\_id后写的数据会覆盖之前的数据。
3. Xid只需要不在同一个binlog文件中出现重复值即可。虽然理论上会出现重复值但是概率极小可以忽略不计。
4. InnoDB的max\_trx\_id 递增值每次MySQL重启都会被保存起来所以我们文章中提到的脏读的例子就是一个必现的bug好在留给我们的时间还很充裕。
5. thread\_id是我们使用中最常见的而且也是处理得最好的一个自增id逻辑了。
当然在MySQL里还有别的自增id比如table\_id、binlog文件序号等就留给你去验证和探索了。
不同的自增id有不同的上限值上限值的大小取决于声明的类型长度。而我们专栏声明的上限id就是45所以今天这篇文章也是我们的最后一篇技术文章了。
既然没有下一个id了课后也就没有思考题了。今天我们换一个轻松的话题请你来说说读完专栏以后有什么感想吧。
这个“感想”,既可以是你读完专栏前后对某一些知识点的理解发生的变化,也可以是你积累的学习专栏文章的好方法,当然也可以是吐槽或者对未来的期望。
欢迎你给我留言,我们在评论区见,也欢迎你把这篇文章分享给更多的朋友一起阅读。