数据库监控与调优【十二】—— JOIN语句优化

本文详细介绍了JOIN语句的不同类型和优化算法,包括NestedLoopJoin、BlockNestedLoopJoin、BatchedKeyAccessJoin以及HashJoin,分析了它们的工作原理、优缺点和适用场景。文章还提供了如何通过调整表的驱动顺序、利用索引和优化器设置来提高JOIN查询性能的策略,并强调了在特定版本的MySQL中,如MySQL8.0.18和8.0.20,JOIN算法的变化和限制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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;
      

      在这里插入图片描述

    • 如果优化器抽风,可使用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;
      

    在这里插入图片描述

  • 如果有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;语句即可

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值