📖 本篇简介:多表 JOIN 是 SQL 里最容易慢、也最容易写错结果的地方。本章汇总 JOIN 方向最经典的 12 个坑,覆盖笛卡尔积、关联列类型不一致、驱动表选错、被驱动表无索引、LEFT JOIN 条件写错位置、重复行膨胀与表数量过多。示例基于 MySQL 8.0。读完你能写出"结果对、走索引、不爆炸"的多表查询。
目录
- 坑 1:漏写 ON 条件变成笛卡尔积
- 坑 2:JOIN 列类型或字符集不一致
- 坑 3:小表驱动大表搞反
- 坑 4:被驱动表关联列没索引
- 坑 5:LEFT JOIN 的条件写进了 WHERE
- 坑 6:JOIN 出重复行把结果撑大
- 坑 7:一次 JOIN 太多张表
- 坑 8:STRAIGHT_JOIN 焊死执行顺序
- 坑 9:JOIN 了根本用不到的表
- 坑 10:USING 和 ON 混用
- 坑 11:关联列隐式转换
- 坑 12:以为 LEFT JOIN 一定比 INNER JOIN 慢
- 速查表:JOIN 写法速记
- 注意事项:JOIN 性能排查顺序
- 自测:你能避坑吗
坑 1:漏写 ON 条件变成笛卡尔积
场景 / 错误示范:
-- 漏写 JOIN 条件,或用逗号隐式连接忘了 WHERE
SELECT * FROM order_t, user;
-- 两表行数相乘:100 万订单 × 1 万用户 = 100 亿行,直接把库跑挂
为什么是坑:没有 ON/WHERE 关联条件,MySQL 把两表所有行两两组合,行数 = A 行数 × B 行数。这是最致命的 JOIN 错误——往往不是故意的,而是写 SQL 时漏了一个条件。一旦上线,就是灾难级慢查询。
正确做法:
-- 永远显式写 JOIN ... ON
SELECT o.*, u.name
FROM order_t o
JOIN user u ON o.user_id = u.id;
-- 关联条件写清楚,优化器才能正确规划
-- 杜绝"逗号隐式连接",全部用显式 JOIN ... ON
加分点:用 JOIN ... ON 显式写法,比逗号隐式连接更难漏掉条件。代码评审时看到 FROM a, b 就要警惕。
坑 2:JOIN 列类型或字符集不一致
场景 / 错误示范:
-- order_t.user_id 是 BIGINT,user.id 是 VARCHAR
SELECT * FROM order_t o JOIN user u ON o.user_id = u.id;
-- 类型不一致:隐式转换,关联列索引失效,还可能匹配错
-- 字符集不一致(见第43章坑4):
-- 两表 user_id 都是 utf8mb4 但 collation 不同,JOIN 报 illegal mix
为什么是坑:JOIN 关联列类型/字符集不一致,MySQL 要么隐式转换导致索引失效,要么直接报 collation 冲突。这是"SQL 写得对、却走不了索引"的隐形根因。表结构设计时没统一,JOIN 时就还债。
正确做法:
-- 关联列类型严格一致
ALTER TABLE order_t MODIFY user_id BIGINT; -- 与 user.id 对齐
-- 字符集/collation 全库统一(见第43章)
-- 建表时同一业务 id 在所有表里同类型、同字符集
-- 巡检:找类型不一致的关联列
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE
FROM information_schema.COLUMNS
WHERE COLUMN_NAME IN ('user_id','id');
加分点:建表规范里写死"所有外键列命名一致、类型一致",能从源头避免 JOIN 类型不一致这个坑。
坑 3:小表驱动大表搞反
场景 / 错误示范:
-- 嵌套循环 JOIN:外层驱动表若选了大表,循环次数爆炸
SELECT * FROM huge_order o -- 1000 万行
JOIN small_user u ON o.user_id = u.id;
-- 若优化器让 huge_order 当驱动表,要循环 1000 万次去查 user
为什么是坑:Nested Loop JOIN 的成本 ≈ 驱动表行数 × 被驱动表单次查找成本。驱动表选大了,循环次数线性放大。虽然 8.0 优化器通常能选对,但统计信息过期时可能选错,慢查询就来了。
正确做法:
-- 让小结果集当驱动表,被驱动表关联列有索引
SELECT * FROM small_user u
JOIN huge_order o ON o.user_id = u.id
WHERE u.id = 123; -- 先在 user 上定位到这一个用户,再去 order 查
-- 驱动表是单行,循环 1 次,用 o.user_id 索引命中该用户订单
-- 优化器选错时,先 ANALYZE TABLE 更新统计信息
ANALYZE TABLE huge_order;
加分点:"小表驱动大表"是 JOIN 优化的核心原则。WHERE 条件把驱动表结果集收得越小,循环次数越少。
坑 4:被驱动表关联列没索引
场景 / 错误示范:
-- 被驱动表 order_t.user_id 没索引
SELECT u.name, o.amount
FROM user u
JOIN order_t o ON o.user_id = u.id
WHERE u.id = 1;
-- 每次外层来一行,都全表扫 order_t,循环次数 × 全表扫
为什么是坑:Nested Loop 里,被驱动表的关联列没索引,每次都全表扫。外层 N 行就是 N 次全表扫,复杂度从"驱动行数 × log(被驱动)"退化成"驱动行数 × 被驱动行数",灾难。这是 JOIN 慢最常见根因。
正确做法:
-- 被驱动表关联列建索引
CREATE INDEX idx_uid ON order_t(user_id);
-- 之后每次 JOIN:用 idx_uid 精准定位,不再全表扫
EXPLAIN: 被驱动表 type=ref(不是 ALL)
加分点:凡是出现在 JOIN ON 里的列,都要检查被驱动表侧有没有索引。这是 JOIN 优化的第一动作。
坑 5:LEFT JOIN 的条件写进了 WHERE
场景 / 错误示范:
-- 想"查所有用户及其已支付订单",把订单条件写在了 WHERE
SELECT u.name, o.amount
FROM user u
LEFT JOIN order_t o ON o.user_id = u.id
WHERE o.status = 1;
-- 结果:没订单的用户被 WHERE 过滤掉了!LEFT JOIN 退化成 INNER JOIN
为什么是坑:LEFT JOIN 保留下表(user)所有行,右表(order)没匹配上时补 NULL。但你把 o.status=1 写进 WHERE,那些右表为 NULL 的行 o.status 是 NULL,不满足 =1,被过滤掉——LEFT JOIN 等于白写,退化成了 INNER JOIN。这个错误很隐蔽。
正确做法:
-- 右表的过滤条件写进 ON,而不是 WHERE
SELECT u.name, o.amount
FROM user u
LEFT JOIN order_t o ON o.user_id = u.id AND o.status = 1;
-- 这样没订单的用户仍保留,订单列补 NULL
-- 左表的过滤条件才放 WHERE
SELECT u.name, o.amount
FROM user u
LEFT JOIN order_t o ON o.user_id = u.id AND o.status = 1
WHERE u.city = '北京'; -- 左表条件,放 WHERE
加分点:记住——"右表(被 LEFT 的那侧)的过滤条件放 ON,左表的过滤条件放 WHERE"。搞混了 LEFT JOIN 就静默退化成 INNER JOIN。
坑 6:JOIN 出重复行把结果撑大
场景 / 错误示范:
-- 一个用户有多条订单,JOIN 后用户行数翻倍
SELECT u.id, u.name, o.amount
FROM user u
JOIN order_t o ON u.id = o.user_id;
-- 一个用户 5 条订单,结果就出 5 行,统计用户数时翻倍
为什么是坑:一对多 JOIN 时,"一"侧的行会被"多"侧的行数重复复制。本来想数"多少用户",JOIN 订单后每个用户重复 N 次,COUNT 出来偏大。很多人没意识到 JOIN 会膨胀行数,直接在结果上 COUNT,数字错了。
正确做法:
-- 数"用户数"用 COUNT(DISTINCT u.id)
SELECT COUNT(DISTINCT u.id) FROM user u JOIN order_t o ON u.id=o.user_id;
-- 或先聚合再 JOIN(避免膨胀)
SELECT u.id, u.name, o.cnt
FROM user u
JOIN (SELECT user_id, COUNT(*) cnt FROM order_t GROUP BY user_id) o
ON u.id = o.user_id;
加分点:凡是一对多 JOIN 后要统计"一"侧数量的,都要警惕行数膨胀,用 DISTINCT 或先聚合。
坑 7:一次 JOIN 太多张表
场景 / 错误示范:
-- 一条 SQL JOIN 了 8 张表
SELECT ...
FROM a
JOIN b ON ...
JOIN c ON ...
JOIN d ON ...
JOIN e ON ...
-- 优化器要在 8! = 40320 种连接顺序里挑,规划时间本身就长,还容易选错
为什么是坑:JOIN 的表一多,优化器要枚举所有连接顺序选最优,规划成本指数上升。而且多表 JOIN 一旦某张被驱动表没索引,性能雪崩。业界经验是单条 SQL JOIN 尽量不超过 3~5 张表。
正确做法:
-- 拆分:先查一张表拿到 id,再用 id 去查另一张
-- 应用层做两次查询,而不是一条 SQL JOIN 5 张
SELECT id FROM user WHERE ...; -- 第一次拿 id
SELECT * FROM order_t WHERE user_id IN (...); -- 第二次查订单
-- 必须 JOIN 时,控制在 3 张以内,且每张被驱动表都有索引
加分点:"宽表 JOIN"思路不奏效时,就拆成"主查询 + 补充查询"在应用层拼装,既快又可控。
坑 8:STRAIGHT_JOIN 焊死执行顺序
场景 / 错误示范:
-- 优化器选错,开发加 STRAIGHT_JOIN 强制按书写顺序
SELECT STRAIGHT_JOIN ... FROM huge_order o JOIN user u ON ...;
-- 把连接顺序焊死,数据量变化后可能反而更慢
为什么是坑:STRAIGHT_JOIN 强制按你写的表顺序连接,绕过优化器。和 FORCE INDEX 一样是"焊死",数据分布变了就不再最优,还掩盖根因。临时救急可以,长期留着是债。
正确做法:
-- 先 ANALYZE TABLE 更新统计信息,让优化器重新选
ANALYZE TABLE huge_order, user;
-- 仍不对再考虑 STRAIGHT_JOIN,且记录原因、限期复查
-- 根治:建对索引、收窄 WHERE,让优化器自然选对
加分点:STRAIGHT_JOIN 和 FORCE INDEX 一样是应急手段。能优化器自己选对,就别手动焊。
坑 9:JOIN 了根本用不到的表
场景 / 错误示范:
-- 复制别人的 SQL,带了一张用不到的表
SELECT u.name
FROM user u
JOIN order_t o ON o.user_id = u.id
JOIN log_t l ON l.user_id = u.id;
-- 结果只用了 u.name,但白白 JOIN 了 order 和 log
为什么是坑:复制粘贴来的 SQL 常常带着已经不需要的 JOIN。每张多余的被驱动表都要查找、过滤,白白增加成本。而且多余 JOIN 还可能引入重复行,把结果搞乱。
正确做法:
-- 清理掉用不到的表
SELECT u.name
FROM user u
WHERE EXISTS (SELECT 1 FROM order_t o WHERE o.user_id = u.id);
-- 只用到"有没有订单"时,用 EXISTS 半连接,不必真把订单列查出来
加分点:评审 SQL 时逐张表问"这张表的字段最后被 SELECT 或过滤用了吗"。没用上就该删,或改成 EXISTS。
坑 10:USING 和 ON 混用
场景 / 错误示范:
-- 两表同名列,用了 USING
SELECT * FROM a JOIN b USING (id);
-- USING 会把 id 列合并成一列;但一旦列名不完全一致,USING 直接报错
为什么是坑:USING(col) 要求两表关联列同名,且会把该列合并成一列。它写起来简洁,但要求列名严格一致,灵活性差。和 ON 混用(有的 JOIN 用 ON、有的用 USING)会让 SQL 风格混乱,且 USING 在列名不一致时直接报错。
正确做法:
-- 统一用 ON,显式写关联条件,更清晰、更灵活
SELECT * FROM a JOIN b ON a.id = b.id;
-- USING 仅在列名严格相同时图简洁用,团队内统一风格
加分点:ON 是通用写法,USING 是语法糖。团队规范里统一用 ON,避免风格混乱和列名耦合。
坑 11:关联列隐式转换
场景 / 错误示范:
-- order.user_id 是 VARCHAR,user.id 是 BIGINT
SELECT * FROM order o JOIN user u ON o.user_id = u.id;
-- 关联时 VARCHAR 列被隐式转数字,order 侧索引失效
为什么是坑:和第 44 章"隐式类型转换"同源——JOIN 关联时类型不一致,被转换的那侧索引用不上。这个坑在 JOIN 里尤其贵,因为它让每次嵌套循环的查找都退化成全表扫。
正确做法:
-- 关联列类型严格一致
ALTER TABLE order MODIFY user_id BIGINT;
-- 与 user.id 同类型,JOIN 时两边都能走索引
加分点:建表时统一"同一业务 id 类型一致",能同时避开 JOIN 隐式转换、分组错乱两个坑。
坑 12:以为 LEFT JOIN 一定比 INNER JOIN 慢
场景 / 错误示范:
-- 听说 LEFT JOIN 慢,把能 INNER JOIN 的也改成了"先 WHERE 再 INNER"
-- 实际上:LEFT JOIN 和 INNER JOIN 的性能差别,取决于是否多保留了不匹配的行
为什么是坑:JOIN 快慢不取决于"LEFT 还是 INNER",而取决于索引和过滤。LEFT JOIN 只是多保留了左表不匹配的行,执行计划和 INNER JOIN 往往差不多。为了"性能"把业务上正确的 LEFT JOIN 改成 INNER,反而丢了没订单的用户,是用性能借口掩盖逻辑错误。
正确做法:
-- 业务要"所有用户,哪怕没订单"——就老老实实 LEFT JOIN
SELECT u.name, o.amount
FROM user u
LEFT JOIN order_t o ON o.user_id = u.id AND o.status = 1;
-- 业务只要"有订单的用户"——才用 INNER JOIN
SELECT u.name, o.amount
FROM user u
JOIN order_t o ON o.user_id = u.id;
加分点:先按业务正确性选 LEFT/INNER,再谈性能。性能不够就加索引、收窄条件,而不是改 JOIN 类型凑速度。
速查表:JOIN 写法速记
| 写法 | 风险 | 正确做法 |
|---|---|---|
| 漏 ON 条件 | 笛卡尔积爆炸 | 显式 JOIN...ON |
| 关联列类型不一致 | 隐式转换失效 | 类型统一 |
| 驱动表选大表 | 循环次数爆炸 | 小结果集驱动 |
| 被驱动表无索引 | N 次全表扫 | 关联列建索引 |
| LEFT 条件写 WHERE | 退化成 INNER | 右表条件放 ON |
| 一对多 JOIN 后 COUNT | 行数膨胀 | COUNT(DISTINCT) 或先聚合 |
| JOIN 太多表 | 规划爆炸 | 拆查询,控制 3~5 张 |
| STRAIGHT_JOIN | 焊死顺序 | ANALYZE 后让优化器选 |
| 多余 JOIN | 白增成本 | 删除或改 EXISTS |
| 关联列隐式转换 | 索引失效 | 类型严格一致 |
注意事项:JOIN 性能排查顺序
JOIN 慢了,按这个顺序查,别上来就改写法。
1. 有没有笛卡尔积:ON 条件写全了吗?行数对得上预期吗?
2. 被驱动表有索引吗:EXPLAIN 看被驱动表 type 是 ref 还是 ALL?
3. 关联列类型一致吗:有没有隐式转换?
4. 驱动表选对了吗:小结果集驱动大表了吗?统计信息过期吗?
5. LEFT JOIN 条件写对位置了吗:右表条件在 ON 里吗?
6. 行数膨胀了吗:一对多导致结果翻倍,统计对吗?
一句话:JOIN 性能 = 驱动表行数 × 被驱动表单次查找成本。被驱动表有索引,是这个公式里最关键的一环。
自测:你能避坑吗
题 1:FROM a, b 漏了关联条件会发生什么?
<details>
<summary>参考答案</summary>
变成笛卡尔积,行数 = a 行数 × b 行数,可能爆炸到几亿行把库跑挂。应改成显式 JOIN ... ON 写清关联条件,杜绝逗号隐式连接。
</details>
题 2:LEFT JOIN 后把右表条件写在 WHERE 里,为什么结果不对?
<details>
<summary>参考答案</summary>
右表没匹配上的行,其右表列是 NULL,o.status=1 不满足,被 WHERE 过滤掉,LEFT JOIN 静默退化成 INNER JOIN,没订单的用户全丢了。右表条件要写进 ON。
</details>
题 3:Nested Loop JOIN 的成本大致怎么估算?
<details>
<summary>参考答案</summary>
成本 ≈ 驱动表行数 × 被驱动表单次查找成本。所以要让小结果集当驱动表(减少循环次数),被驱动表关联列有索引(单次查找走索引而非全表扫)。
</details>
题 4:一对多 JOIN 后直接 COUNT(*) 为什么会数多?
<details>
<summary>参考答案</summary>
一个"一"侧用户有多条"多"侧订单,JOIN 后这个用户重复出现多次,COUNT(*) 把重复行也算进去了。应 COUNT(DISTINCT u.id),或先在多侧聚合再 JOIN。
</details>
题 5:被驱动表的关联列没索引,JOIN 会慢到什么程度?
<details>
<summary>参考答案</summary>
Nested Loop 每次外层来一行都要全表扫被驱动表,复杂度从"驱动行数 × log"退化成"驱动行数 × 被驱动行数"。给关联列建索引后,单次查找走 ref,性能数量级提升。
</details>
题 6:一条 SQL JOIN 了 8 张表,为什么性能不可控?
<details>
<summary>参考答案</summary>
优化器要在 8! 种连接顺序里枚举选最优,规划成本指数上升,还容易选错。业界经验单条 SQL JOIN 控制在 3~5 张,更多就拆成多次查询在应用层拼装。
</details>
题 7:业务上要"所有用户,包括没下过单的",该用 LEFT JOIN 还是 INNER JOIN?
<details>
<summary>参考答案</summary>
用 LEFT JOIN 保留下表(user)所有行。性能不够就加索引收窄条件,不能为了凑速度改成 INNER 丢了没订单的用户。先保业务正确,再谈性能。
</details>
题 8:为什么 STRAIGHT_JOIN 不应该长期留在 SQL 里?
<details>
<summary>参考答案</summary>
它把连接顺序焊死,数据量/分布变化后可能反而更慢,还掩盖了统计信息过期或索引缺失的根因。应先 ANALYZE 更新统计信息让优化器重选,STRAIGHT_JOIN 只作应急。
</details>
题 9:复制来的 SQL 里有张表 JOIN 了却没用到它的任何字段,该怎么处理?
<details>
<summary>参考答案</summary>
逐张表检查"这张表的字段被 SELECT 或过滤用了吗"。没用上就删掉;只是为了判断"有没有某类记录"时,改成 EXISTS 半连接,不必真把该表列查出来。
</details>
题 10:两表关联列类型一致,但 collation 不同,JOIN 会怎样?
<details>
<summary>参考答案</summary>
报 "illegal mix of collations",且无法用索引做等值匹配,退化为转换+全表扫。应全库统一 collation,详见第 43 章字符集篇。
</details>
下一篇预告:第 50 章 子查询与CTE踩坑
文章回复
0 条公开回复