它是什么
流处理是在事件到达的过程中,对一条无界的事件流进行计算,而不是对一个已经结束的文件。每一个流式设计里都会出现四个概念。窗口(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 的重试,以及处理器从检查点重启。三个部分合起来防止重复计数:
- 按事件 id 去重,在处理器的状态里做,在事件进入任何窗口之前。它处理的是逻辑上的重复(同一次点击被发送了两次)。
- 检查点屏障。 处理器定期原子地为自己的输入位置(日志 offset)、所有在途的窗口状态和输出位置做快照。崩溃之后,它从最近的快照重启,只重放其后的事件。
- 事务性 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
常见错误: 按处理时间开窗,却把数字称为实时。在积压期间,一阵追赶的突发会落进同一个窗口,趋势就是错的。要按事件时间开窗,并说出你的水位线和允许延迟是多少。
相关
- 演练:设计广告点击聚合系统 — 在一个涉及计费的管道里用到窗口、exactly-once 与批处理对账。
- 演练:设计 Top K 系统 — 滑动窗口聚合为 heavy hitters 结构提供数据。
- 参考:近似计数与成员判断 — 当精确状态太大时,放进窗口里的那些 sketch。