前两天团队在整理跨年订单报表时,又有人把 SQLite UNION 用成了 JOIN:他想把去年归档表和今年的流水表“拼”成一张表,结果查出来的行数莫名其妙比预期翻倍,后来才发现是横向连接把两边记录做了笛卡尔积。其实这里需要的不是 JOIN,而是 SQLite UNION 子句——把两个 SELECT 的结果纵向叠在一起,而不是把列左右并排。
如果你是第一次接触 SQLite UNION,建议先把“纵向叠加”这个概念刻在脑子里:JOIN 是往右扩展字段,UNION 是往下堆积数据行。理解这一点后,这篇里接下来绝大多数场景你都能自己推导出来。我会尽量按工程实战的顺序来讲:先解决“什么时候用它”,再讲 UNION 与 UNION ALL 的差别,然后带你避开列名、类型、排序上那些最隐蔽的坑,最后聊一聊直接从热搜里冒出来的报错和常见场景。
1. 为什么“纵向合并”比你想的更常用:从两张订单表说起
1.1 先纠正一个思维惯性:两张表合并不等于 JOIN
我在不少项目里发现,很多开发者在面对“把两张结构一样的表合并查询”这个需求时,第一反应永远是 JOIN。为什么?因为 JOIN 太平常了,主表关联明细表、用户表关联订单表,天天写天天见,大家已经把“多张表 = JOIN”固化成条件反射了。
但 JOIN 做的事情是横向拼接:它把 A 表的某一行和 B 表满足条件的某一行合成一行,字段变多了,行数基本不变。假如你的真实需求是“A 表里有一批记录,B 表里也有一批记录,这两批记录结构完全相同,我现在要把它们放在一起看”,JOIN 就完全不合适。如果 JOIN 条件不充分,A 表 100 行、B 表 100 行,你会拿到 10000 行甚至更多;如果 JOIN 条件写得太苛刻,你又会丢掉两边对不上的记录。
SQLite UNION 解决的就是这种“多个容器、同一类型数据、结果纵向累计”的问题。它不关心两张表如何互相匹配,它只负责把第一个 SELECT 的每一行和第二个 SELECT 的每一行首尾相连。
1.2 最小可用案例:跨年订单合并
我造了一个非常常见的场景:数据库里有一张订单归档表 orders_archive,存 2024 年及以前的历史订单;还有一张当前订单表 orders_current,存 2025 年至今的订单。两张表结构几乎一样:
sqlite复制CREATE TABLE orders_archive (
id INTEGER PRIMARY KEY,
order_no TEXT UNIQUE,
customer TEXT,
amount REAL,
created_at TEXT
);
CREATE TABLE orders_current (
id INTEGER PRIMARY KEY,
order_no TEXT UNIQUE,
customer TEXT,
amount REAL,
created_at TEXT
);
现在老板要一份“所有订单清单”,最简单的写法就是:
sqlite复制SELECT order_no, customer, amount, created_at
FROM orders_archive
UNION
SELECT order_no, customer, amount, created_at
FROM orders_current;
这里每个分支都是独立的 SELECT,UNION 把它们的结果集竖着叠在一起。上面的查询会返回两个表里所有订单,如果 2024 年某一笔订单在归档时被误复制到了 orders_current,也就是两表存在完全相同的记录时,UNION 还会把重复行去掉。
1.3 纵向合并的实际应用场景,远不止订单表
订单归档只是其中一个例子。凡是满足下面这些特征的场景,都可以考虑 UNION:
- 业务上按时间分表,比如
log_202401、log_202402、log_202403,需要一次性查多个月日志。 - 数据按来源拆成多张同构表,比如
device_a_data、device_b_data、device_c_data。 - 你用
ATTACH DATABASE挂载了两个 SQLite 库文件,每个库里有各自独立的配置表或流水表,想把它们合并在一起做一次统一查询。 - 新老系统表结构基本相同,但地址不同,数据迁移前后需要做比对。
在这些场景里,UNION 并不是“高级技巧”,而是第一选择。结构几乎相同的表,本来就不该靠 JOIN 硬拉关系。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. UNION 与 UNION ALL 的实际差别:不只是少写一个 ALL
2.1 行为差异:UNION 默认去重,UNION ALL 原样保留
很多教程上来就给结论:UNION 会去重,UNION ALL 不去重。这句话没错,但实际工程里它的影响比字面上大得多。
UNION 在合并两个结果集后,会额外做一次去重操作。只要两个分支的投影列完全相同,UNION 就会把相同行合并成一行。而 UNION ALL 是纯粹的“首尾拼接”,两个分支各自有多少行,结果就有多少行,一个都不少。
看这个例子:
sqlite复制SELECT 'A1001' AS order_no, '张三' AS customer
UNION
SELECT 'A1001', '张三';
因为两行完全相同,UNION 会返回 1 行;如果把 UNION 换成 UNION ALL,返回 2 行。
2.2 性能差异:一次不必要的排序可能拖慢整个查询
UNION 去重是需要代价的。SQLite 在合并结果时,必须在内部对合并后的数据做排序或哈希,才能判断哪些行重复。数据量小的时候,这个额外操作可以忽略不计;数据量一旦上到几十万行、上百万行,UNION 和 UNION ALL 的差距就会非常明显。
我第一次意识到这个问题,是在一个日志查询场景里。当时我处理的是 6 个按周拆分的日志表,每个表大概 20 万行。查询脚本里用了 UNION 去重,结果原本 1 秒不到的查询变成 5 秒多。排查后发现,6 个表里的日志记录都是各自独立写入的,日志消息本身可能存在重复文本,但每一行都带有自己的时间戳和主键,逻辑上根本不需要去重。把 UNION 改成 UNION ALL 之后,查询直接回到 1 秒以内。
所以性能上有一条基本准则:除非你确实需要去重,否则一律用 UNION ALL。等到真出现重复数据时再加去重,比一上来就让 SQLite 做全量比较划算得多。
2.3 你以为的“重复”,未必是 UNION 能去掉的重复
这里有一个很容易被忽略的业务坑:UNION 的去重,是针对你 SELECT 出来的投影列做整体比较,而不是针对某一列。比如你在 SELECT 里带了 id 主键,那么两条记录的 order_no 和 customer 完全一样,只要 id 不同,UNION 就不会认为它们是重复行。
反过来,如果某个客户在两笔订单里购买了完全相同的商品组合,你在 SELECT 里只放 customer 和商品名称,没有带订单号,那么这两笔明明不同的订单,反而会被 UNION 当成重复行吞掉一条。
这说明一个关键点:用 UNION 去重前,先想清楚“重复”的定义是什么。如果你要的是按订单号去重,应该显式地把订单号放进 SELECT,让它参与比较;如果你要的是按客户和金额维度去重,那就要用更精准的分组写法,而不是指望 UNION 顺带帮你做。
在数据同步场景中,还有一个非常普遍的做法:两边数据本来就可能存在重复投递,业务上只需要保留一条,此时 UNION 去重是合理的。但如果你的源数据是流水型数据,每一行都有唯一业务含义,只是长得很像,那就请果断用 UNION ALL。
3. 列名、列顺序和类型亲和性:UNION 结果集的隐形规则
3.1 列数必须一致,但列名只在第一个 SELECT 生效
UNION 的第一个硬性规则:左右两边 SELECT 的列数必须完全一致。否则 SQLite 会直接报错:
text复制SELECTs to the left and right of UNION do not have the same number of result columns
这个错误很直观,列不一致当然没法纵向拼接。
但第二条规则很多人不知道:合并后结果集的列名,只取第一个 SELECT 的列名。第二个 SELECT 里把某一列命名为 foo 还是 bar,对最终结果没有任何影响。
举个例子:
sqlite复制SELECT order_no, customer, amount
FROM orders_archive
UNION
SELECT order_no, customer, amount
FROM orders_current;
最终查询结果的列名就是 order_no、customer、amount。假如你把第二个 SELECT 的 amount 改名为 money,对结果没有影响,外层依然只能通过 amount 访问这一列。
这样做有好处,也有坑。好处是当你从两个不同名称的字段里取同一类数据时,可以统一对外暴露一个名字。坑是当你用某个“来源字段名”去写外层 ORDER BY 时,一旦这个名字不是第一个 SELECT 里的列名,SQLite 就会报“no such column”。我在后面排序那一节会专门讲。
3.2 列位置决定一切:查询能跑通,不代表结果是对的
UNION 不关心左右分支中列名是否一致,它只按位置合并:第一个 SELECT 的第一列,对应第二个 SELECT 的第一列;第一个 SELECT 的第二列,对应第二个 SELECT 的第二列。
这意味着,列顺序写错是 UNION 最隐蔽的错误之一。SQLite 是动态类型数据库,它不会像静态类型数据库那样因为类型不匹配而报错。你完全可以把一个文本列放在第二个分支的第一位,然后在第一个分支的第一位放一个数字列,查询照样能跑,只是结果会变得非常诡异。
看这段代码:
sqlite复制SELECT order_no, amount
FROM orders_archive
UNION
SELECT amount, order_no
FROM orders_current;
列数都是 2,查询能正常执行。但结果里,第一列混合了订单号文本和金额数字,第二列混合了金额数字和订单号文本。你以为你在查订单号和金额,实际上数据已经错位了。更麻烦的是,这种错位不会立刻报错,很可能到排序、统计甚至导出阶段才暴露出来。
所以写 UNION 时一定要逐个检查 SELECT 的列顺序,最好把两边字段用同一种顺序展开,不要依赖“列名相同所以没问题”这个错觉。
3.3 类型亲和性带来的隐性风险,以及用 CAST 兜底
SQLite 的存储类型是动态的,一个字段可以放整数、文本、浮点甚至 BLOB。UNION 在合并不同列时,会对两边的值进行比较,判断是否重复。理论上 SQLite 会按存储类和排序规则比较,但不同来源的数据如果类型不一致,结果很可能不符合你的预期。
最常见的一个坑:某个字段在 A 表里是文本类型,存的是 "1001";在 B 表里是整数类型,存的是 1001。你在两个分支里都 SELECT 这一列,UNION 去重时,它们到底算不算重复?SQLite 在比较时会对数值型文本和整数做一定的转换,但这里的规则并不是总能通吃所有格式,尤其是带前导零的文本,比如 "001001" 和 1001。
如果你对两边的类型一致性没有把握,最好的办法是主动用 CAST 统一类型:
sqlite复制SELECT order_no, CAST(customer AS TEXT) AS customer
FROM orders_archive
UNION
SELECT order_no, CAST(customer AS TEXT)
FROM orders_current;
这样能让比较规则更可控,减少因为隐式转换导致的行数异常。
判断“到底有没有类型坑”,一个粗暴但有效的办法:把两个分支分别跑一遍,看一下同名列的值的格式、长度、空值情况,如果明显混着带引号的数字和裸数字,那你就要注意了。
4. ORDER BY、LIMIT、子查询:SQLite UNION 的语法“禁区”和正确姿势
4.1 分支里不能随手写 ORDER BY
UNION 和普通单表查询有一个显著差异:你通常不能在每个分支的 SELECT 末尾单独写 ORDER BY,再接上下一个 UNION 分支。SQLite 的 UNION 复合查询有自己的整体结构,它的排序只能出现在整个 UNION 的最后,对最终结果集生效。
换句话说,这条写法是错误的:
sqlite复制-- 错误的示范
SELECT order_no, customer, amount FROM orders_archive ORDER BY amount DESC
UNION
SELECT order_no, customer, amount FROM orders_current ORDER BY amount DESC;
SQLite 在执行时会在 ORDER BY 附近报语法错误。因为复合查询没有“先排完第一个分支,再拼第二个分支”这种执行语义,每一个分支在没有括号包住的情况下,都是整体查询的一部分。如果确实希望每个分支都先排完序再合并,你需要把每个分支变成子查询,然后用括号包起来。
4.2 如果确实要“每个分支取 Top N 再合并”
这是一个非常容易踩的场景:你想从归档表里取金额最高的 3 笔订单,从当前表里也取金额最高的 3 笔订单,把它们合并成一份“年度重点订单”清单。
正确写法是先用子查询固定每个分支的范围,再对结果做 UNION:
sqlite复制SELECT order_no, customer, amount, created_at
FROM (
SELECT order_no, customer, amount, created_at
FROM orders_archive
ORDER BY amount DESC
LIMIT 3
)
UNION
SELECT order_no, customer, amount, created_at
FROM (
SELECT order_no, customer, amount, created_at
FROM orders_current
ORDER BY amount DESC
LIMIT 3
)
ORDER BY amount DESC;
这里每个子查询内部都执行了各自的排序和 LIMIT,得到最多 3 条记录;外层 UNION 再对两个“已经缩小过范围”的结果做合并去重;最终 ORDER BY 对整个结果量排序。
如果你确定两个子查询结果里不会出现完全相同的一行,可以用 UNION ALL 替代 UNION,这样还能省去一次去重开销。不过要注意,如果两边可能出现重复订单,而业务只需要看一次,还是用 UNION 更安全。
4.3 外层 ORDER BY 不认第二分支的列名
这一节回到前面提到的列名规则。UNION 最终结果集的列名来自第一个 SELECT,所以外层 ORDER BY 只能引用第一个分支的列名。
假设归档表里排序字段叫 created_at,当前表里同一个业务字段叫 create_timestamp。下面这条查询就会出错:
sqlite复制SELECT order_no, amount, created_at
FROM orders_archive
UNION
SELECT order_no, amount, create_timestamp
FROM orders_current
ORDER BY create_timestamp;
因为最终结果集里根本没有 create_timestamp 这一列,SQLite 会告诉你 no such column。解决办法有两个:
一是强制第一个分支的列名,让外层统一用这个名字:
sqlite复制SELECT order_no, amount, create_timestamp AS created_at
FROM orders_current
UNION
SELECT order_no, amount, created_at
FROM orders_archive
ORDER BY created_at;
二是干脆用列位置,比如 ORDER BY 2 表示按第二列排序。但我建议优先用第一种,因为列位置的数字可读性太差,SQL 一旦变长,后来维护的人很难一眼看懂。
4.4 LIMIT 不能直接限制“每个分支的行数”
再提醒一次,UNION 的 LIMIT 如果直接写在复合查询末尾,它作用的是整个 UNION 结果集,不是某一个分支。比如你想取“两个表合并后金额最高的 5 笔订单”,直接写:
sqlite复制SELECT order_no, customer, amount
FROM orders_archive
UNION
SELECT order_no, customer, amount
FROM orders_current
ORDER BY amount DESC
LIMIT 5;
这里 LIMIT 5 确实作用于最终结果,是合理的。但如果你的原意是“归档表取 5 笔,当前表取 5 笔”,那就必须回到 4.2 的子查询写法,先把每个分支各自 LIMIT 成子查询,再合并。
5. 从热搜里的报错说起:UNION 里的排序规则和类型不匹配排查
5.1 “illegal mix of collations for operation 'union'”到底来自哪里
很多人在搜 SQLite UNION 时,会看到一条很像的报错:illegal mix of collations for operation 'union'。这里先说清楚,这条报错更常见于 MySQL / MariaDB,而不是 SQLite。它说的是 UNION 两侧同一个位置的字段使用了不同的排序规则(collation),数据库无法确定该按哪一套规则来比较和去重。
SQLite 虽然通常不报同样的错误文案,但它同样有排序规则的概念,只是大多数情况下用的是默认的 BINARY。当你给某张表的字段显式指定了 COLLATE NOCASE 或者 COLLATE RTRIM,而另一张表的同位置字段没有指定时,UNION 在判断两行是否重复时,可能表现得不一致。
举一个最容易踩的现象:一张表的 customer 字段建表时用了 COLLATE NOCASE,另一张表没有,那么 '张三' 和 '张三' 这种完全一致的字符串不会出问题,但如果数据里出现了大小写不同的英文名,例如 'ABC' 与 'abc',两侧的比较基准不一样,UNION 去重结果可能会让你觉得“为什么这个能去重,那个不能”。
排查方法很简单:检查建表语句里有没有 COLLATE 关键字。如果确认存在排序规则不统一,可以在 SELECT 里显式指定:
sqlite复制SELECT customer COLLATE NOCASE AS customer
FROM orders_archive
UNION
SELECT customer COLLATE NOCASE
FROM orders_current;
显式指定后,两侧比较规则一致,结果更可控。
5.2 更常见的 SQLite UNION 报错和异常行为排查表
把我在 SQLite 里实际遇到过的问题按“现象 - 原因 - 解法”整理成了一张表,方便你遇到时直接对照。
| 现象 | 原因 | 解法 |
|---|---|---|
| SELECTs to the left and right of UNION do not have the same number of result columns | 两边 SELECT 列数不一致 | 检查两边 SELECT 的字段数量,逐列对齐 |
| no such column: xxx,发生在外层 ORDER BY | ORDER BY 引用了第二个分支的列名 | 让外层引用第一个分支的列名,或用别名统一 |
| UNION 查询结果行数比预期少 | 业务上不同但投影列相同的行被去重 | 判断是否需要去重,不需要就换 UNION ALL |
| UNION 查询结果行数比预期多 | 两边数据有笛卡尔积感,或者 JOIN 被错用 | 确认本次查询是“纵向合并”还是“横向关联” |
| 结果排序看起来乱,数字和文本混在一起 | 同一列两边类型不一致,按存储类排序 | 用 CAST 统一类型,显式转换后再 UNION |
| 大小写重复数据没有被正常去掉 | 两侧排序规则不一致 | 在建表语句或 SELECT 中统一 COLLATE 规则 |
这张表不是图一乐,我每一条都在项目里真真切切碰到过。
5.3 一个“结果行数不对”的真实排查链路
分享一次完整的排查经历。当时有一个同步任务,把历史库的订单插入到当前库,任务跑完后新库里的数据总是比源库少好几条。第一反应是同步逻辑丢数据,但单表 SELECT 逐条比对,两边都有记录。
后来才发现问题出现在一个用于对账的查询里:
sqlite复制SELECT order_no, customer, amount
FROM orders_current
UNION
SELECT order_no, customer, amount
FROM orders_archive;
这条查询的目的是“把所有订单拉出来,人工核对一遍”。但其中有两笔订单,订单号不同,可 customer 和 amount 完全相同。UNION 去重时,比较的是 order_no + customer + amount 三列的组合,订单号不同,它们本不该被去重。问题却出在另一个方向:有两笔完全一样的测试订单,order_no、customer、amount 全部相同,它们分散在两个表里。UNION 认为这是重复行,所以合成了一条。而对账场景里,这两笔订单虽然内容一样,却对应两笔真实业务,不能省略。
当时修起来倒简单:把对账查询改成 UNION ALL,因为对账逻辑只关心每一笔记录是否都出现,并不需要按整行去重。但这次排查提醒了我:不要默认“UNION 一定比 UNION ALL 好”。在去重之前,一定要问自己一句:业务上所谓“同样的数据”到底是什么?是整行投影完全一样,还是某一个唯一键一样?这两者的处理方式完全不同。
如果你需要的是“按订单号去重,但保留数据最完整的那一笔”,单纯 UNION 做不到,因为 UNION 只能按整行投影是否完全一致来判断。正确的做法是把所有记录先用 UNION ALL 合并,再用 GROUP BY 或窗口函数把订单号作为分组条件,自己决定保留哪一行。
6. UNION、JOIN、INTERSECT/EXCEPT:不同集合操作的正确分工
6.1 纵向合并用 UNION,横向关联用 JOIN,别混在一起用
到了工程阶段,关键已经不是“UNION 怎么写”,而是“什么情况下不该写”。
JOIN 解决的是“一张表的数据不够宽,需要把另一张表的字段补进来”的问题。比如订单表里有 customer_id,但客户姓名在客户表里,你想在订单列表里展示客户姓名,这时用 JOIN 把客户表关联进来,得到更宽的记录。
UNION 解决的是“一张表的数据不够长,需要把另一张表同样结构的记录接在下面”的问题。归档订单和当前订单,结构相同,只是存储位置不同,用 UNION 把它们纵向累计成更长的列表。
判断方法很简单:对着需求问一句。如果需求描述里有“同时看到订单信息和客户姓名”,这是横向关联;如果需求描述里有“把几个表的数据全部放一起”,这是纵向合并。用错 JOIN 会得到奇奇怪怪的笛卡尔积,把几千行放大成几百万行;用错 UNION 则可能把本不该合并的字段上下拼在一起,让行的含义彻底混乱。
6.2 集合操作家族:UNION 还有两个兄弟 INTERSECT 和 EXCEPT
SQLite 的集合操作不止 UNION,还有 INTERSECT 和 EXCEPT。它们处理的是同一种问题——多份结构相同的结果集之间的集合关系。
INTERSECT返回两个查询结果中都出现过的行,相当于集合交集。EXCEPT返回只在第一个查询结果中出现、但不在第二个查询结果中出现的行,相当于集合差集。UNION合并两个查询结果,做并集;UNION ALL做并集时保留重复。
在数据表对账场景里,INTERSECT 和 EXCEPT 非常顺手。比如你要找出“归档表和当前表里完全一致的订单记录”,可以写:
sqlite复制SELECT order_no, customer, amount
FROM orders_archive
INTERSECT
SELECT order_no, customer, amount
FROM orders_current;
如果只要查“归档表里有,但当前表里没有的订单”,则把 INTERSECT 换成 EXCEPT。这两个操作对字段的可比性要求更高,如果两侧类型、排序规则不一致,也会出现类似前面提到的去重异常,排查思路完全一致。
这一家子操作其实都建立在同一个底层概念上:SELECT 的结果是一个集合,集合与集合可以做并、交、差。理解了这个,你的 SQL 思维就上了一级台阶。
6.3 和 UNION 经常混在一起的兄弟语法:UPSERT
很多人在搜索“SQLite UNION”的同时,还会搜“sqlite 存在就更新不存在就新增”。这说明大家实际在做一个数据合并 / 数据同步的需求,而这类需求往往包含两个阶段:查询时要用 UNION 把多份数据合并展示,写入时则要用 UPSERT,也就是 INSERT ... ON CONFLICT DO UPDATE,把源表数据落进目标表。
举个例子。你现在需要把 orders_current 表里的数据同步到 orders_archive,订单号存在就更新金额,不存在就新增。注意 orders_archive.order_no 必须有唯一索引,否则 ON CONFLICT 无法定位冲突行。
sqlite复制INSERT INTO orders_archive(order_no, customer, amount, created_at)
SELECT order_no, customer, amount, created_at
FROM orders_current
ON CONFLICT(order_no) DO UPDATE SET
customer = excluded.customer,
amount = excluded.amount,
created_at = excluded.created_at;
这段 SQL 先把 orders_current 的所有记录 SELECT 出来,逐行尝试插入 orders_archive;遇到订单号已经存在的行,就执行 UPDATE,更新客户、金额和时间。excluded 是一个特殊引用,代表“本条 INSERT 本来要插入的新值”。
这里之所以经常和 UNION 一起出现,是因为很多同步工具实际是两段式设计:先读取两边的数据,用 EXCEPT 或比较查询找出差异集合;再把差异集合交给 UPSERT 写入。UNION 负责“对比和查看”,UPSERT 负责“真正落库”。
6.4 实用建议:从需求出发选择方案
我在项目里总结出一个非常朴素的选择顺序,分享给大家:
- 先问这个查询是“补字段”还是“累计记录”。补字段走 JOIN,累计记录考虑 UNION。
- 累计记录时,再问结果是否可能出现完全相同的行。可能出现但业务要求去重,用 UNION;不能出现或不需要去重,用 UNION ALL。
- 如果需要按某个业务键去重,而不是按整行去重,UNION 不够,要选用 GROUP BY 或窗口函数。
- 如果是两条数据之间的“在不在、差多少”问题,优先考虑 EXCEPT / INTERSECT,它们的语义通常更清晰。
- 如果最终目的是把数据写到另一张表,查询部分只是前置步骤,写入部分用 UPSERT 做好冲突处理。
回顾这些年用 SQLite UNION 的经历,我发现真正让这个子句显得“难”的地方,从来不是语法本身,而是使用者对“重复”的定义不够清晰,对“列名和列位置”的规则不够敏感。SQLite 这个数据库最大的特点就是宽容,很多错误它不会当场拒绝你,而是把奇怪的结果悄悄放进数据里,等你在下游分析时才发现端倪。
最后分享一个小技巧:每当你写完一段带 UNION 的查询
