上个月帮同事排查一张统计报表,他写了一个 left join,查出来的人数却比员工花名册少了两行。他没动 where 条件,只是给连接条件加了个多余的过滤,数据又“多”出一截。类似这种 SQL JOIN“看起来每行都对,放进真实报表里总出问题”的现象,我见过太多次。今天这篇文章,我把 SQL JOIN 里最核心的内连接、外连接、交叉连接一次性讲透。文中所有案例都配有可跑通的建表语句和查询代码,适合刚开始学 SQL 的读者,也适合已经写了几年查询,但依然会被结果集数量变化绕晕的开发者。
1. 先别背语法:JOIN 本质是“集合运算”
很多教程一上来就是 inner join 怎么做、left join 怎么写,读者照抄能出结果,但一旦业务条件变化就崩。原因很简单:SQL 里的表本质上是一个“集合”,JOIN 是把两个集合按照指定规则重新组合。如果你不理解组合规则,看到结果行数的增加和减少,肯定会慌。
1.1 为什么你会被 JOIN 结果“骗”了
我把 JOIN 理解成手工拼表。左边一张员工表,右边一张部门表,用部门编号当“卡扣”把两边扣起来。扣上之后,每条结果行来自左右各取一部分字段拼成的新行。听起来很简单,但实际执行时有几个关键问题必须提前建立直觉:
- 如果左边有一行,右边找不到匹配行,这一行到底保不保留?保留的话右边字段填什么?
- 如果左边有一行,右边匹配上了三行,结果是三行还是一行?
- 如果两边都有 NULL 值,NULL 和 NULL 能不能匹配上?
- 如果你在 JOIN 之后又写了
where过滤,会不会把 JOIN 刚补出来的 NULL 行过滤掉?
这四个问题里的任何一个没有想清楚,都可能写错。而它们的答案,恰恰就藏在“内连接、外连接、交叉连接”各自的语义里。
1.2 四种 JOIN 一句话版本
在展开代码之前,先给你一个可以直接贴在脑门上的速记:
| 连接方式 | 一句话语义 | 匹配不上的行怎么处理 |
|---|---|---|
| INNER JOIN | 两边都必须匹配上,只留交集 | 直接丢弃 |
| LEFT JOIN | 左表每一行都保留,右表只补匹配上的字段 | 右表没有匹配时补 NULL |
| RIGHT JOIN | 右表每一行都保留,左表只补匹配上的字段 | 左表没有匹配时补 NULL |
| CROSS JOIN | 左表的每一行去和右表的每一行配对 | 不考虑匹配条件,产生笛卡尔积 |
提示:FULL OUTER JOIN 也是外连接的一种,它是 LEFT 和 RIGHT 的并集。因为 MySQL 原生不支持,我放到第 4 章单独用模拟方式讲解。
1.3 理解执行顺序是看懂一切 JOIN 陷阱的前提
SQL 语句读起来是从 select 开始,但数据库真正干活时不是按你写的顺序来的。发生 JOIN 的 from ... join ... on 阶段,会先于 where 阶段执行。这意味着:
on 阶段用来决定“左右两行是否能拼成一行”;where 阶段用来在拼完之后,对已经形成的宽表做行过滤。
这个顺序直接导致了一个经典问题:left join 之后,如果你在 where 里写了右侧表的过滤条件,数据库会把右侧表匹配不上时补出来的 NULL 行消灭掉,left join 的效果就退化成了 inner join。第 4.4 节我会专门演示。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 复现所有案例:员工表和部门表这样准备
后文每个查询都会基于同一套测试数据。为了避免不同数据库方言干扰,我统一使用 MySQL 8.0 编写代码。SQL Server、PostgreSQL 里除了反引号和 FULL JOIN 模拟位置略有不同,其他基本可以直接运行。
2.1 表结构设计:故意埋了几个“坑”
很多教材建表都喜欢把字段设计得干干净净,实际业务根本不是这样。我故意做了两个设计:
- 员工表
dept_id允许为 NULL,用来模拟“暂时未分配部门的员工”。 - 部门表里有一个人事部,但没有员工属于它,用来模拟“空部门”。
这两个看似不合理的设置,恰恰是测试 inner join 和 left join 差异的最好试金石。自连接还要用到上下级关系,所以员工表里增加 manager_id 字段。建表语句如下:
sql复制CREATE DATABASE IF NOT EXISTS join_demo CHARACTER SET utf8mb4;
USE join_demo;
CREATE TABLE departments (
dept_id INT PRIMARY KEY,
dept_name VARCHAR(50) NOT NULL
) ENGINE = InnoDB;
CREATE TABLE employees (
emp_id INT PRIMARY KEY,
emp_name VARCHAR(50) NOT NULL,
dept_id INT,
salary DECIMAL(10, 2),
manager_id INT
) ENGINE = InnoDB;
建表时我没有加外键约束。真实生产环境中外键能保证数据完整性,但在学习和演示 JOIN 时,外键会限制你插入一些“边界数据”,比如未分配部门的员工。所以我建议先不加外键,把注意力集中在连接语义上。
2.2 造数:让每个 JOIN 场景都有数据可用
sql复制INSERT INTO departments (dept_id, dept_name) VALUES
(1, '研发部'),
(2, '市场部'),
(3, '财务部'),
(4, '人事部');
INSERT INTO employees (emp_id, emp_name, dept_id, salary, manager_id) VALUES
(1, '张三', 1, 20000.00, NULL),
(2, '李四', 1, 15000.00, 1),
(3, '王五', 1, 12000.00, 1),
(4, '赵六', 2, 13000.00, NULL),
(5, '郑十', 2, 10000.00, 4),
(6, '孙七', 3, 9000.00, NULL),
(7, '周八', 3, 7000.00, 6),
(8, '吴九', NULL, 8500.00, NULL);
当前数据关系如下:
- 部门表共 4 条:研发部、市场部、财务部、人事部。
- 员工表共 8 条。
- 研发部有张三、李四、王五;市场部有赵六、郑十;财务部有孙七、周八。
- 人事部没有任何员工。
- 吴九没有部门,
dept_id为 NULL。 - 上下级关系:李四、王五的上级是张三,郑十的上级是赵六,周八的上级是孙七。
为了确保造数没问题,先跑一遍最基础的全表查询:
sql复制SELECT d.dept_name, e.emp_name, e.salary
FROM departments d
LEFT JOIN employees e ON d.dept_id = e.dept_id
ORDER BY d.dept_id, e.emp_id;
3. 内连接:最常用,但也最容易忽略“消失的行”
内连接的关键词是 INNER JOIN,在日常开发里也可以简写成 JOIN。它返回的是左右两个集合的交集:只有两边都满足连接条件的行,才会出现在结果里。
3.1 等值内连接:查出所有有部门归属的员工
需求:列出每个员工及其所属部门名称。因为吴九没有部门,他不满足“员工表的 dept_id 等于部门表的 dept_id”这个条件,所以不会出现在结果中。
sql复制SELECT e.emp_id, e.emp_name, d.dept_name, e.salary
FROM employees e
INNER JOIN departments d ON e.dept_id = d.dept_id
ORDER BY e.emp_id;
结果如下:
| emp_id | emp_name | dept_name | salary |
|---|---|---|---|
| 1 | 张三 | 研发部 | 20000.00 |
| 2 | 李四 | 研发部 | 15000.00 |
| 3 | 王五 | 研发部 | 12000.00 |
| 4 | 赵六 | 市场部 | 13000.00 |
| 5 | 郑十 | 市场部 | 10000.00 |
| 6 | 孙七 | 财务部 | 9000.00 |
| 7 | 周八 | 财务部 | 7000.00 |
员工表有 8 条记录,结果只有 7 行,吴九不见了。这其实是内连接的正常行为:两边都匹配上的才留。很多初学者看到行数变少就开始怀疑自己写错了,其实先想清楚业务语义:一个没有部门的人,自然不应该出现在“员工-部门”关联结果里。
那人事部呢?内连接结果里也没有人事部,因为部门表虽然有人事部这条记录,但员工表里没有任何人的 dept_id = 4,右边匹配不到左边,所以也被丢弃。内连接不会关心你是否是部门表里的“主表”,只要两边有一边没匹配上,这行就不会输出。
3.2 JOIN 条件不一定非用等号:非等值连接实战
很多人的思维被“主外键关联”限制住了,认为 JOIN 条件只能是 =。实际上,连接条件可以是大于、小于、区间判断。最典型的需求是按工资区间匹配等级。
我现在临时定义一个工资等级:10000 及以下算“初级”,10001 到 15000 算“中级”,15001 以上算“高级”。在 MySQL 8.0 里可以用 CTE(公用表表达式)构造这个等级表:
sql复制WITH salary_levels AS (
SELECT '初级' AS level_name, 0 AS min_salary, 10000 AS max_salary
UNION ALL
SELECT '中级', 10001, 15000
UNION ALL
SELECT '高级', 15001, 999999
)
SELECT e.emp_name, e.salary, sl.level_name
FROM employees e
INNER JOIN salary_levels sl ON e.salary BETWEEN sl.min_salary AND sl.max_salary
ORDER BY e.salary DESC;
这里 JOIN 的条件不再是两个表的主外键相等,而是员工工资落在等级表的区间里。结果为:
| emp_name | salary | level_name |
|---|---|---|
| 张三 | 20000.00 | 高级 |
| 李四 | 15000.00 | 中级 |
| 赵六 | 13000.00 | 中级 |
| 王五 | 12000.00 | 中级 |
| 郑十 | 10000.00 | 初级 |
| 孙七 | 9000.00 | 初级 |
| 周八 | 7000.00 | 初级 |
| 吴九 | 8500.00 | 初级 |
这种写法在订单折扣、积分等级、绩效档位场景里非常常见。一定要记住:JOIN 条件是“如何判定左右两行匹配”的规则,不一定要依赖外键。
3.3 NULL 值永远不会“等于”NULL
很多人在处理可空字段时,会以为两个 NULL 能通过 = 匹配上,比如“部门为空”的员工 A 和“部门为空”的员工表 B。但在 SQL 中,NULL 表示“不知道”或“不存在”,两个“不知道”不能说是相等。
所以对 employees 和 departments 做等值 INNER JOIN 时,吴九的 dept_id = NULL 不会去和部门表的任意 dept_id 比较成功。
如果你确实想把 NULL 和 NULL 当作一类,必须用 IS NULL 单独处理,或者用 <=>(MySQL 特有的 null 安全等于)作为连接条件。比如:
sql复制SELECT e1.emp_name, e2.emp_name
FROM employees e1
JOIN employees e2 ON e1.dept_id <=> e2.dept_id
WHERE e1.emp_id < e2.emp_id;
3.4 能别写隐式连接就别写
老代码里经常能看到这样的写法:
sql复制SELECT e.emp_name, d.dept_name
FROM employees e, departments d
WHERE e.dept_id = d.dept_id;
这是 ANSI SQL 89 时代的旧语法,现在虽然还能在 MySQL 里跑,但我强烈不建议你再用。原因是:如果某天你忘了写 where 条件,这条 SQL 会直接变成笛卡尔积,把两张表所有行互相配对,数据量瞬间爆炸。而 SQL 92 的显式 JOIN 语法把连接条件写在 on 后面,JOIN 类型一目了然,排查问题时也能减少一个变量。
4. 外连接:LEFT、RIGHT、FULL 都是在回答“保留谁”
外连接可以理解为:在内连接的基础上,把某一边没匹配上的行也保留下来。保留左边全部记录的叫 LEFT JOIN,保留右边全部记录的叫 RIGHT JOIN,两边都保留的叫 FULL OUTER JOIN。保留那边,数据库就会在另一边补 NULL。
4.1 左连接:左表全保留,右表匹配不上就补 NULL
回到最开始的员工场景。这次需求变了:我要看所有人的部门情况,包括还没分配部门的吴九。此时应该以员工表为主表,员工表放在左侧,使用 LEFT JOIN。
sql复制SELECT e.emp_name, d.dept_name, e.salary
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
ORDER BY e.emp_id;
结果中吴九的部门名称显示为 NULL:
| emp_name | dept_name | salary |
|---|---|---|
| 张三 | 研发部 | 20000.00 |
| 李四 | 研发部 | 15000.00 |
| 王五 | 研发部 | 12000.00 |
| 赵六 | 市场部 | 13000.00 |
| 郑十 | 市场部 | 10000.00 |
| 孙七 | 财务部 | 9000.00 |
| 周八 | 财务部 | 7000.00 |
| 吴九 | NULL | 8500.00 |
这个结果必须重点关注两点:第一,吴九没有丢;第二,部门表里没有人事部,因为这条查询以员工表为主表,人事部作为右表没有被“保留”的资格。
如果你反过来以部门表为主表,想看每个部门各有多少员工,人事部虽然没有员工,但依然要出现在统计里,那么查询应该是:
sql复制SELECT d.dept_name, e.emp_name
FROM departments d
LEFT JOIN employees e ON d.dept_id = e.dept_id
ORDER BY d.dept_id, e.emp_id;
此时结果里会出现人事部,但 emp_name 是 NULL。
4.2 右连接:和左连接是“镜像”关系
RIGHT JOIN 的语义就是保留右表全部记录。上面“部门表在左、员工表在右”的需求,反过来写成右连接也完全等价:
sql复制SELECT d.dept_name, e.emp_name
FROM employees e
RIGHT JOIN departments d ON d.dept_id = e.dept_id
ORDER BY d.dept_id, e.emp_id;
这个查询的结果和上面的 departments LEFT JOIN employees 完全一致。既然两张表换个位置就能用左连接表达,为什么很多团队还是规定“能不用 RIGHT JOIN 就不用”?
因为人脑更习惯从左往右读 SQL。A LEFT JOIN B 读起来是“保留 A 的全部,去 B 里找匹配信息”,直觉上很顺。A RIGHT JOIN B 读起来是“保留 B 的全部”,可 A 在左边、B 在右边,你需要多做一步脑内翻转才能理解主表是哪个。为了可读性,稳妥做法是统一用 LEFT JOIN,把主表写在左侧。
4.3 FULL OUTER JOIN:两边的“孤儿”都保留
Full outer join 返回的是:内连接的结果 + 左表没匹配上的行 + 右表没匹配上的行。放到员工和部门场景里,就是:
- 所有有部门的员工(7 行)
- 没有部门的吴九
- 没有员工的空部门人事部
所以结果应该是 7 + 1 + 1 = 9 行。PostgreSQL、SQL Server、Oracle 都原生支持 FULL OUTER JOIN,但 MySQL 不支持,需要用 LEFT JOIN UNION RIGHT JOIN 模拟:
sql复制SELECT e.emp_name, d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
UNION
SELECT e.emp_name, d.dept_name
FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.dept_id;
很多资料会告诉你这里用 UNION 而不是 UNION ALL,原因是左右两个查询会把“匹配成功的 7 行”各输出一遍,必须去重。这个解释对,但要补充一点:UNION 会对所有字段去重,如果两个 SELECT 里有一些业务字段相同但其他字段不同的行,也会被保留下来。所以这个模拟方式只适用于结果字段完全一致且需要消除重复行的场景。
4.4 经典陷阱:条件放 ON 还是 WHERE,结果天差地别
还是员工表左连接部门表。现在我希望保留所有员工,但如果员工属于研发部,就显示部门名;如果员工不属于研发部,部门名也允许为空。这里的业务语义是“我就是要保留 8 名员工,标记其中哪些是研发部”。
把条件写在 ON 里:
sql复制SELECT e.emp_name, d.dept_name
FROM employees e
LEFT JOIN departments d
ON e.dept_id = d.dept_id
AND d.dept_name = '研发部'
ORDER BY e.emp_id;
结果仍然是 8 行。非研发部门员工和吴九的 dept_name 都是 NULL,因为 ON 阶段只决定了“是否把右表行拼进来”,不影响左表行的保留。
把条件放到 WHERE 里:
sql复制SELECT e.emp_name, d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
WHERE d.dept_name = '研发部'
ORDER BY e.emp_id;
结果只剩 3 行,只包含研发部的张三、李四、王五。原因前面已经说过:JOIN 先执行,LEFT JOIN 补出来的员工行此时已经有了一堆 NULL 字段;WHERE d.dept_name = '研发部' 把 NULL 行全部过滤掉了。最终效果等同于 INNER JOIN ... WHERE ...。
这是我见过 SQL 新人犯的最隐蔽错误之一。排查这类问题时,不要只看结果对不对,
