一、UNION / UNION ALL:合并数据流的第一选择
1.1 基本语义
UNION:集合并集 + 自动去重UNION ALL:简单拼接,不去重
两张表结构兼容时(字段数、字段类型一致),就可以使用。
文档里的例子:
sql
CREATE VIEW t1(s) AS VALUES ('c'), ('a'), ('b'), ('b'), ('c');
CREATE VIEW t2(s) AS VALUES ('d'), ('e'), ('a'), ('b'), ('b');
UNION:去重合并
sql
(SELECT s FROM t1) UNION (SELECT s FROM t2);
+---+
| s |
+---+
| c |
| a |
| b |
| d |
| e |
+---+
UNION ALL:不去重合并
sql
(SELECT s FROM t1) UNION ALL (SELECT s FROM t2);
+---+
| s |
+---+
| c |
| a |
| b |
| b |
| c |
| d |
| e |
| a |
| b |
| b |
+---+
可以看到:
UNION结果中,a / b / c只出现一次;UNION ALL完全保留了两张表中各自的行数。
1.2 在 Streaming 模式下的思考
UNION ALL非常适合多流合并的场景,比如多 topic 合并、按业务线拆分的流汇总成主流;UNION在流上做"全局去重",需要维护所有已经输出过的 key ,这在长时间运行的作业中几乎一定会状态无限膨胀。
经验:
流式场景 99% 情况用UNION ALL,只有明确需要全局去重、且数据量可控时才考虑UNION。
二、INTERSECT / INTERSECT ALL:交集运算
2.1 基本语义
INTERSECT:两张表的交集,结果去重INTERSECT ALL:两张表的交集,保留重复次数
还是用 t1、t2 示例:
sql
(SELECT s FROM t1) INTERSECT (SELECT s FROM t2);
+---+
| s |
+---+
| a |
| b |
+---+
说明两张表都出现过 a、b。
INTERSECT ALL:
sql
(SELECT s FROM t1) INTERSECT ALL (SELECT s FROM t2);
+---+
| s |
+---+
| a |
| b |
| b |
+---+
b 出现了两次,说明在"计数意义上的交集"中,b 至少低于等于两边出现次数的最小值。
2.2 Streaming 场景下的影响
交集的核心逻辑是:两边都出现过才算。在流上要支撑:
- 需要记录:某个值在 t1 中出现了多少次、在 t2 中出现了多少次;
- 这些信息必须长期维护,除非你引入**时间维度(窗口)**或 TTL。
经验:
- 实时流上尽量用"窗口 + JOIN"表达交集语义,比如某个时间窗口内都出现过的用户;
- 直接用全局
INTERSECT/INTERSECT ALL做"无限时间交集",几乎必然是状态炸弹。
三、EXCEPT / EXCEPT ALL:差集运算
3.1 基本语义
EXCEPT:左表中存在,右表中不存在的行,结果去重EXCEPT ALL:也是"左减右",但保留计数差
示例:
sql
(SELECT s FROM t1) EXCEPT (SELECT s FROM t2);
+---+
| s |
+---+
| c |
+---+
t1 中的 c 在 t2 里不存在。
EXCEPT ALL:
sql
(SELECT s FROM t1) EXCEPT ALL (SELECT s FROM t2);
+---+
| s |
+---+
| c |
| c |
+---+
说明:
- t1 中
c出现了 2 次; - t2 中
c出现了 0 次; - 计数差为 2,因此结果中也有两行
c。
3.2 和业务场景的对应
差集非常适合用来表达:
-
"存在于 A 而不存在于 B"的数据:
- 已下单但未支付的订单 ID;
- 完成注册但未完成实名认证的用户;
- 数据对账时,某个系统存在但另一个系统缺失的记录。
同样,在流式场景下:
- 只要你不加时间限定,这个差集逻辑就意味着要记住所有历史出现过的 key,状态难以收敛;
- 真实业务里,通常都应该用窗口 + ANTI JOIN进行限定(你前面已经看过 Window Join 的 ANTI 写法了),比如:
"最近 5 分钟内,出现在流 A 但未出现在流 B 的订单"。
四、IN:子查询 + 存在性过滤(底层会被改写成 Join)
4.1 基本语义
IN 用来判断某个表达式是否存在于子查询结果中。子查询返回的表必须:
- 只有 1 列;
- 且该列类型与被检测的表达式类型一致。
示例:
sql
SELECT user, amount
FROM Orders
WHERE product IN (
SELECT product FROM NewProducts
);
语义很直观:筛选出"下单的商品在 NewProducts 列表中的订单"。
4.2 Flink SQL 底层做了什么?
文档里明确说:
优化器会把
IN条件重写为 Join + Group 操作。
也就是说:
sql
SELECT ...
FROM Orders
WHERE product IN (SELECT product FROM NewProducts)
会被重写为类似:
sql
SELECT ...
FROM Orders o
JOIN (
SELECT DISTINCT product FROM NewProducts
) p
ON o.product = p.product;
对流式查询的影响:
- 这实际上就是一场 Join + 去重;
- 需要维护
NewProducts子查询的结果(相当于维表),随着时间推移,这个集合可能越来越大; - 所以状态可能 无限增长。
解决思路:
- 如果子查询是有限集(静态维表、配置表),可以直接把它做成维表 / Lookup Join;
- 如果是实时变化的集合,考虑结合 水位线 + 窗口 + TTL 控制存活时间;
- 或者改写成有明确时间范围的 Window Join / Interval Join。
五、EXISTS:只关心"有没有",不关心具体值
5.1 基本语义
EXISTS 用来判断子查询是否至少返回一行。语法类似:
sql
SELECT user, amount
FROM Orders
WHERE EXISTS (
SELECT product FROM NewProducts
);
由于这个例子里子查询和外表并没有关联,这条 SQL 的语义是:
只要
NewProducts这个子查询有任何一行,就对Orders的每一行返回 true ------ 实际上没啥意义。
真正有用的 EXISTS 一般都会和外表关联,比如:
sql
SELECT user, amount
FROM Orders o
WHERE EXISTS (
SELECT 1
FROM NewProducts n
WHERE o.product = n.product
);
这就和:
sql
WHERE product IN (SELECT product FROM NewProducts)
语义非常接近了(可以视为一种 SEMI JOIN)。
5.2 底层同样会被改写成 Join
文档指出:
优化器会把 EXISTS 改写成 Join + Group 操作。
因此在流式场景下:
-
它同样需要维护子查询(右表)的状态;
-
状态大小依赖于:
- 右表的去重后 key 数;
- 左右表的 Join 复合逻辑;
-
同样需要结合 TTL、窗口等手段做控制。
六、Streaming 模式下的"无限状态"问题与 TTL
Flink 文档反复强调一个点:
在流式查询中,许多操作的状态大小可能无限增长,包括:
UNION(需要去重时)INTERSECT/EXCEPTIN/EXISTS改写出来的 JoinGROUPING SETS / ROLLUP / CUBE等复杂聚合
解决问题的核心武器:
- State TTL(状态超时) :通过配置
table.exec.state.ttl等参数,为状态设置"保质期"; - 到期后状态会被清理,避免占满内存和磁盘。
但要注意:
- 一旦引入 TTL,就等于你接受了**"只在最近 N 时间内结果严谨,超出范围的结果可被视为过期或忽略"**;
- 对于长期全局去重 / 全局交叉对账类需求,必须非常谨慎,往往更适合跑离线批任务。
七、在实际项目中怎么选?
把上面的内容总结成几条实践建议:
-
合并多路流:优先用
UNION ALL- 不要在 Streaming 里随手
UNION去重; - 去重另开一步专门的聚合或者窗口去重,更可控。
- 不要在 Streaming 里随手
-
交集 / 差集要加时间维度
-
避免"无限历史交集/差集"的思路;
-
更推荐:
- "某个时间窗口内都出现过" → 用 Window Join + INNER / SEMI;
- "某个时间窗口内只出现在一边" → 用 Window Join + ANTI。
-
-
IN / EXISTS 就是"语法糖 + Join"
-
Streaming 模式下,记住它们本质上就是 Join;
-
数据量大时,要考虑改成更明确的:
- Lookup Join;
- Temporal Join;
- Window Join + TTL。
-
-
所有"可能无限长时间保留状态"的操作,都要明确 TTL 策略
- 尤其是在"业务上只关注最近 X 时间"的场景里,TTL 是非常合理的手段;
- 如果业务要求"绝对精确的全局结果",建议把这部分交给离线批处理完成。
小结
Flink SQL 的集合运算和 IN/EXISTS 表面看起来和传统数据库非常像,但只要一进入 Streaming 模式,它们背后就会多出一层 "状态膨胀 vs 结果精度" 的权衡。
可以这样记:
UNION ALL:流合并的默认方案;UNION / INTERSECT / EXCEPT:更适合作离线批或"窗口 + 有界范围"的实时分析;IN / EXISTS:底层其实是 Join,状态问题要特别小心;- 一旦进入"全局语义",就必须认真考虑 TTL、窗口、或者干脆放到离线任务做。