SQL JOIN 全解:语义、ON/WHERE、执行计划与慢SQL优化
2026/9/18 11:00:25 网站建设 项目流程

做后端开发的朋友,估计都见过那张经典的两圆相交图——SQL 里的各种 join 用法,被压缩成几个彩色区域,一眼扫过去觉得懂了,真到写语句的时候又拿不准。我从最早的手写原生 SQL,到后来用 ORM 框架、再回到手写复杂查询做报表和慢 SQL 优化,join 这个词跟了我十来年,踩过的坑能装一箩筐:明明想保留左边的全部数据,结果一过滤就变成了内连接;明明以为一对一,跑出来行数翻了三倍。这篇就把 join 从语义、写法、执行顺序到性能排查完整捋一遍,用两张测试表把每种 join 的结果实实在在跑出来对照。不管你是刚学 SQL 语句的新人,还是天天写复杂查询、偶尔要处理慢 SQL 的老手,看完都能直接拿去用。

1. 先想清楚:join 到底在解决什么问题

1.1 那张经典图为什么总让人误解

两圆相交图本身没毛病,坏就坏在它只画了结果集,没画过程。图上告诉你「中间那块是 inner join」,但没告诉你数据库是先做笛卡尔积再过滤,还是先按索引定位再拼装;没告诉你 on 后面的条件和 where 后面的条件在处理顺序上有什么区别。于是很多人记住了图案,却没记住语义。

我见过一个挺典型的场景:业务要统计「所有用户的下单金额,没下单的显示 0」。有人上来就写users join orders,然后拿where orders.amount > 0去过滤,跑完发现没下单的用户全消失了。问题的根不在 join 语法,而在于他没意识到过滤条件和连接条件放的位置不一样,语义就完全变了。那张图能帮你记住形状,但帮不了你记住这条规则。

所以这篇我不打算再画一遍圆,而是把 join 拆成三个层次来看:语义层(我要什么结果)、写法层(on 和 where 怎么写)、执行层(数据库怎么把它跑出来)。三层对齐了,join 就不会再写错。

1.2 join 的本质:按条件把两个集合拼起来

抛开语法看本质,join 做的就是一件事——根据一个布尔条件,把左表和右表的行两两配对,配上的留下,配不上的按 join 类型决定要不要用空值补位。

  • inner join:只保留配上的组合,配不上的两边都丢掉。
  • left join:左表每一行都必须出现至少一次,右表配不上就用 null 填。
  • right join:反过来,右表每行必须出现,左表配不上用 null 填。
  • full outer join:两边都是「必须出现」,谁配不上谁补 null。

理解了「配不上怎么处理」这一条,你就理解了全部 join 类型。图只是把这个规则可视化了而已。很多人记不住,是因为把「保留哪边」当成了需要死记的东西,其实只要问自己一句:左边那行我丢不丢?右表没找到对应行时我要不要看它?答案自然就出来了。

顺带提一句命名习惯,实际项目里我很少见到有人用 right join,绝大多数场景把表顺序调一下用 left join 就行。理由是人对「主表放左边」这件事有天然的心理惯性,混用 left 和 right 会让后来维护的人反复对照语句和需求。团队里如果有规范,通常也是一条:统一用 left join,禁止 right join

1.3 搞清楚这几种,日常 90% 的场景就够用了

按我的经验,真实业务里的 join 分布大概是这样的:left join 占一半以上,inner join 占三成左右,剩下的被 cross join(多为误用)、full outer join(多为报表)、以及各种 join 加聚合的组合瓜分。

这个分布其实很说明问题。业务查询往往以某张主表为中心向外扩展信息,比如以用户为中心看订单、看积分、看登录记录,天然就是 left join 的形态。而 inner join 通常出现在「必须有对应数据才算数」的场景,比如统计有效订单、关联明细表做汇总。

所以如果你刚开始学 SQL,优先级建议是:inner join 和 left join 吃透,反连接(left join 加 is null)会写,其他几种知道语义、用到时查一下即可。别在一开始就纠结 full outer join 在不同数据库里的兼容性,那不是现阶段的主要矛盾。

2. 手把手建两张表,把每种 join 跑一遍

2.1 测试表结构和造数脚本

光看语法不动手,等于没学。下面这段脚本你可以在 MySQL、SQL Server、PostgreSQL 里基本通用(日期类型和自增语法略有差异,这里用的都是标准写法,通用性最好)。

CREATE TABLE users ( user_id INT PRIMARY KEY, user_name VARCHAR(32), city VARCHAR(32) ); CREATE TABLE orders ( order_id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), status VARCHAR(16) ); INSERT INTO users VALUES (1, '张三', '北京'), (2, '李四', '上海'), (3, '王五', '广州'), (4, '赵六', '深圳'); INSERT INTO orders VALUES (101, 1, 99.00, 'paid'), (102, 1, 50.00, 'paid'), (103, 2, 200.00, 'unpaid'), (104, 5, 30.00, 'paid');

这份数据我特意设计了几个「不干净」的地方,因为真实数据库里从来不干净。赵六(user_id=4)一单都没有,属于左表有右表没有;订单 104 的 user_id=5 在 users 里根本不存在,属于右表有左表没有,业务上叫「孤儿数据」,一般是删用户没清订单或者数据同步出错留下的;张三有两单,属于典型的一对多。这三类情况覆盖了 join 里所有容易出问题的边界。

提示:造测试数据时一定要主动制造「对不上的行」和「一对多的行」,只用整齐的一对一数据做验证,等于没验证。

2.2 六种 join 的语句和结果对照

先上 inner join,也就是最直观的一种:

SELECT u.user_name, o.order_id, o.amount FROM users u INNER JOIN orders o ON u.user_id = o.user_id;

结果三行:张三配 101、张三配 102、李四配 103。王五和赵六被丢掉(因为没订单),订单 104 也被丢掉(因为找不到用户)。注意张三出现了两次,因为他在 orders 侧有两行——这就是一对多导致的「行数放大」,后面讲性能问题时会专门说这个。

left join 把左表兜住:

SELECT u.user_name, o.order_id, o.amount FROM users u LEFT JOIN orders o ON u.user_id = o.user_id;

结果五行:张三两行、李四一行、王五一行(order_id 和 amount 全是 null)、赵六一行(同样全 null)。判断一行是「真的没订单」还是「订单字段本身就是空」,靠 order_id 是否为 null 来判断,因为主键理论上不该为空。

right join 就是把上面的表顺序换一下,结果集是订单 101、102、103、104 四行,其中 104 对应的用户字段全是 null。实际业务里写 right join 的收益很低,还是那句话,调换表顺序用 left join 更清楚。

full outer join 在 MySQL 里没有原生支持,标准写法是这样:

SELECT u.user_name, o.order_id FROM users u FULL OUTER JOIN orders o ON u.user_id = o.user_id;

结果六行:五条正常配对加一条孤儿订单。MySQL 里要模拟它,用left joinright join的结果做union

SELECT u.user_name, o.order_id FROM users u LEFT JOIN orders o ON u.user_id = o.user_id UNION SELECT u.user_name, o.order_id FROM users u RIGHT JOIN orders o ON u.user_id = o.user_id;

这里必须用union而不是union all,否则中间那些两边都配上的行会出现两次。这个坑我亲眼见过有人写错,报表数据直接翻倍。

把六种 join 的关键差异整理成一张表,方便你对着看:

join 类型左表未匹配行右表未匹配行典型用途
inner join丢弃丢弃只统计有关联的记录
left join保留,右表补 null丢弃以左表为主体扩展信息
right join丢弃保留,左表补 null同上,习惯上少用
full outer join保留,补 null保留,补 null双向对账、找差异
cross join全部组合全部组合生成笛卡尔积、造序列
left join + is null保留且右侧一定为空不涉及找出「没有关联数据」的主体

2.3 反连接:找出「没有订单的用户」

反连接这个说法听起来高级,写起来其实就一行:

SELECT u.user_name FROM users u LEFT JOIN orders o ON u.user_id = o.user_id WHERE o.user_id IS NULL;

结果是王五和赵六。这里的关键在于IS NULL的判断列必须选右表中「因为配不上而被补 null」的那个字段,而且这个字段在真实数据里要保证不会本身就有 null,否则会误伤。所以通常选右表的主键或非空列来判空,订单表这里选o.order_ido.user_id更稳妥。

反连接在业务里的使用频率非常高:找出没有登录过的用户、找出没有配置权限的账号、找出没有关联明细的主单。用NOT EXISTS也能实现同样效果,语义更直白:

SELECT u.user_name FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id );

两种写法在不同数据库里的执行效率可能有差异,老版本 MySQL 里NOT IN遇到子查询结果有 null 时会返回空集(这个坑非常经典),而NOT EXISTS和左连接反写相对安全。所以我的建议是:子查询里可能出现 null 的场合,避开NOT IN,用NOT EXISTS或者左连接判空。

2.4 cross join 不是不能用,是不能乱用

cross join生成的是笛卡尔积,四行 users 乘四行 orders 等于十六行。很多教程把它描述成「危险操作」,其实它本身没罪,问题在于很多人是不小心写出笛卡尔积的——比如 join 条件写漏了、或者写成了恒真条件:

-- 危险:条件恒成立,等价于笛卡尔积 SELECT * FROM users u JOIN orders o ON 1 = 1;

四行对四行是十六行,如果是四万行对四万行,就是十六亿行,数据库轻则卡死重则 OOM。我在线上见过一次因为 join 条件里字段类型不一致(一边 int 一边 varchar)导致隐式转换、索引失效,最终执行计划退化成笛卡尔积,直接把一个查询从毫秒级拖到分钟级。

但正经用途也有:生成日期序列、做商品规格的笛卡尔组合(颜色乘尺码)、构造测试数据。这种时候用 cross join 是明确意图,一点问题没有。区别就在于——你是故意要全部组合,还是不小心得到了全部组合。

3. on 和 where 的区别:left join 最大的坑在这里

3.1 一条语句,两种结果

这是我觉得比 join 类型本身更值得讲清楚的一件事。看下面两条语句,只差一个条件位置:

-- 写法 A:条件在 on SELECT u.user_name, o.order_id FROM users u LEFT JOIN orders o ON u.user_id = o.user_id AND o.status = 'paid'; -- 写法 B:条件在 where SELECT u.user_name, o.order_id FROM users u LEFT JOIN orders o ON u.user_id = o.user_id WHERE o.status = 'paid';

写法 A 的结果是:张三两行(都是 paid)、李四一行但订单字段为 null、王五一行 null、赵六一行 null,总共五行。因为o.status = 'paid'只是连接条件的一部分,李四的订单不满足它,但不影响李四这行出现在结果里——只是右表按 null 补。

写法 B 的结果是:只有张三两行,总共两行。因为 where 是在 join 完成之后才执行的过滤,李四那行的o.status已经是 null 了,null = 'paid'结果为未知,直接被过滤掉;王五、赵六同样没了。写法 B 的 left join 事实上退化成了 inner join。

这个差异在实际项目里造成的 bug 太多了,而且往往不容易发现,因为数据量小的时候你可能根本没注意到少了几行。我自己的习惯是:写 left join 时,只要条件涉及右表,先问一句「这个条件是想筛选关联结果,还是想筛选主表数据」。前者写 on,后者写 where。

3.2 一个简单的判断口诀

我总结过一个判断方法,用起来挺顺手:

  • 条件用来限制两边怎么配对:写在ON后面。
  • 条件用来限制最终结果集:写在WHERE后面。
  • 条件作用在主表(保留全部的那一侧):写WHERE后面基本没错。
  • 条件作用在从表(可能被补 null 的那一侧):写ON后面,除非你确实想过滤掉主表的行。

还有个小技巧,如果你不确定某个条件该放哪,把语句跑两遍对比行数。行数变化了,说明这个条件对结果集有实质影响;行数没变但 null 变多了,说明它影响的是配对过程。

注意:有些数据库的优化器会把WHERE里针对从表的IS NULL条件识别为反连接并做等价改写,但这属于优化层面的行为,不能作为你写错位置的理由。语义正确永远优先于依赖优化器。

4. 多表 join 时,怎么理清表和条件

4.1 表顺序其实影响可读性也影响性能

三张表以上 join 的时候,最容易乱。我一般遵循两条原则:主表放最前面,从表按业务从属关系依次排开每个 join 只关联它前面已经出现过的表之一,尽量不要跨表跳着关联。

比如用户、订单、订单明细三张表:

SELECT u.user_name, o.order_id, d.product_name, d.qty FROM users u LEFT JOIN orders o ON u.user_id = o.user_id LEFT JOIN order_detail d ON o.order_id = d.order_id;

这样写,每一步的连接关系都是线性的,读的时候顺着往下走就行。如果写成users直接 joinorder_detail(跳过 orders),虽然语法上可以,但逻辑上跳了一层,条件维护起来容易出错,尤其是后面要加过滤的时候。

至于「left join 之后再 left join,前一个 join 产生的 null 会不会影响后一个」,答案是会的。如果o.order_id因为没匹配到而是 null,那么d.order_id = o.order_id也匹配不上任何东西,明细字段同样是 null。这是符合语义的,但你要清楚结果里每一列可能是被「传播」过来的 null。

4.2 混合 join 类型时的语义叠加

真实查询里经常出现 inner join 和 left join 混用。这时候要小心:只要链条中出现一个 inner join,前面的 left join 保住的行可能就被它砍掉了

SELECT u.user_name, o.order_id, p.pay_no FROM users u LEFT JOIN orders o ON u.user_id = o.user_id INNER JOIN payment p ON o.order_id = p.order_id;

这里的 inner join 要求o.order_id必须有对应支付记录,而没订单的用户o.order_id是 null,必然匹配不上,所以赵六和王五全被过滤掉了。整条语句的实际效果接近于以支付记录为中心的内连接。

这种写法不算错,但意图容易误读。如果本意是「保留所有用户,有支付信息就带上」,应该改成LEFT JOIN payment p。我现在的习惯是,写完之后把语句在脑子里按「从内到外、从连接到过滤」跑一遍,检查每一步丢掉了哪些行,尤其是那些被补上 null 的行还能不能活到最后。

5. join 写对了,为什么还是慢

5.1 三种物理连接算法,知道名字就够用了

语法层只告诉数据库「我要什么」,具体怎么跑是优化器的事。目前主流的物理连接算法就三种:

算法大致思路适合场景
嵌套循环外层每行去内层找匹配外层结果集小、内层连接列有索引
哈希连接小表建哈希表,大表探测大表对大表、等值连接、无合适索引
排序合并两边先排序再归并数据已有序、范围连接

理解这三种算法的意义在于:你会知道为什么「小表驱动大表」在嵌套循环下有优势(外层循环次数少),也会知道为什么给连接列建索引能救命(把内层的全表扫描变成索引查找)。

嵌套循环的代价大致可以这样估:外层行数乘以内层每次查找的代价。假设外层一万行,内层每次查找走索引大概若干次磁盘读或内存读,乘起来就是总代价。如果内层没索引,每次查找变成全表扫描(假设十万行),那就是一万乘十万等于十亿次比较,这就不是一个量级的差别了。

5.2 索引怎么建才对

连接列的索引是重中之重。经验规则:被驱动表的连接列一定要有索引。以FROM users u JOIN orders o ON u.user_id = o.user_id为例,如果优化器选择 users 做外层,那么 orders.user_id 上必须有索引;反过来也要考虑。

另外两个容易忽略的点:

第一,联合索引的顺序。如果 orders 表上经常这样查:先按 user_id 关联,再按 status 过滤,那么(user_id, status)的联合索引比单独两个索引更有效,因为它能让过滤直接在索引里完成,不用回表。

第二,索引列上的函数和隐式转换会废掉索引WHERE DATE(o.created_at) = '2025-01-01'这种写法,函数作用在列上,索引基本用不上,要改成范围查询o.created_at >= '2025-01-01' AND o.created_at < '2025-01-02'。字段类型不一致导致的隐式转换同理,int 列和字符串比、字符集不同的两列相比,都可能让索引失效。

5.3 慢 SQL 排查的一份清单

遇到 join 查询慢,我一般按这个顺序看:

  1. 看执行计划。MySQL 用EXPLAIN,需要真实耗时用EXPLAIN ANALYZE(8.0 以上);SQL Server 里看实际执行计划,注意有没有出现大量的行数估算偏差;Oracle 用EXPLAIN PLAN配合计划表。重点看 type 那一列(MySQL),ALL是全表扫描,index是全索引扫描,rangerefeq_refconst依次更优。
  2. 看估算行数和实际行数差多少。差一个数量级以上,说明统计信息过期了,先更新统计信息再看。
  3. 看连接顺序。驱动表是不是小表,被驱动表的连接列有没有走索引。
  4. 看有没有排序和临时表Using filesortUsing temporary是常见性能杀手,通常和 group by、order by 有关。
  5. 缩小结果集再 join。有时候把过滤提前到子查询里,让参与 join 的数据量先降下来,效果立竿见影。

这里额外提一句,EXPLAIN给出的行数只是估算值,不要把它当成精确值来推理。真要看实际执行情况,得用能输出运行时统计的工具,比如 MySQL 的EXPLAIN ANALYZE会真的执行语句并给出每一步的实际耗时和实际行数。

6. 几个实战里绕不开的场景

6.1 一对多 join 导致行数膨胀,怎么处理

张三有两单,users left join orders出来两行,这是预期行为。但如果你的意图是「统计每个用户的订单总额」,直接 join 再 sum 是对的:

SELECT u.user_id, u.user_name, SUM(o.amount) AS total FROM users u LEFT JOIN orders o ON u.user_id = o.user_id GROUP BY u.user_id, u.user_name;

注意这里用SUM(o.amount),没订单的用户求和结果会是 null(因为所有被加的值都是 null),如果业务要求显示 0,得写COALESCE(SUM(o.amount), 0)或者某些数据库里的IFNULL。这个细节在报表里特别常见,不加处理前端就会显示一片空白。

但如果三张表连环 join,问题会放大。users 一对多 orders,orders 一对多 order_detail,那么 join 完之后每个订单会按明细条数再翻倍,最后 sum 订单金额就会重复累加。这种时候正确的做法是先聚合再 join,把明细聚合到订单粒度,再和用户表连:

SELECT u.user_name, o.order_id, d.total_qty FROM users u JOIN orders o ON u.user_id = o.user_id JOIN ( SELECT order_id, SUM(qty) AS total_qty FROM order_detail GROUP BY order_id ) d ON o.order_id = d.order_id;

这个模式我用了很多年,凡是「多对多链条」的统计,先想办法把中间层聚合掉,再往上层 join。

6.2 join、in、exists 到底选哪个

这三者的选择经常被争论。我的经验是:

  • 结果需要右表的字段,用join
  • 只需要「左表是否存在匹配」这个布尔判断,用exists
  • 右表是小而固定的集合(比如状态码列表),用in更直观。
  • in里如果来自子查询且可能有 null,一定要小心,改写成exists或 join 更安全。

现代优化器的能力已经很强,很多时候这三种写法会被改写成相同的执行计划,所以优先级排序应该是:语义清晰度 > 性能。写完先看执行计划,如果计划一样就直接选最好读的那个版本。

6.3 有些 join 其实可以用窗口函数替代

SQL 窗口函数普及之后,一部分「自连接」场景可以省掉。典型的是「取每个用户的最近一单」:

-- 传统写法:自连接找最大值 SELECT u.user_name, o.order_id, o.amount FROM users u JOIN orders o ON u.user_id = o.user_id JOIN ( SELECT user_id, MAX(amount) AS max_amount FROM orders GROUP BY user_id ) m ON o.user_id = m.user_id AND o.amount = m.max_amount;

用窗口函数会清爽很多:

SELECT user_name, order_id, amount FROM ( SELECT u.user_name, o.order_id, o.amount, ROW_NUMBER() OVER (PARTITION BY o.user_id ORDER BY o.amount DESC) AS rn FROM users u JOIN orders o ON u.user_id = o.user_id ) t WHERE rn = 1;

而且上面那个自连接版本有个隐蔽问题:如果某个用户有两单金额并列最高,结果会出现两行,而窗口函数版本可以通过ROW_NUMBER强制只留一行,用RANK则保留并列。取一条还是取所有并列,这是个业务问题,得根据需求选函数。关于窗口函数和 join 的取舍,我的判断标准是:只要涉及「分组内排名」「分组内取前 N」,优先考虑窗口函数,可读性和可控性都更好。

7. 常见问题速查表和我的使用习惯

7.1 join 问题速查表

现象大概率原因处理办法
left join 后行数莫名减少过滤条件写在了 where 里,作用在从表上把条件挪到 on,或确认是否本该用 inner join
结果行数比预期多存在一对多关系,或者连接条件不唯一先聚合再 join,或检查连接键是否唯一
结果行数爆炸性增长连接条件写漏或恒真,产生笛卡尔积检查 on 条件,确认字段类型和字符集一致
关联字段全为 null连接键类型不一致导致隐式转换,或字符集不同统一字段类型和字符集
NOT IN查不出任何数据子查询结果里包含 null改用NOT EXISTS或左连接判空
查询突然变慢统计信息过期、索引失效、数据量增长更新统计信息、重新查看执行计划
排序字段是右表的列,null 排在最前不同数据库 null 排序规则不同显式指定排序,或把 null 转成特定值
UNION合并时数据重复应该用UNION ALL却用了UNION,或反之明确是否需要去重

关于 null 的排序,这个点特别容易被忽略:MySQL 里 null 默认排在最前,某些数据库里 null 默认排在最后,同一个查询换数据库跑结果顺序就不一样。如果你的业务依赖排序结果,一定要显式处理,别依赖默认行为。

7.2 我自己的几条使用习惯

写了这么多年 join,有几点是我一直坚持的,分享出来供参考。

第一,永远给表起短别名,并且别名要有意义users uorders o这种比t1t2强太多,尤其在七八张表的报表查询里,别名是唯一能让你三分钟后还看得懂语句的线索。

第二,生产环境执行的查询尽量不要用SELECT *。join 场景下SELECT *的危害加倍,因为多表的同名字段会被全部返回,前端或者代码取值时经常取错列;而且一旦加了新字段,查询的返回结构和网络传输量都会变。明确列出需要的列,顺带还能让优化器有机会用上覆盖索引。

第三,写完复杂 join 一定要拿边界数据验证。至少测三种情况:两边都匹配上的、左边有右边没有的、右边有左边没有的。我习惯在造数据时就把这三类行准备好,跑完对着结果数一数行数,比事后在线上排查便宜得多。

第四,养成看执行计划的习惯,哪怕这次查询不慢。看多了你对「什么写法会走什么计划」会有直觉,等到真出问题时排查速度完全不一样。

第五,join 的字段类型和字符集,在建表阶段就对齐。这类问题排查起来最费时间,因为语句看起来完全正确,只有对比表结构才能发现差异。与其事后救火,不如建表时统一规范。

第六,关于安全性,写查询时涉及用户输入的过滤条件,一律走参数化传参,不要用字符串拼接的方式把用户输入拼进 SQL 语句里。这不只是规范问题,拼接方式很容易让数据被当成语句的一部分执行,参数化绑定则天然把数据和语句分开,是最省事的防御手段。

最后再补一个我最近才想明白的点。很多人学 join 卡住,是因为一开始就想把那张图和所有类型全背下来。我的建议反过来:先把SELECTFROMWHEREGROUP BY的逻辑执行顺序彻底搞明白,再去理解 join 只是FROM阶段里的一件事,而且发生在WHERE之前。顺序一清楚,on 和 where 的区别、null 为什么会传递、什么时候会退化成内连接,全都能自己推出来,不需要死记。

至于那条EXPLAIN语句,我在处理慢 SQL 的时候基本是本能反应,先看计划再说别的,比上来就改语句高效得多。这个习惯大概是从一次线上事故之后养成的——当时折腾了两个小时改写法,最后发现问题只是统计信息太旧导致优化器选错了连接顺序,更新一下就好了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询