自增id用完怎么办


MySQL 里有很多自增的 id,每个自增 id 都是定义了初始值,然后不停地往上加步长。虽然自然数是没有上限的,但是在计算机里,只要定义了表示这个数的字节长度,那它就有上限。比如,无符号整型 (unsigned int) 是 4 个字节,上限就是 $2^{32}$-1。

既然自增 id 有上限,就有可能被用完。但是,自增 id 用完了会怎么样呢?主要是下面这几种情况:

表定义自增值 id

说到自增 id,你第一个想到的应该就是表结构定义里的自增字段,也就是自增主键 id。

表定义的自增值达到上限后的逻辑是:再申请下一个 id 时,得到的值保持不变。

如果我们将主键的类型定义为 int unsigned,那么 id 的最大值就是$2^{32}$-1,当 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;
  2. 当 dict_sys.row_id=$2^{48}$时,如果再有插入数据的行为要来申请 row_id,拿到以后再取最后 6 个字节的话就是 0。

也就是说,写入表的 row_id 是从 0 开始到 $2^{48}$-1。达到上限后,下一个值就是 0,然后继续循环。

当然,$2^{48}$-1 这个值本身已经很大了,但是如果一个 MySQL 实例跑得足够久的话,还是可能达到这个上限的。在 InnoDB 逻辑里,申请到 row_id=N 后,就将这行数据写入表中;如果表中已经存在 row_id=N 的行,新写入的行就会覆盖原有的行。

从这个角度看,我们还是应该在 InnoDB 表中主动创建自增主键。因为,表自增 id 到达上限后,再插入数据时报主键冲突错误,是更能被接受的。

毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性;报主键冲突,是插入失败,影响的是可用性。而一般情况下,可靠性优先于可用性。

Xid

在 binlog 和 redo log 中相配合的时候,提到了它们有一个共同的字段叫作 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,是另外维护的。

InnoDB 内部维护了一个 max_trx_id 全局变量,每次需要申请一个新的 trx_id 时,就获得 max_trx_id 的当前值,然后并将 max_trx_id 加 1。

InnoDB 数据可见性的核心思想是:每一行数据都记录了更新它的 trx_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的 trx_id 做对比。

对于正在执行的事务,你可以从 information_schema.innodb_trx 表中看到事务的 trx_id。

下面我们举个例子:

image-20220430103011550

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,这个事务也不是只读事务。

trx_id 并不是我们执行事务时才会 + 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。

thread_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);

巨人的肩膀:极客时间MySQL45讲


文章作者: Sky
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Sky !
  目录