3、MySQL 索引优化

烟雨 5年前 (2021-06-15) 阅读数 569 #MySQL
文章标签 MySQL
示例表:
CREATE TABLE `employees` (
 `id` int(11) NOT NULL AUTO_INCREMENT,
 `name` varchar(24) NOT NULL DEFAULT '' COMMENT '姓名',
 `age` int(11) NOT NULL DEFAULT '0' COMMENT '年龄',
 `position` varchar(20) NOT NULL DEFAULT '' COMMENT '职位',
 `hire_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入职时间',
 PRIMARY KEY (`id`),
 KEY `idx_name_age_position` (`name`,`age`,`position`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COMMENT='员工记录表';

INSERT INTO employees(name,age,position,hire_time) VALUES('LiLei',22,'manager',NOW());
INSERT INTO employees(name,age,position,hire_time) VALUES('HanMeimei',23,'dev',NOW());
INSERT INTO employees(name,age,position,hire_time) VALUES('Lucy',23,'dev',NOW());

‐‐ 插入一些示例数据 , 初始化10W数据
drop procedure if exists insert_emp
delimiter ;;
create procedure insert_emp()
begin
 declare i int;
 set i=1;
 while(i<=100000)do
  insert into employees(name,age,position) values(CONCAT('zhuge',i),i,'dev');
  set i=i+1;
 end while;
end;;
delimiter ;
call insert_emp();

一、联合索引第一个字段用范围不会走索引

EXPLAIN SELECT * FROM employees WHERE name > 'LiLei' AND age = 22 AND position ='manager';

image.png

结论:联合索引第一个字段就用范围查找不会走索引,mysql内部可能觉得第一个字段就用范围,结果集应该很大,回表效率不高,还不如就全表扫描。

二、强制走索引

EXPLAIN SELECT * FROM employees force index(idx_name_age_position) WHERE name > 'LiLei' AND age = 22 AND position ='manager';

image.png

结论:虽然使用了强制走索引让联合索引第一个字段范围查找也走索引,扫描的行rows看上去也少了点,但是最终查找效率不一定比全表扫描高,因为回表效率不高。

三、覆盖索引优化

EXPLAIN SELECT name,age,position FROM employees WHERE name > 'LiLei' AND age = 22 AND position ='manager';

image.png

四、in和or在表数据量比较大的情况会走索引,在表记录不多的情况下会选择全表扫描

EXPLAIN SELECT * FROM employees WHERE name in ('LiLei','HanMeimei','Lucy') AND age = 22 AND position='manager';

image.png

EXPLAIN SELECT * FROM employees WHERE (name = 'LiLei' or name = 'HanMeimei') AND age = 22 AND position='manager';

image.png

4.1、做一个小实验,将employees 表复制一张employees_copy的表,里面保留两三条记录

EXPLAIN SELECT * FROM employees_copy WHERE name in ('LiLei','HanMeimei','Lucy') AND age = 22 AND position ='manager';

image.png

EXPLAIN SELECT * FROM employees_copy WHERE (name = 'LiLei' or name = 'HanMeimei') AND age = 22 AND position ='manager';

image.png

同样的SQL,数据量少了,直接全表扫描了。

五、like KK% 一般情况都会走索引

EXPLAIN SELECT * FROM employees WHERE name like 'LiLei%' AND age = 22 AND position ='manager';

image.png

EXPLAIN SELECT * FROM employees_copy WHERE name like 'LiLei%' AND age = 22 AND position ='manager';

image.png

索引下推(Index Condition Pushdown,ICP)
like KK%其实就是用到了索引下推优化。

5.1、什么是索引下推?

对于辅助的联合索引(name,age,position),正常情况按照最左前缀原则,SELECT * FROM employees WHERE name like 'LiLei%' AND age = 22 AND position ='manager' 该SQL只会走name字段索引,因为根据name字段过滤完,得到过滤后的索引行里的age和position可能无序\可能有序,无法很好的利用索引。
在MySQL5.6之前的版本,这个查询只能在联合索引里匹配到名字是 'LiLei' 开头的索引,然后拿这些索引对应的主键逐个回表,到主键索引上找出相应的记录,再比对age和position这两个字段的值是否符合。
MySQL 5.6引入了索引下推优化,可以在索引遍历过程中,对索引中包含的所有字段先做判断,过滤掉不符合条件的记录之后再回表,可以有效的减少回表次数。使用了索引下推优化后,上面那个查询在联合索引里匹配到名字是 'LiLei' 开头的索引之后,同时还会在索引里直接过滤age和position这两个字段,拿着过滤完剩下的索引对应的主键id再回表查整行数据。
索引下推会减少回表次数,对于innodb引擎的表索引下推只能用于二级索引,innodb的主键索引(聚簇索引)树叶子节点上保存的是全行数据,所以这个时候索引下推并不会起到减少查询全行数据的效果。

5.2、为什么范围查找Mysql没有用索引下推优化?

估计应该是Mysql认为范围查找过滤的结果集过大,like KK% 在绝大多数情况来看,过滤后的结果集比较小,所以这里Mysql选择给 likeKK% 用了索引下推优化,当然这也不是绝对的,有时like KK% 也不一定就会走索引下推。

六、常见sql深入优化

6.1、order by/group by优化举例

6.1.1

image.png

分析:
KEY `idx_name_age_position` (`name`,`age`,`position`) USING BTREE
利用最左前缀法则,查询用到了name索引(从key_len=74也能看出),name索引过滤后,age其实是有序的,所以能使用到age索引,并且列用在排序中。

6.1.2

image.png

分析:
从explain的执行结果来看:key_len=74,查询使用了name索引,由于用了position进行排序,跳过了age,出现了Using filesort(磁盘文件排序)

6.1.3

image.png

分析:
查找只用到索引name,age和position用于排序,无Using filesort。

6.1.4

image.png

分析:
和6.1.3中explain的执行结果一样,但是出现了Using filesort,因为索引的创建顺序为name,age,position,但是排序的时候age和position颠倒位置了

6.1.5

image.png

分析:
与6.1.4对比,在Extra中并未出现Using filesort,因为条件中age=18为常量,在排序中被优化,所以索引未颠倒,不会出现Using filesort。

6.1.6

image.png

分析:
虽然排序的字段列与索引顺序一样,且order by默认升序,这里position desc变成了降序,导致与索引的排序方式不同(条件降序,索引升序),从而产生Using filesort。Mysql8以上版本有降序索引可以支持该种查询方式。

6.1.7

image.png

分析:
对于排序来说,多个相等条件也是范围查询,通过name索引取出来的数据,不是有序的,所以产生了Using filesort。 

6.1.8

image.png

mysql认为数据量太大,并且获取的字段为*,要进行回表操作,所以走了全表扫描,结果产生了Using filesort。
这里可以用覆盖索引优化(idx_name_age_position这个索引已经包含了我们要取的所有字段,即避免了回表操作)。

image.png

6.2、优化总结

  1. MySQL支持两种方式的排序filesort和indexUsing index是指MySQL扫描索引本身完成排序。index效率高,filesort效率低

  2. order by满足两种情况会使用Using index。

    1. order by语句使用索引最左前列

    2. 使用where子句与order by子句条件列组合满足索引最左前列

  3. 尽量在索引列上完成排序,遵循索引建立(索引创建的顺序)时的最左前缀法则。

  4. 如果order by的条件不在索引列上,就会产生Using filesort。

  5. 能用覆盖索引尽量用覆盖索引。

  6. group by与order by很类似,其实质是先排序后分组,遵照索引创建顺序的最左前缀法则。对于group by的优化如果不需要排序的可以加上order by null禁止排序。注意,where高于having,能写在where中的限定条件就不要去having限定了。

6.3、Using filesort文件排序方式

6.3.1、单路排序

是一次性取出满足条件行的所有字段,然后在特定排序内存(sort buffer)中进行排序。

6.3.2、双路排序(又叫回表排序模式)

是首先根据相应的条件取出相应的排序字段和可以直接定位行数据的行ID,然后在特定排序内存(sort buffer)中进行排序,排序完后需要再次取回其它需要的字段。

MySQL 通过比较系统变量 max_length_for_sort_data(默认1024字节) 的大小和需要查询的字段总大小来判断使用哪种排序模式。
如果字段的总长度小于max_length_for_sort_data ,那么使用 单路排序模式。
如果字段的总长度大于max_length_for_sort_data ,那么使用 双路排序模∙式。

七、索引设计原则

7.1、代码先行,索引后上

不知大家一般是怎么给数据表建立索引的,是建完表马上就建立索引吗?
这其实是不对的,一般应该等到主体业务功能开发完毕,把涉及到该表相关sql都要拿出来分析之后再建立索引。

7.2、联合索引尽量覆盖条件

比如可以设计一个或者两三个联合索引(尽量少建单值索引),让每一个联合索引都尽量去包含sql语句里的where、order by、group by的字段,还要确保这些联合索引的字段顺序尽量满足sql查询的最左前缀原则(索引字段创建顺序)。

7.3、不要在小基数字段上建立索引

索引基数是指这个字段在表里总共有多少个不同的值,比如一张表总共100万行记录,其中有个性别字段,其值不是男就是女,那么该字段的基数就是2。
如果对这种小基数字段建立索引的话,还不如全表扫描了,因为你的索引树里就包含男和女两种值,根本没法进行快速的二分查找,那用索引就没有太大的意义了。
一般建立索引,尽量使用那些基数比较大的字段,就是值比较多的字段,那么才能发挥出B+树快速二分查找的优势来。

7.4、长字符串我们可以采用前缀索引

尽量对字段类型较小的列设计索引,比如说什么tinyint之类的,因为字段类型较小的话,占用磁盘空间也会比较小,此时你在搜索的时候性能也会比较好一点。
当然,这个所谓的字段类型小一点的列,也不是绝对的,很多时候你就是要针对varchar(255)这种字段建立索引,哪怕多占用一些磁盘空间也是有必要的。
对于这种varchar(255)的大字段可能会比较占用磁盘空间,可以稍微优化下,比如针对这个字段的前20个字符建立索引,就是说,对这个字段里的每个值的前20个字符放在索引树里,类似于KEY index(name(20),age,position)。
此时你在where条件里前缀搜索(例:like "kk%")的时候,如果是根据name字段来搜索,那么此时就会先到索引树里根据name字段的前20个字符去搜索,定位到之后前20个字符的前缀匹配的部分数据之后,再回到聚集索引提取出来完整的name字段值进行比对。
但是假如你要是order by name,那么此时你的name因为在索引树里仅仅包含了前20个字符,所以这个排序是没法用上索引的, group by也是同理。所以这里大家要对前缀索引有一个了解。

7.5、where与order by冲突时优先where

在where和order by出现索引设计冲突时,到底是针对where去设计索引,还是针对order by设计索引?到底是让where去用上索引,还是让order by用上索引?
一般这种时候往往都是让where条件去使用索引来快速筛选出来一部分指定的数据,接着再进行排序(数据量就少了)。
因为大多数情况基于索引进行where筛选往往可以最快速度筛选出你要的少部分数据,然后做排序的成本可能会小很多。

7.6、基于慢sql查询做优化

可以根据监控后台的一些慢sql,针对这些慢sql查询做特定的索引优化。

八、分页查询优化

很多时候我们业务系统实现分页功能可能会用如下sql实现
select * from employees limit 10000,10;
表示从表 employees 中取出从 10001 行开始的 10 行记录。看似只查询了 10 条记录,实际这条 SQL 是先读取 10010条记录,然后抛弃前 10000 条记录,然后读到后面 10 条想要的数据。因此要查询一张大表比较靠后的数据,执行效率是非常低的。

8.1、根据自增且连续的主键排序的分页查询

 select * from employees limit 90000,5;
 -- 优化为
 select * from employees where id > 90000 limit 5;

对比一下执行计划

EXPLAIN select * from employees limit 90000,5;

image.png

 EXPLAIN select * from employees where id > 90000 limit 5;

image.png

显然改写后的 SQL 走了索引,而且扫描的行数大大减少,执行效率更高。

8.2、根据非主键字段排序的分页查询

select * from employees ORDER BY name limit 90000,5;

查看执行计划

image.png

发现并没有使用 name 字段的索引,而是执行全表扫描,后进行文件排序。
具体原因:扫描整个索引并查找到没索引的行(可能要遍历多个索引树)的成本比扫描全表的成本更高,所以优化器放弃使用索引。
其实关键是让排序时返回的字段尽可能少,所以可以让排序和分页操作先查出主键,然后根据主键查到对应的记录,SQL改写如下
select * from employees e inner join (select id from employees order by name limit 90000,5) edon e.id = ed.id;

image.png

九、Join关联查询优化

‐‐ 示例表:
CREATE TABLE `t1` (
 `id` int(11) NOT NULL AUTO_INCREMENT,
 `a` int(11) DEFAULT NULL,
 `b` int(11) DEFAULT NULL,
 PRIMARY KEY (`id`),
 KEY `idx_a` (`a`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

create table t2 like t1;

 ‐‐ 插入一些示例数据
 ‐‐ 往t1表插入1万行记录
drop procedure if exists insert_t1;
delimiter ;;
create procedure insert_t1()
begin
 declare i int;
 set i=1;
 while(i<=10000)do
  insert into t1(a,b) values(i,i);
  set i=i+1;
 end while;
end;;
delimiter ;
call insert_t1();

drop procedure if exists insert_t2;
delimiter ;;
create procedure insert_t2()
begin
 declare i int;
 set i=1;
 while(i<=100)do
  insert into t2(a,b) values(i,i);
  set i=i+1;
 end while;
end;;
delimiter ;
call insert_t2();

9.1、嵌套循环连接 Nested-Loop Join(NLJ) 算法

一次一行循环地从第一张表(称为驱动表)中读取行,在这行数据中取到关联字段,根据关联字段在另一张表(被驱动表)里取出满足条件的行,然后取出两张表的结果合集。

EXPLAIN select * from t1 inner join t2 on t1.a= t2.a;

image.png

从执行计划中可以看到这些信息
  1. 驱动表是 t2,被驱动表是 t1。先执行的就是驱动表(执行计划结果的id如果一样则按从上到下顺序执行sql);优化器一般会优先选择小表(数据量少)做驱动表。所以使用 inner join 时,排在前面的表并不一定就是驱动表

  2. 当使用left join时,左表是驱动表,右表是被驱动表,当使用right join时,右表时驱动表,左表是被驱动表,当使用join时,mysql会选择数据量比较小的表作为驱动表,大表作为被驱动表。

  3. 一般 join 语句中,如果执行计划 Extra 中未出现 Using join buffer 则表示使用的 join 算法是 NLJ。

上面sql的大致流程如下

image.png

整个过程会读取 t2 表的所有数据(扫描100行),然后遍历这每行数据中字段 a 的值。

根据 t2 表中 a 的值,扫描 t1 表的a的索引,相同则返回。
整体扫描次数(t2扫描100次,t1索引扫描100次,整体200次)。

9.2、基于块的嵌套循环连接 Block Nested-Loop Join(BNL)算法

如果被驱动表的关联字段没索引,使用NLJ算法性能会比较低(下面有详细解释),mysql会选择Block Nested-Loop Join算法。
EXPLAIN select * from t1 inner join t2 on t1.b= t2.b;

image.png





Extra 中 的Using join buffer (Block Nested Loop)说明该关联查询使用的是 BNL 算法。
上面sql的大致流程如下

image.png

整个过程会读取 t2 表的所有数据放入join_buffer。
t1表取出一条,跟 join_buffer 中的数据做对比。满足join条件就返回。
扫描次数:10000(表 t1 的数据总量) + 100(表 t2 的数据总量) =10100次
join_buffer内存区域对比次数(100 * 10000= 100w次)

join_buffer内存区域

join_buffer内存区域也是有大小的,由参数 join_buffer_size 设定的,默认值是 256k。如果放不下表 t2 的所有数据话,策略就是分段放。
比如 t2 表有1000行记录, join_buffer 一次只能放800行数据,那么执行过程就是先往 join_buffer 里放800行记录,然后从 t1 表里取数据跟join_buffer 中数据对比得到部分结果,然后清空 join_buffer ,再放入 t2 表剩余200行记录,再次从 t1 表里取数据跟 join_buffer 中数据对比。

9.3、总结

  1. 关联字段加索引,让mysql做join操作时尽量选择NLJ算法

  2. 小表驱动大表,写多表连接sql时如果明确知道哪张表是小表可以用straight_join写法固定连接驱动方式,省去mysql优化器自己判断的时间。

straight_join

straight_join功能同join类似,但能让左边的表来驱动右边的表,能改表优化器对于联表查询的执行顺。
--  代表指定mysql选着 t2 表作为驱动表。
select * from t2 straight_join t1 on t2.a = t1.a;
straight_join只适用于inner join,并不适用于left join,right join。(因为left join,right join已经代表指定了表的执行顺序)
尽可能让优化器去判断,因为大部分情况下mysql优化器是比人要聪明的。使用straight_join一定要慎重,因为部分情况下人为指定的执行顺序并不一定会比优化引擎要靠谱。

对于小表的定义

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

十、in和exsits优化

原则:小表驱动大表,即小的数据集驱动大的数据集。

10.1、in

当B表的数据集小于A表的数据集时,in优于exists
select * from A where id in (select id from B);

10.2、exists

当A表的数据集小于B表的数据集时,exists优于in
 select * from A where exists (select 1 from B where B.id = A.id);
EXISTS (subquery)只返回TRUE或FALSE,因此子查询中的SELECT * 也可以用SELECT 1替换,官方说法是实际执行时会忽略SELECT清单,因此没有区别。
EXISTS子查询的实际执行过程可能经过了优化而不是我们理解上的逐条对比。
EXISTS子查询往往也可以用JOIN来代替(能用join尽量用join)。

十一、count(*)查询优化

‐‐ 临时关闭mysql查询缓存,为了查看sql多次执行的真实时间
set global query_cache_size=0;
set global query_cache_type=0;

EXPLAIN select count(1) from employees;
EXPLAIN select count(id) from employees;
EXPLAIN select count(name) from employees;
EXPLAIN select count(*) from employees;

以上4条sql只有根据某个字段count不会统计字段为null值的数据行

image.png

四个sql的执行计划一样,说明这四个sql执行效率应该差不多。
count(1)跟count(字段)执行过程类似,不过count(1)不需要取出字段统计,就用常量1做统计,count(字段)还需要取出字段,所以理论上count(1)比count(字段)会快一点。
count(*) 是例外,mysql并不会把全部字段取出来,而是专门做了优化,不取值,按行累加,效率很高,所以不需要用count(列名)或count(常量)来替代 count(*)。
为什么对于count(id),mysql最终选择辅助索引而不是主键聚集索引?因为二级索引相对主键索引存储数据更少,检索性能应该更高,mysql内部做了点优化(应该是在5.7版本才优化)

字段有索引

count(*)≈count(1)>count(字段)>count(主键 id) 
字段有索引,count(字段)走二级索引,二级索引存储数据比主键索引少,所以count(字段)>count(主键 id)。

字段没索引

count(*)≈count(1)>count(主键 id)>count(字段)
字段没有索引count(字段)走不了索引,count(主键 id)还可以走主键索引,所以count(主键 id)>count(字段)。

11.1、查询mysql自己维护的总行数

对于myisam存储引擎的表做不带where条件的count查询性能是很高的,因为myisam存储引擎的表的总行数会被mysql存储在磁盘上,查询不需要计算。

image.png

对于innodb存储引擎的表mysql不会存储表的总记录行数(因为有MVCC机制),查询count需要实时计算。

11.2、show table status

innodb存储引擎,如果只需要知道表总行数的估计值可以用如下sql查询,性能很高。

image.png


版权声明

非特殊说明,本文由Zender原创或收集发布,欢迎转载。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

作者文章
热门