基于本文回答
0
评论

业务要求实现一个“过去1小时的活跃用户数,每5秒刷新一次”的统计。使用常规的 Sliding Window 会导致同一条数据属于数百个窗口,造成极大的性能损耗,有什么优化方案?

知识点图片

这是一个非常经典的流式计算(如 Flink)中的“状态爆炸”(State Explosion)问题。

窗口长度 1 小时(3600秒),滑动步长 5 秒,意味着 3600/5=7203600 / 5 = 720 个窗口重叠。如果使用原生的 Sliding Window,一条数据会被复制并分配到 720 个窗口的状态中,导致极大的 CPU 和内存开销。

这里提到的“活跃用户数”通常指 UV(去重用户数,Count Distinct),这是最难处理的。如果是普通 PV(不去重),优化相对简单。以下针对 UV 和 PV 提供工业界常用的几种彻底解决该问题的优化方案:


方案一:分片/切片窗口(Sliding Window -> Tumbling Windows 聚合)

核心思想:将滑动窗口转化为多个小的滚动窗口(Panes/Buckets)。

不要让一条数据归属 720 个窗口,而是按滑动步长(5秒)划分不重叠的滚动窗口(Tumbling Window)

  1. 第一层聚合: 每 5 秒为一个时间片(Bucket),每条数据只属于 1 个时间片,完全没有放大。在时间片内对用户进行初步聚合。
  2. 第二层聚合: 维护一个长度为 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 中非常优雅,完全避开了大窗口的合并。

  1. KeyBy UserID: 按用户 ID 进行分区。
  2. 状态记录: 使用 ValueState<Long> 记录该用户最后一次活跃的时间戳(lastActiveTime)。
  3. 处理逻辑(ProcessFunction):
    • 当一条数据到来时,如果状态为空,说明是新用户(或者过期后再次活跃),向下游发送一条 +1 的记录。更新 lastActiveTime 为当前时间,并注册一个 当前时间 + 1小时 的定时器。
    • 如果状态不为空,说明 1 小时内活跃过,只更新 lastActiveTime 和定时器,不向下游发送数据
  4. 定时器逻辑(OnTimer):
    • 当定时器触发时,检查当前的 lastActiveTime 是否等于定时器触发时间减去 1 小时。
    • 如果相等,说明该用户整整 1 小时没有新行为,已经“流失”出这个 1 小时窗口,清空状态,并向下游发送一条 -1 的记录。
  5. 下游聚合统计:
    • 下游收到 +1 就把总数加 1,收到 -1 就把总数减 1。
    • 然后下游可以直接开一个 5 秒的滚动窗口(Tumbling Window),每 5 秒输出当前的累计总数即可。

优点:

  • 绝对精确的去重 UV
  • 摒弃了滑动窗口,计算开销极小(只需简单的加减法)。
  • 下游每 5 秒输出一次,非常平滑。
    适用场景: 对精确度要求极高,且 1 小时内的总活跃用户数在内存可承受范围内(每个 UV 对应一个 State 和 Timer)。

方案三:借助外部 KV / 内存数据库(如 Redis)

如果不强求完全在流处理引擎(如 Flink)内部维护庞大的去重状态,可以借助 Redis 实现。

具体实现:使用 Redis ZSET(有序集合)

  1. 写入: Flink / 消费端收到数据后,以 固定Key(例如 active_users_1h)写入 ZSET。
    • Member = UserID
    • Score = 活跃时间戳
    • ZADD active_users_1h <timestamp> <UserID>
  2. 清理过期数据: Flink 每 5 秒(或者单独起个定时任务)执行一次剔除:
    • ZREMRANGEBYSCORE active_users_1h 0 <当前时间戳 - 3600>
  3. 统计结果: 紧接着执行获取总数:
    • ZCARD active_users_1h
    • 获取的结果即为过去 1 小时的精确 UV。

优点: 逻辑极度简单,不用在 Flink 中处理复杂的状态和内存调优。
适用场景: 1 小时内的活跃用户数在百万级别以内(Redis ZSET 节点过大可能会有性能瓶颈,必要时需做分片 Sharding)。


方案四:借助外部 OLAP 引擎(ClickHouse / Doris / StarRocks)

现在的实时数仓能力非常强,遇到这种高频刷新的滑动窗口去重,很多时候直接交给 OLAP 引擎来做会更简单。

具体实现:

  1. Flink 仅仅做简单的数据清洗,将用户的明细数据(带有时间戳)实时写入 OLAP 引擎。
  2. 在 OLAP 引擎中建立物化视图(Materialized View)。利用引擎内置的 Bitmap 或 HLL 聚合函数,按 5 秒的时间粒度预聚合数据。
  3. 前端应用或后端服务每 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 撤回流(方案二) 是最体现技术深度的标准做法。

右滑查看面试常问