半夜收到告警,一条统计 SQL 跑了二十多秒,主库 CPU 直接飙到 90%。登录上去一看,又是 JOIN。这类问题我接过太多次了,凡是跟 JOIN 有关的慢查询,最后查下来无非就是几个原因:驱动表选错了、连接字段没索引、中间结果集膨胀得太离谱、或者干脆是表结构阶段就没给 JOIN 留后路。MySQL 的 JOIN 原理本身不复杂,但恰恰因为“看起来简单”,很多人会在细节上翻车。
这篇文章把 MySQL Join 的核心原理、执行计划怎么看、以及我实际优化过的一些套路完整捋一遍,适合后端开发、DBA、运维和所有被慢查询折磨过的同学。不管你是刚接触 MySQL 的新人,还是已经写过不少复杂 SQL 的老手,只要想把 JOIN 相关的问题一次排查干净,这篇内容应该都能给你一些可复现的思路。
1. 先把 Join 的“账”算明白:三种核心算法原理
1.1 Nested-Loop Join:最朴素也最容易出事
很多人在学校学的第一条 JOIN 原理就是嵌套循环。它的思路特别直白:拿第一张表的每一行,去第二张表里找匹配的行。这个逻辑翻译成程序就是双重 for 循环:
for each row in table_A: for each row in table_B: if row_A.key == row_B.key: output(row_A, row_B)如果 A 表有 N 行,B 表有 M 行,最坏情况下要比较 N×M 次。这就是 Simple Nested-Loop Join,也是理论上最慢的一种 JOIN 方式。区别在于,MySQL 在实际执行的时候会尽量把“第二张表”的匹配从全表扫描变成索引查找,原理还是嵌套循环,但每一行去 B 表查找时走的是索引,速度完全不一样。
我在刚开始排查慢查询时容易犯一个错误,就是看到执行计划里的 “Using join buffer” 就觉得“行,这不是嵌套循环”。实际上 Join Buffer 背后的 Block Nested-Loop Join 也是嵌套循环的思路,只是做了批量优化。后面我会专门讲。
提示:判断一个 JOIN 是不是“笨办法”,核心就看被驱动表有没有用到索引。如果被驱动表的匹配走的是全表扫描,那不管你的 SQL 写得多漂亮,性能都好不到哪去。
1.2 Block Nested-Loop Join 与 Hash Join
MySQL 在 5.x 时代用得最多的不是 Simple Nested-Loop,而是 Block Nested-Loop Join(BNLJ)。它的思路是:驱动表一次读一批行,放到内存里的 join_buffer 中,然后用这一整批数据去扫描被驱动表,和被驱动表的每一行做匹配。这样被驱动表被全表扫描的次数就大大降低,从“每行扫一次”变成了“每批扫一次”。
MySQL 8.0.18 版本以后正式引入了 Hash Join,情况又不一样了。Hash Join 会把其中一张表的数据读出来,在内存里构建一张哈希表,然后扫描另一张表,每行都去哈希表里探测。这种方式非常适合两张表都没有索引、或者连接字段无法走索引的等值连接场景。很多 DBA 在升级到 MySQL 8.0 后发现“无索引 JOIN 也没那么慢了”,其实就是 Hash Join 的功劳。
我整理了一张算法对比表,方便你直接判断当前 SQL 可能走了哪条路:
| 算法 | 核心思路 | 被驱动表是否需要索引 | 适用的连接场景 | 主要瓶颈 |
|---|---|---|---|---|
| Simple NLJ | 逐行嵌套循环 | 最好有索引 | 小表驱动大表 | N×M 次比较,行数大时直接崩 |
| Index NLJ | 嵌套循环 + 索引查找 | 必须要有合适索引 | 最理想场景 | 一次索引查找约 logM,耗时可接受 |
| Block NLJ | 驱动表分批进 join_buffer | 不一定需要 | 无索引时的过渡方案 | join_buffer 不够大时会多次落盘 |
| Hash Join | 建哈希表 + 探测 | 不需要 | 等值连接、无索引大表 | 内存占用,溢写磁盘后变慢 |
1.3 Sort-Merge Join 的适用场景
PostgreSQL、SQL Server 里面很常见 Sort-Merge Join,MySQL 目前基本上用不到。它的思路是:先把两张表按连接字段排序,然后用两个指针像拉链一样从头往后匹配。适合连接字段本身有序、或者非等值连接(比如区间匹配)的场景。
MySQL 官方文档里很少提 Sort-Merge Join,但我在看执行计划时偶尔会看到 “Using filesort” 加上 JOIN 的情况,那并不是真正的 Sort-Merge Join,只是优化器为了后续操作先把结果排序而已。理解它存在的意义主要是帮我们拓宽思路:如果两张表的数据已经是按连接字段有序的,那么一次线性扫描就能完成合并,这在理论上比 Hash Join 还省内存。不过既然是 MySQL 不主推的路径,平时你只需要知道有这回事,不需要太较真。
2. 真正影响 Join 性能的底层因素
2.1 被驱动表连接字段的索引决定了 90% 的性能
我先说结论:优化 JOIN 的第一步,永远是看被驱动表的连接字段上有没有索引。这句话我在无数案例里验证过,基本没错。
我举个例子。有一张某电商平台的订单表 t_order,里面 100 万行数据;另一张是用户表 t_user,50 万行。需求是把订单和用户按 user_id 关联,查用户姓名。如果 t_user.user_id 上没有索引,MySQL 对每一笔订单都要去 t_user 里做一次全表扫描。哪怕订单表只扫 10 万行,每次全表扫 50 万行,那就是 10 万乘以 50 万等于 500 亿次比较,这个数量级在在线业务里是不可能扛住的。
加上索引之后呢?每次匹配变成了 B+ Tree 查找,复杂度从 O(M) 变成 O(logM),500 亿次比较直接降成一两百万次,这中间差了不止两个数量级。所以很多“慢 JOIN”的真相是:表结构设计时压根没想过这个查询路径,等到业务上线才发现慢。
提示:加索引的目标是被驱动表,不是驱动表。驱动表连接字段走索引意义不大,因为驱动表的每一行都会被读出来,把它当“外层循环”理解就行。
2.2 优化器是怎么决定驱动表的
MySQL 的优化器会基于表行数、索引区分度、过滤条件选择性等统计信息来估算每种 JOIN 顺序的成本,然后选择它认为成本最低的那个方案。你要做的第一件事就是看懂 EXPLAIN 输出:哪个表排在最前面,哪个表就是驱动表。
如果统计信息不准,优化器就会“乱点鸳鸯谱”。比如某张表实际只有几千行,但统计信息显示有几十万行,优化器很可能不选它当驱动表,结果性能立刻劣化。这时候解决办法一般是:
- 重新收集统计信息:
ANALYZE TABLE t_user; - 用
STRAIGHT_JOIN强制指定驱动顺序,但只建议临时验证,不建议写死在业务 SQL 里 - 手动改写 SQL 顺序,让优化器多一个参考项
我记得有个生产案例,优化器选了一张 30 万行的订单表当驱动表,被驱动表是只有 500 行映射关系的配置表。表面看确实是小表驱动大表,但问题在于配置表连接字段没索引,导致每行订单去配置表全表扫 500 行,最后跑了 15 秒。我把配置表连接字段加了索引后,同样执行计划瞬间降到 0.05 秒。这里我真正体会到了:驱动表选择重要,但被驱动表有没有索引更重要。
2.3 连接字段的类型一致性与字符集陷阱
连接字段只要发生隐式类型转换,MySQL 就无法直接使用索引,这是最常见的“有索引但用不上”的场景。最典型的就是:a 表 user_id 是 INT,b 表 user_id 是 VARCHAR,JOIN 条件写成a.user_id = b.user_id。字符串跟数字比较时,MySQL 会把字符串转成数字,但转换过程可能作用在索引列上,导致索引失效。
字符集不一致同样致命。比如 a 表 user_id 是 utf8mb4,b 表 user_id 是 latin1,MySQL 要先把两边都转成同一个字符集才能比较。一旦发生隐式转换,驱动表的选择会被影响,被驱动表的索引也可能直接废掉。
这个检查步骤非常快,只要在建表或迁移时保证所有关联字段类型一致、字符集一致,就能避免一大半“莫名其妙慢”的 JOIN。我建议你在新项目开发规范里直接加一条:所有外键关联字段,类型、长度、字符集、排序规则必须完全一致。
3. 优化前的“侦察”工作:Explain 和 optimizer trace 怎么看
3.1 explain 关键字段逐个看
拿到一条慢 JOIN,第一件事不是猜,是跑 EXPLAIN。我一般会重点看这几个字段:
- type:访问类型。从好到差大致是
system > const > eq_ref > ref > range > index > ALL。如果是 ALL,说明是全表扫描,这是最需要警惕的。 - key:实际使用的索引。NULL 代表没用索引,直接就能判断连接字段索引是否生效。
- rows:优化器预估需要读取的行数。这个值不是精确值,但能看出大概的量级。
- Extra:这里信息量最大。出现
Using temporary说明可能要临时表,出现Using filesort说明要额外排序,出现Using join buffer说明走的是 BNLJ 或无索引 JOIN。
我拿一个典型的问题 SQL 举例子:
SELECT a.order_id, a.amount, b.user_name FROM t_order a INNER JOIN t_user b ON a.user_id = b.id WHERE a.status = 1 ORDER BY a.create_time DESC LIMIT 20;EXPLAIN 的输出大概是:
+----+-------+--------+------+---------------+------+---------+------+--------+----------+-----------------------------+ | id | table | type | key | ref | rows | filtered | Extra | +----+-------+--------+------+---------------+------+---------+------------------------------+ | 1 | a | ALL | NULL | NULL | 100000 | 5.00 | Using where; Using temporary; Using filesort | | 1 | b | ALL | NULL | NULL | 50000 | 10.00 | Using where | +----+-------+--------+------+---------------+------+---------+------+--------+----------+-----------------------------+这张执行计划一出来,问题很清楚:两张表都是 ALL,连接字段完全没有利用索引。这时候加一个ALTER TABLE t_user ADD INDEX idx_id (id);就能把 b 表的访问改成eq_ref,理想情况下一秒内就能完成。如果你看到两个都是 ALL,同时又有Using join buffer,那基本断定是 Block Nested-Loop Join 在兜底。
3.2 用 optimizer trace 看优化器的“内心戏”
执行计划只是结果,想搞清楚优化器为什么这么选,要用到 optimizer trace。操作很简单:
SET optimizer_trace = 'enabled=on'; -- 执行你正在排查的那条 SQL SELECT ...; SELECT * FROM INFORMATION_SCHEMA.OPTIMIZER_TRACE; SET optimizer_trace = 'enabled=off';输出里面核心看两块:rows_estimation是优化器对每张表行数的预估,considered_execution_plans是它考虑过的 JOIN 顺序。如果你发现优化器选择了一个明显错误的驱动表,从这里就能找出原因。
比如结果里可能写着它预估 t_user 要扫描 80 万行,实际只有 20 万行。那问题就出在last_query_cost不准,根源往往是 MySQL 的统计信息过期。这种情况你跑一条ANALYZE TABLE就能解决一大部分问题,根本不需要改 SQL。这个技巧在“慢 SQL 优化”场景里非常实用,但我在社区里看到很多朋友并不知道。
3.3 从全表扫描到索引查找:一次真实执行计划的前后对照
有一次我优化某后台报表查询,SQL 语句是查所有未发货订单及其门店名称,两张表都是百万级。优化前 EXPLAIN 显示 t_order 是 ALL,t_store 也是 ALL,且 Extra 里有Using join buffer (Block Nested Loop),查询耗时 32 秒。
我先给 t_store.id 加主键索引,结果 t_store 从 ALL 变成 eq_ref,但 t_order 还是 ALL,耗时降到 12 秒。接着发现 t_order.status 只有 0 和 1 两个值,区分度太低,单独建索引没意义。于是改成在 t_order 上建联合索引(status, store_id),并给 connect column store_id 单独建索引。优化后 EXPLAIN 里驱动表走了 index range,被驱动表走 eq_ref,耗时直接降到 0.2 秒。
这个过程给我最大的启发是:EXPLAIN 不是跑一次就够了。每次加索引、改 SQL 后应该重新看执行计划,对比 type、rows、Extra 的变化,而不是只盯着最终耗时,因为线上环境网络波动和缓存都可能骗你。
4. SQL 改写与表结构调整的实战套路
4.1 用反范式字段代替高频 Join
很多慢 JOIN 的根源,不是 SQL 写得烂,而是表结构设计时把范式看太重。三范式理论上没错,但互联网高并发场景下,频繁 JOIN 往往是不可持续的。我比较推荐的做法是:把高频查询里经常展示的冗余字段直接落到业务表里。
举个例子,之前做电商后台,每笔订单都要关联查询店铺名称。店铺改名不频繁,但我们一天要查几十万次订单列表。后来直接把 store_name 冗余到订单表里,店铺更名时定时任务统一回写订单表。JOIN 消失后,查询从 3 秒降到 30 毫秒,而且锁竞争也少了。这不是激进,而是在“读多写少”字段上的典型空间换时间策略。
冗余字段要注意一致性问题:如果底层数据经常变,比如价格、库存,那不适合冗余;如果是名称、分类名、状态描述这种低频变更字段,冗余是极其高效的方案。你还可以搭配消息队列或事件回放机制来保证最终一致性,这在现在的架构里已经很成熟了。
4.2 拆成多条查询,把大 JOIN 变成多次小查询
有时候一个复杂 SQL 里 JOIN 了五六张表,中间还有 GROUP BY 和子查询。这种语句对优化器来说成本估算非常困难,也容易出现临时表暴涨。我的习惯是:能拆就拆,用应用层做聚合。
比如原来一条 SQL 同时 JOIN 了订单表、用户表、商品表、类目表,我通常会这样拆:
-- 第一步:查订单本身,先缩小范围 SELECT order_id, user_id, product_id, amount FROM t_order WHERE status = 1 AND create_time >= '2024-01-01' AND create_time < '2024-02-01'; -- 第二步:拿上面的 user_id 集合去查用户表 SELECT user_id, user_name FROM t_user WHERE user_id IN (...); -- 第三步:拿 product_id 集合去查商品表 SELECT product_id, product_name FROM t_product WHERE product_id IN (...);应用层最多做两次 N+1 查询,如果数据量不大,性能完全可控。但要注意:这种拆法不适合分页接口,因为第一步结果可能上万行,传给应用层的 id 集合会非常大。比较适合的是报表统计、后台任务、批量导出这类离线场景。
4.3 分页深翻页导致的 JOIN 慢
分页 JOIN 慢是另一个高频问题。典型 SQL 是这样的:
SELECT a.order_id, b.user_name FROM t_order a LEFT JOIN t_user b ON a.user_id = b.id ORDER BY a.order_id LIMIT 1000000, 20;MySQL 会先把所有条件过滤完,再排序,然后抛弃前 100 万行。哪怕后面只取 20 行,前面的 100 万行也必须全部算出来。这时候 JOIN 再快也没用,瓶颈落在 LIMIT 的大偏移量上。
我的处理方法是用“延迟关联”或者“键集分页”。核心思路是:先用最小的代价拿到这一页的主键,再回原表查完整数据:
-- 第一步:只查主键 SELECT a.id FROM t_order a ORDER BY a.id LIMIT 1000000, 20; -- 第二步:再 JOIN 或 IN 查询 SELECT a.order_id, b.user_name FROM t_order a LEFT JOIN t_user b ON a.user_id = b.id WHERE a.id IN (...);这样第一步走覆盖索引,不会产生大量回表;第二步的数据量也被限制在 20 行内,JOIN 的压力极小。如果前端是滚动翻页,那就直接用游标形式:传最后一个订单 id,用WHERE a.id > last_id LIMIT 20,这种键集分页方式在千万级数据上表现最好。
4.4 INNER JOIN 和 LEFT JOIN 的语义陷阱
INNER JOIN 和 LEFT JOIN 在语义上就有区别。优化器对 INNER JOIN 有更大的自由交换两张表的顺序,因为它知道两边结果最终是一致的;但 LEFT JOIN 是外连接,优化器不能随意把右表变成驱动表,否则结果可能就不对了。
所以当你在一个 LEFT JOIN 的 SQL 里发现驱动表不是“左表”时,不用惊讶,优化器在遵守语义的前提下会尽量选小表当驱动表。但如果你在 LEFT JOIN 的右表上使用 WHERE 条件,比如WHERE b.user_name = '张三',这实际上会把 LEFT JOIN 变成 INNER JOIN 的语义,因为条件已经隐含了“b 表必须有匹配记录”,这时优化器可能改变执行策略。这个隐式行为很容易被忽略,但它会影响最终结果和性能。
我见过有人为了让查询变快,把一个 LEFT JOIN 改成 INNER JOIN,结果业务数据被过滤掉,这种优化是绝对不能做的。优化 JOIN 有一个底线:结果集必须不变,这个底线比性能优先级高得多。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
我把日常排查中经常遇到的问题整理成一张速查表,你可以直接对照着处理:
| 现象 | 可能原因 | 排查方法 | 解决建议 |
|---|---|---|---|
| 两张表都小但 JOIN 很慢 | 连接字段类型不一致,索引失效 | EXPLAIN 查看 key 是否为 NULL | 统一字段类型、长度、字符集 |
| 有索引但 type 仍为 ALL | 查询条件里对索引列用了函数或隐式转换 | SHOW WARNINGS 查看转换前后语句 | 去掉函数、修改条件写法 |
| 驱动表是明显的大表 | 统计信息过期或字段区分度太低 | optimizer trace 查看预估行数 | ANALYZE TABLE,或调整 SQL 顺序 |
| 出现 Using temporary + Using filesort | 排序字段和 JOIN 字段冲突 | 查看 ORDER BY 和 GROUP BY 字段 | 建联合索引,或拆分排序查询 |
| 分页越翻越慢 | LIMIT 偏移量过大 | 观察慢 SQL 的 rows 扫描量 | 改用键集分页或游标 |
| join_buffer 飙高内存又没改善 | 被驱动表无索引,靠 join_buffer 硬扛 | EXPLAIN 看 Extra | 加连接字段索引才是根本解法 |
5.2 我踩过的坑
第一个坑是隐式转换。某次我把一个大表订单表的 user_id 建成 VARCHAR(32),用户表 user_id 是 BIGINT,两边数据一模一样,但 JOIN 条件一直用不上索引。当时查了半天,最后 SHOW WARNINGS 才发现 MySQL 自动加了 CAST 转换。改成两边都是 BIGINT 后,执行计划立刻正常了。
第二个坑是 join_buffer_size 调太大。我曾在低配服务器上把 join_buffer_size 调到 64M,结果高并发下内存瞬间被打满,还触发了 OOM。调大 join_buffer 只能改善 BNLJ 的扫描次数,如果连接字段没有索引,治标不治本。我现在更推荐的做法是保持默认 256K 左右,把精力花在 SQL 改写和索引建设上。
第三个坑是 JOIN 里的锁范围。如果 JOIN 里带了FOR UPDATE,锁的粒度会直接影响并发。MySQL 的行锁是在存储引擎层加的,Join 语句会锁住所有扫描到的行,而不只是最终结果集的行。一次慢 Join 可能锁住几万行数据,导致其他事务阻塞。排查时如果遇到“莫名其妙的事务等待”,除了看锁分类,也要确认是不是 JOIN 扫描范围过大。
5.3 参数调整建议
参数调整是优化 JOIN 的辅助手段,不是主力。如果你确认执行计划已经走到最优,但吞吐量还是不够,可以有限地考虑这几个参数:
join_buffer_size:控制在 2M-8M 左右,实测提升 BNLJ 性能有效,但别盲目调到几十 M。sort_buffer_size:影响 filesort 排序性能,调太大会造成内存浪费,建议从 2M 起步试。max_execution_time:可以在 SQL 级别限制最坏执行时间,避免慢查询拖垮数据库。
我最常做的组合是:先看 EXPLAIN,再结合这些参数做压测,每次只改一个变量,对比前后执行计划。这个习惯帮我避开了非常多“调完参数反而更差”的情况。
6. 一个完整优化案例复盘
6.1 原始 SQL 和执行计划
为了让你更直观地理解整个过程,我完整复盘一个案例。背景是一套会员积分系统,需求是统计最近一个月的订单量、订单金额和会员等级。原始 SQL 大致长这样:
SELECT c.customer_id, c.customer_name, l.level_name, COUNT(o.order_id) AS order_cnt, SUM(o.order_amount) AS amount_sum FROM dim_customer c LEFT JOIN dim_level l ON c.level_id = l.id INNER JOIN fact_order o ON c.customer_id = o.customer_id WHERE o.pay_time >= '2024-10-01' AND o.pay_time < '2024-11-01' GROUP BY c.customer_id, c.customer_name, l.level_name;这个 SQL 在测试环境跑,数据量很小,没出问题;上线后数据量一上来,直接超时。EXPLAIN 显示 fact_order 是全表扫描,预估行数 300 万,dim_customer 是驱动表,dim_level 是普通 ref。整体耗时 28 秒。
6.2 优化过程
我做的第一步不是改 SQL,而是看统计信息。执行ANALYZE TABLE fact_order, dim_customer, dim_level;之后重新 EXPLAIN,发现预估行数稍微准确了些,但执行计划基本没变。
第二步是给 fact_order 加索引:ALTER TABLE fact_order ADD INDEX idx_pay_time_customer (pay_time, customer_id);。这个索引能同时帮助 WHERE 过滤和 JOIN 匹配。加完后 EXPLAIN 里 fact_order 的 type 从 ALL 变成 range,rows 从 300 万降到 6 万。
第三步是改写 SQL,把结果集比较小的分组操作提前:
SELECT t.customer_id, c.customer_name, l.level_name, t.order_cnt, t.amount_sum FROM ( SELECT customer_id, COUNT(*) AS order_cnt, SUM(order_amount) AS amount_sum FROM fact_order WHERE pay_time >= '2024-10-01' AND pay_time < '2024-11-01' GROUP BY customer_id ) t INNER JOIN dim_customer c ON t.customer_id = c.id INNER JOIN dim_level l ON c.level_id = l.id;这个改写的核心是:先在小范围内完成聚合,把 6 万行压成几千行,再做表关联。这样 JOIN 两边都是小数据量,MySQL 的选择空间更大,扫描的行数也大幅减少。
6.3 优化结果
优化后重新执行,耗时从 28 秒降到 0.4 秒。执行计划里 t 表是驱动表,预估行数只剩几千行,dim_customer 和 dim_level 都走主键查询。整个过程没有使用任何“魔法参数”,就是索引 + 执行计划解读 + SQL 改写三板斧。
这个案例在线上稳定运行了两个月,没有再出现超时告警。我后来分析过,最大收益其实来自第一步的联合索引,它让 fact_order 的扫描量直接从 300 万降到 6 万;而第二步改写相当于把本来该在 JOIN 里做的事提前到了 GROUP BY,减少了数据在内存和临时表之间的搬运。
6.4 额外建议:归档与读写分离
如果这个系统继续增长,单表数据量到了千万级,我还会建议做两件事。一是把已支付超过一年的订单归档到历史表,线上只保留热数据,这样 JOIN 的数据基数和索引维护成本都会低很多;二是把统计报表这类离线查询放到只读从库或独立的报表库,避免重查询和在线事务争抢资源。
其实 JOIN 优化最怕的不是 SQL 写得差,而是表结构和数据生命周期没规划。等你开始考虑分区、归档、读写分离的时候,很多“慢 SQL 问题”其实已经在上游被解决掉了。
最后再说一个我自己的体会:做了几年 MySQL 优化,我最大的感受是,大多数 JOIN 慢不是优化器的问题,而是表结构设计的时候就没有想过查询会怎么走。索引、驱动表、字段类型一致性这些事,在设计阶段定下来,比事后调优省太多事。如果实在要复用一条 SQL 又不能动表结构,那就先用 EXPLAIN 看清执行计划,再决定是加索引、改写 SQL,还是直接拆查询。别一上来就堆 join_buffer_size,把基线数据量、SQL 语义、执行计划摆在一起看,问题自然就清楚了。