说来惭愧,做了这么多年数据库优化,真正让我对 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) 反而更合适。索引不是建了就完事,建错了比不建更坑,它占空间、拖写入,还没给查询带来任何收益。