ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL Join 性能优化:从执行计划到调优实战案例

MySQL Join 性能优化:从执行计划到调优实战案例

说来惭愧,做了这么多年数据库优化,真正让我对 Join 产生敬畏的,不是某本技术书,而是一次线上事故。某天上午十点,某电商平台的订单查询接口突然从 80ms 飙到 3s,监控大屏直接飘红。当时我第一反应是“索引没了吧”,结果一查,索引好好的,SQL 也就是普通的 inner join。后来定位到根因,是优化器选错了驱动表,小表被当成大表扫,Join Buffer 根本塞不下。那次之后我就明白,MySQL 的 Join 不是“两张表拼一下”那么简单,它背后是成本计算、算法选择、内存博弈的一整套机制。

这篇文章我打算从原理讲到实践,把 Join 的执行逻辑、优化器行为、常见性能坑挨个捋一遍,最后用一个完整的调优案例带你看清楚从“慢得离谱”到“快得合理”的全过程。适合正在写业务 SQL 的开发,也适合刚上手数据库调优的运维,甚至你只是个后端工程师,理解 Join 的底层也能帮你写出不那么“虐”数据库的查询。

1. Join 到底在做什么:告别玄学,先搞清楚原理

1.1 关系型数据库的“拼图游戏”

关系型数据库的设计初衷,就是把数据拆开存放,消除冗余。用户表存用户,订单表存订单,商品表存商品,彼此之间靠外键或者业务字段关联。但业务查询的时候,你需要的是完整的信息,比如“这个用户的订单里有哪些商品”,那数据就得重新拼回去。

打个比方,你手里有两叠名片,一叠是客户信息,一叠是拜访记录,现在你想知道“每个客户最近一次拜访是什么时候”。你不可能把两叠名片分别从头翻到尾就算完事,你得一个客户一个客户地,去另一叠名片里找对应的拜访记录。这个“对着找”的过程,在数据库里就是 Join。

Join 的本质是笛卡尔积的过滤。两张表做连接,没有连接条件就是全交叉(笛卡尔积),有连接条件就是在交叉的基础上,只保留满足条件的配对。MySQL 的优化器要做的事情,就是决定“怎么找”最划算。

1.2 五种 Join 类型的选择矩阵

很多人写 SQL 只知道自己用了 inner join 或者 left join,其实 MySQL 支持多种连接方式,选错语义或者选错类型,后果差别很大。

Join 类型语义返回结果使用场景
inner join内连接只返回两边都匹配的行最常见的关联查询
left join左外连接返回左表全部行,右表没匹配的填 NULL以左表为主体,附带右表信息
right join右外连接返回右表全部行,左表没匹配的填 NULL不太常用,通常可以改写为 left join
cross join交叉连接全部笛卡尔积极少直接使用,多为误写
self join自连接同一张表自己和自己关联行转列、层级关系、找配对

我在实际项目里看到最多的坑,是很多同学以为 left join 一定比 inner join “慢”,其实这个认知不准确。left join 语义上要保留左表所有行,优化器有时候会被迫选择固定的驱动表,没法像内连接那样自由调整连接顺序,这才是它可能变慢的真正原因。

还有一点容易被忽略:如果你在 left join 的右表 where 条件里加了过滤,右表的 NULL 补位行会被过滤掉,这相当于把 left join 在语义上变成了 inner join。很多线上慢查询就是这么来的,SQL 执行结果没错,但执行计划已经不是你以为的那个了。

2. 看执行计划:EXPLAIN 是你的第一副眼镜

2.1 EXPLAIN 输出怎么读

调优 Join 的第一步,不是上来改 SQL,而是先看执行计划。EXPLAIN 是 MySQL 提供给我们的“透视镜”,它能告诉你优化器打算怎么执行这条 SQL。

这里先给一个典型输出的样例:

+----+-------------+-------+------------+------+---------------+----------+---------+-----------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+----------+---------+-----------------------+------+----------+-------------+ | 1 | SIMPLE | u | NULL | ALL | PRIMARY | NULL | NULL | NULL | 1000 | 100.00 | NULL | | 1 | SIMPLE | o | NULL | ref | idx_user_id | idx_user_id | 4 | test.u.id | 2 | 100.00 | Using index | +----+-------------+-------+------------+------+---------------+----------+---------+-----------------------+------+----------+-------------+

这个计划里面,信息量最大的是这几列:

  • type 表示访问类型,从好到差依次是 system > const > eq_ref > ref > range > index > ALL。看到 ALL 就要警惕,这意味着全表扫描。
  • rows 是优化器估计要扫描的行数,不是精确值,但数量级很有参考意义。
  • key 是实际选中的索引,如果为 NULL,说明这个表上没有用到索引。
  • key_len 表示索引使用的字节长度,可以用来判断是不是用了复合索引的完整部分。
  • Extra 里的信息最容易被忽略,但往往最致命。

2.2 type 列:访问类型的优先级

我在指导项目组同学看执行计划的时候,第一步就是教他们看 type。如果一张表的访问类型是 ALL,那不管 SQL 写得多么“优美”,这个查询在大数据量下都会出问题。

举个具体例子,一张订单表 5000 万行,通过 user_id 去关联,如果 user_id 上有索引,MySQL 每从驱动表拿出一行,就能通过索引迅速定位到匹配行,成本极小。但如果 user_id 上没有索引,MySQL 只能对被驱动表做全表扫描,驱动表哪怕是 1000 行,都要做 1000 次 5000 万行的全表扫描,这在任何机器上都扛不住。

所以很多人说 Join 慢,第一嫌疑永远是被驱动表的连接列没索引。这句话虽然不绝对,但在绝大多数场景下是成立的。被驱动表的连接列有索引,type 通常能到 ref 或者 eq_ref,Join 效率就能上一个台阶。

3. 三种 Join 算法:优化器在背后做了什么

3.1 嵌套循环连接:最朴素的思路

嵌套循环连接(Nested Loop Join)是最容易理解的算法。拿驱动表的第一行,去被驱动表找匹配行;然后拿第二行,再去找。一层套一层,所以叫嵌套循环。

这个算法的时间复杂度是 O(mn)(m 是驱动表行数,n 是被驱动表行数),听起来很吓人,但如果被驱动表的连接列有索引,内层循环就变成了索引查找,时间复杂度骤降到 O(mlog n),甚至 O(m*1)。这也是为什么索引对 Join 如此重要——它把“全表找”变成了“直接定位”。

3.2 块嵌套循环连接:批量换思路

块嵌套循环连接(Block Nested Loop Join)是 5.6/5.7 时代 MySQL 的备选方案。当被驱动表上没有合适的索引时,MySQL 不会笨到每取一行驱动表就全表扫一次被驱动表,而是先把驱动表的一批行放到 join_buffer 里,然后一次性把整个被驱动表读一遍,内存中的数据两两判断。

相当于你不再是一个客户跑一趟档案室,而是带上 100 个客户的信息,去档案室批量查阅这 100 个客户对应的记录。IO 次数大幅度降低,代价是要占用内存,内存大小由 join_buffer_size 参数控制。

这个算法在 MySQL 8.0.18 引入 hash join 之后的 8.0.20 版本被彻底移除,所以新版 MySQL 里如果你在 Extra 看到 Using join buffer,实际上是 hash join 的逻辑。但理解它的思路仍然有意义,因为你会在老版本的系统里遇到它。

3.3 哈希连接:新版本的杀手锏

MySQL 8.0.18 引入了 Hash Join,这是一个重大的性能改进。Hash Join 的思路是:把驱动表中连接列的值取出来,计算哈希值,构建一张哈希表到内存里。然后扫描被驱动表,每一行都算出连接列的哈希值,直接去哈希表里探测,命中就匹配。

这个过程只需要扫描两张表各一次,不需要索引,也不需要循环嵌套,特别适合没有任何索引的大表等值连接场景。你在 MySQL 8.0 的版本里执行两个大表 join,如果看到 Extra 里有 Hash Join 字样,说明 MySQL 正在用这个算法。

不过 hash join 需要把一张表(通常是较小的那张)完整放进内存。join_buffer_size 太小的时候,MySQL 会把哈希表分块写入磁盘,性能会断崖式下跌。所以配置过小的 join_buffer_size,hash join 也可能成为性能瓶颈。

4. 驱动表与连接顺序:谁是老大,谁是小弟

4.1 为什么小表驱动大表

在嵌套循环算法中,驱动表决定了外层循环次数,被驱动表决定了内层循环次数。外层每多一行,内层就要多执行一次完整的查找过程。所以常规策略是:行数少的表做驱动表,行数多的表做被驱动表,这样内层查找的次数更少。

这不是什么高深理论,而是一个乘法分配律的问题。1000 行的小表驱动 1 亿行的大表,如果有索引,内层每次查找是毫秒级的,总共也就是 1000 次查找。反过来让大表驱动小表,外层循环 1 亿次,哪怕内层每次只要 0.1 毫秒,累计也要数小时。

4.2 优化器如何选择驱动表

MySQL 的优化器会用基于成本的方式,估算每个执行计划的代价。代价包括 IO 代价和 CPU 代价,估算依据是行数、索引区分度、缓冲池命中率等因素。对于 inner join,优化器有比较大的自由度,它会选它认为成本最低的那个方案。

绝大多数情况下,优化器给出的计划是合理的。但优化器的判断依赖统计信息,如果统计信息过期了——比如表刚经历了大量增删改,但没有运行 ANALYZE TABLE——它可能做出错误的估算,选错驱动表。

还有一点,left join 和 right join 的驱动表是固定的。left join 以左表为驱动表,right join 以右表为驱动表。如果你确定某个 left join 实际上只需要匹配到的数据(左表的某些行可以被过滤掉),你可能会发现性能上不去。

4.3 逼优化器做选择的手段

当优化器的选择确实有问题的时候,我有几个不伤筋动骨的手段:

用 STRAIGHT_JOIN 强制指定驱动顺序。这是 MySQL 提供的一个查询提示,写法是在 from 关键字的位置替换成 straight_join,它会强制按照表出现的顺序来连接。不过我要提醒一句,用了这个你就得对连接顺序负全责,改成这个容易,回退难,因为一旦表的行数发生变化,你的固定顺序可能就不再是优解。

还有一种温和的做法,是改写 SQL 让优化器更容易估算。比如给过滤列补上精确的统计信息,或者给连接列加上更合适的索引。这些手段能改善优化器的信息输入,比强行指定驱动表更安全。

我个人很少用 optimizer hints 去固定驱动顺序,除非是排查线上问题时的临时手段。优化器大部分时候比人靠谱,除非你掌握了充分的证据证明它的选择是错的。

5. 六个最常踩的 Join 性能坑

5.1 坑一:JOIN 列没有索引

这个坑我在前面反复强调,因为它是“Join 慢”的第一大来源。被驱动表的连接列没有索引,MySQL 只能用 hash join 或者全表扫描的方式去匹配。当你加完索引之后,常会发现同样的 SQL 从 3 秒变成 0.1 秒。

有个细节很多人不知道:创建索引的时候,连接列的数据类型必须完全一致。一个表用 int,另一个表用 varchar,连接时明明数据是一样的,但索引可能失效,因为 MySQL 要做隐式类型转换,转换之后就绕过了索引。

5.2 坑二:隐式类型转换

说到隐式类型转换,我单独拿出来讲,因为这个坑非常隐蔽。比如订单表的 user_id 是 varchar 类型,用户表的 id 是 int 类型,你写ON o.user_id = u.id,MySQL 会把 varchar 列转换成数字再比较。一旦做了函数转换,这列上的索引就没法用了。

怎么判断有没有隐式转换?看执行计划里 key_len 和 type。如果连接列的类型不一致,你可能会看到 type 是 ALL,key 是 NULL,明明那列上有索引却没用上。解决办法很粗暴也很有效:改表结构,让两边类型保持一致。这会在上线之前就要做好检查。

5.3 坑三:ORDER BY 和 LIMIT 的协同效应

很多开发同学在写“取最近 10 条订单”的 SQL 时,会直接JOIN ... ORDER BY create_time DESC LIMIT 10。看起来逻辑没问题,但 MySQL 的执行过程可能是这样的:先把所有 join 结果产生出来,然后对所有结果做排序,最后取前 10 条。

如果两张表都很大,join 中间结果可能几百万行,排序代价巨大。我经常建议的做法是先对驱动表做排序和 limit,再 join。比如先选出某个用户最近 10 个订单,再去关联商品信息,这样被排序的数据量只有 10 行。

当然,如果业务上你需要“全局最近 N 条”,那就得有索引支撑。ORDER BY 的列上如果有索引,并且 join 顺序允许走索引路径,MySQL 可以直接避免文件排序。

5.4 坑四:LEFT JOIN 被优化器“优化”掉了

听起来不可思议,但我确实见过。你写了一堆 left join,结果执行计划出来全是 inner join 的算法。原因是你在右表的 where 条件里加了限制,比如LEFT JOIN orders ON users.id = orders.user_id WHERE orders.amount > 100。

这种情况下,右表里没有匹配的行会被过滤掉,left join 的语义已经被摧毁了。如果业务上确实需要“只返回有订单的用户”,那不如直接改成 inner join,也让优化器有更多选择空间。如果业务上需要保留无订单用户,那这条过滤条件必须放到 ON 条件里。

5.5 坑五:NULL 值的隐性陷阱

连接列上存在 NULL 值会带来两个问题。第一个是 NULL 和任何值做等值比较结果都是 NULL,包含 NULL 的行永远不会匹配成功。第二个是 NULL 值不会进入普通索引,如果连接列大量为 NULL,索引的区分度可能受到影响。

我遇到过一个案例,用户在表里存了很多测试数据,user_id 全是 NULL,导致 join 结果总是少一截数据,业务方排查了两天都没发现,最后是我看了数据分布才发现问题。如果你的业务允许连接列为 NULL,请务必确认过滤条件覆盖了这个场景。

5.6 坑六:数据膨胀与分组

有些查询 join 之后行数不对,不是 bug,而是你忘了两张表是多对多关系。比如一张用户表和一张优惠券表 join,一个用户有多张优惠券,结果行数就不是用户数,而是用户和优惠券的配对数量。如果后面再 count、再分组,统计结果很容易被带偏。

这个问题不在性能范畴,但它经常出现在“为什么 join 变慢”的讨论里,因为数据膨胀后的临时表可能非常大,拖垮整个查询。解决思路是先聚合后 join,把多的一侧先按维度聚合到一行,再去关联。

6. 一个完整的调优案例:从 2000 毫秒到 20 毫秒

6.1 现场还原

某订单管理后台有一个接口,返回的是“最近 30 天内有下单记录的用户及其订单数”,SQL 大概是这样的:

SELECT u.id, u.name, COUNT(o.id) AS order_cnt FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY u.id, u.name ORDER BY order_cnt DESC LIMIT 20;

这个 SQL 在数据量上去之后,接口响应时间稳定在 2 秒以上。orders 表已经超过 2000 万行,users 表 50 万行。业务方已经加了索引在 created_at 上,但是效果不明显。

6.2 排查过程

我拿到 SQL 第一件事就是跑 EXPLAIN,关键信息很明确:orders 表的 type 是 ALL,key 是 NULL。这说明 MySQL 为了关联 users 表,需要对 orders 表做全表扫描,即使你建立了 created_at 索引,也没用在关联路径上。

再看 SQL 本身,其实还有一个语义问题。条件是WHERE o.created_at >= ...,这在 left join 里会把右表不匹配的行过滤掉,left join 变成 inner join。同时 GROUP BY u.id, u.name,如果 users 表的主键是 id,那仅 GROUP BY u.id 就够,加 u.name 只是为了让查询合法,但这会让临时表带上 name 列,增加排序和分组的内存消耗。

6.3 优化方案

针对上面的问题,我做了三个改动:

改 inner join。因为 where 条件已经过滤掉了无订单用户,left join 名存实亡,不如明确语义交给优化器。

调整连接方向,把“小表驱动大表”变成现实。优化器对 inner join 有自由度,实际执行会选 2000 万行的 orders 作为驱动表,还是 50 万行的 users?在这条 SQL 里,created_at 过滤条件可让 orders 先缩到一个月内的 20 万行,但优化器如果先按这个过滤再 join,倒是可行的。关键是要给 orders 表的 user_id 建索引,这样无论谁做驱动表,被驱动表都能走索引。

SQL 改写为子查询先行。把 30 天内的订单先做聚合,再关联用户表:

SELECT u.id, u.name, t.order_cnt FROM users u INNER JOIN ( SELECT user_id, COUNT(*) AS order_cnt FROM orders WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY user_id ) t ON u.id = t.user_id ORDER BY t.order_cnt DESC LIMIT 20;

这个改写的核心逻辑是:先把大表 orders 在 30 天窗口内聚合到“每个用户一行”,这样参与 join 的数据量只有 20 万行左右,再进行 join 和排序的成本就低多了。同时子查询里只需要 user_id 和 count,走覆盖索引非常快。

执行后,查询从 2.3 秒降到 28ms,效果立竿见影。

6.4 后续建议

优化完这一个 SQL 之后,我在代码审查里加了两条规范:

第一,写 join 之前先思考一下连接列两边是否都有索引,尤其是被驱动表这一侧。没有索引,任何 SQL 改成子查询或者调整顺序都只是缓解,不解决根本问题。

第二,尽量避免三重以上的表连接。MySQL 在多表连接时,连接顺序的组合复杂度是 O(n!),优化器计算成本也会上升。三层以上连接,每增加一层,执行计划的不确定性都会放大。多数情况下,三层以上的 join 都可以通过拆 SQL、用临时表或者改写成子查询来收敛数据量。

写在最后的几点经验

如果只让我留一句话:先把执行计划看懂,再谈优化。很多调优手段本质上都是在改善优化器的决策质量——要么给它更好的索引,要么给它更少的数据量,要么给它更合理的 SQL 语义。

我个人在实际排查里还有一个习惯:每次定位到慢查询,不止修当前 SQL,还会把这条 SQL 涉及的表统计信息刷新一遍,顺手把慢查询日志里同类模式的 SQL 都捞出来统一看。因为一个连接列没索引的问题,往往是不止一条 SQL 遭殃的。

最后分享一个小技巧:给连接列建索引的时候,记得看复合索引的顺序。如果已有 (user_id, created_at) 这样的索引,那 join 条件可以直接用索引前缀;但如果你是WHERE user_id = xxx AND created_at >= yyy的场景,(created_at, user_id) 反而更合适。索引不是建了就完事,建错了比不建更坑,它占空间、拖写入,还没给查询带来任何收益。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进