MySQL
如果用过或了解过MySQL,那一定知道自增主键了。每个自增id都是定义了初始值,然后按照指定步长增长(默认步长是1)。虽然,自然数是没有上限的,但是在设计表结构的时候,通常都会指定字段长度,那么,这时候id就有上限了。既然有上限,就总有被用完的时候,如果id用完了,怎么办呢?
自增id
说到自增id,在设计表结构的时候自定义一个自增id字段,那么就有一个问题,在插入数据时有可能唯一主键冲、sql事务回滚、批量插入的时候,批量申请自增值等原因导致自增id是不连续的。
表定义的自增值达到上线后的逻辑是:再申请下一个id的时候,获取的是同一个值(最大值)。大家可以插入sql设置id是最大值,再insert一条不主动设置id的语句就可以验证这一结论。这个时候如果再插入就是报主键冲突了。
这里提醒一下:232-1(4294967295)不是一个特别大的数,对于一个频繁插入删除数据的表来说,是可能会被用完的。因此在建表的时候需要考察这个表是否有可能达到这个上限,如果有可能,就应该创建成 8 个字节的 bigint unsigned。
首先创建一个最简单的表,只包含一个自增id,并插入一条数据。
create table t0(id int unsigned auto_increment primary key);
insert into t0 values(null);
通过show命令 show create table t0;
查看表情况
CREATE TABLE `t0` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8
可以发现 AUTO_INCREMENT
已经自动变成2,这离用完还有很远,可以算下最大当前声明的自增ID最大是多少,由于这里定义的是 intunsigned
,所以最大可以达到2的32幂次方 - 1 = 4294967295
这里有个小技巧,可以在创建表的时候,直接声明AUTO_INCREMENT
的初始值
create table t1(
id int unsigned auto_increment primary key
) auto_increment = 4294967295;
insert into t1 values(null);
同样,通过show命令,查看t1的表结构
CREATE TABLE `t1` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4294967295 DEFAULT CHARSET=utf8
可以发现,AUTO_INCREMENT
已经变成4294967295
了,当想再尝试插入一条数据时,得到了下面的异常结果
17:28:03 insert into t1 values(null)
Error Code: 1062. Duplicate entry '4294967295' for key 'PRIMARY'
0.00054 sec
说明,当再次插入时,使用的自增ID还是 4294967295,报主键冲突的错误。
4294967295,这个数字已经可以应付大部分的场景了,如果业务上经常性的插入和删除数据的话,还是存在用完的风险,建议采用bigint unsigned ,这个数字就大了。
不过,还存在另一种情况,如果在创建表没有显式申明主键,会怎么办?
如果是这种情况,InnoDB会自动创建一个不可见的、长度为6字节的row_id
,而且InnoDB 维护了一个全局的 dictsys.row_id
,所以未定义主键的表都共享该row_id
,每次插入一条数据,都把全局row_id
当成主键id,然后全局row_id
加1。
该全局row_id
在代码实现上使用的是bigint unsigned
类型,但实际上只给row_id
留了6字节,这种设计就会存在一个问题:如果全局row_id一直涨,一直涨,直到2的48幂次-1时,这个时候再+1,row_id
的低48位都为0,结果在插入新一行数据时,拿到的row_id
就为0,存在主键冲突的可能性。
所以,为了避免这种隐患,每个表都需要定一个主键。
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 能写到数据表中的值,就有两个特征:
- row_id 写入表中的值范围,是从 0 到 248-1;
- 当
dict_sys.row_id=2^48
时,如果再有插入数据的行为要来申请 row_id,拿到以后再取最后 6 个字节的话就是 0。
虽然,2^48这个数字已经很大了,但是大家要知道 一个系统是可以跑很久的,那么还是可能达到上限的,这时候再申请就会覆盖原来的记录了。因此,尽量不要选择这种方式!
Xid
MySQL中redo log 和 binlog 相配合的时候,它们有一个共同的字段叫作 Xid。它在 MySQL 中是用来对应事务的。
MySQL 内部维护了一个全局变量 global_query_id
,每次执行语句的时候将它赋值给 Query_id,然后给这个变量加 1。如果当前语句是这个事务执行的第一条语句,那么 MySQL 还会同时把 Query_id 赋值给这个事务的 Xid。而 global_query_id
是一个纯内存变量,重启之后就清零了。所以在同一个数据库实例中,不同事务的 Xid 也是有可能相同的。
Innodb trx_id
InnoDB 内部维护了一个 max_trx_id 全局变量,每次需要申请一个新的 trx_id 时,就获得 max_trx_id 的当前值,然后并将 max_trx_id 加 1。
InnoDB 数据可见性的核心思想是:每一行数据都记录了更新它的 trx_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的 trx_id 做对比。但是这个过程有脏读存在,那么这个id就不会是原子性的,存在重复的可能性。
thread_id
其实,线程 id 才是 MySQL 中最常见的一种自增 id。平时在查各种现场的时候,show processlist
里面的第一列,就是 thread_id。
thread_id 的逻辑很好理解:系统保存了一个全局变量 thread_id_counter,每新建一个连接,就将 thread_id_counter 赋值给这个新连接的线程变量。
thread_id_counter 定义的大小是 4 个字节,因此达到 232-1 后,它就会重置为 0,然后继续增加。结果跟row_id一样,就会覆盖原有记录了。
上面介绍了几种MySQL自身的一些自增id,其实,实际运用中,也可能会选择外部的自增主键,然后持久化到数据库,以此来代替数据库自身的自增id。
Redis自增主键
其实外部自增主键的生成方式有很多,redis自身是原子性的,因此高并发也是线程安全的。假设主键字段长度20,以时间+自增数来构成主键,例如:8位日期+12自增数。那么,根据业务性质可以决定时间取年月日或者到毫秒级,那么在毫秒之间自增数的重复概率是极小极小的,基本的业务都能适用。
总结
上面介绍了好几种自增id,每种自增 id 有各自的应用场景,在达到上限后的表现也不同:
1、 表的自增 id 达到上限后,再申请时它的值就不会改变,进而导致继续插入数据时报主键冲突的错误
2、 row_id 达到上限后,则会归 0 再重新递增,如果出现相同的 row_id,后写的数据会覆盖之前的数据
3、 Xid 只需要不在同一个 binlog 文件中出现重复值即可。虽然理论上会出现重复值,但是概率极小,可以忽略不计
4、 InnoDB 的 max_trx_id 递增值每次 MySQL 重启都会被保存起来,所以提到的脏读的例子就是一个必现的 bug
5、 thread_id 是使用中最常见的,而且也是处理得最好的一个自增 id 逻辑了
6、 redis外部自增,毫秒级别,理论上会出现重复值,但是概率极小,可以忽略不计
7、 其实,每种自增id都有各自的适用场景,大家在平时使用中可以根据具体场景再选择。但是要未雨绸缪,因为系统的运行时间和数据的存储,这些都是要考虑在内的,综合考虑,选择一个在系统运行期间一定不会出现重复即刻。