6 SQL语句执行过程中的一些question

1 SQL语句突然变慢

场景:⼀条SQL语句,正常执行的时候特别快,但是有时它会变得特别慢,并且这样的场景很难复现,它不只随机,而且持续时间还很短。(即MySQL偶尔“抖”一下)

当内存数据页跟磁盘数据页内容不⼀致的时候,t称这个内存页为“脏页”。内存数据写⼊到磁盘后,内存和磁盘上的数据页的内容就⼀致了,称为“干净页”;
两种页都在内存中。

平时执行很快的更新操作,其实就是在写内存和日志;
而MySQL偶尔“抖”⼀下的那个瞬间,可能就是在刷脏页(flush)。

引发数据库的flush过程的情况:

1 InnoDB的redo log写满了。这时候系统会停止所有更新操作,把checkpoint往前推进,redo log留出空间可以继续写。(影响非常大,不能更新了)
2 系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰⼀些数据页(不是全部淘汰,这样其他的页可以继续使用,不用全部从磁盘重新读取,否则太耗时),空出内存给别的数据页使用;
如果淘汰的是“脏页”,就要先将脏页写到磁盘。(经常出现的情况,有影响)
3 MySQL认为系统“空闲”的时候。当然,即使是忙碌的时候,也要见缝插针地找时间,只要有机会就刷⼀点“脏页”。(对性能影响不大)
4 MySQL正常关闭。这时候,MySQL会把内存的脏页都flush到磁盘上,这样下次MySQL启动的时候,就可以直接从磁盘上读数据,启动速度会很快。(对性能没有影响,本来就要关了)

第二种情况中,。InnoDB用缓冲池(buffer pool)管理内存,缓冲池中的内存页有三种状态:
第⼀种是,还没有使用的;
第⼆种是,使用了并且是干净页;
第三种是,使用了并且是脏页。

InnoDB的刷盘速度要参考两个因素:⼀个是脏页比例,⼀个是redo log写盘速度。

总结:要合理地设置innodb_io_capacity的值,并且平时要多关注脏页比例,不要让它经常接近75%。

2 count(*)慢得一批?

在不同的MySQL引擎中,没有过滤条件的count(*)有不同的实现方式:
MyISAM引擎把⼀个表的总行数存在了磁盘上,因此执⾏count(*)的时候会直接返回这个数,效率很高;
InnoDB引擎需要把数据一行一行地从引擎里面读出来,然后累积计数。

为什么InnoDB不跟MyISAM⼀样,也把数字存起来?

因为即使是在同⼀个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB表“应该返回多少行”也是不确定的:
可重复读是InnoDB默认的隔离级别,在代码上就是通过多版本并发控制,也就是MVCC来实现的;
每⼀行记录都要判断自己是否对这个会话可见,因此对于count(*)请求来说,InnoDB只好把数据⼀行一行地读出依次判断,可见的行才能够用于计算“基于这个查询”的表的总行数。

如果有⼀个页面经常要显示交易系统的操作记录总数,最好就是在应用层计数,因为数据库的计数各有各的问题:

MyISAM表虽然count(*)很快,但是不⽀持事务;
show table status命令虽然返回很快,但是不准确;
InnoDB表直接count(*)会遍历全表,虽然结果准确,但会导致性能问题。

计数方法:

1 用缓存系统保存计数。不能够保证计数和MySQL表里的数据精确⼀致,因为这两个不同的存储构成的系统,不支持分布式事务,⽆法拿到精确⼀致的视图;
2 在数据库保存计数,使用事务可以解决这个问题。

count(*)、count(主键id)、count(字段)和count(1)等不同用法的性能,有哪些差别:

count()的语义:count()是⼀个聚合函数,对于返回的结果集,⼀行行地判断,如果count函数的参数不
是NULL,累计值就加1,否则不加。最后返回累计值。

所以,count(*)、count(主键id)和count(1) 都表示返回满足条件的结果集的总行数;
count(字段)则表示返回满足条件的数据行里面,参数“字段”不为NULL的总个数。

分析性能差别的时候有如下三个原则

  1. server层要什么就给什么;
  2. InnoDB只给必要的值;
  3. 现在的优化器只优化了count(*)的语义为“取行数”,其他“显而易见”的优化并没有做;
    如count(id),主键id肯定非空,可以直接按照count(*)来处理,但是因为结论其实就是直接使用count(*),所以没必要做无谓的优化

对于**count(主键id)**来说,InnoDB引擎会遍历整张表,把每⼀行的id值都取出来,返回给server层;
server层拿到id后,判断是不可能为空的,就按行累加。

对于**count(1)**来说,InnoDB引擎遍历整张表,但不取值;
server层对于返回的每⼀行,放⼀个数字“1”进去,判断是不可能为空的,按行累加。

可以看出,count(1)执行得要比count(主键id)快。因为从引擎返回id会涉及到解析数据行,以及拷贝字段值的操作。

对于**count(字段)**来说:

  1. 如果这个“字段”是定义为not null的话,⼀行行地从记录里面读出这个字段,判断不能为null,按行累加;
  2. 如果这个“字段”定义允许为null,那么执行的时候,判断到有可能是null时,还要把值取出来再判断⼀下,不是null才累加。也就是前面的第⼀条原则,server层要什么字段,InnoDB就返回什么字段。

但是**count()**是原则的例外,并不会把全部字段取出来,而是专门做了优化:不取值;
**count(
)**肯定不是null,按行累加。

结论:按照效率排序的话,count(字段)<count(主键id)<count(1)≈count(*)

尽量使⽤count(*)。

3 orderby工作原理

假如索引如下:
在这里插入图片描述
sql如下:

CREATE TABLE `t` (
`id` int(11) NOT NULL,
`city` varchar(16) NOT NULL,
`name` varchar(16) NOT NULL,
`age` int(11) NOT NULL,
`addr` varchar(128) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `city` (`city`)
) ENGINE=InnoDB;
select city,name,age from t where city='杭州' order by name limit 1000 ;# 需求

3.1 全字段排序

explain中,如果出现了“Using filesort”,说明需要排序,MySQL会给每个线程分配⼀块内存用于排序,称为sort_buffer

其流程如下:

  1. 初始化sort_buffer,确定放⼊name、city、age这三个字段;

  2. 从索引city找到第⼀个满⾜city='杭州’条件的主键id,也就是图中的ID_X;

  3. 到主键id索引取出整行,取name、city、age三个字段的值,存⼊sort_buffer中;

  4. 从索引city取下⼀个记录的主键id;

  5. 重复步骤3、4直到city的值不满足查询条件为止,对应的主键id也就是图中的ID_Y;

  6. 对sort_buffer中的数据按照字段name做快速排序;

  7. 按照排序结果取前1000行返回给客户端。
    在这里插入图片描述
    如果要排序的数据量小于sort_buffer_size,排序就在内存中完成;(快排)
    如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序,可以通过OPTIMIZER_TRACE来查看是否使用了临时文件。(归并:MySQL将需要排序的数据分成多份,每⼀份单独排序后存在临时文件中。然后把这些有序文件再合并成⼀个有序的大文件,即一份数据对应一个文件)

3.2 rowid排序

3.1的算法只对原表的数据读了⼀遍,剩下的操作都是在sort_buffer和临时文件中执行的;
但这个算法有⼀个问题,就是如果查询要返回的字段很多的话,那么sort_buffer里面要放的字段数太多,这样内存里能够同时放下的行数很少,要分成很多个临时文件,排序的性能会很差。

所以如果单行很大,这个方法效率不够好,因此可以使用SET max_length_for_sort_data = 16;来采用rowid排序:新的算法放⼊sort_buffer的字段,只有要排序的列(即name字段)和主键id。

  1. 初始化sort_buffer,确定放⼊两个字段,即name和id;
  2. 从索引city找到第⼀个满足city='杭州’条件的主键id,也就是索引图中的ID_X;
  3. 到主键id索引取出整行,取name、id这两个字段,存⼊sort_buffer中;
  4. 从索引city取下⼀个记录的主键id;
  5. 重复步骤3、4直到不满足city='杭州’条件为止,也就是索引图中的ID_Y;
  6. 对sort_buffer中的数据按照字段name进行排序;
  7. 遍历排序结果,取前1000行,并按照id的值回到原表中取出city、name和age三个字段返回给客户端。

“结果集”是⼀个逻辑概念,实际上MySQL服务端从排序后的sort_buffer中依次取出id,然后到原表查到city、name和age这三个字段的结果,不需要在服务端再耗费内存存储结果,是直接返回给客户端的。
在这里插入图片描述

3.3 全字段排序和rowid排序的选择

MySQL的⼀个设计思想:如果内存够,就要多利用内存,尽量减少磁盘访问。

因此如果MySQL认为内存足够大,会优先选择全字段排序,把需要的字段都放到sort_buffer中,这样排序后就会直接从内存里面返回查询结果了,不用再回到原表去取数据。

3.4 避免排序

如果能够保证从city这个索引上取出来的行 ,天然就是按照name递增排序的话,是就可以不用再排序了

所以可以在这个表上创建⼀个city和name的联合索引:

alter table t add index city_user(city, name);

在这里插入图片描述
在这个索引里面依然可以用树搜索的方式定位到第⼀个满足city='杭州’的记录,并且额外确保了,接下来按顺序取“下⼀条记录”的遍历过程中,只要city的值是杭州,name的值就⼀定是有序的:

  1. 从索引(city,name)找到第⼀个满足city='杭州’条件的主键id;
  2. 到主键id索引取出整行,取name、city、age三个字段的值,作为结果集的⼀部分直接返回;
  3. 从索引(city,name)取下⼀个记录主键id;
  4. 重复步骤2、3,直到查到第1000条记录,或者是不满足city='杭州’条件时循环结束。
    在这里插入图片描述

3.5 3.4的优化

使用覆盖索引

创建⼀个city、name和age的联合索引:

alter table t add index city_user_age(city, name, age);

这时,对于city字段的值相同的行来说,还是按照name字段的值递增排序的,此时的查询语句同样不再需要排序了,查询语句的执行流程就变成了:

  1. 从索引(city,name,age)找到第⼀个满⾜city='杭州’条件的记录,取出其中的city、name和age这三个字段的值,作为结果集的⼀部分直接返回
  2. 从索引(city,name,age)取下⼀个记录,同样取出这三个字段的值,作为结果集的⼀部分直接返回;
  3. 重复执行步骤2,直到查到第1000条记录,或者是不满足city='杭州’条件时循环结束。
    在这里插入图片描述
    当然,并不是说要为了每个查询能用上覆盖索引,就要把语句中涉及的字段都建上联合索引,毕竟索引还是有维护代价的,这是⼀个需要权衡的决定。

4 正确地显示随机消息

需求:从⼀个单词表中随机选出三个单词

CREATE TABLE `words` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`word` varchar(64) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

插入10000行数据

4.1 内存临时表

1 随机排序取前3个:

select word from words order by rand() limit 3;

这个方法出现了Using temporary和Using filesort,表示需要临时表,并且需要在临时表上排序。

对于临时内存表的排序来说会选择rowid排序,因为对于内存表,回表过程只是简单地根据数据行的位置,直接访问内存得到数据,不会多访问磁盘

执行流程:

  1. 创建⼀个临时表。这个临时表使用的是memory引擎,表里有两个字段,第⼀个字段是double类型,为了后面描述方便,记为字段R(rand),第⼆个字段是varchar(64)类型,记为字段W(word);
    并且,这个表没有建索引。

  2. 从words表中,按主键顺序取出所有的word值;
    对于每⼀个word值,调用rand()函数生成⼀个大于0小于1的随机小数,并把这个随机小数和word分别存⼊临时表的R和W字段中,到此,扫描行数是10000。

  3. 现在临时表有10000行数据了,接下来要在这个没有索引的内存临时表上,按照字段R排序。

  4. 初始化 sort_buffer。sort_buffer中有两个字段,⼀个是double类型,另⼀个是整型。

  5. 从内存临时表中⼀行一行地取出R值和位置信息(位置信息其实就是内存临时表的主键),分别存⼊sort_buffer中的两个字段里;
    这个过程要对内存临时表做全表扫描,此时扫描行数增加10000,变成了20000。

  6. 在sort_buffer中根据R的值进行排序。
    注意,这个过程没有涉及到表操作,所以不会增加扫描行数。

  7. 排序完成后,取出前三个结果的位置信息,依次到内存临时表中取出word值,返回给客户端;
    这个过程中,访问了表的三行数据,总扫描行数变成了20003。
    在这里插入图片描述

4.2 磁盘临时表

如果临时表大小超过了tmp_table_size,那么内存临时表就会转成磁盘临时表。

使用磁盘临时表对应的就是⼀个没有显式索引的InnoDB表的排序过程。

使用优先队列排序算法,不用归并,因为并不需要所有数据都有序,没必要浪费计算量:(现在的SQL语句,只需要取R值最小的3个rowid)

  1. 对于这10000个准备排序的(R,rowid),先取前三行,构造成⼀个大根堆;
  2. 取下⼀个行(R’,rowid’),跟当前堆里面最大的R比较,如果R’小于R,把这个(R,rowid)从堆中去掉,换成(R’,rowid’);
  3. 重复第2步,直到第10000个(R’,rowid’)完成比较。

4.3 随机排序方法

不论是使用哪种类型的临时表,order by rand()这种写法都会让计算过程非常复杂,需要大量的扫描行数,因此排序过程的资源消耗也会很大。

方法1:ID中间可能有空洞,因此选择不同行的概率不⼀样,不是真正的随机。

  1. 取得这个表的主键id的最⼤值M和最小值N;
  2. 用随机函数生成⼀个最大值到最小值之间的数 X = (M-N)*rand() + N;
  3. 取不小于X的第⼀个ID的行。
select max(id),min(id) into @M,@N from t ;
set @X= floor((@M-@N+1)*rand() + @N);
select * from t where id >= @X limit 1;

可以使用如下流程,不会出现因为id不连续而不均匀:

  1. 取得整个表的行数,并记为C。
  2. 取得 Y = floor(C * rand())。 floor函数在这里的作用,就是取整数部分。
  3. 再用limit Y,1 取得⼀行。
select count(*) into @C from t;
set @Y = floor(@C * rand());
set @sql = concat("select * from t limit ", @Y, ",1");
prepare stmt from @sql;
execute stmt;
DEALLOCATE prepare stmt;

如果要取三行,则:

  1. 取得整个表的行数,记为C;
  2. 根据相同的随机方法得到Y1、Y2、Y3;
  3. 再执行三个limit Y, 1语句得到三行数据。
select count(*) into @C from t;
set @Y1 = floor(@C * rand());
set @Y2 = floor(@C * rand());
set @Y3 = floor(@C * rand());
select * from t limit @Y1,1; #在应⽤层代码里取Y1、Y2、Y3值,拼出SQL后再到数据库执⾏
select * from t limit @Y2,1;
select * from t limit @Y3,1;

5 SQL语句逻辑相同,性能却差异巨大

5.1 案例一:条件字段函数操作

对字段做了函数计算会用不上索引

需求:统计发生在所有年份中7月份的xxx总数

select count(*) from 表名 where month(时间字段)=7;

如图:数字就是month()函数对应的值
在这里插入图片描述
如果SQL语句条件用的是where t_modified='2018-7-1’的话,引擎就会按照上面绿色箭头的路线,快速定位到
t_modified='2018-7-1’需要的结果。

但是,如果计算month()函数的话,传⼊7的时候,在树的第⼀层就不知道该怎么办了。

也就是说,对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能;
可以选择遍历主键索引,也可以选择遍历索引t_modified,优化器对比索引大小后发现,索引t_modified更小,遍历这个索引比遍历主键索引来得更快。因此最终还是会选择索引t_modified,不过无法再使用索引快速定位功能,而只能使用全索引扫描

修改如下就能使用上t_modified索引的快速定位能力了:

select count(*) from tradelog 
where (t_modified >= '2016-7-1' and t_modified<'2016-8-1') 
or (t_modified >= '2017-7-1' and t_modified<'2017-8-1') 
or (t_modified >= '2018-7-1' and t_modified<'2018-8-1')
...#还有其他时间就继续加

5.2 隐式类型转换

在MySQL中,字符串和数字做比较的话,是将字符串转换成数字。

可以使用select “10” > 9来做验证:

  1. 如果规则是“将字符串转成数字”,那么就是做数字比较,结果应该是1;
  2. 如果规则是“将数字转成字符串”,那么就是做字符串比较,结果应该是0。

因此,如下语句:

select * from 表1 where 字段xxx=123456;#假设该字段是varchar(32)

对于优化器来说,这个语句相当于:

select * from 表1 where CAST(字段xxx AS signed int) = 123456;

可以看到对索引字段做了函数操作,因此优化器会放弃走树搜索功能

5.3 案例三:隐式字符编码转换

如果两个表的字符集不同,做表连接查询的时候会用不上关联字段的索引。

原因:连接过程中要求在被驱动表的索引字段上加函数操作,即CONVERT()

比如:

select 2.* from 表1 1, 表2 2 where 2.字段xxx=1.字段xxx and 1.id=3; 

其流程为:

第1步,根据id在1表1里找到id=3这⼀行;
第2步,从这一行中取出字段xxx的值;
第3步,根据字段xxx的值到表2中查找条件匹配的行。

问题是出在第3步,如果单独把这⼀步改成SQL语句的话,那就是:

select * from 表2 where 字段xxx=$id=3.字段xxx.value;

如果字符集不同,$id=3.字段xxx.value是utf8mb4,而表2是utf8的话,需要将被驱动数据表2里的字段⼀个个地转换成utf8mb4,再跟$id=3.字段xxx.value做比较;
那么上述sql实际上就等于:

select * from 表2 where CONVERT(字段xxx USING utf8mb4)=$id=3.字段xxx.value;

进行了函数操作,因此索引失效

两种优化/解决方案:

1 把表2上的字段xxx的字符集也改成utf8mb4,这样就没有字符集转换的问题了:

alter table 表2 modify 字段xxx varchar(32) CHARACTER SET utf8mb4 default null;

2 如果数据量比较大, 或者业务上暂时不能做修改的话,就只能采用修改SQL语句的方法:

select 2.* from 表1 1,表2 2 where 2.字段xxx=CONVERT(l.tradeid USING utf8) and 1.id=3;

6 查一行数据也很慢

可以使用show processlist查看语句的状态

出现的原因:表级锁、行锁、查询慢

6.1 表级锁

  • 等MDL锁:Waiting for table metadata lock

session A 通过lock table命令持有表t的MDL写锁,⽽session B的查询需要获取MDL读锁,所以,session B进⼊等待状态,阻塞着、长时间不返回:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WdsiPJvM-1576230242814)(…/…/markdownPicture/assets/1570537940123.png)]

可以找到谁持有MDL写锁,然后把它kill掉:通过查询sys.schema_table_lock_waits这张表,就可以直接找出造成阻塞的process id,把这个连接用kill 命令断开即可:select blocking_pid from sys.schema_table_lock_waits

  • 等flush:Waiting for table flush

MySQL里面对表做flush操作的用法⼀般有以下两个:

flush tables t with read lock;
flush tables with read lock;

如果指定表t的话,代表的是只关闭表t;
如果没有指定具体的表名,则表示关闭MySQL里所有打开的表

出现Waiting for table flush状态的可能情况是:有⼀个flush tables命令被别的语句堵住了,然后它又堵住了select语句:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NqTvEb8Z-1576230242816)(…/…/markdownPicture/assets/1570538595579.png)]

在session A中,每行都调用⼀次sleep(1),这样这个语句默认要执行10万秒,在这期间表t⼀直是被session A“打
开”着;
然后,session B的flush tables t命令再要去关闭表t,就需要等session A的查询结束。这样,session C要再次查询的话,就会被flush 命令堵住了。

6.2 行锁

  • 等行锁

如果select要加读锁,这时候已经有⼀个事务在这行记录上持有⼀个写锁的话,select语句就会被堵住:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8lZLWQou-1576230242818)(…/…/markdownPicture/assets/1570539147419.png)]

怎么查出是谁占着这个写锁:MySQL 5.7版本可以通过sys.innodb_lock_waits表查到,其信息为blocking_trx_id

select * from t sys.innodb_lock_waits where locked_table=`'test'.'t'`\G

6.3 查询慢

  • 无索引
select * from t where c=50000 limit 1;

由于字段c上没有索引,这个语句只能走id主键顺序扫描,因此需要扫描5万⾏。

  • 当前读
select * from t where id=1;#很慢
select * from t where id=1 lock in share mode;#加了锁反而很快

在这里插入图片描述
如上图场景,100w次后,id状态如下:
在这里插入图片描述
session B更新完100万次,⽣成了100万个回滚日志(undo log)。

lock in share mode的SQL语句,是当前读,因此会直接读到1000001这个结果,所以速度很快;
select * from t where id=1这个语句,是⼀致性读,因此需要从1000001开始,依次执行undo log,执行了100万次以后,才将1这个结果返回。

7 关于join

案例:两个表都有⼀个主键索引id和⼀个索引a,字段b上无索引。
往表t2里插⼊1000行数据,在表t1里插⼊100行数据。

7.1 是否使用

结论:(前提是可以使用被驱动表的索引,为Index Nested-Loop Join算法(NLJ))

  1. 使用join语句,性能比强性拆成多个单表执行SQL语句的性能要好,可以减少语句的执行次数
  2. 如果使用join语句的话,需要让小表做驱动表,因为驱动表会做全表搜索,被驱动表则可以走树搜索
    在这里插入图片描述
  3. 从表t1中读入⼀行数据 R;
  4. 从数据行R中,取出a字段到表t2里去查找;
  5. 取出表t2中满足条件的行,跟R组成⼀行,作为结果集的⼀部分;
  6. 重复执行步骤1到3,直到表t1的末尾循环结束。

如果被驱动表没有使用索引:(为Block Nested-Loop Join算法(BNL),Extra字段会出现”Block Nested Loop“字样):
无论选择大表还是小表做驱动表,执行耗时是⼀样的,不过当小表无法一次性加载入内存时,还是小表快;
如果join语句很慢,就把join_buffer_size改大,这样一次性可以加载更多的数据进去;
如果使用的是这个算法,那么就尽量不用join,自己在业务里实现。
在这里插入图片描述

  1. 把表t1的数据读⼊线程内存join_buffer中,由于语句中写的是select *,因此是把整个表t1放⼊了内存;
  2. 扫描表t2,把表t2中的每⼀行取出来,跟join_buffer中的数据做对比,满足join条件的,作为结果集的⼀部分返回。(由于join_buffer是以⽆序数组的方式组织的,因此对表t2中的每⼀行,都要遍历join_buffer中的所有行,此处可优化,见7.2的hash)

如果join_buffer一次性放不下t1,则分段:
在这里插入图片描述

  1. 扫描表t1,顺序读取数据行放⼊join_buffer中,假设放完第88行join_buffer满了,继续第2步;
  2. 扫描表t2,把t2中的每⼀行取出来,跟join_buffer中的数据做对比,满足join条件的,作为结果集的⼀部分返回;
  3. 清空join_buffer;
  4. 继续扫描表t1,顺序读取最后的12行数据放⼊join_buffer中,继续执行第2步。

扫描⾏数是 N+λ * N * M、内存判断 N*M次。(λ * N为k,即分k段)

在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与join的所有字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。

7.2 优化

案例:a为索引,a为1时id为1000,a为2时id为999,依次类推到a为1000时id为1

select * from t1 where a>=1 and a<=100;

这样就顺序不一致,无法顺序读盘了

7.2.1 MRR

Multi-Range Read优化:此优化的主要目的是尽量使用顺序读盘

如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能,这就是MRR优化的设计思路

基本回表流程:主键索引是⼀棵B+树,在这棵树上,每次只能根据⼀个主键id查到⼀行数据;
因此,回表肯定是⼀行一行搜索主键索引的:普通索引a上查到主键id的值后,再根据⼀个个主键id的值到主键索引上去查整行数据,一次一行
在这里插入图片描述
经过MRR优化后,流程如下:

  1. 根据索引a,定位到满足条件的记录,将id值放⼊read_rnd_buffer中;
  2. 将read_rnd_buffer中的id进行递增排序;
  3. 排序后的id数组,依次到主键id索引中查记录,并作为结果返回。
    在这里插入图片描述
    explain中Extra字段会多了Using MRR,得到的结果集的顺序与原先流程的顺序是相反的,因为对id进行排序了

MRR能够提升性能能的核心:这条查询语句在索引a上做的是⼀个范围查询(也就是说,这是⼀个多值查询),可以得到足够多的主键id,这样通过排序以后,再去主键索引查数据,能体现出“顺序性”的优势。

7.2.2 BKA

  • NLJ

可对NLJ算法进行优化

NLJ是从驱动表一行行地取出字段的值,再到被驱动表去做join,对驱动表来说每次都是匹配一个值
在这里插入图片描述
BKA则从驱动表里⼀次性地多拿些行出来,⼀起传给被驱动表。
在这里插入图片描述

  • BNL

BNL也可以通过转换为BKA进行优化:

  1. 把表t2中满足条件的数据放在临时表tmp_t中;
  2. 为了让join使用BKA算法,给临时表tmp_t的字段b加上索引
  3. 让表t1和tmp_t做join操作。
create temporary table temp_t(
    id int primary key, 
    a int, 
    b int, 
    index(b)
)engine=innodb;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);
  • BNL补充方案(理论上效果要好于临时表)

7.1中提到的hash,意思是如果join_buffer里面维护的不是⼀个无序数组,而是⼀个哈希表的话,就不用遍历了,直接做hash查找即可;
假设表t2有100w行,表t1有1000行,1000行全进入了join_buffer,那么就由100w*1000=10亿次判断变成了100万次hash查找

select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000;#b无索引

原流程为:

  1. 把表t1的所有字段取出来,存⼊join_buffer中
  2. 扫描表t2,取出每⼀行数据跟join_buffer中的数据进行对比,
    如果不满足t1.b=t2.b,则跳过;
    如果满足t1.b=t2.b, 再判断其他条件,即是否满足t2.b处于[1,2000]的条件,如果是,就作为结果集的⼀部分返回,否则跳过。

MySQL没有实现hash,可在应用层实现,实现的方法为:(次数为1000+100w+1000*2000)

  1. select * from t1;
    取得表t1的全部1000行数据,在业务端存入⼀个hash结构,如HashSet
  2. select * from t2 where b>=1 and b<=2000;
    获取表t2中满足条件的2000行数据。
  3. 把这2000行数据,⼀行⼀行地取到业务端,到hash结构的数据表中寻找匹配的数据;
    满足匹配的条件的这行数据,就作为结果集的⼀行。

8 自增主键不连续的原因

连续的好处:自增主键可以让主键索引尽量地保持递增顺序插⼊,避免了页分裂,因此索引更紧凑。

表的结构定义存放在后缀名为.frm的⽂件中,不会保存自增值。

不同的引擎对于⾃增值的保存策略不同:

MyISAM引擎的自增值保存在数据文件中。

InnoDB引擎的自增值保存在了内存里,并且到了MySQL 8.0版本后,才有了“自增值持久化”的能力,也就是实现了“如果发生重启,表的自增值可以恢复为MySQL重启前的值”,具体情况是:
在MySQL 5.7及之前的版本,自增值保存在内存里,并没有持久化,每次重启后,第⼀次打开表的时候,都会去找自增值的最⼤值max(id),然后将max(id)+1作为这个表当前的自增值。
例子:如果⼀个表当前数据行里最大的id是10,AUTO_INCREMENT=11;
这时候删除了id=10的行,AUTO_INCREMENT还是11。但如果马上重启实例,重启后这个表的AUTO_INCREMENT就会变成10。
也就是说,MySQL重启可能会修改⼀个表的AUTO_INCREMENT的值。
在MySQL 8.0版本,将自增值的变更记录在了redo log中,重启的时候依靠redo log恢复重启之前的值。

不连续的时机:

1 一个插入语句没有指定主键,因此InnoDB将当前自增值给这个语句后,加1,但是此语句因为其他字段冲突,如唯一索引对应的值已经存在等原因,插入失败,此时语句对应的主键就消失了

唯⼀键冲突是导致自增主键id不连续的第⼀种原因。

2 第二种原因是事务回滚

这两种原因都是因为MySQL没有把表t的自增值改回去,这是为了性能考虑的:

假设有两个并行执行的事务,在申请自增值的时候,为了避免两个事务申请到相同的自增id,肯定要加锁,然后顺序申请。

  1. 假设事务A申请到了id=2, 事务B申请到id=3,那么这时候表t的自增值是4,之后继续执行。
  2. 事务B正确提交了,但事务A出现了唯⼀键冲突。
  3. 如果允许事务A把⾃增id回退,也就是把表t的当前自增值改回2,那么就会出现这样的情况:表里面已经有id=3的行,而当前的自增id值是2。
  4. 接下来,继续执行的其他事务就会申请到id=2,然后再申请到id=3;
    这时,就会出现插⼊语句报错“主键冲突”。

为了解决这个主键冲突,有两种方法:

  1. 每次申请id之前,先判断表里面是否已经存在这个id;
    如果存在,就跳过这个id;
    但是,这个方法的成本很⾼,因为,本来申请id是⼀个很快的操作,现在还要再去主键索引树上判断id是否存在。
  2. 把自增id的锁范围扩大,必须等到⼀个事务执行完成并提交,下⼀个事务才能再申请自增id;
    这个方法的问题,就是锁的粒度太大,系统并发能力大大下降。

可以看到这两个方法都会导致性能问题,罪魁祸⾸就是假设的“允许自增id回退”的前提导致的。

在生产上,尤其是有insert … select这种批量插⼊数据的场景时,从并发插⼊数据性能的角度考虑,建议设置如下:innodb_autoinc_lock_mode=2 ,并且 binlog_format=row,这样做,既能提升并发性,⼜不会出现数据⼀致性问题。

3 第三种原因是批量插入数据(insert … selectreplace … selectload data语句。)时,MySQL有⼀个批量申请⾃增id的策略:

  1. 语句执行过程中,第⼀次申请自增id,会分配1个;
  2. 1个用完以后,这个语句第⼆次申请自增id,会分配2个;
  3. 2个用完以后,还是这个语句,第三次申请自增id,会分配4个;
  4. 依此类推,同⼀个语句去申请自增id,每次申请到的⾃增id个数都是上⼀次的两倍。

如果不使用此策略,预先不知道要申请多少个自增id,那么⼀种直接的想法就是需要⼀个时申请⼀个;
但如果⼀个select … insert语句要插⼊10万行数据,按照这个逻辑的话就要申请10万次;
显然,这种申请自增id的策略,在大批量插⼊数据的情况下,不但速度慢,还会影响并发插⼊的性能;
因此批量申请自增id可以一次性申请多个,减少申请的次数

不过可以看到会浪费很多id,id也不连续

9 自增id用完怎么办

MySQL有不同的自增id,每种自增id有各自的应用场景,在达到上限后的表现也不同:

  1. 表的自增id达到上限后,再申请时它的值就不会改变,进而导致继续插入数据时报主键冲突的错误。
  2. row_id达到上限后,则会归0再重新递增,如果出现相同的row_id,后写的数据会覆盖之前的数据。
  3. Xid只需要不在同⼀个binlog文件中出现重复值即可。虽然理论上会出现重复值,但是概率小,可以忽略不计。
  4. InnoDB的max_trx_id 递增值每次MySQL重启都会被保存起来,脏读bug必会出现。
  5. thread_id是使用中最常见的,达到2^32-1后,它就会重置为0,然后继续增加;
    不会在show processlist里看到两个相同的thread_id,因为MySQL设计了⼀个唯⼀数组的逻辑
发布了235 篇原创文章 · 获赞 264 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/qq_41594698/article/details/103531333
今日推荐