
写了几年 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左表全保留右表能配上就配上、配不上就补 NULLLEFT 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左表全部 右表匹配行极高未匹配时右表字段为 NULLRIGHT JOIN右表全部 左表匹配行较低可改写为 LEFT JOINFULL 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、一个是INTMySQL 在比较时会做隐式类型转换结果就是索引失效执行计划变成全表扫描。最常见的一个坑是两个表的关联字段都是数字但一张表是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;这里有三个表参与 JOINorders 是主表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 在未匹配时为 NULLCOUNT(NULL) 不计数这样能正确统计出 0 单的用户。IFNULL(SUM(o.amount), 0)同理避免 SUM 结果为 NULL。要注意的是如果某用户有大量订单这个查询会把用户表里的每个用户和其所有订单先关联再聚合中间结果集可能会很大。对超大用户量场景可以先聚合订单表再和用户表 LEFT JOINSELECT 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 的掌控会有一个明显的提升。