--- tags: [MySQL, UNION, UNION ALL, INTERSECT, EXCEPT, 集合运算] create time: 2026-05-16 00:00 --- # UNION / UNION ALL ## 概述 UNION 是 SQL 标准中的集合运算,用于将多个 SELECT 的结果纵向合并为一个结果集。核心应用场景包括:**分表数据汇总**、**多源 Feed 流合并**、以及**需要排序去重的跨查询聚合**。 ## 基本语法 ```sql -- 两种形式 SELECT col1, col2 FROM table_a UNION -- 去重(内部排序 + 去重) SELECT col1, col2 FROM table_b; SELECT col1, col2 FROM table_a UNION ALL -- 不去重(直接拼接,性能更高) SELECT col1, col2 FROM table_b; ``` ## UNION vs UNION ALL | 特性 | UNION | UNION ALL | |------|-------|-----------| | **去重** | ✅ 内部去重 | ❌ 保留所有行 | | **性能** | 低(需排序去重) | 高(直接追加) | | **ORDER BY 位置** | 只能放在最后一个 SELECT | 同上 | | **适用场景** | 需要唯一结果的合并 | 已知不重复的合并 | ```mermaid flowchart LR A1["数据源 A"] --> U B1["数据源 B"] --> U U{"UNION"} --> R1["全部数据去重后输出"] U2{"UNION ALL"} --> R2["全部数据直接拼接"] style R1 fill:#FF9F43,color:#000 style R2 fill:#00D866,color:#fff ``` > [!TIP] 首选 UNION ALL > 如果你能通过业务逻辑保证各部分结果不重复(比如按日期区间划分),**一律用 UNION ALL**。UNION 的去重操作需要 Sort/Dedup 阶段,在大结果集上是昂贵操作。 ## INTERSECT 与 EXCEPT(MySQL 8.0.22+) MySQL 8.0.22 开始支持标准 SQL 的 `INTERSECT`(交集)和 `EXCEPT`(差集),补齐了集合运算的最后一块拼图。 ### 基本语法 ```sql -- INTERSECT:取两个查询的交集(两边都有的行) SELECT city FROM customers_china INTERSECT SELECT city FROM customers_japan; -- 结果:只返回同时存在于两个结果集中的城市 -- EXCEPT:取差集(左边有、右边没有的行) SELECT city FROM customers_china EXCEPT SELECT city FROM customers_japan; -- 结果:返回在中国有但日本没有的城市 ``` > [!QUESTION] INTERSECT vs IN / EXISTS? > 两者在语义上等价,但 `INTERSECT` 更直观地表达了"取交集"的意图。实际执行中,Optimizer 通常会将 INTERSECT 转换为 Semi Join 或 Anti Join——因此性能差异不大,选择哪种取决于**可读性**。 ### INTERSECT / EXCEPT vs UNION 行为对比 ```mermaid flowchart LR A["查询 A: {1,2,3}"] --> OP B["查询 B: {2,3,4}"] --> OP OP --> R1["UNION: {1,2,3,4}"] OP --> R2["INTERSECT: {2,3}"] OP --> R3["EXCEPT: {1}"] style R1 fill:#74C0FC,color:#000 style R2 fill:#00D866,color:#fff style R3 fill:#FF9F43,color:#000 ``` | 操作 | 语义 | 去重 | 对应集合论 | |------|------|------|-----------| | `UNION` | A ∪ B | ✅ 默认去重 | 并集 | | `UNION ALL` | A ∪ B(含重复) | ❌ | 多重集并 | | `INTERSECT` | A ∩ B | ✅ 默认去重 | 交集 | | `EXCEPT` | A − B | ✅ 默认去重 | 差集 | > [!NOTE] 默认行为:去重 > INTERSECT 和 EXCEPT 默认都会去重(等价于 DISTINCT)。如果想去重保留所有行,目前 **MySQL 不支持 `INTERSECT ALL` / `EXCEPT ALL`**,需要通过 UNION ALL + 计数的方式模拟。 ### 实用场景 ```sql -- 场景一:找出"既买了 A 又买了 B"的用户(交集) SELECT user_id FROM orders WHERE product_id = 'A' INTERSECT SELECT user_id FROM orders WHERE product_id = 'B'; -- 等价的 EXISTS 写法(对比可读性) SELECT DISTINCT o1.user_id FROM orders o1 WHERE o1.product_id = 'A' AND EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = o1.user_id AND o2.product_id = 'B'); -- 场景二:找出"注册了但从未下单"的用户(差集) SELECT id FROM users EXCEPT SELECT DISTINCT user_id FROM orders; -- 等价的 NOT EXISTS 写法 SELECT u.id FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id); ``` > [!TIP] 何时选 INTERSECT/EXCEPT? > - **逻辑简单、需要取交集/差集**:`INTERSECT` / `EXCEPT` 语法最清晰 > - **需要返回关联表的字段**:用 `EXISTS` / `NOT EXISTS` 或 JOIN,因为 INTERSECT/EXCEPT 只能操作整行或指定列 > - **需要高性能**:大表场景下 EXPLAIN 确认执行计划——Optimizer 通常会转换为 Semi/Anti Join,性能与手写子查询一致 ### 优先级与括号 当混合使用多个集合运算时,默认**从左到右**执行。需要用括号改变优先级: ```sql -- 先 UNION 再 INTERSECT(左到右) SELECT id FROM t1 UNION SELECT id FROM t2 INTERSECT SELECT id FROM t3; -- 实际含义:t1 UNION (t2 INTERSECT t3) ← 错误! -- 实际执行:(t1 UNION t2) INTERSECT t3 ← 从左到右 -- ✅ 用括号明确意图 (SELECT id FROM t1 UNION SELECT id FROM t2) INTERSECT SELECT id FROM t3; -- 也可以用 ORDER BY + LIMIT 修饰每个子句(需括号) (SELECT id FROM t1 ORDER BY id LIMIT 10) INTERSECT (SELECT id FROM t2 ORDER BY id LIMIT 5); ``` > [!WARNING] 注意括号的必要性 > 当 INTERSECT/EXCEPT 与 ORDER BY/LIMIT 混用时,不加括号可能产生歧义。**养成给每个子查询加括号的习惯**,避免优先级意外。 --- ## 实战场景 ### 场景一:多表同构合并 ```sql -- ❌ 错误写法:WHERE / ORDER BY / LIMIT 只作用于最后一个 SELECT SELECT * FROM logs_202601 UNION ALL SELECT * FROM logs_202602 UNION ALL SELECT * FROM logs_202603 UNION ALL SELECT * FROM logs_202604 UNION ALL SELECT * FROM logs_202605 WHERE status = 'error' -- ⚠️ 只过滤 logs_202605! ORDER BY created_at DESC LIMIT 50; -- ✅ 正确写法:如果需要对每个分表单独过滤,用子查询包裹 SELECT * FROM (SELECT * FROM logs_202601 WHERE status = 'error') AS t1 UNION ALL SELECT * FROM (SELECT * FROM logs_202602 WHERE status = 'error') AS t2 UNION ALL SELECT * FROM (SELECT * FROM logs_202603 WHERE status = 'error') AS t3 UNION ALL SELECT * FROM (SELECT * FROM logs_202604 WHERE status = 'error') AS t4 UNION ALL SELECT * FROM (SELECT * FROM logs_202605 WHERE status = 'error') AS t5 ORDER BY created_at DESC LIMIT 50; ``` > [!NOTE] 分表合并的注意事项 > - `WHERE / ORDER BY / LIMIT` **仅作用于 UNION 中最后一个 SELECT**。前面的分表查询不做过滤,全部返回后再合并排序。 > - 如果每个分表数据量很大(百万级),建议**用子查询保护每个表的局部 ORDER BY + LIMIT**,先各取 Top-N 再全局排。这大幅减少中间结果集大小。 > - UNION ALL 不保证顺序,最终 ORDER BY 必不可少。 ### 场景二:多表分页合并(Feed 流) ```sql -- 需求: 用户的动态 Feed 由关注的人和发布的文章混合组成, 按时间排序分页 -- ❌ 应用层先查再排: 至少两次 DB 往返 + 内存归并排序 -- ✅ UNION ALL 一次搞定 SELECT user_id AS source_id, content, 'follow' AS source_type, created_at FROM follow_feed WHERE user_id = 42 UNION ALL SELECT article_id AS source_id, summary AS content, 'article' AS source_type, published_at FROM articles WHERE author_id = 42 ORDER BY created_at DESC LIMIT 20 OFFSET 0; ``` > [!TIP] 何时用 UNION vs CASE WHEN? > - **数据来源是不同表或不同结构**: UNION ALL 是不二之选 > - **同表不同条件的聚合计数**: `SUM(CASE WHEN ...)` 单次扫描更高效 > - **经验法则**: UNION 的 SQL 可读性显著优于多个 OR 条件叠加时, 优先选 UNION ### 场景三:搜索多字段权重排序 ```sql -- 搜索商品:标题匹配权重 > 描述匹配 > 两者都匹配 SELECT id, title, description, 3 AS rank_score -- 标题命中,权重最高 FROM products WHERE title LIKE '%runners%' UNION ALL SELECT id, title, description, 2 AS rank_score -- 描述命中,权重次之 FROM products WHERE description LIKE '%runners%' AND title NOT LIKE '%runners%'; -- 排除已在上面出现的 ORDER BY rank_score DESC, id; ``` > [!NOTE] 多条件搜索的 UNION ALL 策略 > - 通过不同 SELECT 赋予不同权重(`rank_score`),合并后统一 `ORDER BY` 即可实现"加权排序" > - **注意去重边界**:第二个 SELECT 加 `NOT LIKE` 过滤可避免重复输出同一商品。如果无法在前端精确排重,可以考虑外层套一层 `DISTINCT`(代价是会退化为 UNION)或改用 `GROUP BY id` ```sql -- ✅ 正确 SELECT id, name FROM products WHERE price > 100 UNION ALL SELECT id, name FROM products WHERE category = 'sale'; -- ❌ 错误:列数不一致 SELECT id, name FROM products UNION ALL SELECT id FROM discounts; -- ❌ 错误:ORDER BY 放在中间(MySQL 可能忽略或报错) SELECT id FROM table_a ORDER BY id UNION ALL SELECT id FROM table_b; ``` ### UNION 进阶:ORDER BY 与 LIMIT 的行为 ```sql -- ⚠️ 问题:每个 SELECT 的 ORDER BY 在合并后无效 SELECT id FROM orders WHERE status = 'pending' ORDER BY created_at DESC UNION ALL SELECT id FROM orders WHERE status = 'shipped' ORDER BY created_at DESC; -- 上面的 ORDER BY 基本被 MySQL 忽略 -- ✅ 正确解法:用子包装保护每个查询的排序 SELECT * FROM ( SELECT id, created_at FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 50 ) AS a UNION ALL SELECT * FROM ( SELECT id, created_at FROM orders WHERE status = 'shipped' ORDER BY created_at DESC LIMIT 50 ) AS b ORDER BY created_at DESC LIMIT 20; ``` > [!QUESTION] 为什么子查询能保护 ORDER BY? > MySQL 优化器发现 UNION 外还有 ORDER BY + LIMIT 时, 会认为内部排序有用,从而保留它。 > 但官方文档并不保证这种行为——这是基于执行计划的经验结论, 生产环境务必确认。 --- ## UNION 的执行流程 ```mermaid flowchart TD A["查询 1"] --> R1["结果集 1"] B["查询 2"] --> R2["结果集 2"] C["查询 N"] --> RN["结果集 N"] R1 --> Temp["临时表 + 唯一索引
UNION 有, UNION ALL 无"] R2 --> Temp RN --> Temp Temp --> Dedup{"需要去重?"} Dedup -->|是| Sort["排序 + 去重"] Dedup -->|否| Direct["直接输出"] Sort --> Output["最终结果集"] Direct --> Output style Dedup fill:#FF9F43,color:#000 style Sort fill:#EE5A24,color:#fff ``` ## 执行计划特征(EXPLAIN) 用 `EXPLAIN` 观察 UNION 的执行特征,能直观看到 Optimizer 的处理策略: ```sql CREATE TABLE users_a (id INT PRIMARY KEY, name VARCHAR(64), city_id INT); CREATE TABLE users_b (id INT PRIMARY KEY, name VARCHAR(64), city_id INT); EXPLAIN SELECT * FROM users_a WHERE city_id = 10 UNION ALL SELECT * FROM users_b WHERE city_id = 10; ``` 期望的 EXPLAIN 输出: ``` +----+--------------+------------+------+---------------+------+---------+-------+-------+------+ | id | select_type | table | type | key | extra| rows | ... | ref | | +----+--------------+------------+------+---------------+------+---------+-------+-------+------+ | 1 | PRIMARY | users_a | ref | idx_city_id | NULL | 100 | ... | const | | | 2 | UNION | users_b | ref | idx_city_id | NULL | 100 | ... | const | | +----+--------------+------------+------+---------------+------+---------+-------+-------+------+ ``` 关键解读: | 字段 | 含义 | UNION 中的表现 | |------|------|--------------| | **select_type** | 查询类型 | `PRIMARY`(第一个 SELECT) + 每个后续 SELECT 标记为 `UNION` | | **table** | 涉及的表 | 每个 UNION 分支独立显示一行 | | **Extra** | 附加信息 | UNION ALL 无额外信息;使用去重版 UNION 时 Extra 会出现 `Using temporary; Using filesort` | > [!WARNING] UNION 去重的隐藏代价 > > ```sql > -- 对比两个版本的 Extra > EXPLAIN SELECT * FROM users_a WHERE city_id = 10 > UNION ALL -- Extra: (空) > SELECT * FROM users_b WHERE city_id = 10; > > EXPLAIN SELECT * FROM users_a WHERE city_id = 10 > UNION -- Extra: Using temporary; Using filesort > SELECT * FROM users_b WHERE city_id = 10; > ``` > > MySQL 内部会将所有 UNION 分支的结果收集到一个**临时表**中,然后对这个临时表做**全表扫描 + 文件排序**来完成去重。当结果集很大时,这就是性能瓶颈所在。 > > 生产排查建议:如果 UNION 查询慢,先用 `EXPLAIN` 确认 Extra 是否包含 `Using temporary`;再用 `EXPLAIN ANALYZE`(MySQL 8.0.16+)查看各分支的实际行数和耗时。 --- ## 与 JOIN 的选择 ```sql -- 场景:获取每个部门的员工总数 + 总监姓名 -- JOIN 方案(交叉维度,适合取不同列) SELECT d.name, COUNT(e.id) AS emp_count, mgr.name AS manager FROM departments d LEFT JOIN employees e ON d.id = e.dept_id LEFT JOIN employees mgr ON d.manager_id = mgr.id GROUP BY d.id; -- UNION 方案(平行维度,适合合并同类数据) SELECT dept_id, 'total' AS metric, COUNT(*) AS value FROM employees GROUP BY dept_id UNION ALL SELECT dept_id, 'managers' AS metric, COUNT(*) AS value FROM employees WHERE is_manager = 1 GROUP BY dept_id; ``` > [!QUESTION] UNION 还是多个查询? > 很多场景中 UNION 看起来方便,但背后可能有更好的解法: > - **应用层合并**:在 Go/Java 中发两次查询然后合并数组(零 DB 压力) > - **STORED PROCEDURE**:存储过程中多次查询 + 临时表 > - **视图**:封装 UNION 逻辑供多次复用 > > 核心原则:**能不在数据库做的就不做**。UNION 的代价是排序、去重、临时表。 ## 性能对比:UNION vs 替代方案 | 方案 | DB 往返次数 | 临时表开销 | 排序开销 | 适用规模 | |------|------------|-----------|---------|---------| | **UNION ALL** | 1 次 | 有(结果集缓冲) | 仅外层的 ORDER BY | 百万行级 | | **UNION(去重)** | 1 次 | 有(唯一索引) | Sort + Dedup | 十万行以内 | | **应用层 N 次查询** | N 次 | 无 | 内存归并 | 任意,受网络影响 | | **SUM(CASE WHEN)** | 1 次 | 无 | 无 | 单表聚合计数 | ```mermaid flowchart LR A["数据源数量 > 1"] --> B{"能否用单次扫描解决?"} B -->|是: 单表多条件| C["SUM CASE WHEN
最优"] B -->|否| D{"结果集是否已知不重复?"} D -->|是| E["UNION ALL
推荐"] D -->|否| F["UNION
去重"] style C fill:#00D866,color:#fff style E fill:#74C0FC,color:#000 style F fill:#FF9F43,color:#000 ``` ## 常见陷阱与排查 | 陷阱 | 症状 | 解法 | |------|------|------| | **列数不匹配** | `The used SELECT statements have a different number of columns` | 确保每个 SELECT 的列数完全相同;用 `NULL` 补齐 | | **类型隐式转换** | UNION 中对应列类型不一致导致全表扫描或结果错误 | 手动 CAST 到一致类型(如 `CAST(0 AS CHAR)`) | | **ORDER BY 位置错误** | MySQL 忽略分支内的 ORDER BY 或报错 1221 | ORDER BY / LIMIT 放整个 UNION 的最后;需要保护内部排序请用派生表包装 | | **UNION ALL 产生重复行** | 业务期望唯一结果但实际有重复 | 检查是否真的有去重需求 — 有的话用 UNION;无法避免时用 DISTINCT/GROUP BY | | **分表 WHERE 漏写** | WHERE 只过滤最后一个分表,前面全部数据入库 | 每个分表子查询独立加 WHERE,或用视图封装 | | **分页错位** | OFFSET 导致合并后第一页显示第二页数据 | 先合并再整体 OFFSET,不能用局部 LIMIT + OFFSET 代替全局分页 | ### 实战排查 Checklist 遇到 UNION 相关慢查询或异常时,按以下顺序逐项检查: ```mermaid flowchart TD S["UNION 查询异常/慢"] --> C1{"EXPLAIN 看了吗?"} C1 -->|没看| STOP["🛑 先跑 EXPLAIN
别盲猜,看 select_type 确认分支数"] C1 -->|看了| C2{"Extra 有 Using temporary 吗?"} C2 -->|"是:UNION 去重"| D1["换 UNION ALL + 业务侧保证不重复"] C2 -->|"否"| C3{"列数/列类型一致吗?"} C3 -->|"不一致"| D2["CAST 统一类型
否则可能隐式转换导致索引失效"] C3 -->|"一致"| C4{"ORDER BY 在最终位置吗?"} C4 -->|"否,放在中间"| D3["移动到最后
或在每个分支外加子查询包裹"] C4 -->|"是"| C5{"WHERE 对所有分支都生效吗?"} C5 -->|"否"| D4["每个分子查询独立加条件"] C5 -->|"是"| DONE["✅ SQL 结构正确,
继续排查索引和数据分布"] style STOP fill:#EE5A24,color:#fff style D1 fill:#FF9F43,color:#000 style D2 fill:#FF9F43,color:#000 style D3 fill:#FF9F43,color:#000 style D4 fill:#EE5A24,color:#fff style DONE fill:#00D866,color:#fff ``` ## 核心要点回顾 | 主题 | 一句话 | |------|--------| | UNION ALL vs UNION | 能用 ALL 就绝不用 UNION — 去重的临时表+文件排序代价远超想象 | | INTERSECT / EXCEPT | 8.0.22+ 原生支持交集与差集,语义比 IN/EXISTS 更直观,Optimizer 会转为 Semi/Anti Join | | ORDER BY/LIMIT 作用域 | 只在 UNION 最后一个 SELECT 生效;跨表排序必须外层包装子查询 | | 字段匹配规则 | 列数必须相同,类型应尽量兼容 — 列名取自第一个 SELECT | | Feed 流/多源合并 | UNION ALL 的经典场景,一次 DB 往返搞定多数据源混合 | | 分表汇总 | 同构分表最理想的聚合方式,但注意 WHERE 只对最后一个分支生效 | | 执行计划标识 | PRIMARY + UNION 双行 — Extra 中出现 `Using temporary` 就是去重开销的信号 | > [!TIP] 核心心法 > **UNION ALL 是你最好的朋友,UNION 是你的最后手段。** > 写 UNION 查询前问自己三个问题:① 结果集真的需要去重吗?② 能不能用 CASE WHEN 单表扫描代替?③ 如果拆成 N 次简单查询,应用层合并是不是更清晰?大部分时候答案会让你回到 UNION ALL。 ## 关联笔记 - [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]] — SELECT 基础语法与 UNION 的结合使用 - [[hhs/MySQL/02-SQL核心/07-DML 增删改]] — DML 中的批量操作与 UNION 的互补关系