MySQL Join算法与调优白皮书(二)

时间:2020-12-27 00:51:59
Index Nested-Loop Join
 
(接上篇)由于访问的是辅助索引,如果查询需要访问聚集索引上的列,那么必要需要进行回表取数据,看似每条记录只是多了一次回表操作,但这才是INLJ算法最大的弊端。首先,辅助索引的index lookup是比较随机I/O访问操作。其次,根据index lookup再进行回表又是一个随机的I/O操作。所以说,INLJ最大的弊端是其可能需要大量的离散操作,这在SSD出现之前是最大的瓶颈。而即使SSD的出现大幅提升了随机的访问性能,但是对比顺序I/O,其还是慢了很多,依然不在一个数量级上。例如下面的这个SQL语句:
 
SELECT
  COUNT(*)
FROM
  part,
  lineitem
WHERE
  l_partkey = p_partkey
      AND p_retailprice > 2050
AND l_discount > 0.04;
 
其中p_partkey是表part的主键,l_partkey是表lineitem的一个辅助索引,由于表part数据较小,因此作为外表(驱动表)。但是内表Join完成后还需要判断条件l_discount > 0.04,这个在聚集索引上,故需要回表进行读取。根据explain得到上述SQL的执行计划如下图所示:

MySQL Join算法与调优白皮书(二)

Block Nested-Loop Join
 
算法说明
 
在有索引的情况下,MySQL会尝试去使用Index Nested-Loop Join算法,在有些情况下,可能Join的列就是没有索引,那么这时MySQL的选择绝对不会是最先介绍的Simple Nested-Loop Join算法,因为那个算法太粗暴,不忍直视。数据量大些的复杂SQL估计几年都可能跑不出结果,如果你不信,那就是too young too simple。或者Inside君可以给你些SQL跑跑看。
 
Simple Nested-Loop Join算法的缺点在于其对于内表的扫描次数太多,从而导致扫描的记录太过庞大。Block Nested-Loop Join算法较Simple Nested-Loop Join的改进就在于可以减少内表的扫描次数,甚至可以和Hash Join算法一样,仅需扫描内表一次。
 
接着Inside君带你来看看Block Nested-Loop Join算法的伪代码:
 
For each tuple r in R do
  store used columns as p from R in join buffer
  For each tuple s in S do
    If p and s satisfy the join condition
      Then output the tuple
 
可以看到相比Simple Nested-Loop Join算法,Block Nested-LoopJoin算法仅多了一个所谓的Join Buffer,然为什么这样就能减少内表的扫描次数呢?下图相比更好地解释了Block Nested-Loop Join算法的运行过程:

MySQL Join算法与调优白皮书(二)

可以看到Join Buffer用以缓存链接需要的列,然后以Join Buffer批量的形式和内表中的数据进行链接比较。就上图来看,记录r1,r2 … rT的链接仅需扫内表一次,如果join buffer可以缓存所有的外表列,那么链接仅需扫描内外表各一次,从而大幅提升Join的性能。
 
Join Buffer
 
变量join_buffer_size
 
从上一节中可以发现Join Buffer是用来减少内表扫描次数的一种优化,但Join Buffer又没那么简单,在上一节中Inside君故意忽略了一些实现。
 
首先变量join_buffer_size用来控制Join Buffer的大小,调大后可以避免多次的内表扫描,从而提高性能。也就是说,当MySQL的Join有使用到Block Nested-Loop Join,那么调大变量join_buffer_size才是有意义的。而前面的Index Nested-Loop Join如果仅使用索引进行Join,那么调大这个变量则毫无意义。
 
变量join_buffer_size的默认值是256K,显然对于稍复杂的SQL是不够用的。好在这个是会话级别的变量,可以在执行前进行扩展。Inside君建议在会话级别进行设置,而不是全局设置,因为很难给一个通用值去衡量。另外,这个内存是会话级别分配的,如果设置不好容易导致因无法分配内存而导致的宕机问题。
 
需要特别注意的是,变量join_buffer_size的最大值在MySQL 5.1.22版本前是4G-1,而之后的版本才能在64位操作系统下申请大于4G的Join Buffer空间。
 
Join Buffer缓存的对象
 
Join Buffer缓存的对象是什么,这个问题相当关键和重要。然在MySQL的官方手册中是这样记录的:
 
Only columns of interest to the join are  stored in the join buffer, not whole rows.
 
可以发现Join Buffer不是缓存外表的整行记录,但是columns of interest具体指的又是什么?Inside君的第一反应是Join的列。为此,Inside君又去查了下mysql internals,查询得到的说明如下所示:
 
We only store the used columns in the join buffer, not the whole rows.
 
used columns还是非常模糊。为此,Inside君询问了好友李海翔,也是官方MySQL优化器团队的成员,他答复我的结果是:“所有参与查询的列”都会保存到Join Buffer,而不是只有Join的列。最后,Inside君调试了MySQL,在sql_join_buffer.cc文件中验证了这个结果。
 
比如下面的SQL语句,假设没有索引,需要使用到Join Buffer进行链接:
 
SELECT a.col3 FROM a,b
  WHERE a.col1 = b.col2
  AND a.col2 > …. AND b.col2 = …
 
假设上述SQL语句的外表是a,内表是b,那么存放在Join Buffer中的列是所有参与查询的列,在这里就是(a.col1,a.col2,a.col3)。
 
通过上面的介绍,我们现在可以得到内表的扫描次数为:
 
Scaninner_table = (Rn * used_column_size) / join_buffer_size + 1
 
对于有经验的DBA就可以预估需要分配的Join Buffer大小,然后尽量使得内表的扫描次数尽可能的少,最优的情况是只扫描内表一次。
 
Join Buffer的分配
 
需要牢记的是,Join Buffer是在Join之前就进行分配,并且每次Join就需要分配一次Join Buffer,所以假设有N张表参与Join,每张表之间通过Block Nested-Loop Join,那么总共需要分配N-1个Join Buffer,这个内存容量是需要DBA进行考量的。
 
Join Buffer可分为以下两类:
regular join buffer
incremental join buffer
 
regular join buffer是指Join Buffer缓存所有参与查询的列, 如果第一次使用Join Buffer,必然使用的是regular join buffer。
 
incremental join buffer中的Join Buffer缓存的是当前使用的列,以及之前使用Join Buffer的指针。在多次进行Join的操作时,这样可以极大减少Join Buffer对于内存开销的需求。
 
此外,对于NULL类型的列,其实不需要存放在Join Buffer中,而对于VARCHAR类型的列,也是仅需最小的内存即可,而不是以CHAR类型在Join Buffer中保存。最后,从MySQL 5.6版本开始,对于Outer Join也可以使用Join Buffer。
 
Block Nested-Loop Join总结
 
Block Nested-Loop Join极大的避免了内表的扫描次数,如果Join Buffer可以缓存外表的数据,那么内表的扫描仅需一次,这和Hash Join非常类似。但是Block Nested-Loop Join依然没有解决的是Join比较的次数,其仍然通过Join判断式进行比较。综上所述,到目前为止各Join算法的成本比较如下所示:

MySQL Join算法与调优白皮书(二)

未完待续......