Flink SQL 中的 SELECT DISTINCT批流一体下的去重与状态管理

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 的行为跟传统数据库基本一样:

  1. 输入数据是有界的(有开始有结束);
  2. Flink 会把所有行都看一遍,维护一个"已经见过的 key 集合";
  3. 每遇到一行新数据,就判断这个 key 是否已经见过;
  4. 没见过 → 输出并记住;
    见过 → 丢弃。

背后通常是某种基于哈希的去重或排序去重:

  • 小规模数据:可能是 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)中

使用前先问自己三个问题:

  1. 我是要"终身唯一"还是"某个时间范围内唯一"?
  2. 业务能不能接受"过期后再次出现时被当成新值"?
  3. distinct key 的基数大概有多大,内存放得下吗?

一般建议:

  • 真·终身去重 → 谨慎使用,尽量转为离线计算或用外部存储 + 稀疏结构(如 Bloom Filter)等方案;
  • 时间范围去重 → 优先用窗口聚合 COUNT(DISTINCT ...) 或 TTL 明确边界;
  • 实时明细去重(只要遇到就过滤) → 可以用 DISTINCT + 相对较短的 TTL,把业务语义写清楚。

7. 小结

这一小节的核心点可以压缩成三句话:

  1. SELECT DISTINCT = 对结果集按选出的列去重,批模式下基本等同于传统数据库的行为;
  2. 在流模式下,DISTINCT 需要持久维护"已见过的值"的状态,理论上会无限增长;
  3. 你可以用 State TTL 控制状态大小,但那是用"正确性"换"资源",在用之前一定想清楚业务语义。

如果你在写 Flink SQL 的"基础语法"系列,SELECT / WHERE / DISTINCT 可以自然串成一块:

  • SELECT:选字段 + 做计算;
  • WHERE:提前过滤;
  • DISTINCT:按需要去重(批很爽,流要谨慎)。

后面再接上 GROUP BY、窗口、JOIN,整套 SQL 文档就顺起来了。

相关推荐
_dindong42 分钟前
Linux网络编程:I/O多路转接之epoll
linux·服务器·网络·sql·mysql
隔壁阿布都42 分钟前
MySQL 自动化定期备份及故障恢复
数据库·mysql·自动化
java_logo1 小时前
GITLAB Docker 容器化部署指南
linux·运维·数据库·docker·容器·eureka·gitlab
zwm_yy1 小时前
mongodb回顾
数据库·mongodb
半路_出家ren1 小时前
LNMP环境与应用配置
linux·数据库·mysql·nginx·网络安全·php·lnmp
stone51 小时前
流段的上游查询
数据库·sql·mysql
曹牧1 小时前
Oracle:字段值中含有单引号
数据库·oracle