索引优化三

分页查询优化

业务系统分页功能可能会用如下sql实现,表示取出从10001行开始的10行记录,看似只查询10条,实际SQL是先读取10010条记录,然后抛弃前10000条记录,然后读到后面10条目标数据。因此要查询一张大表比较靠后的数据,执行效率是非常低的:

1
explain select * from employees limit 10000,10;

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

改写后的SQL走了索引,且扫描行数大大减少,执行效率更高,但很多场景并不实用,表中可能某些记录被删主键空缺,导致主键不连续结果不一致,这里没添加单独order by,表示通过主键排序,该优化只适合主键自增且连续结果按照主键排序

1
explain select * from employees where id > 90000 limit 10;

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

并没有使用name字段的索引,扫描整个索引并查找到没索引的行的成本比扫描全表的成本更高,所以优化器放弃使用索引。

1
explain select * from employees ORDER BY name limit 90000, 10;

优化关键是让排序时返回的字段尽可能少,可让排序分页操作先查出主键,然后根据主键查到对应的记录

1
explain select * from employees e inner join (select id from employees order by name limit 90000,5) ed on e.id = ed.id;

Join关联查询优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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();

-- 往t2表插入100行记录
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();

MySQL的表关联常见有Nested-Loop Join嵌套循环连接算法Block Nested-Loop Join基于块的嵌套循环连接算法两种算法

嵌套循环连接算法

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

执行计划结果的id如果一样则按从上到下顺序执行sql,先执行的就是驱动表,从执行计划中可知,t2是驱动表,t1是被驱动表, 优化器一般会优先选择小表做驱动表,使用inner join时,排在前面的表并不一定是驱动表

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

从t2表中读取一行数据,若t2表有查询过滤条件,会从过滤结果里取出一行数据,取出关联字段a,到t1表中查找;取出t1表中满足条件的行,跟t2中获取到的结果合并,作为结果返回给客户端;然后继续下一条数据。

整个过程会读取t2表的所有数据,即扫描t2表100行,然后遍历每行数据中字段a的值,根据t2表中a的值索引扫描 t1表中的对应行,1次扫描可认为读取t1表一行完整数据,由于t1表的a字段有索引,故扫描100次。因此整个过程扫描了200行。若被驱动表的关联字段没索引,使用NLJ嵌套循环连接算法性能会比较低;

left join左表是驱动表,right join右表是驱动表,join优化器会选择数据量比较小的表作为驱动表

一般join语句中,若执行计划Extra中未出现Using join buffer则表示使用的是NLJ嵌套循环连接算法

基于块的嵌套循环连接算法

把驱动表的数据读入到join_buffer,然后扫描被驱动表,把被驱动表每一行取出来join_buffer中的数据做对比

Extra中的Using join buffer (Block Nested Loop)说明该关联查询使用的是基于块的嵌套循环连接算法

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

首先将t2表的所有数据放入到join_buffer中,把t1表中每一行取出,跟join_buffer中的数据对比,返回满足join条件的数据。

整个过程对t1表和t2表都做了一次全表扫描,因此扫描的总行数为10000+100=10100join_buffer里的数据是无序的,故对t1表中的每一行,都要做100次判断,故内存中的判断次数是100 * 10000= 100万次。

join_buffer是由参数join_buffer_size设定的默认为256k。若放不下所有数据,就是分段放。若t2表有1000行记录,join_buffer一次只能放800行,则先往join_buffer里放800行,然后从t1表里取数据跟join_buffer 中数据对比得到部分结果,然后清空join_buffer,再放入t2表剩余200行,再次从t1表里取数据跟join_buffer中数据对比,所以就多扫了一次t1表。

若使用嵌套循环连接算法,这里扫描行数为100万次,且是磁盘扫描,而基于块的嵌套循环连接算法内存计算,即使扫描次数更多但性能更高。对于被驱动表的关联字段没索引的关联查询,一般都会使用BNL算法,若有索引一般选择NLJ算法有索引的情况下NLJ算法比BNL算法性能更高

关联SQL优化

关联字段加索引,让MySQL做join操作时尽量选择嵌套循环连接算法小表驱动大表,写多表连接SQL时若明确知道哪张表是小表可以用straight_join写法固定连接驱动方式,省去MySQL优化器自己判断时间 ;

straight_join功能同join类似,能让左表来驱动右表能改表优化器对于联表查询的执行顺序。 由于left join,right join已代表指定了表的执行顺序,故straight_join只适用于inner join

1
EXPLAIN select * from t2 straight_join t1 on t2.a = t1.a;

尽可能让优化器去判断,大部分情况下MySQL优化器是比人要聪明,使用straight_join一定要慎重,因为部分情况下人为指定的执行顺序并不一定比优化引擎靠谱;

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

in和exsits优化

当t2表的数据集小于t1表的数据集时,in优于exists的写法

1
explain select * from t1 where a in (select a from t2);

等价于:

1
2
3
for(select a from t2){
select * from t1 where t1.a = t2.a
}

exists优于in的写法,将主查询t2的数据,放到子查询t1中做条件验证,根据验证结果true或false来决定主查询的数据是否保留

1
explain select * from t2 where exists(select 1 from t1 where t1.a = t2.a);

exists子查询只返回true或false,因此子查询中的select *也可以用select 1替换,官方说法是实际执行时会忽略select清单,因此没有区别;exists子查询的实际执行过程可能经过了优化而不是理解上的逐条对比;exists子查询往往也可以用join来代替,何种最优需要具体问题具体分析;

count(*)查询优化

只有根据某个字段count才不会统计字段为null值的数据行,下面4个语句的执行计划跑出来都一样,执行效率差不多,全都是使用的二级索引

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

字段有索引时执行效率从大到小:count(*)≈count(1)>count(字段)>count(主键id);因为字段有索引,count(字段)统计走二级索引,因为二级索引存储数据比主键索引少,所以count(字段)>count(主键id)

字段无索引时执行效率从大到小:count(*)≈count(1)>count(主键id)>count(字段) ;因为字段没有索引count(字段)统计走不了索引,count(主键id)还可以走主键索引,所以count(主键id)>count(字段)

count(1)跟count(字段)执行过程类似,不过count(1)不需要取出字段统计,就用常量1做统计,count(字段)还需要取出字段,故理论上count(1)比count(字段)会快一点。

count(*) 是例外,MySQL并不会把全部字段取出来,而是专门做了优化,不取值按行累加,效率很高,故不需要用count(列名)或count(常量)来替代count(*)

count(id)最终选择辅助索引而不是主键聚集索引,因为二级索引相对主键索引存储数据更少,检索性能应该更高大概5.7版本才优化

查询MySQL自己维护的总行数

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

1
explain select count(*) from test_myisam;

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

show table status

若只需要知道表总行数的估计值可以用如下SQL查询,性能很高

1
show table status like 'employees';

将总数维护到Redis

插入或删除表数据行的时候同时维护redis里的表总行数key的计数值,用incrdecr命令,但是这种方式可能不准很难保证表操作和redis操作的事务一致性

增加数据库计数表

插入或删除表数据行的时候同时维护计数表,让它们在同一个事务里操作