索引优化实战
一、字符串索引优化
先说结论:
- 直接创建完整索引,这样可能占用大量空间。
- 使用前缀索引,节省空间,但会增加查询扫描次数,且不能使用覆盖索引。
- 倒序存储,再创建前缀索引,用于绕过字符串前缀索引区分度不够的问题。
- 创建 hash 字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描。
下面详细探讨一下前缀索引的影响
前缀索引
假设我们维护了这样一张表
sql
create table user(
ID bigint unsigned primary key,
email varchar(64),
...
)engine=innodb;
业务上由于要使用邮箱登录,所以业务代码中一定会出现类似于这样的语句
sql
select * from user where email='xxx@xxx.com';
此时如果没有为 email 字段创建索引,那么就会全表扫描,效率非常低。从前面文章我们了解到"最左前缀原则"特性,MySQL是支持前缀索引的,所以我们可以为 email 字段创建一个前缀索引,如下:
sql
alter table user add index index2(email(6));
这样就可以为 email 字段的前 6 个字符创建一个索引,这样查询语句就可以使用这个索引了。 当然如果不指定长度默认就包含全部字符串了:
sql
alter table user add index index1(email);
email 索引结构示意图:

email(6) 索引结构示意图:
可以看到前缀索引占用空间要小很多的。这是一种空间换时间的策略,因为这样可能会导致查询扫描更多行。
我们先看下面这个语句
sql
select id,email from user where email = 'zhangssxyz@xxx.com%';
使用索引1时,执行顺序如下:
- 在index1中先找到所有满足'zhangssxyz@xxx.com'的值,这里会拿到ID2的值。
- 根据覆盖索引,直接将ID2的值和email放入结果集,不需要回表查询了。
使用索引2时,执行顺序如下:
- 在index2中先找到所有满足'zhangs'这个值,这里会先拿到ID1的值。
- 这里由于前缀索引需要回表判断是否满足结果集要求。到主键上查到主键值是ID1的行,判断出email的值不是'zhangssxyz@xxx.com',这行记录丢弃;
- 取index2上刚刚查到的位置的下一条记录,发现仍然是'zhangs',取出ID2,再到ID索引上取整行然后判断,这次值对了,将这行记录加入结果集;
- 重复上一步直到index2上查找出来的值不等于'zhangs',这时候就可以停止了。
可以看到,使用前缀索引时,会增加查询扫描次数,且不能使用覆盖索引。
但是,对于这个查询语句来说,如果你定义的index2不是email(6)而是email(7),也就是说取email字段的前7个字节来构建索引的话,即满足前缀'zhangss'的记录只有一个,也能够直接查到ID2,只扫描一行就结束了。
所以定义的前缀区分度越大,那么查询时扫描的行也就越少,可以做到既节省空间又不额外增加太多查询扫描次数。
你可以通过下面sql判断前缀区分度,一般来说会先预设一个损失比例,例如5%。然后,在返回的 L4~L7 中,找出Ln/count(*) >=95% 的值,假设这里 L6、L7 都满足,你就可以选择前缀长度为 6。
sql
select
count(distinct left(email,4))as L4,
count(distinct left(email,5))as L5,
count(distinct left(email,6))as L6,
count(distinct left(email,7))as L7,from user;
当然有的时候可能会涉及一些特殊情况,例如身份证。,我们国家的身份证号,一共18位,其中前6位是地址码,所以同一个县的人的身份证号前6位一般会是相同的。这时身份证的前缀区分度就非常低了。可能你需要创建长度为12以上的前缀索引,才能够满足区分度要求。因此你可以考虑下面的方法:
第一种方式是使用倒序存储。如果你存储身份证号的时候把它倒过来存,每次查询的时候,你可以这么写:
sql
select * from user where id_card = reverse('12345620000101001X');
第二种方式是使用哈希字段索引。你可以再存储身份证号的时候,再计算出一个哈希值,把这个哈希值存储到索引中。查询的时候,先计算哈希值,再到索引中查询。
sql
alter table user add id_card_crc int unsigned, add index(id_card_crc);
select field_list from user where id_card_crc=crc32('input_id_card_string') and id_card='input_id_card_string'
二、count(*)优化
在某些统计类的业务中,经常会涉及到count()这样的查询语句。如:select count(*) from t
随着表数据量越大,你会发现查询速度越来越慢了。 究其原因还是在于count()函数在InnoDB引擎会全表扫描。
在聊count()函数优化前,我先给你介绍一下count()的实现方式。
count(*)实现方式
你首先要明确的是,在不同的MySQL引擎中,count(*)有不同的实现方式。
- MyISAM引擎把一个表的总行数存在了磁盘上,因此执行count(*)的时候会直接返回这个数,效率很高;
- 而InnoDB引擎就麻烦了,它执行count(*)的时候,需要把数据一行一行地从引擎里面读出来,然后server层根据结果集来统计计数。
这里需要注意的是,我们这里讨论的是没有过滤条件的count(*),如果加了where条件的话,MyISAM表也是需要全表的。
其实你可以思考一下,MyISAM引擎不支持事务,所以将表的个数记录在磁盘中,逻辑上是安全的。但是Innodb不同,由于MVCC机制的存在,所以count(*)的实现就比较复杂了。
关于MVCC这里简单介绍一下,innodb支持事务隔离级别对吧,那么在常用的可重复读隔离级别下,不同的事务之间是不可见的,如果InnoDB也将表个数记录在磁盘中,逻辑上就会有问题。 例如现在有A、B、C三个会话,假设从上到下是按照时间顺序执行的,同一行语句是在同一时刻执行的。那么在可重复读隔离级别下会count(*)会产生下面的效果。这才是符合逻辑的。
优化方案介绍
其实优化count(*)本质就是避免全表扫描。那么自然而然就可以想到:
- 使用缓存在保存,在每次插入删除时都更新缓存值。
- 维护一张表,记录当前表的行数。
注:下面这两种分析都是建立在单机模式下 这里如果你用Redis来存放count(*)的值,可能会存在很多隐患,例如:
1.缓存中间件一般都不支持崩溃恢复,一旦缓存崩溃,就会导致count()的值丢失。所以在插入时需要判断是否要重新同步一次count()
2.将计数保存在缓存系统中的方式,还不只是丢失更新的问题。即使Redis正常工作,这个值还是逻辑上不精确的,甚至还会导致数据不一致。 一种是,查到的100行结果里面有最新插入记录,而Redis的计数里还没加1; 另一种是,查到的100行结果里没有最新插入的记录,而Redis的计数里已经加了1。
那如果维护一张额外的表是否会有问题呢? 在同一个数据库里面就不会有redis那样的问题了,可以直接利用其本身事务特性,就能保住逻辑一致性。

上面这种场景,虽然会话B的读操作仍然是在T3执行的,但是因为这时候更新事务还没有提交,所以计数值加1这个操作对会话B还不可见。 因此,会话B看到的结果里,查计数值和"最近100条记录"看到的结果,逻辑上就是一致的。
总结 其实,把计数放在Redis里面,不能够保证计数和MySQL表里的数据精确一致的原因,是这两个不同的存储构成的系统,不支持分布式事务,无法拿到精确一致的视图。而把计数值也放在MySQL中,就解决了一致性视图的问题。
不同count()函数的性能分析
count()语义:对于返回的结果集,一行行地判断,如果count函数的参数不是 NULL,累计值就加 1,否则不加。最后返回累计值。
所以,count(*)、count(主键 id)和count(1)都表示返回满足条件的结果集的总行数; 而count(字段),则表示返回满足条件的数据行里面,参数"字段"不为NULL的总个数。
至于分析性能差别的时候,你可以记住这么几个原则:
- server层要什么Innodb就给什么。
- Innodb只给server层需要的值。
- 现在的优化器只优化了count(*)的语义为"取行数",其他count()函数的优化并没有做。
下面我再来一个个解释说明:
count(主键id): InnoDB会遍历整张表,将主键id都放入结果集,返回给server层,server层拿到结果集后,一行行判断id不能为NULL,则直接按行进行累加。
count(1): InnoDB会遍历整张表,没找到一行就往结果集放入一个1,返回给server层,server层拿到结果集后,直接按行进行累加。相比count(主键id)能够节省行数据解析的时间。
count(字段): InnoDB会遍历整张表,将字段值都放入结果集,返回给server层,server层拿到结果集后:
- 如果字段定义了not null约束,则会判断不能为NULL,直接按行进行累加。
- 如果字段没有定义not null约束,则会一行行判断是否为NULL,不是则按行进行累加。
== count()有专门优化: == 并不会把全部字段取出来。count()肯定不是null,server层会按照结果集直接按行累加。
所以结论是: 字段有索引:count()≈count(1)>count(字段)>count(主键 id) //字段有索引,count(字段)统计走二级索引,二级索引存储数据比主键索引少,所以count(字段)>count(主键 id) 字段无索引:count()≈count(1)>count(主键 id)>count(字段) //字段没有索引count(字段)统计走不了索引,count(主键 id)还可以走主键索引,所以count(主键 id)>count(字段)
三、分页查询优化
示例表:
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='员工记录表';
很多时候我们业务系统实现分页功能可能会用如下sql实现
sql
select * from employees limit 10000,10;
表示从表 employees 中取出从 10001 行开始的 10 行记录。看似只查询了 10 条记录,实际这条 SQL 是先读取 10010 条记录,然后抛弃前 10000 条记录,然后读到后面 10 条想要的数据。因此要查询一张大表比较靠后的数据,执行效率是非常低的。
1、根据自增且连续的主键排序的分页查询
如果主键连续且自增的情况下,可以使用下面sql替换,可以改写成按照主键去查询从第 10001开始的10行数据。
sql
select * from employees where id > 10000 limit 10;
这条sql表示在主键索引中,先找到id大于10000的记录,然后limit 10,返回10条记录。就不需要便利前10000条记录了,大大减少了扫描成本。
2、根据非主键字段排序的分页查询
根据一个非主键字段排序的分页查询sql如下:
sql
select * from employees ORDER BY name limit 90000,5;
这里数据量很大、需要的结果集又是所有数据字段用不上覆盖索引。这种情况下优化器,极大概率会认为:扫描整个索引并回表获取数据的成本比扫描全表的成本更高,所以优化器会放弃使用索引,直接走全表扫描。
这里可以让排序时返回的字段尽可能少,所以可以让排序和分页操作先查出主键,然后根据主键查到对应的记录,SQL改写如下
sql
select * from employees e
inner join (select id from employees order by name limit 90000,5) ed on e.id = ed.id;
原 SQL 使用的是 filesort 排序,而优化后的 SQL 使用的是索引排序 using index。
四、Join查询优化
对于关联sql的优化如下,对于为什么这么做可以优化关联查询的性能可以看后面对于算法原理的介绍。
- 关联字段加索引,让mysql做join操作时尽量选择NLJ算法,驱动表因为需要全部查询出来,所以过滤的条件也尽量要走索引,避免全表扫描,总之,能走索引的过滤条件尽量都走索引
- 小表驱动大表,写多表连接sql时如果明确知道哪张表是小表可以用straight_join写法固定连接驱动方式,省去mysql优化器自己判断的时间
示例表:
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表插入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();
1. SQL中各种连接介绍
INNER JOIN:返回两个表的匹配得上的数据,不匹配不显示 LEFT JOIN:即使右表中没有匹配,也从左表返回所有的行,没匹配的数据填空null RIGHT JOIN:即使左表中没有匹配,也从右表返回所有的行,没匹配的数据填空null FULL JOIN:返回两个表的所有数据,没匹配的数据填空null straight_join:straight_join功能同join类似,但能让左边的表来驱动右边的表,能改表优化器对于联表查询的执行顺序。
2. 嵌套循环连接 Nested-Loop Join(NLJ) 算法
NLJ:一次一行循环地从第一张表(称为驱动表)中读取行,在这行数据中取到关联字段,根据关联字段在另一张表(被驱动表)里取出满足条件的行,然后取出两张表的结果合集。
例如这条sql的执行流程大致如下:
sql
select * from t1 straight_join t2 on t1.a= t2.a;

再看一个执行计划的例子:
sql
EXPLAIN select * from t1 inner join t2 on t1.a= t2.a;

从执行计划中可以看到这些信息:
- 驱动表是 t2,被驱动表是 t1。先执行的就是驱动表(执行计划结果的id如果一样则按从上到下顺序执行sql);优化器一般会优先选择小表做驱动表,用where条件过滤完驱动表,然后再跟被驱动表做关联查询。所以使用 inner join 时,排在前面的表并不一定就是驱动表。
- 当使用left join时,左表是驱动表,右表是被驱动表,当使用right join时,右表时驱动表,左表是被驱动表,当使用join时,mysql会选择数据量比较小的表作为驱动表,大表作为被驱动表。
- 使用了 NLJ算法。一般join语句中,如果执行计划Extra中未出现Using join buffer则表示使用的join算法是 NLJ。
select * from t1 inner join t2 on t1.a= t2.a;
的大致流程如下:
- 从表 t2 中读取一行数据(如果t2表有查询过滤条件的,用先用条件过滤完,再从过滤结果里取出一行数据);
- 从第 1 步的数据中,取出关联字段 a,到表 t1 中查找;
- 取出表 t1 中满足条件的行,跟 t2 中获取到的结果合并,作为结果返回给客户端;
- 重复上面 3 步。
整个过程会读取t2表的所有数据(扫描100行),然后遍历这每行数据中字段a的值,根据t2表中a的值索引扫描t1表中的对应行(扫描100次t1表的a索引,再回表一次获取t1表一行完整数据,也就是总共t1表也扫描了101行)。因此整个过程扫描了201行。
如果被驱动表的关联字段没索引,使用NLJ算法性能会比较低(下面有详细解释),mysql会选择Block Nested-Loop Join算法。
3. 基于块的嵌套循环连接 Block Nested-Loop Join(BNL)算法
把驱动表的数据读入到 join_buffer 中,然后扫描被驱动表,把被驱动表每一行取出来跟 join_buffer 中的数据做对比。
可以看下面执行计划
sql
EXPLAIN select * from t1 inner join t2 on t1.b= t2.b;

可以看执行计划中Extra中Using join buffer (Block Nested Loop),表示使用了BNL算法。
BNL算法的执行流程如下:
- 把驱动表t2的所有数据放入到 join_buffer 中
- 把表被驱动表t1中每一行取出来,跟 join_buffer 中的数据做对比
- 返回满足join条件的数据
这个流程会对t1表和t2表都做一次全表扫描,扫描行数10000 + 100 = 10100行。join_buffer内存对比次数10000 * 100 = 1百万次。
这个例子里表t2才100行,要是表t2是一个大表,join_buffer放不下怎么办呢?
join_buffer默认大小是256k,但是可以通过join_buffer_size
参数来修改。
如果放不下表t2的所有数据话,策略很简单,就是分段放。比如先放入60条到join_buffer,对比完成后清空join_buffer再放入剩下40条。但是如果出现分n段放的情况,就会导致被驱动表t1要被全表扫描n次才能完成全部比较了。
被驱动表的关联字段没索引为什么要选择使用BNL算法而不使用Nested-Loop Join呢?
如果是NLJ算法,每次从t2拿到一条数据都需要去t1中全表扫描,因此总扫描行要100万次。 很显然,用BNL磁盘扫描次数少很多,相比于磁盘扫描,BNL的内存计算会快得多。
4. Multi-Range Read 优化
在介绍InnoDB的索引结构时,提到了"回表"的概念。回表是指,InnoDB在普通索引a上查到主键id的值后,再根据一个个主键id的值到主键索引上去查整行数据的过程。
主键索引是一棵B+树,在这棵树上,每次只能根据一个主键id查到一行数据。因此,回表肯定是一行行搜索主键索引的。但是大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。
如下面这个sql
sql
select * from t1 where a>=1 and a<=100;
开启MRR:set optimizer_switch="mrr_cost_based=off"
MRR会将主键id先排序后再根据排序后的主键id去回表查询数据。使用MRR优化后的执行流程图如下:

MRR能够提升性能的核心在于,这条查询语句在索引a上做的是一个范围查询,可以得到足够多的主键id。这样通过排序以后,再去主键索引查数据,才能体现出"顺序性"的优势。
但是,如果数据量较小或者查询模式不适合MRR优化,使用MRR可能会导致额外的开销(read_rnd_buffer),从而降低性能。因此,在使用MySQL进行大范围查询时,可以考虑启用MRR优化。但是,在一般的查询场景下,由于MRR优化可能会带来一些额外的开销,因此MySQL默认情况下不开启MRR优化。
5. Batched Key Access
BKA算法是利用MRR对NLJ算法进行优化。
NLJ算法执行逻辑是:从驱动表中读取一行行值,再到被驱动表做join。这样每次都是匹配一个值对于被驱动表来说是用不上MRR优化的。
因此BKA增加了一个join_buffer用来保存被驱动表取出的部分数据,然后再去被驱动表中做批量join。这样被驱动表就可以用上MRR优化了,这里join_buffer等效于read_rnd_buffer。
如果要使用 BKA 优化算法的话,你需要在执行 SQL 语句之前,先设置
set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';
6. BNL算法的性能问题
我们知道Buffer Pool对查询的加速效果,依赖于一个重要的指标,即:内存命中率。
可以在show engine innodb status
结果中,查看一个系统当前的BP命中率。一般情况下,一个稳定服务的线上系统,要保证响应时间符合要求的话,内存命中率要在 99% 以上。
InnoDB内存管理用的是最近最少使用 (Least Recently Used, LRU) 算法,这个算法的核心就是淘汰最久未使用的数据。
InnoDB对Buffer Pool的LRU算法做了优化:Buffer Pool前5/8的空间作为young区,后3/8的空间作为old区。第一次从磁盘读入内存的数据页,会先放在old区域头部。如果1秒之后这个数据页不再被访问了,就不会将其移动到LRU young区,这样对Buffer Pool的命中率影响就不大。
前面提到BNL如果join_buffer放不下会分段执行导致多次扫描被驱动表,这时候如果被驱动表是一个冷表。多次扫描一个冷表,而且这个语句执行时间超过1秒,就会导致再次扫描冷表的时候,把冷表的数据页移到LRU young区头部。这样就会影响Buffer Pool的缓存命中率了。
如果冷表数据量大于Buffer Pool 3/8时,由于LRU算法机制会淘汰掉old区末尾数据页,所以之前放入old区的冷表数据会被直接淘汰掉,不会进入young区,因此不会对Buffer Pool命中率产生多大影响。
但如果冷表数据量小于Buffer Poll 3/8时,当再次扫描全表时就会将冷数据放到young区了。
大表join操作虽然对IO有影响,但是在语句执行结束后,对IO的影响也就结束了。但是,对Buffer Pool的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。
为了减少这种影响,你可以考虑增大 join_buffer_size 的值,减少对被驱动表的扫描次数。
也就是说,BNL 算法对系统的影响主要包括三个方面:
- 可能会多次扫描被驱动表,占用磁盘 IO 资源;
- 判断join条件需要执行 M*N 次对比(M、N 分别是两张表的行数),如果是大表就会占用非常多的 CPU 资源;
- 可能会导致Buffer Pool的热数据被淘汰,影响内存命中率。
7. BNL转BKA
知道了BNL算法对系统的影响后,其实最好直接在被驱动表中建立索引,这时候就直接转成BKA算法了。
但有些sql可能不适合在被驱动表中建立索引。比如下面这种场景:
sql
select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000;
如果在表t2中插入了100万行数据,但是经过where条件过滤后,需要参与join的只有 2000行数据。如果这条语句同时是一个低频的SQL语句,那么再为这个语句在表t2的字段b上创建一个索引就很浪费了。
但是,如果使用 BNL 算法来 join 的话,这个语句的执行流程是这样的:
- 把表t1的所有字段取出来,存入join_buffer中,t1有1000行数据。
- 扫描表t2,取出每一行数据跟join_buffer中的数据进行对比
- 如果不满足 t1.b=t2.b,则跳过;
- 如果满足 t1.b=t2.b, 再判断其他条件,也就是是否满足 t2.b 处于[1,2000]的条件,如果是,就作为结果集的一部分返回,否则跳过。
此时BNL会导致在内存进行1000*100万=10亿次的等值比较,这个判断工作量就很大了。
这时候,我们可以考虑使用临时表。使用临时表的大致思路是:
- 先将t2中满足where条件的行数据取出,放到一个临时表temp中。
- 然后,在临时表temp上为字段b创建一个索引。
- 最后,让t1 join temp表。
sql
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);
上面sql需要在同一个连接中执行,因为临时表存在于内存中,一但连接结束,临时表就会被自动删除。
总体来看,不论是在原表上加索引,还是用有索引的临时表,我们的思路都是让join语句能够用上被驱动表上的索引,来触发BKA算法,提升查询性能。
五、Hash Join
MySQL8.0.18已经支持Hash Join。但目前似乎只支持inner join。
Hash Join是一种高效的等值连接算法,其核心原理是将驱动表的数据构建成一个内存中的哈希表(Build阶段),然后扫描被驱动表的数据,通过哈希值在内存哈希表中查找匹配的行(Probe阶段),从而实现连接。这种方法无需依赖索引,对于没有索引的表或大数据集能显著提高连接性能。如果驱动表数据过大无法完全放入内存,MySQL会进行分区和溢写到磁盘,然后分批在内存中处理分片数据。默认最多128个分区块。
Classic Hash Join(In-memory Hash Join) 如果内存放得下驱动表的所有数据则默认以来Classic Hash Join就好了。

-
Build 阶段(构建哈希表) MySQL选择一个表作为构建输入(通常是驱动表,较小的表),并将其中的连接字段数据加载到内存的 join_buffer 中。 然后,以连接字段的值为键,hash(key)找到要存放桶中位置。将驱动表的数据行构建成一个哈希表,存储在内存中。
-
Probe 阶段(探测哈希表) MySQL扫描被驱动表(较大的表)的每一行。 对被驱动表当前行的连接字段计算哈希值。 在内存中的哈希表中根据计算出的哈希值进行查找。 如果找到匹配的记录,则将匹配的行发送给客户端;如果没有匹配到,则跳过。 直到被驱动表的全部记录被遍历完,Hash Join 过程结束。
驱动表在build阶段进行一次读IO,被驱动表在probe阶段进行了一次读IO,所以整体IO开销是(M+N),其中M是驱动表的行数,N是被驱动表的行数。
内存溢出时的处理:Hybrid Hash Join(Out-of-memory Hash Join) 当构建阶段的驱动表数据量过大,无法全部存入内存中的join_buffer时,MySQL会执行磁盘溢写操作。 具体来说,驱动表和被驱动表都会按照Join条件进行分区,并生成临时文件写到磁盘上。 然后,MySQL对分片数据执行内存中的Hash Join操作。 对于每个分片,都重复进行Build和Probe过程,直到所有分片都处理完毕。
阶段一:先写入hash table,如果内存放不下,就写入磁盘分区块。

注意:写入磁盘时使用的hash2,与放入hash table时执行不是同一个hash算法

阶段二:Probe阶段,在探测hash table时也会将数据写入磁盘分区块。

之后再读取每对块文件都重复进行Build和Probe过程,直到所有分片都处理完毕。
这种算法的代价是,驱动表和被驱动在build阶段进行一次读IO和一次写IO,在probe阶段进行了一次读IO,所以整体IO开销是3*(M+N),其中M是驱动表的行数,N是被驱动表的行数。
六、in和exsits优化
原则:小表驱动大表,即小的数据集驱动大的数据集
in:当B表的数据集小于A表的数据集时,in优于exists
csharp
select * from A where id in (select id from B) // B是驱动表
#等价于:
for(select id from B){
select * from A where A.id = B.id
}
exists: 当A表的数据集小于B表的数据集时,exists优于in
csharp
select * from A where exists (select 1 from B where A.id = B.id) // A是驱动表
#等价于:
for(select * from A){
select * from B where B.id = A.id
}
#A表与B表的ID字段应建立索引
总结
本文详细探讨了MySQL索引优化的多种实战技巧:
-
字符串索引优化:介绍了完整索引、前缀索引、倒序存储和哈希字段索引四种方案,并分析了前缀索引的区分度与性能平衡。
-
count(*)优化:分析了不同存储引擎中count(*)的实现原理,提出了使用额外表记录计数和不同count函数的性能差异。
-
分页查询优化:针对大表分页查询的性能问题,提供了基于自增主键和非主键排序的两种优化方案。
-
Join查询优化:详细讲解了NLJ、BNL、BKA等连接算法的原理和适用场景,以及如何通过索引和临时表优化连接查询。
-
Hash Join:介绍了MySQL 8.0.18引入的Hash Join算法,包括内存哈希表和磁盘溢写的处理机制。
-
in和exists优化:根据"小表驱动大表"原则,分析了in和exists的适用场景。
通过这些优化技巧,可以显著提升MySQL查询性能,减少资源消耗,提高系统响应速度。