我的其他文章也讲解的比较有趣,如果喜欢博主的讲解方式,可以多多支持一下,感谢!
其他优质专栏: 【SpringBoot】【多线程】【Redis】【✨设计模式专栏(已完结)】…等
如果喜欢作者的讲解方式,可以点赞收藏加关注,你的支持就是我的动力
✨更多文章请看个人主页: 码熔burning
大家好!又双叒叕是我,你们的老朋友,一个幽默的程序员。
今天,咱们来点更刺激的,聊聊那个让无数英雄竞折腰的——JOIN
查询优化!
你是不是也写过那种“九九八十一难”般的JOIN语句,一执行,MySQL就跟便秘似的,半天憋不出一个P(结果)?或者,你看着EXPLAIN
那一堆眼花缭乱的Nested Loop
、Using join buffer
,感觉智商被按在地上摩擦?
别慌!JOIN虽然复杂,但它不是“爱情魔咒”,只要你摸清了它的“脾气秉性”,掌握了正确的“撩妹技巧”(优化方法),它也能从“世纪大难题”变成你SQL工具箱里的“瑞士军刀”!
准备好了吗?系好安全带,咱们的“JOIN优化探索号”飞船,马上起航!目的地——高效JOIN的“幸福彼岸”!
想象一下,你的数据被精心设计,分门别类地存放在不同的“小抽屉”(表)里。比如:
students
表:存放学生的基本信息(学号、姓名、班级ID)。classes
表:存放班级信息(班级ID、班主任、教室)。scores
表:存放学生的考试成绩(学号、科目、分数)。现在,你想知道“火箭班所有学生的姓名及其各科成绩”,单靠一个“抽屉”肯定搞不定吧?你得把students
和scores
这两个抽屉打开,根据“学号”这个共同的线索,把它们关联起来。
JOIN
,就是数据库世界里的“联谊会主持人”! 它的核心任务,就是根据你指定的“共同话题”(连接条件),把来自不同表(抽屉)的相关数据行“拉郎配”,组合成一个更完整、更有意义的结果集。
没有JOIN
,数据就是一座座孤岛;有了JOIN
,数据才能汇聚成汪洋大海,展现出真正的价值!
在MySQL的“联谊会”上,主持人(JOIN)会根据你的要求,采用不同的“配对策略”。咱们先来快速认识一下几位常见的“联谊会司仪”:
INNER JOIN
(内连接):最严格的“司仪”,只介绍那些在两个表里都能找到“共同话题”(匹配连接条件)的行。如果A表的某行在B表找不到伴儿,或者B表的某行在A表找不到伴儿,对不起,它俩都不能参加这场“内涵派对”。
SELECT ... FROM tableA INNER JOIN tableB ON tableA.col = tableB.col;
(或者直接 SELECT ... FROM tableA, tableB WHERE tableA.col = tableB.col;
,效果类似,但推荐显式JOIN
)LEFT JOIN
(左连接,也叫 LEFT OUTER JOIN
):偏心眼的“司仪”,以左边的表(FROM
子句中先出现的表)为准。左表的每一行都会出现在结果中。
NULL
来填充,“强行配对,找不到对象就给你个空气伴侣”。SELECT ... FROM tableA LEFT JOIN tableB ON tableA.col = tableB.col;
RIGHT JOIN
(右连接,也叫 RIGHT OUTER JOIN
):跟LEFT JOIN
反过来,以右边的表为准。
A RIGHT JOIN B
都可以改写成 B LEFT JOIN A
,效果一样,但LEFT JOIN
更常用,可读性可能更好。FULL JOIN
(全连接,也叫 FULL OUTER JOIN
):最大方的“司仪”,左边右边的客人一个都不落下!
NULL
。NULL
。FULL OUTER JOIN
关键字。但别灰心,你可以通过LEFT JOIN ... UNION ... RIGHT JOIN
(或者 LEFT JOIN ... UNION ALL ... RIGHT JOIN WHERE A.key IS NULL
等变体) 来模拟实现全连接的效果。-- 模拟FULL JOIN (注意,对于匹配上的行会显示两次,如果想去重用UNION)
SELECT * FROM tableA LEFT JOIN tableB ON tableA.id = tableB.id
UNION ALL -- 或者 UNION 去重
SELECT * FROM tableA RIGHT JOIN tableB ON tableA.id = tableB.id
WHERE tableA.id IS NULL; -- 只取右表有而左表没有的部分
CROSS JOIN
(交叉连接,也叫笛卡尔积):最“疯狂”的“司仪”,不做任何筛选,把A表的每一行和B表的每一行都强行“拉郎配”一次。
SELECT ... FROM tableA CROSS JOIN tableB;
或者 SELECT ... FROM tableA, tableB;
(不加任何WHERE
连接条件时)ON
或WHERE
连接条件了!了解了这些“司仪”的性格,我们才能更好地指挥它们干活。
当MySQL收到一个JOIN请求后,它内部是怎么运作的呢?难道真的是挨个比较吗?不完全是,它也有一套自己的“相亲算法”。
在MySQL的早期版本以及很多情况下,JOIN操作的核心算法是嵌套循环连接 (Nested Loop Join, NLJ) 及其变种。
这是最原始、最容易理解,但也通常是最低效的一种。
算法描述:
伪代码示意:
FOR each row R1 in OuterTable:
FOR each row R2 in InnerTable:
IF R1 joins with R2 ON join_condition:
Output (R1, R2)
性能噩梦:如果外层表有M行,内层表有N行,那么总的比较次数大约是 M * N!如果内外层表都没有索引,那每次在内层表查找都是全表扫描,I/O次数大约是 M + M*N
(外层扫一遍,内层扫M遍)。数据量一大,简直是“龟速行驶”。
MySQL的“嫌弃”:由于SNLJ效率太低,现代MySQL优化器会极力避免使用它,除非万不得已(比如连接条件极其复杂,没有任何索引可用)。
当被驱动表(内层表)的连接字段上有索引时,情况就大不一样了!INLJ闪亮登场!
算法描述:
伪代码示意:
FOR each row R1 in OuterTable:
LOOKUP R2 in InnerTable USING INDEX ON InnerTable.join_column WHERE InnerTable.join_column = R1.join_column_value:
IF R2 is found:
Output (R1, R2)
性能飞跃:
logN
或常数级别),总的I/O次数大约是 M + M * (索引查找成本)
。相比SNLJ的M + M*N
,效率提升是数量级的!EXPLAIN
中的信号:当你用EXPLAIN
分析JOIN语句时,如果看到被驱动表的type
是eq_ref
(对于唯一索引/主键连接) 或 ref
(对于普通二级索引连接),通常就意味着用上了INLJ,这是个好兆头!-- 假设 students.class_id 和 classes.id 都有索引,且 classes.id 是主键
EXPLAIN SELECT s.name, c.class_name
FROM students s
INNER JOIN classes c ON s.class_id = c.id;
-- 可能的EXPLAIN结果:
id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
---|---|---|---|---|---|---|---|---|---|
1 | SIMPLE | s | ALL | idx_class_id | NULL | NULL | NULL | 1000 | |
1 | SIMPLE | c | eq_ref | PRIMARY | PRIMARY | 4 | test.s.class_id | 1 |
– (这里students是驱动表,classes是被驱动表,classes.id用了主键索引,type=eq_ref,完美!)
当被驱动表(内层表)的连接字段上没有可用索引时,SNLJ太慢,MySQL又不想坐以待毙,于是就有了BNL。这通常是MySQL在无法使用INLJ时的“无奈之举”。
算法描述:
join_buffer_size
参数控制)。伪代码示意:
Initialize JoinBuffer
FOR each row R1 in OuterTable:
IF JoinBuffer is full:
FOR each row R2 in InnerTable: // Scan InnerTable once
FOR each buffered_R1 in JoinBuffer:
IF buffered_R1 joins with R2 ON join_condition:
Output (buffered_R1, R2)
Clear JoinBuffer
Store R1's relevant columns in JoinBuffer
// Process any remaining rows in JoinBuffer (last batch)
IF JoinBuffer is not empty:
FOR each row R2 in InnerTable: // Scan InnerTable again (potentially)
FOR each buffered_R1 in JoinBuffer:
IF buffered_R1 joins with R2 ON join_condition:
Output (buffered_R1, R2)
性能特点:
外层表总行数 / Join Buffer能容纳的外层表行数
。如果Join Buffer足够大,甚至可能只扫描内层表一次(理想情况,很少见)。EXPLAIN
中的信号:Extra
列出现 Using join buffer (Block Nested Loop)
。join_buffer_size
的关键:这个参数的大小直接影响BNL的效率。Buffer越大,一次能缓存的外层表行越多,内层表的扫描次数就越少。但要注意,这个Buffer是每个连接独享的,设置过大可能导致内存问题(详见上一篇《全局参数优化》)。
从MySQL 8.0.18版本开始,当JOIN操作无法使用索引(即BNL的适用场景)时,MySQL引入了更高效的哈希连接 (Hash Join) 算法,并在后续版本中逐渐用它来替代BNL。
算法描述 (简化版):
性能优势:
EXPLAIN
中的信号:在MySQL 8.0.18+,如果JOIN无法使用索引,你可能会在Extra
列看到类似 Using hash join
或在EXPLAIN FORMAT=JSON
的输出中看到hash_join
的执行计划。
内存依赖:哈希连接也需要内存来构建哈希表。如果内存不足以容纳整个构建表的哈希表,MySQL可能会采用更复杂的“分块哈希连接”或“溢出到磁盘的哈希连接”,性能会有所下降,但通常仍优于BNL。
替代BNL:在MySQL 8.0.20及更高版本中,BNL被哈希连接完全取代。也就是说,即使你看到Using join buffer
,其底层实现也可能是哈希连接了。
小结JOIN执行算法:
MySQL优化器会“绞尽脑汁”地选择最高效的JOIN算法。它的首选永远是INLJ(用上索引),因为这通常是最快的。如果实在没办法用索引,它在8.0之前会退而求其次用BNL,在8.0.18之后则更倾向于用更强大的Hash Join。
知道了MySQL是如何“相亲”的,我们就能对症下药,写出让它“一见钟情”的JOIN语句了!
这是JOIN优化的第一金科玉律,没有之一!
ON tableA.col1 = tableB.col2
,那么tableA.col1
和tableB.col2
都应该是索引候选者。尤其是被驱动表(内层表)的连接列,必须要有索引!WHERE
子句中用于筛选的列。ORDER BY
和 GROUP BY
中用到的列。INT
,一个VARCHAR
),MySQL可能需要进行隐式类型转换,这会导致索引失效!
ON users.user_id (INT) = orders.user_id_str (VARCHAR)
-> 索引可能失效!ON users.user_id (INT) = orders.user_id (INT)
ON FUNC(tableA.col) = tableB.col
也会让tableA.col
上的索引失效(除非你用了MySQL 8.0的函数索引)。段子手吐槽:
不给JOIN列加索引,就像派了一个近视800度的士兵去战场上肉眼索敌,然后你还怪他打不准?!给他配个“八倍镜”(索引)啊,大哥!
在嵌套循环类的JOIN中(SNLJ, INLJ, BNL),驱动表(外层表)的选择对性能有很大影响。
WHERE
条件过滤之后)。因为驱动表会被完整扫描(或部分扫描),它的行数越少,外层循环的次数就越少。INNER JOIN
,优化器有权调整表的连接顺序。LEFT JOIN
,左表固定为驱动表。RIGHT JOIN
,右表固定为驱动表(或者MySQL可能将其改写为等价的LEFT JOIN
再处理)。STRAIGHT_JOIN
:STRAIGHT_JOIN
关键字来“强制”指定连接顺序。SELECT ... FROM tableA STRAIGHT_JOIN tableB ...
会强制tableA
作为驱动表。
STRAIGHT_JOIN
能带来明显性能提升时才考虑。大多数情况下,相信优化器。STRAIGHT_JOIN
反而可能让性能更差。驱动表选择原则(通用思路):
WHERE
过滤后)作为驱动表。WHERE
子句 VS ON
子句:
INNER JOIN
:WHERE
和ON
中的条件在逻辑上是等价的,MySQL优化器可能会重新安排它们的执行顺序。但通常建议连接相关的条件写在ON
中,单表筛选条件写在WHERE
中,更清晰。LEFT JOIN
/ RIGHT JOIN
(OUTER JOIN):ON
和WHERE
的条件位置非常重要!
ON
条件:是在生成临时连接结果集之前就用来筛选被驱动表(对于LEFT JOIN是右表,对于RIGHT JOIN是左表)的记录的。如果被驱动表的记录不满足ON
条件,它就不会参与连接,其对应的驱动表行在结果中相关列为NULL。WHERE
条件:是在临时连接结果集(驱动表所有行 + 匹配上的被驱动表行或NULL)生成之后,再对这个结果集进行最终的筛选。WHERE
子句中,对于OUTER JOIN,可能会导致本应保留的驱动表行(因为OUTER JOIN的特性)因为被驱动表部分为NULL而不满足WHERE
条件,从而被错误地过滤掉,使得OUTER JOIN的行为退化成类似INNER JOIN。ON
子句里!-- 需求:查询所有学生及其数学课的成绩(没有数学成绩的也显示学生,成绩为NULL)
-- 正确写法 (筛选数学课的条件在ON里)
SELECT s.name, sc.score
FROM students s
LEFT JOIN scores sc ON s.student_id = sc.student_id AND sc.subject = '数学';
-- 错误写法 (筛选数学课的条件在WHERE里,会导致没有数学成绩的学生整行被过滤掉)
SELECT s.name, sc.score
FROM students s
LEFT JOIN scores sc ON s.student_id = sc.student_id
WHERE sc.subject = '数学'; -- 这实际上变成了INNER JOIN的效果
尽早过滤:通过在WHERE
子句或ON
子句中添加有效的筛选条件,尽早地把不需要的数据行给“咔嚓”掉,这样参与JOIN运算的行数就少了,性能自然提升。
join_buffer_size
才派得上用场。join_buffer_size
是“治标不治本”的。首要任务永远是检查并优化JOIN列的索引!不厌其烦地再次强调EXPLAIN
的重要性!对于任何你觉得慢的JOIN查询,第一件事就是把它扔给EXPLAIN
“体检”一下。
table
: 表的读取顺序(大致反映了驱动表和被驱动表的顺序)。type
: 连接类型!这是判断JOIN效率的核心。
system
> const
> eq_ref
(唯一索引/主键JOIN) > ref
(普通二级索引JOIN)ref_or_null
> index_merge
> unique_subquery
> index_subquery
range
> index
(全索引扫描) > ALL
(全表扫描)type
是eq_ref
或ref
,说明索引用上了,很好!如果是ALL
或index
,那就要警惕了,可能是BNL或Hash Join。possible_keys
: 可能用到的索引。key
: 实际用到的索引。如果是NULL
,说明没用上索引。key_len
: 用到的索引长度。越短越好(在能区分记录的前提下)。ref
: 显示了哪些列或常量被用于索引查找。rows
: MySQL估计需要扫描的行数。越小越好。Extra
: 包含大量重要信息!
Using index
: 覆盖索引,非常好!Using where
: 使用了WHERE子句进行过滤。Using temporary
: 可能用了临时表(比如GROUP BY
或UNION
操作)。Using filesort
: 文件排序,性能杀手,需要优化ORDER BY
或相关索引。Using join buffer (Block Nested Loop)
: 说明用了BNL算法。Using join buffer (Batched Key Access)
: BKA是一种优化的BNL,结合了MRR(Multi-Range Read)。Using hash join
(MySQL 8.0.18+): 说明用了哈希连接。Not exists
: 用于反连接优化。通过仔细解读EXPLAIN
的输出,你就能诊断出JOIN的瓶颈在哪里,是索引没用上?还是驱动表选错了?还是Join Buffer太小(或者说,应该加索引)?
除了Hash Join,MySQL 8.0还在JOIN优化方面做了一些其他改进:
IN (SELECT ...)
的子查询会被优化器转换为更高效的JOIN。ON
或WHERE
连接条件,或者条件写错导致全匹配。SELECT *
,尤其是在JOIN大表时,会增加大量不必要的IO和网络传输,也可能让覆盖索引失效。按需索取,才是王道!ON
里的被驱动表筛选条件,错放到了WHERE
里,导致结果不符合预期。EXPLAIN
就上线,也不能芝麻大点事就用STRAIGHT_JOIN
或FORCE INDEX
。先理解,再优化。ANALYZE TABLE
可能有助于更新统计信息。假设我们有两张表:
employees
(员工表): emp_no
(PK), first_name
, last_name
, hire_date
, dept_no
(FK, 有索引)departments
(部门表): dept_no
(PK), dept_name
查询需求:找出所有在 ‘Sales’ 部门,并且是在 '2023-01-01’之后入职的员工姓名。
糟糕的写法 (可能):
-- 假设departments表非常大,employees表相对较小
SELECT e.first_name, e.last_name
FROM departments d, employees e -- 隐式JOIN,容易写漏条件
WHERE d.dept_name = 'Sales'
AND e.hire_date > '2023-01-01'
AND e.dept_no = d.dept_no; -- 连接条件放最后,可读性稍差
优化思路与较好的写法:
INNER JOIN
。employees.dept_no
, departments.dept_no
, employees.hire_date
都有索引。departments.dept_name
也最好有索引,如果经常用它查询。departments.dept_name = 'Sales'
能筛选出很少的部门(比如就1个),那么departments
作为驱动表可能更好。employees.hire_date > '2023-01-01'
能筛选出很少的员工,那么employees
作为驱动表可能更好。推荐写法:
SELECT e.first_name, e.last_name
FROM employees e
INNER JOIN departments d ON e.dept_no = d.dept_no -- 连接条件清晰
WHERE d.dept_name = 'Sales' -- 筛选条件1
AND e.hire_date > '2023-01-01'; -- 筛选条件2
-- 使用EXPLAIN分析:
EXPLAIN SELECT e.first_name, e.last_name
FROM employees e
INNER JOIN departments d ON e.dept_no = d.dept_no
WHERE d.dept_name = 'Sales'
AND e.hire_date > '2023-01-01';
EXPLAIN结果分析要点:
type
是不是eq_ref
或ref
。key
列是否都用上了合适的索引。rows
列估算的扫描行数是不是尽可能小。Extra
列有没有Using filesort
或不希望出现的Using join buffer
。如果发现性能不佳,比如EXPLAIN
显示某个表的type
是ALL
,那就要重点检查该表的连接列和WHERE
条件列的索引情况。
呼!关于MySQL的JOIN优化,咱们今天这趟“星际穿越”算是把主要景点都逛了一遍。从JOIN的种类、执行原理,到各种优化秘籍和避坑指南,信息量确实不小。
但记住,JOIN优化不是一门“玄学”,它是有章可循的科学。核心就三点:
WHERE
和ON
条件尽早过滤)而这一切的基础,都离不开你对EXPLAIN
输出的“火眼金睛”般的解读能力。
JOIN优化,就像当一个数据库界的“月老”,你的目标就是用最少的“相亲成本”(系统资源),让合适的“男女嘉宾”(数据行)最高效地“牵手成功”(组合成结果)。这需要你对双方(表结构、数据分布、索引情况)都有深入的了解,还需要一点点“成人之美”的耐心和智慧。