做 MySQL 开发这些年,我见过太多因为查询写法不当引发的线上事故。JOIN、子查询、UNION 这三个 SQL 武器,几乎每天都在和后端代码打交道。用得好,能把几百毫秒的慢查询压到几毫秒;用不好,一条看似简单的语句就能把数据库 CPU 打满。这篇文章就结合我实际踩过的坑,把三者的选型逻辑和调优手段完整讲透。适合后端开发、DBA、正在准备 MySQL 面试的同学参考。
1. 为什么需要三剑客:从一条慢查询说起
1.1 一条线上事故的复盘
记得有一次,业务方反馈某个报表接口超时,数据库 CPU 直接飙到 90% 以上。我拉出慢查询日志,发现罪魁祸首是下面这条语句:
sql复制SELECT *
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN products p ON o.product_id = p.id
JOIN order_items oi ON o.id = oi.order_id
JOIN payments pay ON o.id = pay.order_id
JOIN ...
这条 SQL 一口气 JOIN 了七张表。虽然每张表的记录量都不算夸张,但 JOIN 的笛卡尔积放大效应导致扫描行数呈指数级增长。后来我把它拆成两条查询,用临时表承接中间结果,响应时间从 3 秒降到 80 毫秒。这次事件让我真正意识到,JOIN 不是不能用,而是得有策略地用。
1.2 三剑客各解决什么问题
JOIN、子查询、UNION 在 SQL 里干的活不一样:
- JOIN:把多张表的行按关联条件横向拼接到一起,适合读取"一个主对象以及它关联的明细/维度信息"。
- 子查询:在一条语句中内嵌查询结果,适合"先算一批中间结果,再基于它做二次筛选"的场景。
- UNION:把多个 SELECT 的结果集纵向合并,适合"同一结构的数据来自不同表,需要统一返回"。
如果你能清晰地说出上面这句话,选型时基本就不会跑偏。接下来我分别拆细节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JOIN:多表关联的正确打开方式
2.1 JOIN 类型与执行逻辑
JOIN 大致分 INNER JOIN、LEFT JOIN、RIGHT JOIN、FULL JOIN(MySQL 原生不支持,需要模拟)。它们的关键区别是保留哪一侧的行:
- INNER JOIN:只保留两表匹配上的行。
- LEFT JOIN:左表全保留,右表无匹配补 NULL。
- RIGHT JOIN:右表全保留,左表无匹配补 NULL。
- FULL JOIN:两表全保留,MySQL 里要用 LEFT JOIN + UNION 来实现。
执行逻辑上,MySQL 8.0 之前的优化器主要用 Nested Loop Join(嵌套循环连接),8.0.20 之后默认支持 Hash Join。简单理解:
- Nested Loop:从驱动表拿一行,去匹配另一张表。
- Hash Join:把一张表建成哈希表,再遍历另一张表去探测。
用生活类比:Nested Loop 像你拿本通讯录,挨个打电话问对方有没有你要的型号;Hash Join 是先在 Excel 里做好索引,再快速查。
选择驱动表时,优化器倾向用小表作为驱动表,因为外层循环次数越少,总匹配次数越少。这里有个容易忽略的点:LEFT JOIN 中 LEFT 侧表不一定永远作为驱动表,优化器仍然会基于估算成本决定执行顺序,这是很多人理解上的盲区。
2.2 为什么大厂不建议多表 JOIN
很多面试题都会问"为什么大厂不建议使用多表 join",我的理解是:
- 可维护性差:一张 SQL 里 join 五六张表,代码评审的人看你都像是在看天书。后面接手的工程师想改需求,第一反应是重写。
- 性能不可控:join 越多,执行计划越复杂,优化器选错索引的概率越高。数据量一旦增长,扫描放大效应迅速把数据库拖垮。
- 扩展性差:当单表数据量过大需要分库分表时,跨库 join 基本没法做。大厂通常会先把多表 join 拆成多次单表查询,再在应用层组装,或者把数据同步到分析型存储里做宽表。
这并不是说多表 join 绝对不能用,而是"在分布式、高并发的大规模场景下不划算"。对中小项目,适当的 join 反而更高效、更简单。你如果只有几百张表,每张表几万行,非要去搞微服务化拆分,反而增加了无谓的网络开销和应用层复杂度。
所以在写 JOIN 之前,先问自己三个问题:
- 我是不是真的需要一次性拿到这么多字段?
- 能不能把大表拆小,或者先在应用层做一次过滤?
- 数据量增长之后,这个 JOIN 还能扛得住吗?
2.3 JOIN 优化三件套:索引、驱动表、过滤条件前置
我在实际调优 JOIN 时,核心就三件事:
第一,给关联字段建索引。 JOIN 的 ON 条件里,右侧表(被驱动表)的关联字段必须有索引。常见的坑是在 JOIN 之前已经建过索引,但因为字段类型不一致(比如 char vs varchar),索引直接失效,需要隐式转换才能匹配。另一个坑是字段编码不同,也会导致索引失效。
sql复制-- 注意:user_id 类型是 int,但查询里传了字符串 '123',可能导致索引失效
EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id
WHERE u.id = '123';
第二,控制驱动表。 MySQL 优化器大多数情况会自己选择小表做驱动,但如果你发现执行计划里驱动表选得不对劲,可以用 STRAIGHT_JOIN 强制指定驱动顺序,不过这个用法建议只在明确知道执行计划时才用,线上不要乱加。
第三,过滤条件前置。 把 WHERE 条件尽量放到 JOIN 之前,减少中间结果集。
我一个比较典型的优化案例:
sql复制-- 优化前
SELECT * FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE u.status = 1
ORDER BY o.created_at DESC
LIMIT 10;
问题在于 LEFT JOIN 之后再去过滤 users.status,会让 LEFT JOIN 失去保留左表全行的意义,而且排序可能会走 filesort。优化方法是先把 orders 按时间过滤,再关联:
sql复制SELECT o.id, o.order_no, u.status
FROM orders o
LEFT JOIN users u ON o.user_id = u.id AND u.status = 1
WHERE o.created_at >= '2025-01-01'
ORDER BY o.created_at DESC
LIMIT 10;
把过滤条件放进 ON 子句,不影响 LEFT JOIN 语义(如果确认业务就是要保留所有订单),还能让优化器有更多选择。这个思路同样适用于 RIGHT JOIN 和 INNER JOIN。
2.4 一个 JOIN 优化实操:500 万行的困惑
再给一个具体场景。order_items 表有 500 万行,products 表 5 万行。查询某天最大的 100 笔订单明细。我第一次写是这样:
sql复制SELECT oi.*, p.name
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.created_at BETWEEN '2025-01-01 00:00:00' AND '2025-01-01 23:59:59'
ORDER BY oi.amount DESC
LIMIT 100;
执行计划显示 oi 作为驱动表,但扫描了 20 万行。原因是没有在 product_id 上建索引。给 products.id 建了主键索引当然有,但 order_items.product_id 的索引缺失,导致 join 时频繁随机 IO。补上 ALTER TABLE order_items ADD INDEX idx_product_id (product_id); 之后,同样的查询扫描行数降到几千行,响应时间从 300ms 降到 25ms。
这个案例说明,JOIN 性能往往不是 JOIN 本身的问题,而是索引设计的问题。你把索引补上,优化器自然能做出更好的选择。
3. 子查询:灵活工具的正确用法
3.1 子查询的分类与执行逻辑
子查询按返回结果分:
- 标量子查询:返回单行单列,比如
SELECT (SELECT max(amount) FROM orders)。 - 行子查询:返回单行多列。
- 表子查询:返回多行多列,通常配合 IN、EXISTS、FROM 子句使用。
- 相关子查询:内层查询引用外层表的列,每行都要重新执行,性能风险较大。
执行逻辑上,MySQL 优化器会尽量把子查询改写成 JOIN 或使用物化(materialization)。简单说:
- 物化:把子查询结果先放到临时内存/磁盘表,再和外层查询做匹配。
- 非物化:直接在内存里做判断,不生成临时表。
MySQL 8.0 对子查询的优化其实提升了不少,比如半连接(semi-join)和物化策略,自动避免很多之前"子查询慢成狗"的情况。所以如果你还在用 MySQL 5.7,遇到子查询性能问题,第一件事是考虑升级到 8.0,而不是急着改 SQL。
3.2 WITH AS 临时表的正确姿势
很多现代 SQL 开发会用到 WITH AS,也就是通用表表达式(CTE)。它可以把复杂查询拆成多个可读性强的中间步骤:
sql复制WITH recent_orders AS (
SELECT user_id, COUNT(*) AS order_count
FROM orders
WHERE created_at >= '2025-01-01'
GROUP BY user_id
),
top_users AS (
SELECT user_id, order_count
FROM recent_orders
ORDER BY order_count DESC
LIMIT 100
)
SELECT u.id, u.name, t.order_count
FROM users u
JOIN top_users t ON u.id = t.user_id;
CTE 的临时结果集默认会物化吗?MySQL 8.0 里,如果 CTE 被引用多次,通常物化成临时表;如果只引用一次,优化器可能会直接合并到外层,和普通子查询没区别。
需要注意的点:
- CTE 的名字在当前查询里最多存在一个查询块中,不同查询块之间不共享。
- 如果 CTE 查询的结果特别大,物化临时表可能写入磁盘,反而更慢。这时要评估是否真的适合。
- 千万不要为了好看,把大表数据全部装进 CTE 再外层过滤;CTE 不是内存数据库。它和普通视图类似,只是作用域只限当前查询。
3.3 子查询改 JOIN 的边界与陷阱
我一直跟团队强调:优先写语义清晰的查询,别追求"写得越短越高级"。子查询改成 JOIN 有边界条件,写错了反而是坑。
举一个常见例子,查"有订单的用户":
sql复制-- 子查询写法
SELECT id, name FROM users WHERE id IN (SELECT user_id FROM orders);
-- JOIN 写法
SELECT DISTINCT u.id, u.name FROM users u JOIN orders o ON u.id = o.user_id;
虽然结果一样,但 IN 子查询在 MySQL 优化器里会被改写成半连接,性能通常不差。但如果子查询里需要去重,或者 JOIN 放大了行数,就必须用 DISTINCT 或 GROUP BY 来收敛,否则结果会重复,这一点容易被忽略。
还有一对容易踩坑的是 NOT IN 和 NOT EXISTS:
NOT IN子查询,如果子查询结果里有 NULL,整个条件就会变成 UNKNOWN,查不出任何行。这是经典大坑。NOT EXISTS不存在这个问题。
我一般建议:遇到 NOT IN 直接改成 NOT EXISTS 或 LEFT JOIN + IS NULL,避免 NULL 陷阱。
3.4 一个子查询优化实测:重复计算的代价
有个实际案例:查"最近 30 天有订单且订单金额高于平均值的用户"。一开始我写得比较直接:
sql复制SELECT u.id, u.name
FROM users u
WHERE u.id IN (
SELECT user_id FROM orders
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id
HAVING SUM(amount) > (SELECT AVG(amount) FROM orders WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY))
);
这个语句逻辑没错,但执行计划里内层子查询被反复计算,效率很低。优化方向:
- 用 CTE 把 30 天内的聚合结果物化一次。
- 平均值单独计算一次,避免每行重复执行。
改完版本:
sql复制WITH ord_30d AS (
SELECT user_id, SUM(amount) AS total, AVG(amount) AS avg_amount
FROM orders
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id
),
avg_all AS (
SELECT AVG(amount) AS avg_amount FROM orders WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
)
SELECT u.id, u.name
FROM users u
JOIN ord_30d o ON u.id = o.user_id
CROSS JOIN avg_all a
WHERE o.total > a.avg_amount;
这个版本让 30 天订单聚合只算一次,平均金额只算一次,可读性也高很多。实测从 1.2 秒降到了 200ms。所以说,子查询的性能问题,多半是重复计算和临时表放大导致的。
4. UNION:结果集合并的细节与坑
4.1 UNION 和 UNION ALL 的区别
UNION 会把多个 SELECT 的结果合并,并去掉重复行;UNION ALL 则直接合并,不去重。差别就在:
- 去重意味着你要比较所有列,如果有大字段(比如长文本),开销会很大。
- 在 MySQL 中,UNION 底层实现还会对结果集做临时表去重,可能走文件排序。
如果你能确定各结果集之间不会出现重复行,优先用 UNION ALL。比如按月份分表,把 1 月、2 月、3 月的订单查出来合并,理论上不会重复,直接用 UNION ALL 就对了,能省掉去重开销。
4.2 字符集不一致报错处理
这个坑我印象太深了。某次从多个库取数做报表,第一版 SQL 长这样:
sql复制SELECT name FROM user_utf8
UNION
SELECT name FROM user_latin1;
结果直接报错:Illegal mix of collations for operation 'UNION'。原因是两个表的字符集排序规则不同。解决办法是显式指定 COLLATE:
sql复制SELECT name COLLATE utf8mb4_unicode_ci FROM user_utf8
UNION ALL
SELECT name COLLATE utf8mb4_unicode_ci FROM user_latin1;
或者统一把列的字符集转换:
sql复制SELECT CONVERT(name USING utf8mb4) FROM user_latin1;
只要两边的排序规则一致,UNION 就能正常执行。生产环境最好统一数据库、表、列的字符集,能省掉很多类似的隐性坑。我发现很多团队建库时随手选个默认字符集,等不同库之间开始同步数据,问题才集中爆发。
4.3 UNION 的排序、去重与性能
UNION 里如果每段子查询都要排序,通常要小心:
- 在 UNION 里直接写 ORDER BY,如果没有配合 LIMIT,优化器可能对整体结果排序,非常慢。
- 如果子查询里需要先取每部分 TopN,再合并取 TopN,需要用括号包起来写:
sql复制(SELECT * FROM orders_a ORDER BY amount DESC LIMIT 10)
UNION ALL
(SELECT * FROM orders_b ORDER BY amount DESC LIMIT 10)
ORDER BY amount DESC
LIMIT 10;
这才是真正的"每区先取 Top,再合并取 Top",避免全量合并再排。
还有一点:UNION 两边的查询都尽量只查需要的列,不要到处 SELECT *,否则临时表会很大,去重比较也要花很多时间。
实际做过一个例子:两个分表各 200 万行,业务需要查两个表里金额大于 1000 的记录并按时间倒序分页。直接用 SELECT ... FROM table_a WHERE amount > 1000 UNION SELECT ... FROM table_b WHERE amount > 1000 ORDER BY created_at DESC LIMIT 20 OFFSET 0,结果执行了 5 秒。优化方式:
- 两个子查询都先按索引把 created_at 过滤到目标时间窗口后再 UNION ALL。
- 避免 SELECT *。
- 如果业务允许,做成分页内二次查询,不直接对全量结果排序。
优化后 5 秒降到 300ms。核心思路就是:让每一段 SELECT 都尽量小,再合并。
5. 三剑客选型策略与性能调优实战
5.1 选型判断矩阵
我把三者的选择浓缩成一张表:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 需要同时读取主表和子表字段 | JOIN | 横向拼接最自然 |
| 子表数据需要过滤后作为整体判断 | 子查询/CTE | 先算中间结果,再过滤 |
| 多个同结构结果集合并 | UNION / UNION ALL | 纵向合并 |
| 数据量特别大,join 后行数爆炸 | 子查询/CTE + 应用层组装 | 避免笛卡尔积放大 |
| 目标结果集可能跨多查询 | UNION,且尽量 UNION ALL | 省去去重开销 |
选型逻辑其实就一句话:先想清楚最终结果集的形态,再反推用什么操作。横向拼接用 JOIN,纵向拼接用 UNION,中间计算拆步骤用子查询/CTE。三者在业务里经常混用,但每一层的目的要清晰。
5.2 EXPLAIN 读懂执行计划
遇到慢查询,第一步永远是 EXPLAIN。我常用 EXPLAIN ANALYZE 在 MySQL 8.0 里看真实执行耗时:
sql复制EXPLAIN ANALYZE
SELECT ... FROM orders o JOIN users u ON o.user_id = u.id;
重点看这几个字段:
- type:ALL(全表扫)、range、ref、eq_ref、const。越靠右性能越好。
- key:实际使用的索引。
- rows:预估扫描行数。如果严重偏离实际,说明统计信息过期,可能需要 ANALYZE TABLE。
- Extra:Using temporary、Using filesort 通常是要优化的标志。
有次我排查一个 JOIN 慢查询,EXPLAIN 显示用到了主键索引,但 rows 估算差了 100 倍,执行计划严重跑偏。原因是表的数据量变化很大,统计信息没更新。跑一次 ANALYZE TABLE 之后,索引选择恢复正常,查询时间降低了一个数量级。
5.3 索引设计对三者的影响
索引对 JOIN、子查询、UNION 的影响不同:
- JOIN:ON 条件的被驱动表字段必须有索引。复合索引要注意字段顺序,把等值条件放前面,范围条件放后面。
- 子查询:IN/EXISTS 子查询里的关联字段最好有索引;外层过滤字段的索引同样重要。
- UNION:UNION 的每段 SELECT 最好是各表独立最优的查询,否则合并后再优化很难。两边的字段类型尽量一致,避免隐式转换和排序问题。
有个很常见的坑:WHERE order_id + 5 > 100 这种写法会导致 order_id 索引失效。因为索引存储的是原始值,对列做运算后无法直接使用索引。正确写法是 WHERE order_id > 95。这一点在很多优化文章里反复出现,但实际中还是经常看到。MySQL 里对列做函数、算术运算,或者进行隐式类型转换,都可能让索引无法生效。
5.4 参数调优与 SQL 改写技巧
除了 SQL 本身,服务器参数也可能影响三者性能:
- join_buffer_size:JOIN 的时候如果被驱动表没有可用索引,会使用 join buffer 做 Block Nested Loop。调大 join_buffer_size 能缓解部分场景,但它是会话级参数,不宜设置太大,否则并发时会占用大量内存。
- tmp_table_size、max_heap_table_size:子查询物化、UNION 去重都会用到临时表。超过阈值后临时表会从内存转磁盘,性能直线下降。
- sort_buffer_size:ORDER BY 排序用的内存。过小会造成大批次文件排序。
我通常把 tmp_table_size 和 max_heap_table_size 设置在 64MB~128MB 之间,join_buffer_size 视情况调整,默认不要调太高,避免 OOM。
SQL 改写技巧方面:
- 用 UNION ALL 代替 UNION,除非确认必须去重。
- 用 EXISTS 代替 IN(在 MySQL 8.0 里区别不大,但老版本和小数据量时有差异)。
- 把过滤条件前置,能减少中间结果集。
- 避免在 WHERE/ON 子句里对列做函数运算或隐式类型转换。
6. 高频问题与排查实录
6.1 连接不上 MySQL 的排查
这个和查询优化关系不大,但作为开发总会被问到。报错一般是:
code复制ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock'
大概率是 MySQL 服务没启动,或者 socket 文件路径不对。Linux 上先确认服务状态:
bash复制systemctl status mysqld
# 或
service mysql status
如果服务在运行,但还报这个错,查看配置文件里的 socket 路径,和客户端连接时指定的一致。比如连接时加 -S /tmp/mysql.sock,或者直接用 TCP 连接 -h 127.0.0.1 -P 3306。Docker 部署的 MySQL,容器内 socket 路径和宿主机不同,需要映射或直接用 TCP 连,这个踩过坑的人应该都有体会。
还有一个隐蔽情况:socket 文件被清理过,但 MySQL 进程还在。重启 MySQL 服务通常能恢复。
6.2 字符集报错的解决方案
UNION、JOIN 都可能遇到字符集不一致的报错。解决办法我在 4.2 已经说过,核心是统一 COLLATE。最好在建库建表时就固定 utf8mb4,线上规范也建议统一 utf8mb4_unicode_ci 或 utf8mb4_0900_ai_ci。如果已经出现不一致,用 CONVERT() 或 COLLATE 显式转换。
6.3 查询变慢的常规体检流程
当你接手一条慢查询,可以按这个顺序排查:
- 先看执行计划,确认 type 是否存在 ALL 全表扫描。
- 看是否用了预期索引。如果没有,检查 WHERE/JOIN 条件里的列是否有函数或隐式转换。
- 看 rows 与真实数据量是否匹配,不匹配就 ANALYZE TABLE。
- 看 Extra 里有没有 Using temporary、Using filesort。如果有,尝试调整索引或改写 SQL。
- 检查是否锁表或有长事务在阻塞。这个经常会在排查 JOIN 查询时发现,有时候 SQL 本身没问题,就是被别的事务锁住,等锁等太久。
6.4 常见问题速查表
| 问题现象 | 常见原因 | 解决方向 |
|---|---|---|
| JOIN 慢 | 被驱动表缺索引 / 驱动表选错 | 补索引、STRAIGHT_JOIN、过滤条件前置 |
| 子查询超时 | 子查询重复执行 / 物化临时表过大 | 用 CTE、改写 JOIN、缩小结果集 |
| UNION 报字符集错 | 两边 COLLATE 不一致 | 显式 COLLATE 或 CONVERT |
| UNION 去重耗时高 | 数据量大且不需要去重 | 换 UNION ALL |
| 查询锁表 | 长事务未提交 | 查 information_schema.innodb_trx,杀掉阻塞事务 |
| 索引失效 | 对索引列做运算/隐式类型转换 | 改写条件,保持列独立 |
| ERROR 2002 连接不上 | 服务未启动 / socket 路径不对 | 检查服务、确认 socket、用 TCP 连接 |
最后再分享一个我个人的习惯:每次写完一条查询,我都会习惯性地跑一次 EXPLAIN,看一眼 type 和 rows 合不合理。这个动作坚持下去,你对查询性能的直觉会越来越准。JOIN、子查询、UNION 三剑客本身没有高低贵贱之分,关键在于能不能在合适的场景用合适的手段,并把索引、执行计划、参数这些底层细节打磨到位。
