不连续的自增主键
-
由于自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,因此索引更紧凑
-
自增值保存在哪儿
- 表的结构定义存放在后缀名为.frm 的文件中,但是并不会保存自增值。
- 不同的引擎对于自增值的保存策略不同
- MyISAM 引擎的自增值保存在数据文件中
- InnoDB 引擎的自增值,其实是保存在了内存里(MySQL 8.0 版本后才有自增值持久化的能力
- 在 MySQL 5.7 及之前的版本
- 自增值保存在内存里,并没有持久化,每次重启后主动将当前 max(id) + 1作为自增值
- MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值(作关联外键时需当心
- 在 MySQL 8.0 版本
- 自增值的变更记录在了 redo log 中,重启的时候依靠 redo log 恢复重启之前的值
- 在 MySQL 5.7 及之前的版本
-
自增值修改机制
-
如果字段 id 被定义为 AUTO_INCREMENT,在插入一行数据的时候,自增值的行为如下
- 如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段
- 如果插入数据时 id 字段指定了具体的值,就直接使用语句里指定的值(插入值 X,自增值是Y
- 如果 X<Y,那么这个表的自增值不变;
- 如果 X≥Y,就需要把当前自增值修改为新的自增值;
-
自增值生成算法
- 从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 X 的值,作为新的自增值
- auto_increment_offset:自增的初始值(默认值是1
- auto_increment_increment:步长(默认值是1
- 在一些场景下,使用的就不全是默认值,双 M 主备结构要求双写时,步长设置为2
- 让一个库的自增 id 都是奇数,另一个库都是偶数,避免两个库生成主键发生冲突
- 从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 X 的值,作为新的自增值
-
自增值的修改时机
-
模拟唯一键冲突时流程来演示自增值的修改时机
- 这个表的自增值改成 3,是在真正执行插入数据的操作之前
- 碰到唯一键冲突时,并未自增值再改回去
- 唯一键冲突是导致自增主键 id 不连续的第一种原因
-
事务回滚也会产生类似的现象,这就是第二种原因
-
自增值不回退主要原因是为了提高性能(若要实现回退,将极大降低系统并发能力
-
-
自增锁的优化
- 自增 id 锁并不是一个事务锁,而是每次申请完就马上释放,以便允许别的事务再申请
- 在 MySQL 5.0 版本的时候,自增锁的范围是语句级别(影响并发度
- 果一个语句申请了一个表自增锁,这个锁会等语句执行结束以后才释放
- MySQL 5.1.22 版本引入了一个新策略,新增参数 innodb_autoinc_lock_mode,默认值是 1
- 这个参数的值被设置为 0 时,表示采用之前 MySQL 5.0 版本的策略
- 这个参数的值被设置为 1 时
- 普通 insert 语句,自增锁在申请之后就马上释放
- 类似 insert … select 这样的批量插入数据的语句,自增锁还是要等语句结束后才释放
- 这个参数的值被设置为 2 时,所有的申请自增主键的动作都是申请后就释放锁
- 在生产上,尤其是有 insert … select 这种批量插入数据的场景时,从并发插入数据性能考虑
- 建议设置为:innodb_autoinc_lock_mode=2 ,并且 binlog_format=row
- 既能提升并发性,又不会出现数据一致性问题
- 批量插入数据包含的语句类型是 insert … select、replace … select 和 load data 语句
- 对于批量插入数据的语句,MySQL 有一个批量申请自增 id 的策略
- 语句执行过程中,第一次申请自增 id,会分配 1 个;
- 1 个用完以后,这个语句第二次申请自增 id,会分配 2 个;
- 2 个用完以后,还是这个语句,第三次申请自增 id,会分配 4 个;
- 依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍。
- 批量申请自增 id 的策略也正是出现自增 id 不连续的第三种原因
-
insert 锁情况
-
insert … select 语句
- 栗子(可重复读隔离级别下、binlog_format=statement
- session A:insert into t2(c,d) select c,d from t;
- session B:insert into t values(-1,-1,-1);
- 如果未加锁且两个事物并发执行,可能同步至备库时会出现主备不一致情况
- 对表 t 的所有行和间隙加锁是为了保证 日志和数据的一致性
- 栗子(可重复读隔离级别下、binlog_format=statement
-
insert 循环写入
-
栗子:insert into t(c,d) (select c+1, d from t force index© order by c desc limit 1);
-
explain 结果
-
Using temporary 表示这个语句用到了临时表(需要把表 t 的内容读出来,写入临时表
-
Explain 结果里的 rows=1 是因为受到了 limit 1 的影响(实际扫描了 5行
-
-
查看 Innodb_rows_read 变化
- 创建临时表,表里有两个字段 c 和 d
- 按照索引 c 扫描表 t,依次取 c=4、3、2、1,然后回表,读到 c 和 d 的值写入临时表(4条
- 由于语义里面有 limit 1,所以只取了临时表的第一行,再插入到表 t 中(1条
-
这个语句会导致在表 t 上做全表扫描,并且会给索引 c 上的所有间隙都加上共享的 next-key lock
-
这里需要使用临时表的原因
- 这类一边遍历数据,一边更新数据的情况,如果读出来的数据直接写回原表,就可能在遍历过程中,读到刚刚插入的记录,新插入的记录如果参与计算逻辑,就跟语义不符
-
由于这个语句涉及的数据量很小,可以考虑使用内存临时表来做这个优化
create temporary table temp_t(c int,d int) engine=memory; insert into temp_t (select c+1, d from t force index(c) order by c desc limit 1); insert into t select * from temp_t; drop table temp_t;
-
-
insert 唯一键冲突
-
唯一键冲突加锁(可重复读(repeatable read)隔离级别下
- 发生唯一键冲突的时候,并不只是简单地报错返回,还在冲突的索引上加了锁
- session A 持有索引 c 上的 (5,10]共享 next-key lock(读锁)
- 主键索引、唯一索引冲突时均是加的 next-key lock
-
唯一键冲突 – 死锁
-
死锁场景复现
- 在 session A 执行 rollback 语句回滚的时候,session C 几乎同时发现死锁并返回
-
状态变化图 – 死锁
- T2 时刻,B,C 同时执行相同的 insert 语句,发现了唯一键冲突,加上读锁
- T3 时刻,A 回滚,B,C 同时发起插入操作,申请加写锁(发生死锁
-
这里死锁的场景即常见的锁兼容死锁场景:双方都获得读锁且都想申请死锁,导致死锁
- 读锁与读锁 兼容,写锁 与 读写锁 互斥
- 死锁还有另外一种场景场景:AB - BA 场景
-
-
-
Insert into … on duplicate key update
- 栗子:insert into t values(11,10,10) on duplicate key update d=100;
- 这里语义的逻辑是,插入一行数据,如果碰到唯一键约束,就执行后面的更新语句
- 冲突后会更新对应值(这里是 d = 100),且会给索引 c 上 (5,10] 加 next-key lock(写锁)
- 如果有多个列违反了唯一性约束,就会按照索引的顺序,修改跟第一个索引冲突的行
- 需要注意的是,执行这类语句的 affected rows 返回的是 2,很容易造成误解
- 真正更新的只有一行,只是在代码实现上,insert 和 update 都认为自己成功
- update 计数加了 1, insert 计数也加了 1,即 affected rows 返回的是 2
- 栗子:insert into t values(11,10,10) on duplicate key update d=100;
复制表
- 常见三种复制表的方式
- mysqldump 方法
- 导出 CSV 文件
- 物理拷贝方法
- 以上三种方式的优缺点(后两种都是逻辑备份方式,支持跨引擎
- 物理拷贝的方式速度最快,尤其对于大表拷贝来说是最快的方法
- 如果出现误删表情况,用备份恢复出误删之前的临时库,然后再把临时库中的表拷贝到生产库上,是恢复数据最快的方法
- 但这种方法的使用也存在一定的局限性
- 必须是全表拷贝,不能只拷贝部分数据
- 需要到服务器上拷贝数据,在用户无法登录数据库主机的场景下无法使用
- 由于是通过拷贝物理文件实现的,源表和目标表都是使用 InnoDB 引擎时才能使用
- 用 mysqldump 生成包含 INSERT 语句文件的方法,可以在 where 参数增加过滤条件部分导出
- 不足之一是不能使用 join 这种比较复杂的 where 条件写法
- 用 select … into outfile 的方法是最灵活的,支持所有的 SQL 写法
- 缺点之一就是每次只能导出一张表的数据,而且表结构也需要另外的语句单独备份
- 物理拷贝的方式速度最快,尤其对于大表拷贝来说是最快的方法
用户授权
- grant 语句会同时修改数据表和内存,判断权限的时候使用的是内存数据
- 因此,规范地使用 grant 和 revoke 语句,是不需要随后加上 flush privileges 语句的
- flush privileges 语句本身会用数据表的数据重建一份内存权限数据,
- 所以在权限数据可能存在不一致的情况下再使用此命令
- 这种不一致往往是由于直接用 DML 语句操作系统权限表导致的(尽量不要使用这类语句
- 在使用 grant 语句赋权时:grant super on . to ‘ua’@’%’ identified by ‘pa’; 除了赋权外还包含了
- 如果用户’ua’@’%'不存在,就创建这个用户,密码是 pa;
- 如果用户 ua 已经存在,就将密码修改成 pa;
- 不建议的写法,因为这种写法很容易就会不慎把密码给改了
分区表
-
分区表是什么
-
创建分区表
CREATE TABLE `t` ( `ftime` datetime NOT NULL, `c` int(11) DEFAULT NULL, KEY (`ftime`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1 PARTITION BY RANGE (YEAR(ftime)) (PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB, PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB, PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB, PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB); insert into t values('2017-4-1',1),('2018-4-1',1);
- 初始化插入了两行记录,按照定义的分区规则,分别落在 p_2018 和 p_2019 这两个分区上
-
这个表包含了一个.frm 文件和 4 个.ibd 文件,每个分区对应一个.ibd 文件
- 对于引擎层来说,这是 4 个表;
- 对于 Server 层来说,这是 1 个表;
-
除了以范围分区(range)以外,MySQL 还支持 hash 分区、list 分区等分区方法
-
-
分区表的引擎层行为
-
举个在分区表加间隙锁的例子,目的是说明对于 InnoDB 来说,这是 4 个表
-
对于普通表来说的加锁范围
- 也就是说,‘2017-4-1’ 和’2018-4-1’ 这两个记录之间的间隙是会被锁住的
-
分区表 t 的加锁范围
- 由于分区表的规则,session A 的 select 语句只操作了分区 p_2018(深绿色为加锁范围
-
-
再来一个MyISAM 分区表的例子
- 预期:由于 MyISAM 引擎只支持表锁,所以这条 update 语句会锁住整个表 t 上的读
- 实际:MyISAM 的表锁是在引擎层实现的,session A 加的表锁,其实是锁在分区 p_2018 上
-
手动分表和分区表有什么区别
- 在性能上,这和分区表并没有实质的差别
- 手工分表的逻辑,也是找到需要更新的所有分表,然后依次执行更新
- 从引擎层看,这两种方式也是没有差别的
- 一个是由 server 层来决定使用哪个分区,一个是由应用层代码来决定使用哪个分表
- 其实这两个方案的区别,主要是在 server 层上
- 打开表的行为,即一个是第一次访问的时候需要访问所有分区
- 分区表共用 MDL 锁
- 在性能上,这和分区表并没有实质的差别
-
-
分区策略
- 每当第一次访问一个分区表的时候,MySQL 需要把所有的分区都访问一遍(MyISAM 引擎时
- 一个典型的报错情况是这样的:如果一个分区表的分区很多并且超过阈值而报错
- open_files_limit 参数使用的是默认值 1024,访问这个表时,需要打开所有的文件
- MyISAM 分区表使用的分区策略,我们称为通用分区策略(generic partitioning)
- 每次访问分区都由 server 层控制
- 通用分区策略因历史问题,在文件管理、表管理的实现上很粗糙,因此有比较严重的性能问题
- MySQL 从 5.7.17 开始,将 MyISAM 分区表标记为即将弃用 (deprecated)
- 从 MySQL 8.0 版本开始,就不允许创建 MyISAM 分区表
- 从 MySQL 5.7.9 开始,InnoDB 引擎引入了本地分区策略(native partitioning)
- 策略是在 InnoDB 内部自己管理打开分区的行为
- 目前来看,只有 InnoDB 和 NDB 这两个引擎支持了本地分区策略
- 每当第一次访问一个分区表的时候,MySQL 需要把所有的分区都访问一遍(MyISAM 引擎时
-
分区表的 server 层行为
- MySQL 在第一次打开分区表的时候,需要访问所有的分区
- 在 server 层,认为这是同一张表,因此所有分区共用同一个 MDL 锁
- 在引擎层,认为这是不同的表,因此 MDL 锁之后的执行过程,会根据分区表规则,只访问必要的分区
-
分区表的应用场景
- 分区表一个显而易见的优势是对业务透明,相对于用户分表来说,使用分区表的业务代码更简洁
- 分区表可以很方便的清理历史数据
- 可以直接通过 alter table t drop partition …这个语法删掉分区,从而删掉过期历史数据
- 与使用 delete 语句删除数据相比,优势是速度快、对系统影响小
自增id 用完
- 每种自增 id 有各自的应用场景,在达到上限后的表现也不同
-
表的自增 id 达到上限后,再申请时值就不会改变,进而导致继续插入数据时报主键冲突的错误
- 无符号整型 (unsigned int) 是 4 个字节,上限就是 2^32-1 = 4294967295
- 如果有可能达到这个上限,就应该创建成 8 个字节的 bigint unsigned
-
row_id 达到上限后,则归 0 再重新递增,如果出现相同的 row_id,后写的数据会覆盖之前的数据
- 如果没有指定主键,那么 InnoDB 会给你创建一个不可见的,长度为 6 个字节的 row_id
- InnoDB 维护了一个全局的 dict_sys.row_id 值,所有无主键的 InnoDB 表插入时均会使用
- 在代码实现时 row_id 是一个长度为 8 字节的无符号长整型 (bigint unsigned)
- 但 InnoDB 在设计时,给 row_id 留的只是 6 个字节的长度,只存放了最后 6 个字节
- row_id 写入表中的值范围,是从 0 到 248-1
- 当 dict_sys.row_id=248时,如果再有插入数据的行为要来申请 row_id,拿到以后再取最后 6 个字节的话就是 0(即达到上限后,继续循环
- 但 InnoDB 在设计时,给 row_id 留的只是 6 个字节的长度,只存放了最后 6 个字节
- 对比表自增 id 策略,表自增 id 到达上限后,再插入数据时报主键冲突错误,是更能被接受的
- 毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性
- 报主键冲突,是插入失败,影响的是可用性
- 而一般情况下,可靠性优先于可用性
-
Xid 只需要不在同一个 binlog 文件中出现重复值即可。虽然理论上会出现重复值,但是概率极小,可以忽略不计
- MySQL 内部维护了一个全局变量 global_query_id,每次执行语句的时候将它赋值给 Query_id,然后给这个变量加 1
- 若为事务执行的第一条语句,那么 MySQL 还会同时把 Query_id 赋值给这个事务的 Xid
- global_query_id 是一个纯内存变量,重启之后就清零
- 在同一个数据库实例中,不同事务的 Xid 也是有可能相同的
- MySQL 重启之后会重新生成 binlog 文件,保证了同一个 binlog 中,Xid 一定是惟一的
- global_query_id 定义的长度是 8 个字节,这个自增值的上限是 2^64-1
- MySQL 内部维护了一个全局变量 global_query_id,每次执行语句的时候将它赋值给 Query_id,然后给这个变量加 1
-
InnoDB 的 max_trx_id 递增值每次 MySQL 重启都会被保存起来,所以我们文章中提到的脏读的例子就是一个必现的 bug,好在留给我们的时间还很充裕
-
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_id(加锁读 除外
- T2 时刻查到的这个很大的数字是怎么来的呢
- 这个数字是每次查询的时候由系统临时计算出来的
- 它的算法是:把当前事务的 trx 变量的指针地址转成整数,再加上 2^48
- 使用这个算法,就可以保证以下两点
- 因为同一个只读事务在执行期间,它的指针地址是不会变的,查询出的 trx_id 一样
- 如果有并行的多个只读事务,每个事务的 trx 变量的指针地址肯定不同
- 为什么还要再加上 248呢?
- 目的是要保证只读事务显示的 trx_id 值比较大,正常情况下就会区别于读写事务的 id
- 只在理论上可能出现一个读写事务与一个只读事务显示的 trx_id 相同的情况
- 只读事务不分配 trx_id,有什么好处呢?
- 一个好处是,这样做可以减小事务视图里面活跃事务数组的大小
- 在创建事务的一致性视图时,InnoDB 就只需要拷贝读写事务的 trx_id
- 另一个好处是,可以减少 trx_id 的申请次数(减少了并发事务申请 trx_id 的锁冲突
- 一个好处是,这样做可以减小事务视图里面活跃事务数组的大小
-
-
thread_id 是我们使用中最常见的,而且也是处理得最好的一个自增 id 逻辑了
-
系统保存了一个全局变量 thread_id_counter,每新建一个连接,就将 thread_id_counter 赋值给这个新连接的线程变量
-
thread_id_counter 定义的大小是 4 个字节,因此达到 232-1 后,它就会重置为 0,然后继续增加
-
不会在 show processlist 里看到两个相同的 thread_id 原因(唯一数组的逻辑
do { new_id= thread_id_counter++; } while (!thread_ids.insert_unique(new_id).second);
-
-