自连接、交叉连接与复杂 JOIN:一条问题链讲透

我曾把员工查经理的 SQL 里的 LEFT JOIN 写成 INNER JOIN,结果 CEO 整个人从报表里消失了,我对着结果数了半小时人头。

这篇文章把自连接、交叉连接、复杂 JOIN 串成一条递进问题链,读完你能独立拆解多层关联查询,并避开我踩过的每一个坑。

一、场景:答案不在另一张表,而在同一张表内部

你写 JOIN 时默认要拼两张表,但如果问题的答案就藏在同一张表里呢?

业务场景 表结构 关联字段指向
员工与经理 employees(employee_id, name, manager_id) manager_id → 本表 employee_id
分类父子层级 categories(category_id, name, parent_id) parent_id → 本表 category_id
同用户同天订单 orders(order_id, user_id, order_date) user_id 相等且 order_date 相等

这些场景的共同点,是关联的两方本质上为同一张表的不同行。这种用法叫自连接(Self Join)。

自连接不是新语法,只是在 FROM 子句里给同一张表取两个别名,然后像连接两张表一样连接它们。

那同一张表到底怎么连自己?先看我踩的第一个坑。

二、踩坑:CEO 为什么从结果里消失

我当时的写法,CEO 直接没了:

sql 复制代码
SELECT e.name AS employee_name,
       m.name AS manager_name
FROM employees e
JOIN employees m
  ON e.manager_id = m.employee_id;

把 JOIN 换成 LEFT JOIN,CEO 回来了:

sql 复制代码
SELECT e.name AS employee_name,
       m.name AS manager_name
FROM employees e
LEFT JOIN employees m
  ON e.manager_id = m.employee_id;

别名让同一张表在逻辑上变成两张表:e 扮演员工,m 扮演经理。CEO 的 manager_id 为 NULL,内连接找不到匹配行,根节点被丢弃;外连接保留左表全部行,右表列填充 NULL。

latex 复制代码
 employees e                employees m
┌────┬──────┬────────┐     ┌────┬──────┐
│ id │ name │ mgr_id │     │ id │ name │
├────┼──────┼────────┼────►├────┼──────┤
│ 1  │ CEO  │ NULL   │ ✗        无匹配行
│ 2  │ 张三 │ 1      │────►│ 1  │ CEO  │
│ 3  │ 李四 │ 1      │────►│ 1  │ CEO  │
└────┴──────┴────────┘     └────┴──────┘
  INNER JOIN:第 1 行被丢弃
  LEFT  JOIN:第 1 行保留,右侧为 NULL

注意:自连接先定角色,再定连接类型;根节点要保留,就用 LEFT JOIN。

三、比较与去重:同表的行怎么比、怎么不重复

根节点保住了,但同一张表还会遇到两类问题:两行之间怎么比较,重复行怎么找。

模式 典型问题 推荐写法 最容易错的点
层级查询 员工及其经理 同表 LEFT JOIN 内连接丢根节点
同表比较 工资高于部门均值 窗口函数 / 子查询 比较时漏掉部门条件
同表去重 找同名同邮箱用户 同表 JOIN + 不等号 不等号方向导致翻倍

同表去重的标准写法:

sql 复制代码
SELECT a.user_id, a.name, a.email
FROM users a
JOIN users b
  ON a.email = b.email
 AND a.user_id < b.user_id;

我在 a.user_id < b.user_id 这个条件上翻过车,三种写法结果完全不同:

连接条件 自己配自己 重复对输出 结果行数
a.user_id = b.user_id 是,全部无效 --- 全是噪声
a.user_id <> b.user_id 否 (1,2) 与 (2,1) 各一次 2 倍
a.user_id < b.user_id 否 只保留一个方向 1 倍

同表比较用窗口函数,只需扫描一次表,找工资高于本部门均值的员工:

sql 复制代码
SELECT name, salary, department_id
FROM (
    SELECT name, salary, department_id,
           AVG(salary) OVER (PARTITION BY department_id) AS avg_salary
    FROM employees
) t
WHERE salary > avg_salary;

要查 CEO 到基层员工的完整层级路径,用递归公用表表达式(Recursive CTE),锚点查询找根,递归部分找下一层:

sql 复制代码
WITH RECURSIVE org AS (
    SELECT employee_id, name, manager_id, 1 AS level
    FROM employees
    WHERE manager_id IS NULL
    UNION ALL
    SELECT e.employee_id, e.name, e.manager_id, o.level + 1
    FROM employees e
    JOIN org o ON e.manager_id = o.employee_id
)
SELECT * FROM org ORDER BY level, employee_id;

还要注意连接方向:e.manager_id = m.employee_id 表示"e 的经理是 m",反写成 m.manager_id = e.employee_id,整条上下级关系就倒了。

注意:自连接去重靠不等号定方向: <** 只留一条,<> 结果翻倍。**

四、交叉连接:漏掉 ON 是灾难,还是另一种原材料

连接条件写错会丢行、会翻倍;那如果干脆不写条件,会发生什么?

我有一次漏写 ON,测试库瞬间返回几十万行,客户端直接卡死。这背后就是交叉连接(Cross Join):不指定任何连接条件,左表每行与右表每行逐一配对,结果为笛卡尔积(Cartesian Product),行数等于 m × n。

左表 m 行 右表 n 行 结果 m × n 行
10 10 100
1,000 1,000 1,000,000
100,000 100,000 10,000,000,000

所有 JOIN 都等价于"先做交叉连接,再按条件过滤"。内连接过滤后即为结果;外连接再把未匹配的左表行补回,右表列填 NULL。

交叉连接的合法用途:

  • 生成组合:颜色表 × 尺寸表,直接生成电商 SKU
  • 生成序列:数字表自交叉得到 100 个数,再配合日期函数补齐缺失日期
  • 理解 ON 与 WHERE:ON 在连接过程中过滤,WHERE 在连接完成后过滤;内连接下两者等价,外连接下结果可能完全不同

生成颜色与尺寸的全部组合:

sql 复制代码
SELECT c.color, s.size
FROM colors c
CROSS JOIN sizes s;

用数字表交叉连接生成 1 到 100 的序列:

sql 复制代码
WITH digits AS (
    SELECT 0 AS d UNION ALL SELECT 1 UNION ALL SELECT 2
    UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
    UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8
    UNION ALL SELECT 9
)
SELECT a.d + b.d * 10 + 1 AS n
FROM digits a
CROSS JOIN digits b
ORDER BY n;

控制风险只有一条:先用 CTE 过滤、降数据量,再 CROSS JOIN,绝不让两张原始大表直接交叉。

注意:交叉连接是 JOIN 的原材料,不是废物;但大表直接交叉,等于给数据库埋雷。

五、复杂 JOIN:三个真实业务案例,逐个拆

单点都清楚了,可真实业务是多层关联叠在一起,该怎么下手?

案例一:找出每个部门工资最高的员工及其经理。

latex 复制代码
employees
   │ GROUP BY department_id
   ▼
dept_max(部门最高工资)
   │ JOIN  e.department_id = dm.department_id
   │  AND  e.salary = dm.max_salary
   ▼
JOIN departments        ── 取部门名
   ▼
LEFT JOIN employees m   ── 取经理名
sql 复制代码
WITH dept_max AS (
    SELECT department_id, MAX(salary) AS max_salary
    FROM employees
    GROUP BY department_id
)
SELECT e.name AS employee_name,
       e.salary,
       d.department_name,
       m.name AS manager_name
FROM employees e
JOIN dept_max dm
  ON e.department_id = dm.department_id
 AND e.salary = dm.max_salary
JOIN departments d
  ON e.department_id = d.department_id
LEFT JOIN employees m
  ON e.manager_id = m.employee_id;

我漏过 department_id 条件,只写 salary = max_salary,结果别的部门同薪资的员工被串了进来。"本部门"和"最高工资"两个条件必须同时成立。

案例二:分类树展开祖先路径,并统计每个分类的商品数。

sql 复制代码
WITH RECURSIVE category_tree AS (
    SELECT category_id, name, parent_id, name AS path
    FROM categories
    WHERE parent_id IS NULL
    UNION ALL
    SELECT c.category_id, c.name, c.parent_id,
           CONCAT(ct.path, ' > ', c.name)
    FROM categories c
    JOIN category_tree ct ON c.parent_id = ct.category_id
)
SELECT ct.category_id, ct.name, ct.path,
       COUNT(p.product_id) AS product_count
FROM category_tree ct
LEFT JOIN products p ON p.category_id = ct.category_id
GROUP BY ct.category_id, ct.name, ct.path
ORDER BY ct.path;

LEFT JOIN 保证没有商品的分类不消失;COUNT(p.product_id) 统计的是商品数,写成 COUNT(*) 会把补出来的 NULL 行也算进去。

案例三:找出连续下单的用户。连续两天用自连接:

sql 复制代码
SELECT DISTINCT a.user_id, a.order_date
FROM orders a
JOIN orders b
  ON a.user_id = b.user_id
 AND b.order_date = DATE_ADD(a.order_date, INTERVAL 1 DAY);

连续三天再自连接就很绕,用窗口函数 LAG 更清晰:

sql 复制代码
WITH daily AS (
    SELECT DISTINCT user_id, order_date
    FROM orders
)
SELECT user_id, order_date
FROM (
    SELECT user_id, order_date,
           LAG(order_date, 1) OVER (PARTITION BY user_id ORDER BY order_date) AS prev_date,
           LAG(order_date, 2) OVER (PARTITION BY user_id ORDER BY order_date) AS prev2_date
    FROM daily
) t
WHERE prev_date  = DATE_SUB(order_date, INTERVAL 1 DAY)
  AND prev2_date = DATE_SUB(order_date, INTERVAL 2 DAY);

从"能跑"到"可维护",手段就这四类:

手段 做法 作用
CTE 拆分 每步中间结果命名 可读、可单独调试
窗口函数 AVG OVER、LAG、LEAD 单次扫描,替代部分自连接
索引 manager_id、department_id、user_id 等连接字段 连接提速可达数量级
EXPLAIN 查看执行计划 确认驱动表与连接顺序

注意:复杂 JOIN 不靠一把梭,靠 CTE 拆解;每步有名字,才谈得上可维护。

六、总结延伸:JOIN 没有玄学,只有三步

JOIN 的本质是"笛卡尔积 + 选择 + 投影":交叉连接提供全部组合,ON 条件做选择,SELECT 做投影。

三类问题各有一个关键:

  • 自连接:别名、方向、根节点
  • 交叉连接:先降数据量,再做配对
  • 复杂 JOIN:CTE 拆解、窗口函数、索引、EXPLAIN

我现在写 JOIN 固定三个习惯:

  1. 永远写清别名与连接条件,不依赖数据库默认行为
  2. 能用 CTE 就不写巨型嵌套子查询
  3. 先拿小数据量验证结果,再用 EXPLAIN 看执行计划

术语速查表

术语 英文 一句话解释
自连接 Self Join 同一张表取两个别名互相连接
交叉连接 Cross Join 不写连接条件,返回两表全部组合
笛卡尔积 Cartesian Product 两表行两两配对,行数为 m × n
递归公用表表达式 Recursive CTE 锚点查询加递归查询,用于层级展开
窗口函数 Window Function 不折叠行的聚合计算,如 AVG OVER、LAG
内连接 Inner Join 只保留两表匹配成功的行
外连接 Outer Join 保留左表全部行,未匹配处右表填 NULL
执行计划 Execution Plan 数据库执行 SQL 的步骤与连接顺序说明

参考链接

相关推荐
言乐61 小时前
JavaScript概括前端原理
开发语言·前端·javascript·python·ecmascript
charmingmm8151 小时前
数据库和数据表的设计和命名经验
数据库
小马同学-1 小时前
MySQL5.x源码安装
mysql
回眸&啤酒鸭1 小时前
DeepSeek V4.1 Flash 批量处理实战指南
服务器·前端·javascript
用户3374096924261 小时前
2026-09-27-Qwen-Image-2.1-1660Ti本地部署实战
前端
BIM云平台开发1 小时前
DAZ里,如何保存现在姿势,如果调错了,可以重新导回
linux·服务器·前端·daz基础教程
传人1 小时前
网页文字滚动效果如何做
前端·javascript·jquery
EatFan1 小时前
Spring Boot 4 迁移避坑清单:Jackson 3、starter 拆分与最低 JDK 口径核对(含若依/芋道/CRMEB 升级对照)
java·数据库·spring boot·spring boot 4·java 21·jakarta ee 11·jackson 3
福兮说1 小时前
前后端算的 MD5、SHA-256 对不上?编码、换行、BOM、HMAC、JSON 顺序,八个原因逐个实测
前端·javascript·node.js·json·哈希算法