前言
从今天开始,小怪将使用CSDN社区作为MySQL案例的应用场景,详细剖析MySQL的实现细节。
相信博友们都知道CSDN社区个人主页上有一个“关注了”和“关注者”的功能,尤其是“关注者”功能,当你看到消息提醒,又有人关注你啦,你可能会点进关注者列表瞅瞅:咦,今天又有哪位大佬关注我啦!有点小激动,哈哈哈!
今天小怪就以“关注者”这个功能为例,看看我们是怎么用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为关联表数量),比较各种组合的执行成本的代价也就越高,进而相应的查询语句执行就会变慢。
既然语句执行慢的原因之一是花在了驱动关系的成本分析上,那么,我们就来看一下这个驱动关系成本分析的过程是什么样的?
在讲解成本分析的过程之前,我们先来看下成本分析相关的几个核心结构,因此,在这里,我以《导读》中的语句为例来讲解这些结构:
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中呢?先别急,我们先来看下这张图:
有没有发现,这不就是一个图的深度遍历嘛!没错!
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在比较不同的驱动关系成本的时候,也使用了深度遍历,这种遍历方式在算法上叫做贪婪搜索。因此,《前言》中的语句,其贪婪搜索的过程就变成这样:
如上图,MySQL分别计算了user表驱动t_user_relation表和t_user_relation表驱动user表的成本:
- user -> t_user_relation:user表查询成本为2.6,t_user_relation查询成本为9.6,因此,总成本为2.6 + 9.6 = 12.2。
- t_user_relation -> user:t_user_relation表查询成本为2.0127,user表查询成本为6,因此,总成本为2.0127 + 6 = 8.0127。
那么,现在我们了解了MySQL比较驱动关系成本的过程,回到关联表过多,导致查询变慢的问题,我们知道如果关联表很多,那么,MySQL处理查询语句时,不得不做更多的表顺序组合,对各种组合进行贪婪搜索,来比较它们的成本。这对MySQL而言,势必影响查询的性能。
剪枝调优
好在MySQL给我提供了参数,可以调整这个变量的值。prune_level变量默认为1,我们只需执行下面的命令,就可以将prune_level变量置成0,表示关闭剪枝功能。
set optimizer_prune_level = 0;
遍历深度调优
MySQL在实现贪婪搜索时,使用递归的方法做深度遍历,那么,如果联表的数量非常大,递归调用产生的临时内存空间就会非常大,对于内存敏感的MySQL而言,是不太能接受的,因此,MySQL对遍历的最大深度做了限制,默认为62。
随之而来的问题出现了,如果连接的表数为100,那么,以一张表开始深度遍历其他表,就会触达MySQL的最大深度限制,我们可以想象,这个O(62)的空间复杂度对查询性能的影响是巨大的,因此,我们肯定希望可以调小这个遍历最大深度的阈值。
那么,我们将这个阈值调整到多少合适呢?MySQL很聪明,它给我们提供了一个阈值0,这表示什么含义呢?0表示MySQL自身给我们动态计算了最大遍历深度,计算规则如下:
- 如果表数量小于等于7,那么,最大遍历深度为表数量+1
- 如果表数量大于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。
当然,如果你对本文的内容还有疑惑,也欢迎在评论区提问,知无不言,言无不尽!