1. SELECT DISTINCT 是干嘛的?
SELECT DISTINCT 是 SQL 里最经典的"去重"工具:
- 语义:对结果集按选出的列做去重;
- 行为:对于重复的行,只保留一行。
示例:
sql
SELECT DISTINCT id
FROM Orders;
如果 Orders 中有:
| id | price |
|---|---|
| 1 | 10.0 |
| 1 | 12.0 |
| 2 | 5.0 |
SELECT DISTINCT id 的结果是:
| id |
|---|
| 1 |
| 2 |
注意:去重是按 SELECT 出来的列来算的,比如:
sql
SELECT DISTINCT id, price
FROM Orders;
那 (1, 10.0) 和 (1, 12.0) 就不算重复,会保留两行。
2. 在批处理里的表现:一次性扫描,一次性去重
在 批处理(Batch) 场景中,SELECT DISTINCT 的行为跟传统数据库基本一样:
- 输入数据是有界的(有开始有结束);
- Flink 会把所有行都看一遍,维护一个"已经见过的 key 集合";
- 每遇到一行新数据,就判断这个 key 是否已经见过;
- 没见过 → 输出并记住;
见过 → 丢弃。
背后通常是某种基于哈希的去重或排序去重:
- 小规模数据:可能是 hash-based;
- 大规模数据:可能拆成分区 + 外排序等。
总之,对你作为 SQL 使用者来说:它就像普通数据库的 DISTINCT 一样用就行。
3. 在流处理里的问题:状态可能"长到飞起"
重点来了:
在 流式查询(Streaming) 中,SELECT DISTINCT 就不再只是一个简单的去重操作了,而是一个持续维护"已出现集合"的长期任务。
想象一下这样一条流式 SQL:
sql
SELECT DISTINCT user_id
FROM UserActions;
UserActions 是一个 Kafka 流,用户操作永远在来:
- 每来一行,就要判断 user_id 是否出现过;
- 要做到"真正的 DISTINCT",你就要把所有出现过的 user_id 都记住;
- 于是,这部分状态会不断增长,理论上是无限的。
Flink 文档里那句话其实就是这个意思:
对于流式查询,为了计算
SELECT DISTINCT,需要维护的状态可能会无限增长,状态大小取决于不同去重值的数量。
翻译成人话:
流式
DISTINCT= 维护一个"无上限的 Set",除非你做了时间限制或 TTL。
4. 用 State TTL 控制状态大小(以及要付出的代价)
为了防止状态爆炸,Flink 提供了状态 TTL(Time-To-Live) 配置,你可以为查询设置一个"状态最长存活时间":
- 超过这个时间没被访问的 key 会被清理;
- 状态占用内存/RocksDB 空间可以控制在一个比较健康的范围。
文档的提醒也很直接:
你可以配置合适的 state TTL 来防止状态过大,但这可能影响结果正确性。
为什么会影响正确性?
举个简单例子:
-
你有一条流:
SELECT DISTINCT user_id FROM UserActions; -
假设
table.exec.state.ttl = '7d'; -
用户 A 在第一天产生一条日志 → user_id= A 被记录在状态中 → 输出一次 A;
-
如果用户 A 在第 15 天又产生一条日志:
- 由于超过了 7 天,之前的 A 已经过期被清理;
- 再次出现时,算子会认为是**"一个新的 A"**;
- 又输出一次 A。
也就是说:TTL 越短,去重"记忆"越差,结果越不靠谱 。
所以要明确一点:
设置 TTL 是在 "正确性" 和 "资源占用" 之间做权衡。
5. 几种常见的去重需求与实现方式
在实际业务中,"去重"并不都是同一个意思。我们可以分几类来看:
5.1 "全局唯一去重":看到一次就算数(典型 SELECT DISTINCT)
需求例子:
- "统计所有曾经访问过系统的 user_id 列表";
- "把所有出现过的设备 ID 打一份清单"。
如果这是一个 离线任务,用批模式跑一次即可:
sql
SELECT DISTINCT user_id
FROM UserActions;
如果你非要搞成一个实时流式任务:
- 需要承受状态越来越大的现实;
- 至少要设置一个足够长的 TTL(比如几个月甚至更长);
- 还要预估:
"我系统里最多可能有多少不同 user_id?"
这个在大规模用户体系下通常不是一个好主意,更适合做离线导出。
5.2 "一段时间内去重":只关心最近 N 天 / N 小时
场景例子:
- "统计最近 7 天内活跃的用户数(去重)";
- "最近 1 小时内有多少不同 IP 访问了系统"。
这里其实要的是时间范围内的去重,而不是"历史以来的终身去重"。
你可以用两种思路:
思路 1:窗口 + 去重
sql
SELECT
window_start,
window_end,
COUNT(DISTINCT user_id) AS uv
FROM TABLE(
TUMBLE(TABLE UserActions, DESCRIPTOR(event_time), INTERVAL '1' DAY)
)
GROUP BY window_start, window_end;
这里是 window 聚合里的 COUNT(DISTINCT ...),算子会只在一个窗口内维护去重状态,窗口结束后可以释放状态,不会无限增长。
思路 2:TTL + 明确"过期即忘记"
还是用:
sql
SELECT DISTINCT user_id
FROM UserActions;
但全局配置:table.exec.state.ttl = '7d' 之类,接受这种语义:
"只要用户在 7 天以上不出现,就把他的存在感忘记掉;如果他再来一次,就当成又'新增'了一个 user_id。"
这种要求比较"宽松"的业务可以接受,比如:
- 某些"活跃用户集合"的近似统计;
- 某些去重报警场景。
5.3 "按主键去重保留最新":其实更适合用窗口 + ROW_NUMBER
还有一种常见需求其实不是 SELECT DISTINCT 本身,而是:
对某个 key 去重,只保留最新的一条记录(或者第一条记录)。
比如:
- "对订单流按 order_id 去重,只保留最新状态的一条";
- "按设备号去重,只保留最近上报的一条心跳"。
这种需求用 SELECT DISTINCT 并不好表达,更推荐:
sql
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY event_time DESC
) AS rn
FROM Orders
) t
WHERE rn = 1;
这里 DISTINCT 就退居二线了,核心是窗口函数 + 排序。
6. 对 SELECT DISTINCT 的使用建议(批 / 流分别看)
6.1 在批任务(Batch)中
-
可以大胆用
SELECT DISTINCT做脱重:- 清洗去重;
- 统计 global distinct 值;
- 构建维表唯一 key;
-
就按普通数据库使用即可,不用太担心状态问题(任务会结束)。
6.2 在流任务(Streaming)中
使用前先问自己三个问题:
- 我是要"终身唯一"还是"某个时间范围内唯一"?
- 业务能不能接受"过期后再次出现时被当成新值"?
- distinct key 的基数大概有多大,内存放得下吗?
一般建议:
- 真·终身去重 → 谨慎使用,尽量转为离线计算或用外部存储 + 稀疏结构(如 Bloom Filter)等方案;
- 时间范围去重 → 优先用窗口聚合
COUNT(DISTINCT ...)或 TTL 明确边界; - 实时明细去重(只要遇到就过滤) → 可以用
DISTINCT+ 相对较短的 TTL,把业务语义写清楚。
7. 小结
这一小节的核心点可以压缩成三句话:
SELECT DISTINCT= 对结果集按选出的列去重,批模式下基本等同于传统数据库的行为;- 在流模式下,DISTINCT 需要持久维护"已见过的值"的状态,理论上会无限增长;
- 你可以用 State TTL 控制状态大小,但那是用"正确性"换"资源",在用之前一定想清楚业务语义。
如果你在写 Flink SQL 的"基础语法"系列,SELECT / WHERE / DISTINCT 可以自然串成一块:
SELECT:选字段 + 做计算;WHERE:提前过滤;DISTINCT:按需要去重(批很爽,流要谨慎)。
后面再接上 GROUP BY、窗口、JOIN,整套 SQL 文档就顺起来了。