【MySQL】MySQL索引优化实战(下)
1. 嵌套循环连接 Nested-Loop Join(NLJ) 算法
2. 基于块的嵌套循环连接 Block Nested-Loop Join(BNL)算法
如果 join_buffer 太小,装不下 t2(驱动表) 怎么办???
被驱动表的关联字段没索引为什么要选择使用 BNL 算法而不使用 NLJ 呢?
in:当B表的数据集小于A表的数据集时,in优于exists
exists:当A表的数据集小于B表的数据集时,exists优于in
分页查询优化详解
SQL数据准备
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=1 DEFAULT CHARSET=utf8 COMMENT='员工记录表';
使用存储过程批量生成数据
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('zhang',i),i,'dev');
set i=i+1;
end while;
end;;
delimiter;
call insert_emp();
注意:如果直接复制,有可能会报错!MySQL对于存储过程的格式要求很严格,所以这里建议小伙伴如果直接复制报错的话就“手打”吧~~~
分页场景描述
很多时候,我们的业务场景中都会使用到分页查询,比如前端页面要做一个每页10条记录的列表,就会用到分页查询,当这个动作有可能会是MyBatisPlus帮我们做的。
select * from employees limit 10000,10;
表示从表 employees 中取出从 10001 行开始的 10 行记录。
看似只查询了 10 条记录,实际这条 SQL 是先读取 10010 条记录,然后抛弃前 10000 条记录,然后读到后面 10 条想要的数据。因此要查询一张大表比较靠后的数据,执行效率是非常低的。
所以我们就好考虑如何区优化分页查询!
官网关于分页查询优化的方案
1. 根据自增且连续的主键排序的分页查询
例如最原先,我们的SQL是这样子的
-- 0.123s
select * from employees limit 90000,5;
注意,这里没有 order by 语句,默认是使用主键排序(id)
查询从第 90001开始的五行数据
我们再看表 employees ,因为主键是自增并且连续的,所以可以改写成按照主键去查询从第 90001开始的五行数据。
-- 0.031s
select * from employees where id > 90000 limit 5;
我们最后在使用Explain关键字来对比一下
从执行时间 和 explain 综合分析,改写后的SQL走了索引,而且扫描的行数大大减少,执行效率更高。
但是,看到这里,相信大家一定发现了一些问题!
假如主键不是自增且连续的呢?
结果不是按照主键排序的呢?
假如中间删除了一些记录呢?
所以,这种优化的前提条件太苛刻了!!!这样子改写在绝大多数场景是不适用的!表中某些记录被删后,主键空缺,会导致结果不一致。
2. 根据非主键字段排序的分页查询(常见 | 推荐)
假设我们是对 employees表的name字段进行排序
-- 0.305
-- 联合索引(name,age,position)
select * from employees ORDER BY name limit 90000,5;
再用explain分析一下
虽然我们是用联合索引的第一个字段排序,应该是会走索引的,但是事实并非如此!
这个点我们之前的文章种有提到过,可能是由于要扫描的字段太多了,然后又要回表,所以MySQL“觉得” 可能要遍历多个索引树,其成本比扫描全表的成本更高,所以优化器放弃使用索引!!!
我们可以使用覆盖索引优化一下,让它不回表,执行效率自然提高了,但是这个并不是我们要讲的重点!
关于limit的优化思路(perfect!)
关键是让排序时返回的字段尽可能少,所以可以让排序和分页操作先查出主键,然后根据主键查到对应的记录。
-- 0.051s
select * from employees e
inner join
(select id from employees order by name limit 90000,5) ed
on e.id = ed.id;
可见这样子优化完之后,基本上都用到了索引,而且没有了文件排序(Using filesort)。
而且这里的表关联也是用到了主键关联(eq_ref)效率是很高的!
从执行效率上来说从原先的 0.3s 到现在的 0.05s ,基本上是提升了一个数量级!
表Join关联原理详解及其优化
SQL数据准备
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 造数据,10万的数量级
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();
T1表有100000条记录,T2表中有100条记录。
注:如果存储过程执行报错,请按上面的代码手动“敲”一遍,就肯定没有问题!
MySQL的表关联常见有两种算法
- Nested-Loop Join 算法
- Block Nested-Loop Join 算法
1. 嵌套循环连接 Nested-Loop Join(NLJ) 算法
一次一行循环地从第一张表(称为驱动表)中读取行,在这行数据中取到关联字段,根据关联字段在另一张表(被驱动表)里取出满足条件的行,然后取出两张表的结果合集。
EXPLAIN select * from t1 inner join t2 on t1.a= t2.a;
SQL的大致流程
- 从表 t2 中读取一行数据(如果t2表有查询过滤条件的,用先用条件过滤完,再从过滤结果里取出一行数据);
- 从第 1 步的数据中,取出关联字段 a,到表 t1 中查找;
- 取出表 t1 中满足条件的行,跟 t2 中获取到的结果合并,作为结果返回给客户端;
- 重复上面 3 步。
驱动表结论
- 驱动表是 t2,被驱动表是 t1。先执行的就是驱动表(执行计划结果的id如果一样则按从上到下顺序执行sql)。优化器一般会优先选择小表做驱动表,用where条件过滤完驱动表,然后再跟被驱动表做关联查询。所以使用 inner join 时,排在前面的表并不一定就是驱动表。
- 当使用 left join 时,左表是驱动表,右表是被驱动表;
- 当使用 right join 时,右表时驱动表,左表是被驱动表;
- 当使用 join 时,MySQL会选择数据量比较小的表作为驱动表,大表作为被驱动表。
- 一般 join 语句中,如果执行计划 Extra 中未出现 Using join buffer 则表示使用的 join 算法是 NLJ。
整个过程会读取 t2 表的所有数据(扫描100行),然后遍历这每行数据中字段 a 的值,根据 t2 表中 a 的值索引扫描 t1 表中的对应行(扫描100次 t1 表的索引,1次扫描可以认为最终只扫描 t1 表一行完整数据,也就是总共 t1 表也扫描了100行)。因此整个过程扫描了 200 行。
如果被驱动表的关联字段没索引,使用NLJ算法性能会比较低(下面有详细解释),mysql会选择Block Nested-Loop Join算法。
2. 基于块的嵌套循环连接 Block Nested-Loop Join(BNL)算法
讲BNL之前,先提一句,MySQL 8.0.20之后 BNL 被废了... 用哈希联接替代!!!
我的学习能力 :: 我的SQL 8.0 参考手册 :: 8.2.1.4 哈希联接优化 (mysql.com)https://dev.mysql.com/doc/refman/8.0/en/hash-joins.html好 ~ 现在继续我们的BNL,虽然被废了,但是它的逻辑还是要了解一下的!
把驱动表的数据读入到 join_buffer 中,然后扫描被驱动表,把被驱动表每一行取出来跟 join_buffer 中的数据做对比。
EXPLAIN select * from t1 inner join t2 on t1.b= t2.b;
Extra 中 的Using join buffer (Block Nested Loop)说明该关联查询使用的是 BNL 算法。
SQL的大致流程
- 把 t2 的所有数据放入到 join_buffer 中
- 把表 t1 中每一行取出来,跟 join_buffer 中的数据做对比
- 返回满足 join 条件的数据
如果 join_buffer 太小,装不下 t2(驱动表) 怎么办???
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 中数据对比。
所以就多扫了一次 t1 表。
被驱动表的关联字段没索引为什么要选择使用 BNL 算法而不使用 NLJ 呢?
如果上面 第二条SQL(不走索引的)使用 NLJ,那么扫描行数为 100 * 10000 = 100万次,这个是磁盘扫描。
如果是 BNL,那么它会有10000+100次的磁盘扫描,100万次的内存过滤。
很显然,用BNL磁盘扫描次数少很多,相比于磁盘扫描,BNL的内存计算会快得多!
因此MySQL对于被驱动表的关联字段没索引的关联查询,一般都会使用 BNL 算法;如果有索引一般选择 NLJ 算法,有索引的情况下 NLJ 算法比 BNL算法性能更高。
对于小表定义的明确
这里讲的大表、小表并不完全指一张表记录数的大小,而是指按照条件(where)过滤后剩下的符合条件的记录数的大小(也就是参与JOIN的数据)!数据量小的那个表,就是“小表”,应该作为驱动表。
关联SQL的优化总结(重点!!!)
(1)被驱动表的关联字段(尽量)一定要加索引。
(2)小表驱动大表,MySQL有可能选错(大、小表),我们可以使用 straight_join 字段来指定那一张表作为驱动表!省去MySQL优化器自己判断的时间
straight_join:用于指定左边的表来驱动右边的表,能改表优化器对于联表查询的执行顺序。但是,只适用于inner join,并不适用于left join,right join(因为left join,right join已经代表指定了表的执行顺序)。但是使用一定要慎重,一般情况下,MySQL是不会出错的!
IN 和 EXISTS 优化
优化原则
小表驱动大表,即小的数据集驱动大的数据集
IN
in:当B表的数据集小于A表的数据集时,in优于exists
-- 先执行里面,再执行外面!
select * from A where id in (select id from B);
EXISTS(基本不用)
exists:当A表的数据集小于B表的数据集时,exists优于in
将主查询A的数据,放到子查询B中做条件验证,根据验证结果(true或false)来决定主查询的数据是否保留
-- 先执行外面,再拿外层的结果集去与内层做关联
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来代替(可能性能更好),何种最优需要具体问题具体分析。
表count查询优化
count语句性能比较
-- PRIMARY KEY (`id`)
-- KEY `idx_name_age_position` (`name`,`age`,`position`) USING BTREE
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哪一条执行效率高???
答:4条SQL的执行计划都是用 index,执行效率都差不多!!!
如果硬是要分一个高低,那么我认为应该是这样的:
首先这个玩意不能一概而论,要看“字段”有没有索引?!
当字段有索引时
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(id)
在MySQL5.7版本之后,做了优化,由于二级索引的数据量少,检索速度更快,所以当有二级索引的时候,MySQL会优先选择二级索引。
关于count(*)!!!
count(*) 是例外,MySQL并不会把全部字段取出来,而是专门做了优化,不取值,按行累加,效率很高,所以不需要用count(列名)或count(常量)来替代 count(*)。
而且 MySQL 会对 count(*) 和 count(1) 有个优化,如果有多个二级索引的时候,优化器会使用key_len 最小的二级索引进行扫描。
只有当没有二级索引的时候,才会采用主键索引来进行统计。
关于count常见的优化方法
(1)查询mysql自己维护的总行数
对于myISAM存储引擎的表做不带where条件的count查询性能是很高的(比Innodb的高!),因为myISAM存储引擎的表的总行数会被MySQL存储在磁盘上,查询不需要计算。
对于innodb存储引擎的表MySQL不会存储表的总记录行数(因为有MVCC机制),查询count需要实时计算。
(2)show table status
show table status like 'employees'
如果只需要知道表总行数的估计值可以这么用,性能很高!!!但是精确度不高!
(3)将总数维护到Redis里
插入或删除表数据行的时候同时维护redis里的表总行数key的计数值(用incr或decr命令)
但是这种方式可能不准,很难保证表操作和redis操作的事务一致性(不可能完全保证!)
(4)增加数据库计数表
插入或删除表数据行的时候同时维护计数表,让他们在同一个事务里操作。
表名 | 该表的总行数 |
employees | 99999行 |
employees_copy | 100行 |
... | ... |
MySQL自身的事务操作是可以保证100%的一致性的!!