实时计算基础:流处理与批处理对比、事件时间与处理时间、水位线机制
这一章,我们聊聊实时计算的几个核心概念。说实话,很多初学者刚接触时都会被这些术语绕晕。我当年也是,看了半天文档,感觉每个字都认识,连起来就不知道在说什么了。
别急,我们一个一个拆开讲。搞懂了这些,后面做特征工程才能得心应手。
流处理 vs 批处理:两种思维模式
先问个问题:你平时处理数据,是等数据攒够了再算,还是来一条算一条?
前者就是批处理,后者就是流处理。说白了,一个像「攒够一车再发货」,一个像「快递小哥随叫随到」。
| 维度 | 批处理 | 流处理 |
|---|---|---|
| 数据到达 | 批量、周期性 | 持续、无界 |
| 延迟要求 | 分钟级到小时级 | 毫秒级到秒级 |
| 计算触发 | 定时调度 | 事件到达即触发 |
| 典型框架 | Hive、Spark SQL | Flink、Kafka Streams |
| 状态管理 | 无状态或外部存储 | 内置有状态计算 |
我在项目中遇到过这样一个场景:业务方要求实时统计用户点击量,延迟不能超过 5 秒。如果用批处理,每 10 分钟跑一次,那这 10 分钟内的数据就全丢了。这就是典型的流处理场景。
核心区别一句话总结:批处理处理的是「有界数据」,流处理处理的是「无界数据」。你永远不知道流数据什么时候结束——它可能永远不会结束。
事件时间与处理时间:两个时钟的博弈
这是实时计算里最容易踩坑的地方。我刚开始做 Flink 项目时,就被这两个时间搞出过线上事故。
事件时间(Event Time):数据实际发生的时间。比如用户点击按钮的那一刻,手机上报的时间戳。
处理时间(Processing Time):数据到达计算引擎的时间。也就是你服务器上「现在」的时间。
你想想看,这两个时间能一样吗?
- 用户手机网络不好,数据延迟了 10 秒才到服务器
- 用户离线操作,联网后批量上报
- 消息队列积压,数据排队等待处理
这些情况都会导致事件时间和处理时间不一致。如果你用处理时间做统计,那结果就是错的。
我曾经踩过的坑:有个实时大屏项目,我用处理时间做窗口聚合。结果业务方发现,凌晨 2 点的数据被算到了凌晨 3 点的窗口里。排查了半天,原来是凌晨 2 点有个定时任务把 CPU 打满了,消息队列积压了将近 1 小时。从那以后,我所有实时任务都强制使用事件时间。
那是不是所有场景都用事件时间?也不是。处理时间也有它的优势:延迟最低、实现简单。如果你对结果的精确性要求不高,只求快,那处理时间就够了。
水位线(Watermark):解决乱序问题的关键
好,现在我们知道要用事件时间了。但问题来了:数据是乱序到达的。
比如用户 A 在 10:00:01 点击了按钮,用户 B 在 10:00:00 点击了按钮。但 B 的网络不好,他的数据反而比 A 晚到了 5 秒。这时候你怎么办?
水位线就是用来解决这个问题的。
水位线是一个时间戳,它告诉系统:「在这个时间戳之前的数据,应该都到齐了。如果还没到,我就当它丢了。」
我的理解方式:水位线就像是一个「截止收件」的闹钟。闹钟响了,我就开始统计,不再等迟到的数据了。当然,你也可以设置一个「宽限期」,让闹钟晚几分钟再响。
水位线的生成方式有两种:
- 周期性水位线:每隔一段时间(比如 200ms)生成一次。适合大多数场景。
- 基于事件的水位线:根据数据中的某个特殊标记来生成。比如数据流中每隔 1000 条插入一个「水位线标记」。
下面这张图展示了水位线在乱序数据流中的工作流程:
从图中可以看到:数据 D 的事件时间是 10:00:10,但实际到达时间比 C 还晚。当水位线推进到 10:00:10 时,系统就知道:第一个窗口的数据应该到齐了,可以触发计算了。
水位线的设置策略
水位线设得太小,容易丢数据;设得太大,延迟又高。怎么平衡?
我个人习惯用这个公式:
水位线延迟 = 预估最大乱序时间 + 安全余量
比如你的业务场景中,99% 的数据能在 5 秒内到达,那水位线可以设成 5 秒。如果要求更严格,可以设成 10 秒。
一个小技巧:刚开始不确定乱序程度时,可以先设一个较大的水位线(比如 30 秒),然后通过监控「迟到数据量」来逐步调优。如果迟到数据很少,说明水位线可以收窄。
在 Flink 中,设置水位线的代码大概是这样的:
DataStream<Event> stream = env.addSource(kafkaConsumer);
stream
.assignTimestampsAndWatermarks(
WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, timestamp) -> event.getEventTime())
)
.keyBy(event -> event.getUserId())
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.aggregate(new MyAggregateFunction());
这段代码做了三件事:
- 告诉 Flink 用事件时间,从数据中提取时间戳
- 设置水位线延迟为 5 秒
- 按 10 秒的滚动窗口做聚合
总结一下
这一章我们聊了三个核心概念:
- 流处理 vs 批处理:一个处理无界数据,一个处理有界数据。选哪个取决于你的延迟要求。
- 事件时间 vs 处理时间:事件时间更准确,但需要处理乱序;处理时间更简单,但结果可能不精确。
- 水位线:解决乱序问题的核心机制。设得太小丢数据,设得太大增延迟,需要根据业务场景调优。