写了几年 SQL,和 JOIN 打交道的次数早就数不清了。不管你是做后端开发、数据分析还是数据库运维,只要涉及多张表的数据组合,JOIN 基本是绕不开的关键字。很多初学者最开始接触 JOIN 时,可能只会背“内连接取交集、左连接以左表为主”这种口诀,但真正落到业务里,什么时候用 INNER JOIN、什么时候用 LEFT JOIN、为什么有时候 JOIN 查出来数据变多了、为什么明明有索引却还是慢,这些问题光靠背口诀是解决不了的。
这篇内容我系统梳理一下 MySQL 里 JOIN 的完整用法,从底层执行逻辑到七种 JOIN 的写法与区别,再到常见调优手段和实际业务中容易踩的坑,全部用“人话”讲清楚。不管你是刚入门的新手,还是写了好几年 SQL 但没系统整理过 JOIN 知识点的老手,这篇内容应该都能帮你补上一些盲区。
1. 先搞明白 JOIN 到底在做什么
1.1 从笛卡尔积说起,JOIN 就是把两张大表拼成一张宽表
很多人第一次听说笛卡尔积是在大学数据库课上,但真到了工作中反而不太提这个名词了。其实 JOIN 的本质就是笛卡尔积加过滤条件。笛卡尔积的意思是:左表的每一行,去和右表的每一行做组合。如果左表有 100 行,右表有 200 行,那笛卡尔积的结果就是 100 × 200 = 20000 行。
JOIN 语句做的事情,就是先产生这种组合,然后用关联条件把不需要的组合过滤掉。比如:
SELECT * FROM orders INNER JOIN users ON orders.user_id = users.id;这个查询本质上就是先把 orders 表和 users 表做笛卡尔积,然后只保留orders.user_id = users.id的那些行。
在实际执行时,MySQL 肯定不会真的把所有组合都先算出来再过滤,那样内存早就爆了。优化器会选择合适的执行方式(比如先读小表、再根据连接字段去大表里查),但你在理解 JOIN 的结果时,用“笛卡尔积 + 过滤条件”这个模型去推,永远是准确的,尤其适合用来排查那些“为什么查出来的行数比我预期多”的诡异问题。
注意:JOIN 不加 ON 条件、或者 ON 条件写错,等于直接做笛卡尔积,结果行数会爆炸式增长。这也是很多新手出问题的重灾区。
1.2 驱动表与被驱动表:MySQL 先读谁、后读谁
JOIN 执行时,MySQL 会选一张表先读取,这张表叫“驱动表”,然后再根据关联条件去另一张表里匹配数据,这张表叫“被驱动表”。
这个顺序很重要,因为它直接影响查询性能。MySQL 的优化器一般会遵循一个原则:小表驱动大表。也就是说,让行数少的表作为驱动表,行数多的表作为被驱动表,这样被驱动表上的索引才能发挥最大价值。
举个例子:SELECT * FROM a JOIN b ON a.id = b.a_id,如果 a 表有 1000 行,b 表有 100 万行,优化器大概率会拿 a 表当驱动表,然后对 b 表做 1000 次基于主键或索引的查找。如果反过来用 b 表驱动 a 表,就要对 a 表做 100 万次查找,虽然 a 表只有 1000 行,但 100 万次访问的开销远大于 1000 次访问。
你可以通过 EXPLAIN 查看执行计划,第一行通常就是驱动表。不过要注意,MySQL 8.0 之后的 EXPLAIN 输出格式有调整,但驱动关系的逻辑依然存在。
1.3 什么时候用 JOIN,什么时候用子查询
这是一个经常被问到的问题。从本质上说,JOIN 和子查询在很多场景下可以互相改写,但执行计划不一定相同。
在 MySQL 5.6 及更早版本中,IN子查询的优化做得很差,经常会把子查询结果物化成一张临时表,导致性能不高。从 5.7 开始,优化器对子查询做了大量改进,很多IN子查询会被自动改写成半连接(semi-join),性能已经非常接近 JOIN 了。
但即便如此,我个人的经验是:如果子查询出现在FROM子句中(也就是派生表),而且这个派生表的数据量比较大,那么 MySQL 通常无法给派生表建立合适的索引,性能往往会比直接 JOIN 差。这种情况下,优先考虑改成 JOIN 写法。
-- 不推荐的写法:大派生表 SELECT * FROM ( SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id ) t INNER JOIN users ON users.id = t.user_id; -- 推荐写法:直接 JOIN + GROUP BY SELECT users.*, t.total FROM users INNER JOIN ( SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id ) t ON users.id = t.user_id;上面这个改写其实没有本质变化,真正影响性能的是派生表本身能不能下推条件。更稳妥的做法是尽量用 JOIN 加 WHERE 条件让优化器有更多选择空间。
2. 七种 JOIN 类型逐个拆解
2.1 INNER JOIN:只要两边都能匹配上的数据
INNER JOIN 是最常用的 JOIN 类型,它只返回左表和右表中满足关联条件的行。说白了就是两个集合的交集。
SELECT * FROM users INNER JOIN orders ON users.id = orders.user_id;这条 SQL 只返回“下过单的用户”和“这些用户的订单”,没下过单的用户不会出现在结果里,没有对应用户的订单也不会出现。
实际业务中,INNER JOIN 常用于“必须存在”的场景:比如查订单详情必须关联用户表拿用户名、关联商品表拿商品名,任何一边数据缺失都没意义。这种场景用 INNER JOIN 语义上完全正确,性能上因为结果集被过滤了一部分,通常也不会差。
2.2 LEFT JOIN:左表全保留,右表能配上就配上、配不上就补 NULL
LEFT JOIN(左连接)返回左表的全部行,右表只有匹配上的行才会显示,没匹配上的字段用 NULL 填充。这个特性在实际业务中特别重要。
SELECT users.name, orders.order_no FROM users LEFT JOIN orders ON users.id = orders.user_id;这条 SQL 会返回所有用户。如果某个用户从来没有下过单,那orders.order_no就是 NULL。
我记得刚工作那会儿踩过一个大坑:用 LEFT JOIN 统计每个用户的订单数,结果发现没下过单的用户查出来是 NULL,不是 0,导致前端展示直接报错。后来才意识到应该用IFNULL(COUNT(orders.id), 0)来处理。这个细节后面我会专门讲。
LEFT JOIN 是业务中用的最多的 JOIN 类型之一,因为“主表数据必须全部保留,附属信息能配上就配上”这个逻辑,和很多业务天然一致。
2.3 RIGHT JOIN:和 LEFT JOIN 完全镜像
RIGHT JOIN(右连接)和 LEFT JOIN 逻辑完全对称,右表全保留,左表能配上就配上,配不上补 NULL。
SELECT users.name, orders.order_no FROM users RIGHT JOIN orders ON users.id = orders.user_id;这条 SQL 返回所有订单,并关联出用户信息。如果某个订单的 user_id 在 users 表里不存在,那 users.name 就是 NULL。
在实际开发中,RIGHT JOIN 的使用频率远低于 LEFT JOIN。我不太建议刻意使用 RIGHT JOIN,因为大多数开发者的阅读习惯是从左往右看 SQL 的,RIGHT JOIN 一多,可读性会变差。如果碰到必须“以右表为主”的业务,完全可以把表位置换一下,用 LEFT JOIN 重写。
2.4 FULL JOIN:全外连接,MySQL 不支持,但可以模拟
FULL JOIN(全外连接)返回左表和右表的并集,两边匹配不上的行都会保留,缺失的字段补 NULL。
MySQL 官方一直没有支持 FULL JOIN,但我们可以用 LEFT JOIN + RIGHT JOIN + UNION 来模拟。不过说实话,全外连接在实际业务中用得极少,因为“两边都全保留”的场景通常可以通过两条 SQL 或者 UNION ALL 解决。
-- 模拟 MySQL 的 FULL JOIN SELECT users.name, orders.order_no FROM users LEFT JOIN orders ON users.id = orders.user_id UNION SELECT users.name, orders.order_no FROM users RIGHT JOIN orders ON users.id = orders.user_id;注意这里要用 UNION 而不是 UNION ALL,因为要自动去重,去掉两种查询里都出现过的重叠数据。
2.5 CROSS JOIN:除非故意,否则别用
CROSS JOIN 就是纯粹的笛卡尔积,不加任何过滤条件。左表 100 行、右表 200 行,结果就是 20000 行。
SELECT * FROM users CROSS JOIN orders;实际业务中直接使用 CROSS JOIN 的场景非常少。唯一稍微常见的用途,是生成一些测试数据或者做行列转换。比如你要生成一个“用户 × 月份”的笛卡尔组合,用来补全报表中缺失的日期,CROSS JOIN 可以派上用场。
但如果你在正常业务查询中看到 CROSS JOIN,大概率是忘写 ON 条件了,这种情况一定要警惕。
2.6 SELF JOIN:自己连接自己,处理层级和相邻数据
SELF JOIN(自连接)其实就是同一张表和它自己关联。注意,SELF JOIN 不是一个独立的 JOIN 语法,而是 JOIN 的一种用法。它的难点在于别名管理,因为同一张表出现两次,必须用不同的别名区分。
最常见的应用场景是层级结构,比如员工表和上级的关系:
SELECT e.name AS employee_name, m.name AS manager_name FROM employees e LEFT JOIN employees m ON e.manager_id = m.id;这里 e 表示员工自己,m 表示对应的上级。LEFT JOIN 可以保证顶级员工(manager_id 为 NULL 的员工)也能查出来,只是 manager_name 为 NULL。
SELF JOIN 另一个典型应用是“找出连续记录”。比如你要找出同一用户连续两天都有登录的记录,可以通过自连接把今天和昨天的数据拼在同一行里比较。这类需求用窗口函数也能做,但 MySQL 8.0 之前没有窗口函数时,SELF JOIN 是主流方案。
2.7 NATURAL JOIN:看起来很省事,但千万别在生产环境用
NATURAL JOIN(自然连接)会自动把两张表中同名字段作为等值连接条件,不需要写 ON 子句。
-- 如果 users 和 orders 都有 user_id 字段,就会自动用 user_id 关联 SELECT * FROM users NATURAL JOIN orders;听起来很方便,但实际使用风险很大。因为 NATURAL JOIN 是根据“同名字段”自动推断关联条件的,只要两张表有任何同名字段,都会被拿去参与连接。你没法控制具体用哪个字段做关联,也没法指定关联方式(等值之外的操作符完全不可能)。一旦表结构发生变化,比如新增了一个同名字段,查询结果会直接改变,而且这种改变非常隐蔽。
我的建议很明确:NATURAL JOIN 只适合在临时查询或者学习时图省事用,生产环境一律避开,老老实实写 INNER JOIN + ON,明确表达关联条件才是正确做法。
2.8 JOIN 类型速查表
| JOIN 类型 | 返回内容 | 使用频率 | 关键注意事项 |
|---|---|---|---|
| INNER JOIN | 两边都匹配的行 | 极高 | 不匹配的数据直接消失 |
| LEFT JOIN | 左表全部 + 右表匹配行 | 极高 | 未匹配时右表字段为 NULL |
| RIGHT JOIN | 右表全部 + 左表匹配行 | 较低 | 可改写为 LEFT JOIN |
| FULL JOIN | 两边全部行 | MySQL 不支持 | 用 UNION 模拟 |
| CROSS JOIN | 笛卡尔积所有组合 | 极低 | 易导致结果集爆炸 |
| SELF JOIN | 自己关联自己 | 中等 | 必须用不同的表别名 |
| NATURAL JOIN | 自动用同名字段关联 | 极低 | 有隐性风险,不推荐 |
3. 调优实战:让 JOIN 跑得更快
3.1 EXPLAIN 看执行计划,先摸清驱动关系
排查 JOIN 性能问题的第一步永远是看执行计划:
EXPLAIN SELECT * FROM orders o INNER JOIN users u ON o.user_id = u.id WHERE o.status = 1;执行结果里关键的列是这几项:
- type:访问类型,从好到差依次是 system > const > eq_ref > ref > range > index > ALL。ALL 是全表扫描,必须警惕。
- key:实际用到的索引名。如果是 NULL,说明没走索引。
- rows:估算的扫描行数,值越小越好。
- Extra:如果出现 Using join buffer (Block Nested Loop)、Using temporary、Using filesort,都要注意,说明有优化空间。
JOIN 查询中,被驱动表的关联字段如果没有索引,执行计划往往会出现Using join buffer (Block Nested Loop),这时候就需要考虑给关联字段建立索引。
3.2 关联字段建索引:JOIN 性能的核心命脉
JOIN 的性能关键,在于被驱动表关联字段上有没有合适的索引。比如orders.user_id如果建了索引,那么每次拿驱动表的 user_id 去 orders 表匹配时,走的是索引查找(ref),速度非常快。如果没有索引,就得对 orders 表做全表扫描,性能直接雪崩。
给关联字段建索引的正确姿势是:
ALTER TABLE orders ADD INDEX idx_user_id (user_id);这里要注意索引顺序。如果查询里经常用(user_id, status)一起过滤,那建联合索引(user_id, status)比单独建两个索引效果更好。联合索引还支持覆盖索引优化,能减少回表次数。
实操心得:我曾经在一个订单表上做过一次 JOIN 优化,表的关联字段是 user_id,数据量大概 500 万行。没建索引时,一条简单的 LEFT JOIN 查询要跑 4 秒多;建完索引之后,直接降到 30 毫秒。这个差距完全是指数级的,所以关联字段索引真的是一票否决项。
3.3 小表驱动大表:优化器不一定总是对的
虽然 MySQL 优化器一般会选小表作为驱动表,但优化器判断的“小”是基于统计信息的估算值,有时候统计信息不准确,或者 WHERE 过滤条件非常复杂,优化器也会选错驱动表。
这种情况下,有两种手段可以干预:
第一种是使用STRAIGHT_JOIN强制指定驱动顺序。STRAIGHT_JOIN的语义和 INNER JOIN 类似,但会强制按照 SQL 中表的书写顺序来驱动。
-- 强制先读取 users,再匹配 orders SELECT * FROM users STRAIGHT_JOIN orders ON users.id = orders.user_id;第二种是调整 SQL 的书写顺序,让优化器更容易选择正确的驱动表。不过这种手段依赖优化器的版本和统计信息,有时候改了表顺序也没用。
其实更优雅的做法是更新表的统计信息:
ANALYZE TABLE users; ANALYZE TABLE orders;让优化器拿到更准确的基数估计。大多数情况下,这一步就能解决驱动表选错的问题。
3.4 join_buffer_size:被忽视的隐性问题
当被驱动表无法使用索引时,MySQL 会使用 Block Nested Loop(块嵌套循环)算法,这时会用到一个叫 join buffer 的内存区域。
简单说,join buffer 是 MySQL 为了减少内层循环的磁盘访问次数而做的优化。如果 join buffer 太小,一部分数据会被反复读取,性能下降明显。如果 join buffer 设置得合理,可以一次性缓存尽可能多的驱动表数据,减少被驱动表的访问次数。
查看当前设置:
SHOW VARIABLES LIKE 'join_buffer_size';默认值通常是 256KB 或者 1MB(不同版本不同),单位是字节。如果遇到大表 JOIN 且无法走索引,可以适当调大这个值,比如 8MB 或 16MB。
但要注意,join_buffer_size 是 session 级别的变量,不是全局的。乱调大它会导致每个连接都占用更多内存,并发高的时候反而拖垮数据库。我的建议是:优先解决索引问题,实在解决不了再考虑调这个参数,而且只在需要的会话里临时设置。
-- 只在当前会话临时设置 SET SESSION join_buffer_size = 8 * 1024 * 1024;3.5 关联字段的类型必须一致,否则索引会失效
这一点太重要了,值得单独拿出来讲。
如果关联字段的类型不一致,比如一个是VARCHAR、一个是INT,MySQL 在比较时会做隐式类型转换,结果就是索引失效,执行计划变成全表扫描。
最常见的一个坑是:两个表的关联字段都是数字,但一张表是INT、另一张表是BIGINT,或者一个是CHAR、一个是VARCHAR。从业务角度看不出来有什么区别,但数据库比较时会走隐式转换。
还有一种更隐蔽的情况:字符集不一致。比如一张表的字段是utf8mb4,另一张表是utf8,关联时 MySQL 需要对其中一列做字符集转换,同样可能导致索引失效。
排查方法是查看表的元数据:
SHOW CREATE TABLE orders; SHOW CREATE TABLE users;重点核对关联字段的数据类型、长度、字符集、排序规则。如果发现不一致,尽早统一。
4. 常见问题与排查技巧实录
4.1 为什么 JOIN 查出来的数据变多了?一对多导致的行数翻倍
这是一个让很多新手抓狂的问题。原本想查 100 个用户,JOIN 之后结果变成了 300 行。
原因通常是:左表和右表是一对多关系。比如一个用户有多个订单,那么一个用户会在结果中出现多次,每个订单占一行。
SELECT users.name, orders.order_no FROM users LEFT JOIN orders ON users.id = orders.user_id;如果小明有 3 个订单,那结果里就会出现 3 行“小明”,每一行对应一个订单号。这其实是 JOIN 的正常行为,不是 bug。但如果你没意识到这个特性,很容易在统计时出问题。
比如你要统计用户数,直接COUNT(*)会得出一个虚高的数字。正确做法是COUNT(DISTINCT users.id),或者先对订单表做聚合,再和用户表关联。
SELECT users.name, t.order_count FROM users LEFT JOIN ( SELECT user_id, COUNT(*) AS order_count FROM orders GROUP BY user_id ) t ON users.id = t.user_id;这是一种比较优雅的写法,能避免一对多导致的结果集膨胀。
4.2 ON 和 WHERE 的区别,LEFT JOIN 里最容易被误用的过滤条件
LEFT JOIN 中,ON 后面的条件和 WHERE 后面的条件,执行顺序和语义完全不同,这个知识点几乎每次面试都会考,但实际工作中真正理解的人并不算多。
-- 写法一:在 ON 里过滤 SELECT users.name, orders.order_no FROM users LEFT JOIN orders ON users.id = orders.user_id AND orders.status = 1; -- 写法二:在 WHERE 里过滤 SELECT users.name, orders.order_no FROM users LEFT JOIN orders ON users.id = orders.user_id WHERE orders.status = 1;写法一的意思是:先匹配user_id,再对订单做status = 1的过滤。如果一个用户只有已取消的订单,那么 JOIN 后该用户仍然会出现在结果里,但订单字段为 NULL。因为 LEFT JOIN 的语义就是“左表全保留”,ON 里的附加条件只是决定了右表能不能匹配上。
写法二的意思是:先完成 LEFT JOIN,然后对结果集做 WHERE 过滤。这时候如果一个用户只有已取消的订单,JOIN 后该订单行因为 status 不等于 1 被过滤掉了,而且由于用户没有其他订单,最终这个用户直接消失。
我在实际开发中见过不少因为 ON 和 WHERE 混用导致数据对不上的案例。最典型的就是统计“有有效订单的用户数”时,一眼没注意把过滤条件写在 ON 里,结果把没有有效订单的用户也统计进去了。
经验总结:ON 决定“匹配规则”,WHERE 决定“结果集筛选”。LEFT JOIN 中想保留主表全部数据时,对右表的过滤尽量放在 ON 中。
4.3 三表及以上 JOIN:连接顺序和结果集踩坑
多表 JOIN 时,MySQL 优化器的选择空间更大,但犯错概率也更高。常见的问题要么是性能差,要么是结果集和预期不符。
手写多表 JOIN 时,我的经验是两个原则:
第一,逐层 JOIN,不要一次把所有表都拼上去。也就是先 JOIN 两张表,把中间结果集尽可能缩小,再和第三张表 JOIN。这样每轮参与连接的数据量都更小,执行效率更高。
第二,注意表之间的关联方向。比如订单表和用户表、订单表和商品表,这两组关联都是多对一,最终结果集行数会回到订单表的行数左右。但如果其中一组关联是一对多,结果集就会膨胀。
调优时,可以先用 EXPLAIN 查看每一张表分别处于什么位置、预估的 rows 是多少。找出那个 rows 异常大的表,通常是问题点。
4.4 使用 DISTINCT 消除重复?先想想重复的来源
很多人在 JOIN 后发现结果有重复数据,第一反应是在 SELECT 后面加一个 DISTINCT 去重。这种做法有时候可以救急,但它掩盖的是“关联设计不合理”这个根本问题。
其实 DISTINCT 在很多场景下是无奈之举。比如关联到一张明细表,导致主表数据重复,但业务上只需要主表字段,这时候用 DISTINCT 或 GROUP BY 都可以达到目的。但代价是 MySQL 需要对结果做排序或分组,数据量一大,性能下降明显。
更好的办法是从源头解决重复:检查关联关系中是否存在一对多,如果是,考虑先聚合明细表,再和主表连接。这样既没有重复数据,也不需要 DISTINCT,性能还更好。
4.5 LEFT JOIN 右表字段全是 NULL?大概率是关联条件写反了
这也算是一个高频低级错误。LEFT JOIN 查出来的右表字段一堆 NULL,可能不是因为右边没有匹配数据,而是你把关联条件写反了。
-- 错误的写法:拿右表的 user_id 去关联左表的 id SELECT users.name, orders.order_no FROM orders LEFT JOIN users ON users.id = orders.user_id;这段 SQL 本身没有语法错误,但如果你本意是“查所有用户及其订单”,那这就错了。现在是以 orders 作为左表,所有订单都会保留,users 只是用来补充用户信息。如果一个订单的 user_id 在 users 表里不存在(比如用户被删了),那 users.name 就会是 NULL。
所以在排查“右表全是 NULL”的问题时,先确认谁是真正的主表。
4.6 关联字段有 NULL 时,等值条件永远匹配不上
这是 JOIN 的一个天然特性:NULL 与其他任何值(包括 NULL 本身)做等值比较,结果都是 NULL(即 FALSE)。也就是说,一张表里如果关联字段存在 NULL,那么这个 NULL 值永远不会匹配上另一张表的任何值。
具体表现是:INNER JOIN 时,关联字段为 NULL 的行直接消失;LEFT JOIN 时,关联字段为 NULL 的行会保留在结果里,但右表字段全为 NULL。
处理逻辑如下:
SELECT users.name, orders.order_no FROM users LEFT JOIN orders ON users.id = orders.user_id WHERE orders.user_id IS NOT NULL OR orders.user_id IS NULL;上面的写法没有实际意义,只是用来展示 NULL 匹配的坑。实际业务中如果确实需要处理关联字段为 NULL 的数据,可以用COALESCE提前把 NULL 转成业务上的哨兵值,再做关联。
5. 实际业务场景中的 JOIN 案例
5.1 案例一:订单列表要展示用户名和商品名
这是最常见的多表查询场景。原生写法:
SELECT o.order_no, u.name AS user_name, p.product_name, o.amount FROM orders o INNER JOIN users u ON o.user_id = u.id INNER JOIN products p ON o.product_id = p.id WHERE o.status = 1 ORDER BY o.created_at DESC;这里有三个表参与 JOIN:orders 是主表,users 和 products 是维度表。orders 和 users、orders 和 products 都是多对一关系,所以结果集行数等于订单行数,不会膨胀。
执行计划里,orders 首先被读取,然后分别对 users 和 products 的主键做 eq_ref 查询,这种查询性能非常高。只要 users 表和 products 表的主键存在,这个查询基本不需要额外优化。
5.2 案例二:统计每个用户的订单数和总金额
这个场景用 LEFT JOIN 加 GROUP BY,保留没下过单的用户:
SELECT u.id, u.name, COUNT(o.id) AS order_count, IFNULL(SUM(o.amount), 0) AS total_amount FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name ORDER BY total_amount DESC;这里用COUNT(o.id)而不是COUNT(*),因为 o.id 在未匹配时为 NULL,COUNT(NULL) 不计数,这样能正确统计出 0 单的用户。IFNULL(SUM(o.amount), 0)同理,避免 SUM 结果为 NULL。
要注意的是:如果某用户有大量订单,这个查询会把用户表里的每个用户和其所有订单先关联,再聚合,中间结果集可能会很大。对超大用户量场景,可以先聚合订单表,再和用户表 LEFT JOIN:
SELECT u.id, u.name, t.order_count, t.total_amount FROM users u LEFT JOIN ( SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) t ON u.id = t.user_id ORDER BY t.total_amount DESC;这种写法在 orders 表非常大时效果更明显,因为先聚合减少了参与 JOIN 的行数。
5.3 案例三:自连接处理“相邻记录”问题
假设有一张股票价格表 stock_prices,字段是 id、symbol、trade_date、close_price。要求找出“连续两天价格上涨”的记录,用自连接可以这样写:
SELECT p1.symbol, p1.trade_date AS current_date, p1.close_price, p2.trade_date AS prev_date, p2.close_price AS prev_price FROM stock_prices p1 INNER JOIN stock_prices p2 ON p1.symbol = p2.symbol AND p1.trade_date = DATE_ADD(p2.trade_date, INTERVAL 1 DAY) WHERE p1.close_price > p2.close_price;这里的关键是利用DATE_ADD(p2.trade_date, INTERVAL 1 DAY)让 p1 的日期等于 p2 日期的下一天,从而实现“今天的价格 > 昨天的价格”这个条件。这种写法很直观,但要注意关联字段上一定要有 (symbol, trade_date) 的联合索引,否则性能会很糟糕。
有了窗口函数之后,这类需求也可以用 LAG() 来实现,可读性更好。但自连接在 MySQL 5.7 及更早版本中依然是这批需求的主力方案。
5.4 案例四:用 LEFT JOIN 实现报表补零
报表场景里,经常遇到“某个月没有数据,但报表里必须显示 0”。这时候可以造一张日期维度表(或者月份维度表),用 LEFT JOIN 把它和业务表关联。
SELECT m.month_date, IFNULL(SUM(o.amount), 0) AS month_amount FROM ( SELECT '2025-01-01' AS month_date UNION ALL SELECT '2025-02-01' UNION ALL SELECT '2025-03-01' UNION ALL SELECT '2025-04-01' ) m LEFT JOIN orders o ON DATE_FORMAT(o.created_at, '%Y-%m-01') = m.month_date GROUP BY m.month_date ORDER BY m.month_date;这种写法的核心逻辑是:月份维度表作为左表,业务表作为右表,即使右表没有匹配数据,左表的月份也会保留下来,配合IFNULL就能实现补零效果。
6. 最后分享几个 JOIN 相关的实用习惯
写 JOIN 写了这么多年,总结几个个人经验,不一定写在官方文档里,但确实能帮你少走弯路。
第一,所有的 JOIN 必须显式写清楚 ON 条件,不要依赖 NATURAL JOIN 或者省略 ON。哪怕是 INNER JOIN,如果没写 ON 条件,MySQL 直接给你做笛卡尔积,线上事故就这么来的。
第二,查 JOIN 性能问题时,先看被驱动表关联字段有没有索引,再看类型、字符集是否一致,这两步能解决 80% 以上的 JOIN 慢问题。
第三,LEFT JOIN 中过滤右表条件时,想清楚你是要“匹配时过滤”还是要“结果集过滤”,把条件放对位置。一个最简单的判断方法:如果希望左表数据全部保留,右表的过滤条件优先放 ON 里;如果你本来就要把不符合条件的数据整体排除,那放 WHERE 里。
第四,多表 JOIN 时,从业务含义出发,把关联关系拆成多对一或一对一,尽量避免在 JOIN 过程中产生一对多的膨胀。如果确实无法避免,在统计时用COUNT(DISTINCT 主表主键)来校准数据。
JOIN 这个关键字本身并不复杂,复杂的是它背后对数据关系的理解和执行计划的掌握。把本文的几类场景和坑位过一遍,再回到自己的业务里动手试试,相信你对 JOIN 的掌控会有一个明显的提升。