Skip to main content

参考:流处理——窗口、事件时间、水位线与 Exactly-Once

一份关于流处理的参考——tumbling、sliding 与 session 窗口,事件时间与水位线,作为系统属性的 exactly-once,热点 key 加盐,以及 Lambda 与 Kappa。

它是什么

流处理是在事件到达的过程中,对一条无界的事件流进行计算,而不是对一个已经结束的文件。每一个流式设计里都会出现四个概念。窗口(window) 把无尽的流切成有限的组来聚合。事件时间(event time) 说明窗口是按哪个时钟来切的。水位线(watermark) 说明一个窗口何时已经足够完整、可以输出。而 exactly-once 是整个管道的一个属性,不是某个组件上的一个开关。

Important

核心要点: 按事件时间而不是到达时间来开窗;用水位线决定何时输出,用**允许延迟(allowed lateness)**限定等待多久;对更晚到达的数据发往一个 side output,而不是丢进虚空。“Exactly-once”需要三个组件一起前进:去重、有检查点的状态,以及事务性的 sink。

何时需要它

广告点击聚合、指标、欺诈检测和实时看板,归根结底都是”按窗口数东西”。追问总是那几个:迟到的事件怎么办、重复怎么办、热点 key 怎么办,以及数字错了怎么修?

Note

面试信号: 大多数候选人只是把 exactly-once 说成一项需求。点出三个部分(event-id 去重、检查点屏障、事务性 sink),说明你知道它是系统的属性,而不是一项设置。再补上一条批处理对账路径,说明你知道流仍然可能是错的。

窗口

窗口定义例子
Tumbling固定、不重叠的区间每个广告每分钟的点击数
Sliding固定长度,以较小的步长前进;窗口之间重叠最近 5 分钟的平均值,每分钟更新一次
Session按 key 由一段不活跃的间隔关闭一个用户的一次浏览会话

长度为 L、步长为 s 的滑动窗口,会把每个事件放进 L ÷ s 个窗口里,所以一个每 1 分钟滑动一次的 5 分钟窗口,工作量是 tumbling 窗口的五倍。要选择能回答问题的最粗粒度的窗口。

事件时间、处理时间与水位线

  • 事件时间是事件发生的时间(事件里的一个时间戳)。处理时间是系统看到它的时间。二者的差别来自网络延迟、重试、离线设备和积压。
  • 使用处理时间的窗口简单,但只要有延迟就是错的。按事件时间的窗口是对的,但必须决定窗口何时结束,因为迟到的事件可能仍会到达。
  • 水位线是系统对”不会再有早于时间 T 的事件到达”的估计。它是告诉引擎一个窗口已经足够完整、可以输出的时钟。它随着看到新的事件而前进,再减去一个配置的乱序容忍度。
  • 允许延迟(allowed lateness) 让一个已关闭窗口的状态多保留一段时间,使迟到的事件仍能更新结果。

算例。 一个一分钟的 tumbling 窗口 10:00 到 10:01,允许延迟一小时:

水位线发生什么
到达 10:01窗口第一次输出结果(“准时”的答案)
在 10:01 到 11:01 之间一个事件时间为 10:00:30 的迟到事件到达;窗口更新并重新输出一个修正后的结果
超过 11:01窗口状态被丢弃;之后属于它的任何事件都发往 side output

超出允许延迟的事件发往一个单独的 topic(side output),而不是被悄悄丢弃,批处理作业也会读这个 topic,所以什么都不会永久丢失,只是被推迟。取舍在于内存和新鲜度:更多的延迟意味着保存更多状态、下游有更多修正。

Exactly-once 是系统的属性

重复来自被重试的 API 调用、客户端 SDK 的重试,以及处理器从检查点重启。三个部分合起来防止重复计数:

  1. 按事件 id 去重,在处理器的状态里做,在事件进入任何窗口之前。它处理的是逻辑上的重复(同一次点击被发送了两次)。
  2. 检查点屏障。 处理器定期原子地为自己的输入位置(日志 offset)、所有在途的窗口状态和输出位置做快照。崩溃之后,它从最近的快照重启,只重放其后的事件。
  3. 事务性 sink。 对输出存储的写入与检查点属于同一次提交。没有它,在”检查点成功”与”写入成功”之间崩溃,会漏掉或重复事件。

缺少任何一个,保证就不成立。Exactly-once 只在管道的边界内部成立;离开边界的副作用(一封邮件、对外部 API 的调用)无法被撤回,需要用幂等键来处理。

流里的热点 key

输入按 key(一个广告 id)分区,处理器的 keyed state 使用同样的 key,这样随分区数线性扩展。但 hash(key) 分散的是 key,不是负载:在一次大事件期间,一个广告可能占所有点击的 10%,把一个分区钉在 100%,其余的闲着。

用 salting 做两阶段聚合。 检测热点 key(按量排名前 0.01%,用一个近似计数器),对它们使用 key + 随机后缀(0..N) 作为分区键。第一阶段聚合每个加了 salt 的子 key;第二阶段按不加 salt 的 key 分区,在每个窗口里把 N 个部分结果加起来。它多花一次 shuffle,但不会让热点分区熔化。N 按每个 key 观察到的负载来选;4 到 16 通常就足够了。

Lambda 与 Kappa:分开掌管新鲜度和正确性

流式路径快,但有它自己看不到的故障模式:晚于允许延迟的事件、去重逻辑的 bug、从一个过期的检查点重启的作业。Lambda 的答案是第二条路径:

  • 一个对原始事件(保存在廉价存储里)的批处理作业,重新计算已完成的窗口,例如每小时一次,并覆盖流式路径的结果。
  • 读取旧桶(计费)的读者始终读到对账后的数据;读取新鲜尾部(看板)的读者读到流式结果,“秒级内大概是对的”。

这样,流式路径可以是错的,而系统仍然最终正确。Kappa 用对同一条流、同一份代码的重放来取代批处理路径:一条管道、一个数据模型,运维更简单,但它要求这条流是事实的来源。在正确性要求严格(涉及金钱)、而批处理计算又便宜的地方,一个显式的批处理层往往值得它的分量。

Warning

生产实践: 一个悄悄出错的聚合,比一个缺失的聚合更糟。无论你选择哪种架构,都要建立对账,并说明一个数字在事后是怎么被修正的,同时留意管道卡住的症状:停止前进的水位线、lag 不断增长的 consumer group,以及无限增长的窗口状态。

如何选择

需求选择原因
按固定间隔计数Tumbling 事件时间窗口最简单的正确聚合
一个滚动指标Sliding 窗口,用能用的最粗步长成本随重叠增长
每个用户的活跃突发Session 窗口以间隔为边界
乱序或移动端的事件事件时间、水位线、允许延迟到达顺序会说谎
晚于延迟界限的事件Side output 加批处理修正不会悄悄丢任何东西
来自重试的重复Event-id 去重加检查点状态逻辑重复和重放重复
倾斜的 key用 salting 做两阶段聚合分散热点分区
严格的正确性(计费)在原始事件上做批处理对账层流看不到它自己的错误

Caution

常见错误: 按处理时间开窗,却把数字称为实时。在积压期间,一阵追赶的突发会落进同一个窗口,趋势就是错的。要按事件时间开窗,并说出你的水位线和允许延迟是多少。

相关