实时计算基础:流处理与批处理对比、事件时间与处理时间、水位线机制

这一章,我们聊聊实时计算的几个核心概念。说实话,很多初学者刚接触时都会被这些术语绕晕。我当年也是,看了半天文档,感觉每个字都认识,连起来就不知道在说什么了。

别急,我们一个一个拆开讲。搞懂了这些,后面做特征工程才能得心应手。

流处理 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 条插入一个「水位线标记」。

下面这张图展示了水位线在乱序数据流中的工作流程:

水位线机制在乱序数据流中的工作流程 10:00:00 10:00:05 10:00:10 10:00:15 10:00:20 A B C D E A B C D E 水位线推进 WM1 WM2 WM3 WM4 窗口 [10:00:00, 10:00:10) 窗口 [10:00:10, 10:00:20) 当水位线超过窗口结束时间时,触发窗口计算。迟到的数据(如 D)可能被丢弃或进入侧输出流。

从图中可以看到:数据 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());

这段代码做了三件事:

  1. 告诉 Flink 用事件时间,从数据中提取时间戳
  2. 设置水位线延迟为 5 秒
  3. 按 10 秒的滚动窗口做聚合

总结一下

这一章我们聊了三个核心概念:

  • 流处理 vs 批处理:一个处理无界数据,一个处理有界数据。选哪个取决于你的延迟要求。
  • 事件时间 vs 处理时间:事件时间更准确,但需要处理乱序;处理时间更简单,但结果可能不精确。
  • 水位线:解决乱序问题的核心机制。设得太小丢数据,设得太大增延迟,需要根据业务场景调优。