自增主键优化

之前我见过有的业务设计依赖于自增主键的连续性,也就是说,这个设计假设自增主键是连续的。但实际上,这样的假设是错的,因为自增主键不能保证连续递增。
今天这篇文章,我们就来说说这个问题,看看什么情况下自增主键会出现 “空洞”?
为了便于说明,我们创建一个表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命令,就可以看到如下图所示的结果:
自动生成的AUTO_INCREMENT值
自动生成的AUTO_INCREMENT
可以看到,表定义里面出现了一个AUTO_INCREMENT=2,表示下一次插入数据时,如果需要自动生成自增值,会生成id=2
其实,这个输出结果容易引起这样的误解:自增值是保存在表结构定义里的。实际上,表的结构定义存放在后缀名为.frm的文件中,但是并不会保存自增值。
 
不同的引擎对于自增值的保存策略不同。
  • MyISAM引擎的自增值保存在数据文件中。
  • InnoDB引擎的自增值,其实是保存在了内存里,并且到了MySQL 8.0版本后,才有了“自增值持久化”的能力,也就是才实现了“如果发生重启,表的自增值可以恢复为MySQL重启前的值”,具体情况是:
    • 在MySQL 5.7及之前的版本,自增值保存在内存里,并没有持久化。
      • 每次重启后,第一次打开表的时候,都会去找自增值的最大值max(id),然后将max(id)+1作为这个表当前的自增值。
        举例来说,如果一个表当前数据行里最大的id是10,AUTO_INCREMENT=11。这时候,我们删除id=10的行,AUTO_INCREMET还是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值填到自增字段;
  1. 如果插入数据时id字段指定了具体的值,就直接使用语句里指定的值。
根据要插入的值和当前自增值的大小关系,自增值的变更结果也会有所不同。假设,某次要插入的值是X,当前的自增值是Y。
  1. 如果X<Y,那么这个表的自增值不变;
  1. 如果X≥Y,就需要把当前自增值修改为新的自增值。
新的自增值生成算法是:从auto_increment_offset开始,以auto_increment_increment为步长,持续叠加,直到找到第一个大于X的值,作为新的自增值。
其中,auto_increment_offsetauto_increment_increment是两个系统参数,分别用来表示自增的初始值和步长,默认值都是1。
备注:在一些场景下,使用的就不全是默认值。比如,双M的主备结构里要求双写的时候,我们就可能会设置成auto_increment_increment=2,让一个库的自增id都是奇数,另一个库的自增id都是偶数,避免两个库生成的主键发生冲突。
auto_increment_offsetauto_increment_increment都是1的时候,新的自增值生成逻辑很简单,就是:
  1. 如果准备插入的值>=当前自增值,新的自增值就是“准备插入的值+1”;
  1. 否则,自增值不变。
这就引入了我们文章开头提到的问题,在这两个参数都设置为1的时候,自增主键id却不能保证是连续的,这是什么原因呢?

自增值的修改时机

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

唯一索引导致自增id不连续

假设,表t里面已经有了(1,1,1)这条记录,这时我再执行一条插入数据命令:
insert into t values(null, 1, 1);
这个语句的执行流程就是:
  1. 执行器调用InnoDB引擎接口写入一行,传入的这一行的值是(0,1,1);
  1. InnoDB发现用户没有指定自增id的值,获取表t当前的自增值2;
  1. 将传入的行的值改成(2,1,1);
  1. 将表的自增值改成3;
  1. 继续执行插入数据操作,由于已经存在c=1的记录,所以报Duplicate key error,语句返回。
对应的执行流程图如下:
insert(null, 1,1)唯一键冲突
insert(null, 1,1)唯一键冲突
可以看到,这个表的自增值改成3,是在真正执行插入数据的操作之前。这个语句真正执行的时候,因为碰到唯一键c冲突,所以id=2这一行并没有插入成功,但也没有将自增值再改回去
所以,在这之后,再插入新的数据行时,拿到的自增id就是3。也就是说,出现了自增主键不连续的情况。
如下图所示就是完整的演示结果。
一个自增主键id不连续的复现步骤
一个自增主键id不连续的复现步骤
可以看到,这个操作序列复现了一个自增主键id不连续的现场(没有id=2的行)。可见,唯一键冲突是导致自增主键id不连续的第一种原因

事务回滚导致的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,之后继续执行。
  1. 事务B正确提交了,但事务A出现了唯一键冲突。
  1. 如果允许事务A把自增id回退,也就是把表t的当前自增值改回2,那么就会出现这样的情况:表里面已经有id=3的行,而当前的自增id值是2。
  1. 接下来,继续执行的其他事务就会申请到id=2,然后再申请到id=3。这时,就会出现插入语句报错“主键冲突”。
而为了解决这个主键冲突,有两种方法:
  1. 每次申请id之前,先判断表里面是否已经存在这个id。如果存在,就跳过这个id。但是,这个方法的成本很高。因为,本来申请id是一个很快的操作,现在还要再去主键索引树上判断id是否存在。
  1. 把自增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版本的策略,即语句执行结束后才释放锁;
  1. 这个参数的值被设置为1时:
      • 普通insert语句,自增锁在申请之后就马上释放;
      • 类似insert … select这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;
  1. 这个参数的值被设置为2时,所有的申请自增主键的动作都是申请后就释放锁。
你一定有两个疑问:为什么默认设置下,insert … select 要使用语句级的锁?为什么这个参数的默认值不是2?
答案是,这么设计还是为了数据的一致性。

批量更新导致id不连续

我们一起来看一下这个场景:
批量插入数据的自增锁
批量插入数据的自增锁
在这个例子里,我往表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值。所以,自增锁直到语句执行结束才释放,就是为了达到这个目的。
  1. 另一种思路是,在binlog里面把插入数据的操作都如实记录进来,到备库执行的时候,不再依赖于自增主键去生成。这种情况,其实就是innodb_autoinc_lock_mode设置为2,同时binlog_format设置为row。
因此,在生产上,尤其是有insert … select这种批量插入数据的场景时,从并发插入数据性能的角度考虑,我建议你这样设置:innodb_autoinc_lock_mode=2 ,并且 binlog_format=row.这样做,既能提升并发性,又不会出现数据一致性问题。
 
需要注意的是,我这里说的批量插入数据,包含的语句类型是insert … selectreplace … selectload data语句。
 
但是,在普通的insert语句里面包含多个value值的情况下,即使innodb_autoinc_lock_mode设置为1,也不会等语句执行完成才释放锁。因为这类语句在申请自增id的时候,是可以精确计算出需要多少个id的,然后一次性申请,申请完成后锁就可以释放了。
也就是说,批量插入数据的语句,之所以需要这么设置,是因为“不知道要预先申请多少个id”。
 
既然预先不知道要申请多少个自增id,那么一种直接的想法就是需要一个时申请一个。
但如果一个select … insert语句要插入10万行数据,按照这个逻辑的话就要申请10万次。显然,这种申请自增id的策略,在大批量插入数据的情况下,不但速度慢,还会影响并发插入的性能。
因此,对于批量插入数据的语句,MySQL有一个批量申请自增id的策略
  1. 语句执行过程中,第一次申请自增id,会分配1个;
  1. 1个用完以后,这个语句第二次申请自增id,会分配2个;
  1. 2个用完以后,还是这个语句,第三次申请自增id,会分配4个;
  1. 依此类推,同一个语句去申请自增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=2id=3, 第三次被分配到id=4id=7
    由于这条语句实际只用上了4个id,所以id=5id=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,是很有必要的。今天的例子给这个结论多了一个理由。
     
     
    MySQL对自增主键锁做了优化,尽量在申请到自增id以后,就释放自增锁。因此,insert语句是一个很轻量的操作。不过,这个结论对于“普通的insert语句”才有效。也就是说,还有些insert语句是属于“特殊情况”的,在执行过程中需要给其他资源加锁,或者无法在申请到自增id以后就立马释放自增锁。
    那么,今天这篇文章,我们就一起来聊聊这个话题。

    自增id用完怎么办?

    MySQL里有很多自增的id,每个自增id都是定义了初始值,然后不停地往上加步长。虽然自然数是没有上限的,但是在计算机里,只要定义了表示这个数的字节长度,那它就有上限。比如,无符号整型(unsigned int)是4个字节,上限就是2的32次-1。
    既然自增id有上限,就有可能被用完。但是,自增id用完了会怎么样呢?
    今天这篇文章,我们就来看看MySQL里面的几种自增id,一起分析一下它们的值达到上限以后,会出现什么情况。

    表定义自增值id

    说到自增id,你第一个想到的应该就是表结构定义里的自增字段,自增主键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值,再试图执行插入语句,报主键冲突错误。
    2的32次-1(4294967295)不是一个特别大的数,对于一个频繁插入删除数据的表来说,是可能会被用完的。因此在建表的时候你需要考察你的表是否有可能达到这个上限,如果有可能,就应该创建成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到2的48次-1;
    1. dict_sys.row_id=2的48次时,如果再有插入数据的行为要来申请row_id,拿到以后再取最后6个字节的话就是0。
    也就是说,写入表的row_id是从0开始到248-1。达到上限后,下一个值就是0,然后继续循环。
    当然,2的48次-1这个值本身已经很大了,但是如果一个MySQL实例跑得足够久的话,还是可能达到这个上限的。在InnoDB逻辑里,申请到row_id=N后,就将这行数据写入表中;如果表中已经存在row_id=N的行,新写入的行就会覆盖原有的行。
    要验证这个结论的话,你可以通过gdb修改系统的自增row_id来实现。注意,用gdb改变量这个操作是为了便于我们复现问题,只能在测试环境使用。
    row_id用完的验证序列
    row_id用完的验证序列
    row_id用完的效果验证
    row_id用完的效果验证
    可以看到,在我用gdb将dict_sys.row_id设置为2的48次-1之后,再插入的a=2的行会出现在表t的第一行,因为这个值的row_id=0。之后再插入的a=3的行,由于row_id=1,就覆盖了之前a=1的行,因为a=1这一行的row_id也是1。
    从这个角度看,我们还是应该在InnoDB表中主动创建自增主键。因为,表自增id到达上限后,再插入数据时报主键冲突错误,是更能被接受的。
    毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性;报主键冲突,是插入失败,影响的是可用性。而一般情况下,可靠性优先于可用性。
     

    Xid

    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个字节,这个自增值的上限是2的64次-1。要出现这种情况,必须是下面这样的过程:
    1. 执行一个事务,假设Xid是A;
    1. 接下来执行2的64次查询语句,让global_query_id回到A;
    1. 再启动一个事务,这个事务的Xid也是A。
    不过,2的64次这个值太大了,大到你可以认为这个可能性只会存在于理论上。

    Innodb trx_id

    Xid和InnoDB的trx_id是两个容易混淆的概念。
    Xid是由server层维护的。InnoDB内部使用Xid,就是为了能够在InnoDB事务和server之间做关联。但是,InnoDB自己的trx_id,是另外维护的
    其实,你应该非常熟悉这个trx_id。它就是在讲事务可见性时,用到的事务id(transaction 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
    现在,我们一起来看一个事务现场:
    事务的trx_id
    事务的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。而这个很大的数,只是显示用的。一会儿我会再和你说说这个数据的生成逻辑。
    1. 直到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;
    1. InnoDB的后台操作,比如表的索引信息统计这类操作,也是会启动内部事务的,因此你可能看到,trx_id值并不是按照加1递增的。
    那么,T2时刻查到的这个很大的数字是怎么来的呢?
    其实,这个数字是每次查询的时候由系统临时计算出来的。它的算法是:把当前事务的trx变量的指针地址转成整数,再加上2的48次。使用这个算法,就可以保证以下两点:
    1. 因为同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在 innodb_trx还是在innodb_locks表里,同一个只读事务查出来的trx_id就会是一样的。
    1. 如果有并行的多个只读事务,每个事务的trx变量的指针地址肯定不同。这样,不同的并发只读事务,查出来的trx_id就是不同的。
    那么,为什么还要再加上2的48次呢?
    在显示值里面加上2的48次,目的是要保证只读事务显示的trx_id值比较大,正常情况下就会区别于读写事务的id。但是,trx_idrow_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里使用的是可重复读隔离级别。具体的操作流程如下:
    复现脏读
    复现脏读
    由于我们已经把系统的max_trx_id设置成了2的48次-1,所以在session A启动的事务TA的低水位就是2的48次-1。
    在T2时刻,session B执行第一条update语句的事务id就是2的48次-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篇文章《事务到底是隔离的还是不隔离的?》中的相关内容。

    thread_id

    接下来,我们再看看线程id(thread_id)。其实,线程id才是MySQL中最常见的一种自增id。平时我们在查各种现场的时候,show processlist里面的第一列,就是thread_id
    thread_id的逻辑很好理解:系统保存了一个全局变量thread_id_counter,每新建一个连接,就将thread_id_counter赋值给这个新连接的线程变量。
    thread_id_counter定义的大小是4个字节,因此达到2的32次-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达到上限后,再申请时它的值就不会改变,进而导致继续插入数据时报主键冲突的错误。
    1. 00达到上限后,则会归0再重新递增,如果出现相同的row_id,后写的数据会覆盖之前的数据。
    1. Xid只需要不在同一个binlog文件中出现重复值即可。虽然理论上会出现重复值,但是概率极小,可以忽略不计。
    1. InnoDB的max_trx_id 递增值每次MySQL重启都会被保存起来,所以我们文章中提到的脏读的例子就是一个必现的bug,好在留给我们的时间还很充裕。
    1. thread_id是我们使用中最常见的,而且也是处理得最好的一个自增id逻辑了。
    当然,在MySQL里还有别的自增id,比如table_id、binlog文件序号等,就留给你去验证和探索了。
    不同的自增id有不同的上限值,上限值的大小取决于声明的类型长度