JOIN语句优化-JOIN种类、算法与原理
JOIN的种类
笛卡尔连接(cross join)
-- 举例:通过笛卡尔连接查询两张表的结果集和单查两张表的结果集对比
SELECT count( * ) FROM users a CROSS JOIN orders b;
SELECT ( SELECT count( * ) FROM users ) * ( SELECT count( * ) FROM orders );
-- 如果 cross join 带有on子句,就相当于inner join
SELECT * FROM users a CROSS JOIN orders b ON a.id = b.user_id;
SELECT * FROM users a INNER JOIN orders b ON a.id = b.user_id;
JOIN算法1-Nested-Loop Join(NLJ)
翻译成中文叫嵌套循环Join
如图,存在三张表,t1、t2、t3,使用NLJ算法,执行过程大概是这样的,先查询t1中符合条件的数据,然后通过for循环遍历这些数据,在循环中查询t2表符合条件的数据,并使用reference key匹配,也就是join中的on条件。接着,再查询出第三张表中数据匹配,由于第三张表是全表扫描,所以第三张表直接是一个for循环,循环t3表全部数据,判断满足条件就返回给客户端。
不难发现,NLJ算法是比较简单粗暴的,外层循环结果集越多,内层循环扫描的次数就越多。如果外层循环的表数据量大,比如t1表数据量大,执行效率是非常地下的。
JOIN算法2-Block Nested-Loop Join(BNLJ MySQL 5.6引入)
翻译成中文叫块嵌套循环join
如图,还是刚刚三张表,查询t1和t2和之前一样,但是查询t3表就存在区别,把t1和t2表需要用到的字段存储到join buffer(连接缓存)。一直存入join buffer数据,当join buffer满时,开始循环t3表,用t3表的数据和join buffer里面的数据比较,如果匹配就返回给客户端。
对比NLJ和BNLJ
如果使用NLJ,for each row in t2如果存在100个元素就得执行100次for each row in t3,扫描t3表100次,总共需要扫描t3表10000次。
如果使用BNLJ,mysql会把这100条数据缓存到join buffer,如果join buffer足够大,会把这100条数据都存放到缓存,只要执行一次for each row in t3即可,这样,大幅度减少了内存表扫描次数。
但是如果这100条数据无法全部放到join buffer,需要扫描多少次t3表存在以下计算公式。
- (S * C)/join_buffer_size + 1
- S:缓存的t1/t2表的一行数据大小
- C:缓存的行数
- join_buffer_size:join buffer的大小
假设,第一层有100条数据,第二层有100条数据,join buffer中可以存放t1/t2数据为100条,则需要100 * 100 / 100 + 1=101,需要扫描101次。
使用join buffer的条件
-
只有在连接类型为ALL、index或range的时候才可以使用join buffer
-
第一个nonconst table不会分配join buffer,即使连接类型是ALL或index
-
join buffer只会缓存需要的字段,而非整行数据
-
可以通过join_buffer_size变量设置join buffer大小
-
-- 查看默认的join_buffer_size,默认为262144字节,即256K -- 一般建议设置比较小的全局join_buffer_size,默认值比较合适 SHOW VARIABLES LIKE 'join_buffer_size'; -- 设置当前会话的join_buffer_size SET join_buffer_size = 1024 * 1024 * 50; -- 设置全局的join_buffer_size SET GLOBAL join_buffer_size = 1024 * 1024 * 50;
-
-
每个能被缓存的join都会分配一个join buffer,一个查询可能拥有多个join buffer
-
join buffer在执行连接之前会分配,在查询完成后会释放
如何知道一条sql使用了BNLJ?
-- Extra中出现:Using join buffer (Block Nested Loop)表示使用了BNLJ
EXPLAIN SELECT * FROM users a LEFT JOIN orders b ON a.id = b.user_id;
JOIN算法3-Batched Key Access Join(BKA MySQL 5.6引入)
-
BKA的基石:Multi Range Read(MRR)
-
-- salaries表存在from_date和to_date的组合索引 -- 该查询会使用到索引,但是可能会伴随大量的随机IO -- 因为数据是按照主键排列,而不是按照from_date字段排列 -- 使用MRR可以优化这个随机IO,MRR不是扫描索引,检查到索引里面某一行再去表数据获取数据。而是把符合条件的索引都丢到缓存里面 /* 比如扫描到索引: [from_date,to_date,(id,from_date)] [1979-06-06,1980-06-06,(30000,1979-06-06)] [1978-06-06,1979-06-06,(20000,1978-06-06)] [1968-06-06,1969-06-06,(80000,1968-06-06)] 之后会按照主键排序: [1978-06-06,1979-06-06,(20000,1978-06-06)] [1979-06-06,1980-06-06,(30000,1979-06-06)] [1968-06-06,1969-06-06,(80000,1968-06-06)] 排序完成之后再到表中读取数据 而B+Tree数据结构里面的叶子节点都是按照主键排序的 而现在MRR也是按照主键顺序排序,在读取数据时就会比较接近顺序IO 顺序IO的性能要比随机IO的性能好很多,查询就会快很多 */ EXPLAIN SELECT * FROM salaries WHERE from_date <= '1980-01-01';
-
-
MRR核心:将随机IO转换为顺序IO,从而提升性能
- 当然,使用MRR也不一定有性能提升,因为带来了排序的开销
MRR参数
-
optimizer_switch的子参数
-
mrr:是否开启mrr,on开启,off关闭
-
mrr_cost_based:表示是否要开启基于成本计算的MRR,on开启,off关闭
-
-- 查询mrr默认值 -- index_merge=on,index_merge_union=on,index_merge_sort_union=on,index_merge_intersection=on,engine_condition_pushdown=on,index_condition_pushdown=on,mrr=on,mrr_cost_based=on,block_nested_loop=on,batched_key_access=off,materialization=on,semijoin=on,loosescan=on,firstmatch=on,duplicateweedout=on,subquery_materialization_cost_based=on,use_index_extensions=on,condition_fanout_filter=on,derived_merge=on,use_invisible_indexes=off,skip_scan=on,hash_join=on,subquery_to_derived=off,prefer_ordering_index=on,hypergraph_optimizer=off,derived_condition_pushdown=on -- 可以看到mrr=on,mrr_cost_based=on,表示mrr开启,基于成本计算的mrr也开启 SHOW VARIABLES LIKE '%optimizer_switch%';
-
-
read_rnd_buffer_size:指定mrr缓存大小,即存放排序后索引内存大小
-
-- 查询mrr默认缓存大小 -- 默认是262144字节,即256K SHOW VARIABLES LIKE '%read_rnd_buffer_size%';
-
由于mrr_cost_based的计算非常保守,所以默认情况下EXPLAIN SELECT * FROM salaries WHERE from_date <= '1980-01-01';
这个例子是不会使用mrr的。可以通过Extra参数项查看。里面是Using index condition
可以通过关闭mrr_cost_based,实现以上例子使用mrr
SET optimizer_switch = 'mrr_cost_based=off';
再次执行上面的例子,可以看到Extra参数项里面显示Using index condition; Using MRR
,表示使用了mrr
BKA流程
之前NLJ和BNLJ,如果每匹配到一条数据,就去读取一次表数据,IO操作太频繁,而且往往都是随机IO,性能很差。
假设t3表是存在索引的,从join buffer中读取数据,用t3表的索引和join buffer匹配,筛选出匹配的索引之后,再通过MRR对t3表的主键进行排序,最后再读取t3表的数据。
BKA的参数
-
optimizer_switch的子参数
-
batched_key_access:on开启,off关闭
-
-- 查询batched_key_access默认值 -- index_merge=on,index_merge_union=on,index_merge_sort_union=on,index_merge_intersection=on,engine_condition_pushdown=on,index_condition_pushdown=on,mrr=on,mrr_cost_based=on,block_nested_loop=on,batched_key_access=off,materialization=on,semijoin=on,loosescan=on,firstmatch=on,duplicateweedout=on,subquery_materialization_cost_based=on,use_index_extensions=on,condition_fanout_filter=on,derived_merge=on,use_invisible_indexes=off,skip_scan=on,hash_join=on,subquery_to_derived=off,prefer_ordering_index=on,hypergraph_optimizer=off,derived_condition_pushdown=on -- 可以看到batched_key_access=off,表示BKA算法默认是关闭的 SHOW VARIABLES LIKE '%optimizer_switch%';
-
-- 开启BKA SET optimizer_switch = 'batched_key_access=on';
-
-- 测试使用BKA查询的情况 -- 当使用BKA的时候,会在Extra里面展示Using join buffer (Batched Key Access) EXPLAIN SELECT * FROM salaries a, employees b WHERE a.from_date = b.birth_date;
-
JOIN算法4-HASH JOIN(MySQL 8.0.18引入 替代BNLJ)
- MySQL 8.0.18引入,用来替代BNLJ
- 选择一个较小的表,以减少建立哈希表的时间和空间,对其中每个元素上的连接属性(join attribute)采用哈希函数得到哈希值,从而建立一个哈希表。对另一个表,扫描它的每一行并计算连接属性的哈希值,与bulid phase建立的哈希表对比,若有落在同一个bucket的,如果满足连接谓词(predicate)则连接成新的表。在内存足够大的情况下建立哈希表的过程时整个表都在内存中。本质上就是join buffer缓存外部循环的hash表,内层循环遍历时到hash表匹配。
- 原理剖析好文:https://mysqlserverteam.com/hash-join-in-mysql-8/
HASH JOIN注意点
- MySQL 8.0.18才引入,且有很多限制,比如不能作用于外连接,比如left join/right join等。从MySQL 8.0.20开始,限制少了很多,外连接可以作用,建议使用MySQL 8.0.20或更高版本
- 从MySQL 8.0.18开始,hash join的join buffer时递增分配的,这意味着,你可以将join_buffer_size设置的比较大。具体是这样的,一开始可能join_buffer_size只是256K,后续因为不够用会进行自动扩容,一直扩容到设置的值为止。而在MySQL 8.0.18中,如果使用了外连接,外连接没法用hash join,此时join_buffer_size会按照设置的值直接分配内存,因此join_buffer_size还是得谨慎设置
- 从MySQL 8.0.20开始,BNLJ已被删除了,用hash join替代了BNLJ
- HASH JOIN注意点好文推荐:https://dev.mysql.com/doc/refman/8.0/en/hash-joins.html
另外之前测试的sql语句EXPLAIN SELECT * FROM users a LEFT JOIN orders b ON a.id = b.user_id;
是基于MySQL 8.0.18的。在不同版本的mysql中使用的算法是不同的
-- Extra中出现:Using join buffer (Block Nested Loop)表示使用了BNLJ 基于MySQL 8.0.18
-- Extra中出现:Using where; Using join buffer (hash join)表示使用了hash join 基于MySQL 8.0.20
EXPLAIN SELECT * FROM users a LEFT JOIN orders b ON a.id = b.user_id;
JOIN语句优化-如何优化JOIN语句
驱动表 vs 被驱动表
- 外层循环的表是驱动表,内层循环的表是被驱动表
还是之前的三张表,因此,t1是t2的驱动表,t2是t1的被驱动表。t2是t3的驱动表,t3是t2的被驱动表
例如:
EXPLAIN SELECT
*
FROM
employees e
LEFT JOIN dept_emp de ON e.emp_no = de.emp_no
LEFT JOIN departments d ON de.dept_no = d.dept_no
WHERE
e.emp_no = 10001;
通过执行结果,可以根据id列显示的规则(如果explain的结果包括多个id值,则数字越大越先执行;而对于相同id的行,则表示从上往下依次执行),得出执行顺序是e->de->d,所以e表就是de表的驱动表,de表又是d表的驱动表
JOIN调优原则
-
用小表驱动大表,即用数据量比较小的表作为驱动表,数据量比较大的表作为被驱动表
-
一般不需要人工考虑,关联查询优化器会自动选择最优的执行顺序
-- 尽管s表写在前面,但是mysql依然使用e表作为驱动表 -- 其中s表2,838,426行数据,e表292,025行数据,mysql评估e表数据更少,故将e表作为驱动表 EXPLAIN SELECT * FROM salaries s LEFT JOIN employees e ON s.emp_no = e.emp_no WHERE e.emp_no = 10001;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mQwOeeFm-1687789650751)(.\picture\SQL\QQ图片20230523151303.png)]
-
如果优化器抽风,可使用STRAIGHT_JOIN优化,强制使用左表作为驱动表
-- 此时mysql会使用s表作为驱动表 EXPLAIN SELECT * FROM salaries s STRAIGHT_JOIN employees e ON s.emp_no = e.emp_no WHERE e.emp_no = 10001;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Jqv9NHrO-1687789650751)(.\picture\SQL\QQ图片20230523151945.png)]
-
-
如果有where条件,应当要能够使用索引,并尽可能的减少外层循环的数据量
- 不管是使用NLJ、BNLJ、BKA、HASH JOIN算法,外层循环的数据量越大,内层扫描表的次数也就越多
-
join的字段尽量创建索引
-
需要注意的是,联表查询存在隐式转换可能会导致索引失效的问题
-- 如果s表的字段emp_no和e表的字段emp_no类型不一致,就可能导致索引失效的问题 EXPLAIN SELECT * FROM salaries s LEFT JOIN employees e ON s.emp_no = e.emp_no WHERE e.emp_no = 10001;
-
故join字段的类型要保持一致
-
-
尽量减少扫描的行数(explain-rows)
- 尽量控制在百万以内(经验之谈,仅供参考)
-
参与join的表不要太多
-
阿里编程规约建议不超过3张
-
对于NLJ,表越多,循环嵌套越多,扫描表的次数也就越多
-
而HASH JOIN对于多张表join的支持,理论上会好些,但是要在MySQL 8.0.18才会支持,业界使用这个版本的MySQL公司较少
-
如果业务就是要操作多张表,可以根据业务把一条操作多张表的sql分为多条sql,再通过代码将查询结果组装成最后想要的数据
-- 拆分前 SELECT * FROM employees e LEFT JOIN dept_emp de ON e.emp_no = de.emp_no LEFT JOIN departments d ON de.dept_no = d.dept_no WHERE e.emp_no = 10001; -- 拆分后 SELECT * FROM employees WHERE emp_no = 10001; SELECT * FROM dept_emp WHERE emp_no = 10001; SELECT * FROM departments WHERE dept_no = 'd005';
拆分后的sql尽管要写一堆业务代码,而且要和数据库通信三次,但是拆分后执行都是简单的sql,并且都是基于主键的,性能非常好。在一些情况下,拆分之后的sql可能比拆分之前的sql性能更好
再者,拆分之后的sql很简单,所以优化起来比较容易,复杂sql操作了多张表,执行计划很复杂,分析和优化都很困难
所以不要以编写复杂sql为荣!
-
-
如果被驱动表的join字段用不了索引,且内存较为充足,可以考虑把join buffer设置得大一些
- join buffer设置大一些,可以减少内部循环的次数
ps:实际项目中,如果遇到比较诡异的问题,可以在mysql终端使用show warnings;
语句查看问题。执行完EXPLAIN语句后跟show warnings;
语句即可。