Join查询联表的数量最大到底不要超过多少,有人说5,有人说6??

4,031 阅读13分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

导读

从今天开始,小k将使用掘金社区作为MySQL案例的应用场景,详细剖析MySQL的实现细节。

相信掘友们都知道掘金社区个人主页上有一个“关注了”和“关注者”的功能,尤其是“关注者”功能,当你看到消息提醒,又有人关注你啦,你可能会点进关注者列表瞅瞅:咦,今天又有哪位大佬关注我啦!有点小激动,哈哈哈!

今天小k就以“关注者”这个功能为例,看看我们是怎么用SQL实现该功能的,语句背后的执行原理又是怎样的?

我们先来看看如何用SQL实现“关注者”功能:

SELECT user.user_name, user.avatar, user.position, user.company FROM user LEFT JOIN t_user_relation ON user.user_id = t_user_relation.follow_user_id WHERE t_user_relation.followed_user_id = 10008

上面这条SQL表示查询user_id=10008的关注列表,SELECT字段包含用户名user_name、头像avatar、职位position和公司company,where条件的字段为followed_user_id,即被关注者用户id,从上面的语句看,这是一个left join查询,当然因为“关注者”列表这个功能是一个在线功能,一般我们通过先查t_user_relation表,根据被关注者用户id得到关注者用户id,后拿关注者用户id查user表来完成这个功能。不过,这里,我只是拿这个案例来开启今天联表查询这个主题。

《Join查询深度优化 - 不为人知的新方法》一文中我讲解了Join查询的几种策略,通过这篇文章,你应该对Join查询的基本过程有了一定的了解,那么,结合上面这个案例,那么,只要让MySQL使用Index Nested-Loop Join策略来执行这条语句,性能上还是可以有所保证的,因为查询能命中索引。

但是,这个案例中的SQL只关联了两张表user和t_user_relation,如果在有些场景下,比如,报表查询,由于跨业务域的原因,这时候可能不得不关联5张、6张、10张甚至更多的表来获取想要的数据,那么,此时的联表查询可能就会变得很慢,那么,我到底在语句中关联几张表才能保证语句执行不至于慢得离谱呢?

《MySQL为什么选择执行计划A而不选择B(上)》《MySQL为什么选择执行计划A而不选择B(下)》两篇文章中,我详细讲解了基于MySQL成本模型的优化策略:MySQL会针对多个潜在的QEP(查询执行计划),比较其执行成本,最后选择执行成本最低的来执行语句。

而联表查询,比如上面的案例,既可以用user表来驱动t_user_relation表,也可以反过来,用t_user_relation表来驱动user表,那么,MySQL同样会基于成本模型,比较这两种方案的执行成本,最后,选择成本最低的来执行语句,即选择最低成本的驱动关系。

既然MySQL是通过比较不同的驱动关系的执行成本来选择驱动表的,那么,表关联越多,意味着各种驱动关系组合就越多(最坏的情况有2的n次方-1种组合,n为关联表数量),比较各种组合的执行成本的代价也就越高,进而相应的查询语句执行就会变慢。

既然语句执行慢的原因之一是花在了驱动关系的成本分析上,那么,我们就来看一下这个驱动关系成本分析的过程是什么样的?

在讲解成本分析的过程之前,我们先来看下成本分析相关的几个核心结构,因此,在这里,我以《导读》中的语句为例来讲解这些结构:

image.png

Join

一条语句的结构解析后就会存放在Join这个结构里,包含select列、from表、where、groupby、orderby等信息。

  • best_ref:比较执行计划成本过程中,当前最低成本的执行计划。包含了该计划中每个阶段的执行信息,如上图绿色箭头指向的JOIN_TAB数组就是这个最低成本执行计划中每个阶段的执行情况。假设《导读》中语句当前最低成本的执行计划是user -> t_user_relation,即先查询user表,后查询t_user_relation,我们来看下这个JOIN_TAB:

    • JOIN_TAB:包含一个阶段执行使用的相关表及该阶段的执行成本情况。主要包含下面几个核心属性:

      • table_ref:一个阶段使用的表信息。如上图,由于执行计划为user -> t_user_relation,因此,第一个JOIN_TAB中的table_ref表示查询user表这个阶段,user表的相关信息。第二个JOIN_TAB中的table_ref表示查询t_user_relation表阶段,t_user_relation表的相关信息。

      • dependent:一个阶段使用的表,其依赖的表。比如《导读》中的语句中where条件的字段使用右表t_user_relation的字段followed_user_id,MySQL可以单独使用该条件查询t_user_relation,这时候,user和t_user_relation表之间就没有依赖关系。

        但是,如果我把语句改成这样:

        SELECT user.user_name, user.avatar, user.position, user.company FROM user LEFT JOIN t_user_relation ON user.user_id = t_user_relation.follow_user_id WHERE user.user_id = 10008
        

        那么,我们发现where条件的字段使用左表user的字段user_id,因此,使用该条件查询t_user_relation表时依赖user表的user_id字段,因此,这时候,上图中第二个JOIN_TAB中的dependent就是user表,表示t_user_relation表查询依赖user表中的字段。

      • read_time:一个阶段使用的表读取时间。如上图,由于执行计划为user -> t_user_relation,因此,第一个JOIN_TAB中的read_time表示查询user表这个阶段,读取user表的时间。第二个JOIN_TAB中的read_time表示查询t_user_relation表阶段,读取t_user_relation表的时间。

  • positions:表示比较执行计划成本过程中,当前在计算成本的执行计划。如上图中的positions为user -> t_user_relation,表示先查询user表,后查询t_user_relation表。其内部由多个POSITION组成一个数组。每个POSITION按执行计划顺序,表示该执行计划中的某一个阶段。

    • POSITION:如上图,第二个POSITION表示查询t_user_relation表这个阶段,该阶段用st_position结构描述。我们来看下这个st_position结构:
      • prefix_rowcount:当前阶段之前(包含当前阶段)的总扫描行数。比如,上图第二个POSITION为查询t_user_relation表阶段,该阶段之前一共只有查询user表这一个阶段,因此,该POSITION中的prefix_rowcount为查询t_user_relation扫描行数+查询user扫描行数=8。
      • prefix_cost:当前阶段之前(包含当前阶段)的总执行成本。比如,上图第二个POSITION为查询t_user_relation表阶段,该阶段之前一共只有查询user表这一个阶段,因此,该POSITION中的prefix_cost为查询t_user_relation成本+查询user成本=12.2。
      • read_cost:当前阶段的执行成本。比如,上图第二个POSITION为查询t_user_relation表阶段,因此,该POSITION中的read_cost为9.6,表示查询t_user_relation表的成本为9.6。
      • rows_fetched:当前阶段的扫描行数。比如,上图第二个POSITION为查询t_user_relation表阶段,由于条件t_user_relation.followed_user_id = 10008查询命中索引,因此,该POSITION中的rows_fetched为0,表示根据该条件查询t_user_relation表扫描了0行。
      • JOIN_TAB:同上面的JOIN_TAB结构。如上图,st_position中的JOIN_TAB指向JOIN_TAB数组中的第二个JOIN_TAB,表示查询t_user_relation表阶段执行使用的相关表及该阶段的执行成本情况。
  • best_read:比较执行计划成本过程中,整个语句当前的最低执行成本。如上图,假设《导读》中语句当前的最低执行成本为12.2。

驱动表选择

讲解完核心数据结构之后,你可能有个疑问:既然st_position中的JOIN_TAB和JOIN_TAB数组中的JOIN_TAB的关系是1:1,为什么MySQL不把st_position放到JOIN_TAB数组中的JOIN_TAB中呢?先别急,我们先来看下这张图:

image.png

有没有发现,这不就是一个图的深度遍历嘛!没错!

  • t1 -> t2 -> t4:表示依次查询t1、t2和t4表:
    • t1 -> t2
      • 分析t1,得到t1成本为2.6,将该成本带入t2,即图中第一个红色箭头上的2.6。
      • 分析t2,得到t2成本为9.6,带入成本2.6 + t2成本9.6,得到t1 -> t2总成本12.2。将12.2带入t4。即图中第二个红色箭头上的12.2。
    • t2 -> t4
      • 分析t4,得到t4成本为1.6,带入成本12.2 + t4成本1.6,得到t1 -> t2 -> t4总成本13.8。
  • t1 -> t3 -> t4:表示依次查询t1、t3和t4表,同理,得到t1 -> t3 -> t4总成本12.8。

我们拿这个过程对比上面的Join结构,是不是发现,刚好st_position这个结构可以描述上面这个深度遍历过程中,两个节点的关系,因此,MySQL单独设计了st_position来表示执行计划遍历路径中两张表的成本关系。其中,st_position中的prefix_cost就是当前节点之前(包含当前节点)的总成本,read_cost就是当前节点的查询成本。

在讲完核心结构之后,我们可以看看MySQL是如何比较驱动关系的成本的,上面我提到了深度遍历,聪明的小伙伴已经猜到了,没错!MySQL在比较不同的驱动关系成本的时候,也使用了深度遍历,这种遍历方式在算法上叫做贪婪搜索。因此,《导读》中的语句,其贪婪搜索的过程就变成这样:

image.png

如上图,MySQL分别计算了user表驱动t_user_relation表和t_user_relation表驱动user表的成本:

  1. user -> t_user_relation:user表查询成本为2.6,t_user_relation查询成本为9.6,因此,总成本为2.6 + 9.6 = 12.2。
  2. t_user_relation -> user:t_user_relation表查询成本为2.0127,user表查询成本为6,因此,总成本为2.0127 + 6 = 8.0127。

由于8.0127 < 12.2,因此,MySQL选择t_user_relation表来驱动user表。关于具体的查询成本分析过程,可以阅读这2篇文章《MySQL为什么选择执行计划A而不选择B(上)?》《MySQL为什么选择执行计划A而不选择B(下)?》

那么,现在我们了解了MySQL比较驱动关系成本的过程,回到关联表过多,导致查询变慢的问题,我们知道如果关联表很多,那么,MySQL处理查询语句时,不得不做更多的表顺序组合,对各种组合进行贪婪搜索,来比较它们的成本。这对MySQL而言,势必影响查询的性能。

剪枝过程

因此,MySQL引入了prune_level这个变量,来减少搜索遍历的次数。我们来看下这个减少的过程,假设一条语句关联的表有4张:t1、t2、t3、t4:

image.png

在此,假设MySQL先选择t1表作为驱动表来计算查询成本,那么,这个过程如上图:

  1. t1 - > t2,即t1表先驱动t2表,得到t1的查询成本为2.6,t2的查询成本为1.6,两者总和4.2。
  2. t1 -> t3,即t1表驱动t3表,得到t1的查询成本为2.6,t3的查询成本为8.6,两者总和11.2,由于prune_level变量设置为1,表示对路径剪枝,因此,MySQL发现t1 -> t3成本11.2大于t1 -> t2成本4.2,t1 -> t3遍历分支结束,从t1开始不再遍历其他表,只关注t1 -> t2的遍历分支。如上图,step1中的t3打了叉,表示t1 -> t3遍历分支结束。将4.2带入t3和t4。即图中第二个红色箭头上的4.2和图中第二个绿色箭头上的4.2。
  3. t2 -> t3,即开始从t2驱动t3,得到t3的查询成本为5.6,加上带入成本4.2,4.2 + 5.6 = 9.8。
  4. t2 -> t4,即开始从t2驱动t4,得到t4的查询成本为1.6,加上带入成本4.2,4.2 + 1.6 = 5.8。由于prune_level变量设置为1,表示对路径剪枝,因此,MySQL发现t1 -> t2 -> t3成本9.8大于t1 -> t2 -> t4成本5.8,t1 -> t2 -> t3遍历分支结束,从t2开始不再遍历其他表,只关注t1 -> t2 -> t4的遍历分支。如上图,step2中的t3打了叉,表示t1 -> t2 -> t3遍历分支结束。将5.8带入t3。即图中第三个绿色箭头上的5.8。
  5. t4 -> t3,即开始从t4驱动t3,得到t3的查询成本为5.6,加上带入成本5.8,5.8 + 5.6 = 11.4。
  6. 因此,得到驱动关系:t1 -> t2 -> t4 -> t3,总成本11.4。

上面的过程演示了prune_level变量减少贪婪搜索遍历次数的过程,我们发现通过该变量剪枝遍历分支,可能会造成有些分支成本很低,但是,没有再去遍历的情况,比如,下面这张图:

image.png

图中,t1 -> t2 -> t3 -> t4这条遍历分支,其成本总和为10.4,明显比t1 -> t2 -> t4 -> t3的成本总和11.4要小,因此,prune_level变量为1,开启剪枝功能,可能会忽略成本更低的分支,因此,在联表查询时,如果表的数量很少,prune_level变量为1就不那么合适了。

剪枝调优

好在MySQL给我提供了参数,可以调整这个变量的值。prune_level变量默认为1,我们只需执行下面的命令,就可以将prune_level变量置成0,表示关闭剪枝功能。

set optimizer_prune_level = 0;

遍历深度调优

MySQL在实现贪婪搜索时,使用递归的方法做深度遍历,那么,如果联表的数量非常大,递归调用产生的临时内存空间就会非常大,对于内存敏感的MySQL而言,是不太能接受的,因此,MySQL对遍历的最大深度做了限制,默认为62。

随之而来的问题出现了,如果连接的表数为100,那么,以一张表开始深度遍历其他表,就会触达MySQL的最大深度限制,我们可以想象,这个O(62)的空间复杂度对查询性能的影响是巨大的,因此,我们肯定希望可以调小这个遍历最大深度的阈值。

那么,我们将这个阈值调整到多少合适呢?MySQL很聪明,它给我们提供了一个阈值0,这表示什么含义呢?0表示MySQL自身给我们动态计算了最大遍历深度,计算规则如下:

  1. 如果表数量小于等于7,那么,最大遍历深度为表数量+1
  2. 如果表数量大于7,那么,最大遍历深度为7

发现没,MySQL将7作为动态计算最大遍历深度的阈值,你可以想想为什么用7?但是,从这个动态计算得到的阈值7来看,MySQL可能还是建议我们联表遍历的深度不要超过7。PS:当然最合理的方式是动态统计遍历深度性能,然后,根据统计结果确定最大遍历深度,MySQL源码里做了这样的TODO,这是给我发挥的机会吗,哈哈哈!开玩笑的。

因此,这就回答了我文章标题的问题:Join查询联表的数量最大不要超过多少?答案是7。因为如果我们联表的数量小于等于7,那么,势必从一张表开始遍历其他表的深度不会超过7。

既然可以将最大遍历深度的阈值调整为0,那么,我们该如何调整呢?具体调整方法如下:

set optimizer_search_depth = 0;

总结

最后,我们来总结一下今天的内容:联表成本分析的核心数据结构Join以及驱动表选择的过程。

同时,我还提供了2个参数调优:

场景参数调优
如果表连接数不多建议set optimizer_prune_level = 0; 关闭剪枝功能
如果表连接数小于等于7建议set optimizer_search_depth = 0; 让MySQL自身动态计算最大遍历深度

还回答了标题的问题:Join查询联表的数量最大到底不要超过多少?

MySQL给到的建议是:Join查询联表的数量最大不要超过7

我是小k,如果你觉得这篇文章不错,记得点赞 + 关注哦!

当然,如果你对本文的内容还有疑惑,也欢迎在评论区提问,知无不言,言无不尽!