业务要求实现一个“过去1小时的活跃用户数,每5秒刷新一次”的统计。使用常规的 Sliding Window 会导致同一条数据属于数百个窗口,造成极大的性能损耗,有什么优化方案?
这是一个非常经典的流式计算(如 Flink)中的“状态爆炸”(State Explosion)问题。
窗口长度 1 小时(3600秒),滑动步长 5 秒,意味着 个窗口重叠。如果使用原生的 Sliding Window,一条数据会被复制并分配到 720 个窗口的状态中,导致极大的 CPU 和内存开销。
这里提到的“活跃用户数”通常指 UV(去重用户数,Count Distinct),这是最难处理的。如果是普通 PV(不去重),优化相对简单。以下针对 UV 和 PV 提供工业界常用的几种彻底解决该问题的优化方案:
方案一:分片/切片窗口(Sliding Window -> Tumbling Windows 聚合)
核心思想:将滑动窗口转化为多个小的滚动窗口(Panes/Buckets)。
不要让一条数据归属 720 个窗口,而是按滑动步长(5秒)划分不重叠的滚动窗口(Tumbling Window)。
- 第一层聚合: 每 5 秒为一个时间片(Bucket),每条数据只属于 1 个时间片,完全没有放大。在时间片内对用户进行初步聚合。
- 第二层聚合: 维护一个长度为 720 的队列(或者状态列表),存放过去 1 小时的 720 个时间片的结果。每过 5 秒,剔除最老的时间片,加入最新的时间片,然后合并这 720 个时间片的结果输出。
针对 UV(去重)的具体实现:
- 如果是近似 UV(推荐): 使用 HyperLogLog (HLL)。第一层 5 秒窗口生成一个 HLL 对象;第二层保留 720 个 HLL 对象,每 5 秒将这 720 个 HLL 进行 Merge(HLL 的 Merge 操作非常轻量),然后求
count。 - 如果是精确 UV(UserID 是整型): 使用 RoaringBitmap(位图)。第一层 5 秒窗口生成一个 Bitmap;第二层按位或(OR)合并 720 个 Bitmap,然后求
cardinality。
优点: 彻底解决了一条数据膨胀 720 倍的问题。
适用场景: 数据量极大,且接受 HLL 的误差(通常 1%~2%),或者用户 ID 可以转为连续 Int 使用 Bitmap。
方案二:基于“最后活跃时间”的撤回流(Retraction Stream)方案(Flink Native)
核心思想:不使用窗口,而是记录用户的“最后活跃时间”,利用定时器(Timer)触发过期扣减。
这个方案在 Flink 中非常优雅,完全避开了大窗口的合并。
- KeyBy UserID: 按用户 ID 进行分区。
- 状态记录: 使用
ValueState<Long>记录该用户最后一次活跃的时间戳(lastActiveTime)。 - 处理逻辑(ProcessFunction):
- 当一条数据到来时,如果状态为空,说明是新用户(或者过期后再次活跃),向下游发送一条
+1的记录。更新lastActiveTime为当前时间,并注册一个当前时间 + 1小时的定时器。 - 如果状态不为空,说明 1 小时内活跃过,只更新
lastActiveTime和定时器,不向下游发送数据。
- 当一条数据到来时,如果状态为空,说明是新用户(或者过期后再次活跃),向下游发送一条
- 定时器逻辑(OnTimer):
- 当定时器触发时,检查当前的
lastActiveTime是否等于定时器触发时间减去 1 小时。 - 如果相等,说明该用户整整 1 小时没有新行为,已经“流失”出这个 1 小时窗口,清空状态,并向下游发送一条
-1的记录。
- 当定时器触发时,检查当前的
- 下游聚合统计:
- 下游收到
+1就把总数加 1,收到-1就把总数减 1。 - 然后下游可以直接开一个 5 秒的滚动窗口(Tumbling Window),每 5 秒输出当前的累计总数即可。
- 下游收到
优点:
- 绝对精确的去重 UV。
- 摒弃了滑动窗口,计算开销极小(只需简单的加减法)。
- 下游每 5 秒输出一次,非常平滑。
适用场景: 对精确度要求极高,且 1 小时内的总活跃用户数在内存可承受范围内(每个 UV 对应一个 State 和 Timer)。
方案三:借助外部 KV / 内存数据库(如 Redis)
如果不强求完全在流处理引擎(如 Flink)内部维护庞大的去重状态,可以借助 Redis 实现。
具体实现:使用 Redis ZSET(有序集合)
- 写入: Flink / 消费端收到数据后,以
固定Key(例如active_users_1h)写入 ZSET。Member= UserIDScore= 活跃时间戳ZADD active_users_1h <timestamp> <UserID>
- 清理过期数据: Flink 每 5 秒(或者单独起个定时任务)执行一次剔除:
ZREMRANGEBYSCORE active_users_1h 0 <当前时间戳 - 3600>
- 统计结果: 紧接着执行获取总数:
ZCARD active_users_1h- 获取的结果即为过去 1 小时的精确 UV。
优点: 逻辑极度简单,不用在 Flink 中处理复杂的状态和内存调优。
适用场景: 1 小时内的活跃用户数在百万级别以内(Redis ZSET 节点过大可能会有性能瓶颈,必要时需做分片 Sharding)。
方案四:借助外部 OLAP 引擎(ClickHouse / Doris / StarRocks)
现在的实时数仓能力非常强,遇到这种高频刷新的滑动窗口去重,很多时候直接交给 OLAP 引擎来做会更简单。
具体实现:
- Flink 仅仅做简单的数据清洗,将用户的明细数据(带有时间戳)实时写入 OLAP 引擎。
- 在 OLAP 引擎中建立物化视图(Materialized View)。利用引擎内置的 Bitmap 或 HLL 聚合函数,按 5 秒的时间粒度预聚合数据。
- 前端应用或后端服务每 5 秒向 OLAP 发起一次 SQL 查询:
SELECT BITMAP_UNION_COUNT(user_id_bitmap) FROM table WHERE time >= NOW() - INTERVAL 1 HOUR
优点: 架构解耦,流处理极轻量,OLAP 的 Bitmap 向量化执行查 1 小时的数据通常在毫秒级。
适用场景: 公司已有 ClickHouse / Doris 等组件,且有成熟的实时写入通道。
💡 总结与选型建议
| 方案 | 精确度 | 实现难度 | 资源消耗 | 适用场景推荐 |
|---|---|---|---|---|
| 1. HLL + 切片窗口 | 近似 (误差1-2%) | 中 | 极低(内存极省) | 首选推荐:接受轻微误差的大规模互联网 C 端业务。 |
| 2. +1/-1 撤回流 | 100% 精确 | 偏高 (需写底层 Process) | 中(取决于 1h 的 UV 量) | 强依赖精确 UV,且不能依赖外部系统的纯 Flink 计算。 |
| 3. Redis ZSET | 100% 精确 | 极低 | Redis 内存占用大 | 中小体量业务,开发周期短,求快。 |
| 4. OLAP Bitmap | 100% 精确 | 低 (依赖SQL) | 依赖外部集群资源 | 架构中已经有 ClickHouse/Doris/StarRocks,直接用。 |
最务实的建议:
如果业务不要求绝对精确(比如大屏展示活跃人数),坚决采用 “分片窗口 + HyperLogLog” 的方案;
如果业务必须精确且公司有实时数仓,直接用 OLAP 方案;
如果是纯实时计算部门自己扛,写 Flink 撤回流(方案二) 是最体现技术深度的标准做法。