做后端开发的朋友,估计都见过那张经典的两圆相交图——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 join和right 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_id比o.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 查询慢,我一般按这个顺序看:
- 看执行计划。MySQL 用
EXPLAIN,需要真实耗时用EXPLAIN ANALYZE(8.0 以上);SQL Server 里看实际执行计划,注意有没有出现大量的行数估算偏差;Oracle 用EXPLAIN PLAN配合计划表。重点看 type 那一列(MySQL),ALL是全表扫描,index是全索引扫描,range、ref、eq_ref、const依次更优。 - 看估算行数和实际行数差多少。差一个数量级以上,说明统计信息过期了,先更新统计信息再看。
- 看连接顺序。驱动表是不是小表,被驱动表的连接列有没有走索引。
- 看有没有排序和临时表。
Using filesort和Using temporary是常见性能杀手,通常和 group by、order by 有关。 - 缩小结果集再 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 u、orders o这种比t1、t2强太多,尤其在七八张表的报表查询里,别名是唯一能让你三分钟后还看得懂语句的线索。
第二,生产环境执行的查询尽量不要用SELECT *。join 场景下SELECT *的危害加倍,因为多表的同名字段会被全部返回,前端或者代码取值时经常取错列;而且一旦加了新字段,查询的返回结构和网络传输量都会变。明确列出需要的列,顺带还能让优化器有机会用上覆盖索引。
第三,写完复杂 join 一定要拿边界数据验证。至少测三种情况:两边都匹配上的、左边有右边没有的、右边有左边没有的。我习惯在造数据时就把这三类行准备好,跑完对着结果数一数行数,比事后在线上排查便宜得多。
第四,养成看执行计划的习惯,哪怕这次查询不慢。看多了你对「什么写法会走什么计划」会有直觉,等到真出问题时排查速度完全不一样。
第五,join 的字段类型和字符集,在建表阶段就对齐。这类问题排查起来最费时间,因为语句看起来完全正确,只有对比表结构才能发现差异。与其事后救火,不如建表时统一规范。
第六,关于安全性,写查询时涉及用户输入的过滤条件,一律走参数化传参,不要用字符串拼接的方式把用户输入拼进 SQL 语句里。这不只是规范问题,拼接方式很容易让数据被当成语句的一部分执行,参数化绑定则天然把数据和语句分开,是最省事的防御手段。
最后再补一个我最近才想明白的点。很多人学 join 卡住,是因为一开始就想把那张图和所有类型全背下来。我的建议反过来:先把SELECT、FROM、WHERE、GROUP BY的逻辑执行顺序彻底搞明白,再去理解 join 只是FROM阶段里的一件事,而且发生在WHERE之前。顺序一清楚,on 和 where 的区别、null 为什么会传递、什么时候会退化成内连接,全都能自己推出来,不需要死记。
至于那条EXPLAIN语句,我在处理慢 SQL 的时候基本是本能反应,先看计划再说别的,比上来就改语句高效得多。这个习惯大概是从一次线上事故之后养成的——当时折腾了两个小时改写法,最后发现问题只是统计信息太旧导致优化器选错了连接顺序,更新一下就好了。