引言
请你想象这么一个场景:
假设双十一大战蔓延到全球,而你是一位全球电商平台的负责人,总部在北京,你想统计一下双十一的销售总额。
此刻,上海的一位用户在零点准时点击支付,下单了一台最新款的iPhone28 Pro max。几乎同时,洛杉矶的一位用户也下了单。
但由于网络或者其它情况,洛杉矶的订单数据,比上海的晚了几秒钟到达北京的数据中心。 现在,你要实时统计"第一分钟"的全球销售额。
那么问题来了:这个"第一分钟",应该以谁为准?
以你北京服务器时间为准?那洛杉矶的订单就可能被划入第二分钟,导致统计错误。
以用户实际下单的时间为准?但系统又怎么知道哪些数据还在路上?
这不是虚构出来的场景,而是所有流处理系统,每天都要面对的问题。
Flink的做法是,它没有强行统一时间,而是提供了三种时间语义,即处理时间、事件事件和摄入时间,分别应对不同场景。
简单来说
- 处理时间:就是只看自己的时间,简单直接,但会因表的速度不同,而得出不一样的结果。
- 事件时间:寻找事件发生的原本时间,结果统一,但需要一套复杂的转换机制。
- 摄入时间:一个折中的处理方案。
至于选择哪一种方式,这其实不是技术问题,而是一个关于准确性和延迟的考量,需要你对业务有更深入的理解: 你追求的是极致的性能,还是结果永远正确?
Flink为什么需要三种时间语义?
现实世界,时间是唯一且固定的。 但在流处理的场景中,想要统一时间,是一件很奢侈的事情。
原因在于三个难题
网络延迟
数据从产生到被处理,需要经过很长的流程: 从终端设备到网关,再到消息队列,最后被计算节点消费。
这就像你下单一件商品,从厂家发到你的手里,必然存在物理上的传输时间。这种延迟是固有的、无法消除的。
如果我们只用处理时间,就相当于只用你收到快递时间为准,来统计快递的运输效率,这必然会导致因果倒置,结论有误。
分布式系统的异步效应
在一个由数百个节点组成的集群中,你无法保证所有机器的系统时钟完全同步。即便有NTP协议,微小的偏差也永远存在。
这说明,"同时"这个概念,在分布式系统中就是一个伪命题。
如果你的程序依赖处理时间,两个原本同时发生的事件,可能因为被分配到两个节点上,就会被判定为先后发生。 你的分析结果,将取决于不可控的硬件时钟精度,这无疑是荒谬的。
对确定性的要求
试想,你昨晚生成的日报显示销售额是1亿,今早因为一些延迟数据到达,重算后变成了1.02亿。你应该相信哪个数字?
如果依赖处理时间,两次计算的结果必然不同,你永远无法得到一个确定的结论。这就像用一把弹性卷尺去测量货物,每次结果都不同,所有的财务核算和业务分析都没有意义。
所以,Flink引入多种时间语义,绝对不是工程师故意炫技,而是应对上述种种困境的解决方案。 它放弃了追求绝对统一的 时间概念,转而接受时间的相对性,并提供不同的视角,来适配不同的业务场景。
这背后的哲学是:当你无法改变世界时,就改变看待世界的方式。
处理时间、事件时间和摄入时间,就是三种世界观。选择哪一种,取决于你的业务是更看重效率,还是真相,或是需要在两者间寻找一个平衡点。
理解了这一层,我们就不再是机械地配置参数,而是在选择业务的切入角度。
三大时间语义
如果Flink是一个流水线工厂的话,那这三种时间语义,就是三种不同类型的计时器,决定了生产的速率和准确度。
处理时间
处理时间是挂在墙上的时钟。
管理员规定:每到整点,我们就打包过去一小时生产的产品。 他从不关心产品上标注的生产日期,只相信墙上的钟。
即使一个标注着 9:50
生产的产品在 10:01
才送到流水线,它也会被算作下一个小时 (10:00-11:00)
的产量。
处理时间的逻辑是:一切以我为准。我只相信数据到我这里的时间,而不是它实际发生的时间。
所以在代码里面,你只需要告诉Flink程序,使用基于系统的时间窗口,这是一种最简单的实现形式。
scss
// 设置使用处理时间(Flink 1.12+ 后,更推荐直接在窗口上指定)
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
dataStream
.keyBy(...)
// 关键:使用'TumblingProcessingTimeWindows',1小时的处理时间滚动窗口
.window(TumblingProcessingTimeWindows.of(Time.hours(1)))
.sum(...);
TumblingProcessingTimeWindows
:这个类就是挂在墙上的时钟。- 每当服务器的系统时间走过一个整点(例如从
9:59:59
跳到10:00:00
),Flink就会立即触发计算上一个小时(9:00-10:00
)内它收到的所有数据。
选择处理时间的好处是,延迟最低,响应最快,性能也是最好的。
代价就是,如果数据有延迟或者乱序的情况,你的Flink程序无法准确识别出来,导致计算结果有误。 也就是说,同样一批数据,消费两次,可能会得出不一样的结果。
处理时间最常用的场景,是做实时监控仪表盘,比如实时显示网站每秒的点击量,追求的是速度,对准确性要求不高,趋势对了就行。
事件时间
事件时间是产品上的出厂日期。
现在,管理员变得严谨了。他要求:每个产品都必须标注准确的出厂日期(事件时间)。 他还有一个聪明的规则(水位线):当我看到一批产品的最新出厂日期是 10:05
时,我就认为 10:00
之前出厂的产品理论上都到齐了(假设我允许最多5分钟的延迟)。这时,他才会打包 10:00
之前的产品。
事件时间的逻辑是:我只认数据自带的时间戳,并且我会耐心等一会儿,以防有延迟的产品。
所以从代码角度来看,实现事件时间需要两个步骤:
- 告诉Flink如何提取时间戳
- 定义你的耐心有多大(水位线策略)
less
// 设置使用事件时间
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
// 从数据中提取时间戳,并生成水位线
DataStream<Event> eventStream = dataStream.assignTimestampsAndWatermarks(
// 水位线策略:允许最多5秒的乱序
WatermarkStrategy
.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, timestamp) -> event.getTimestamp()) // 告诉Flink时间戳在哪
);
eventStream
.keyBy(...)
// 使用'TumblingEventTimeWindows',1小时的事件时间滚动窗口
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.sum(...);
.withTimestampAssigner(...)
:这就是在告诉Flink:这个字段event.getTimestamp()
就是产品的出厂日期forBoundedOutOfOrderness(Duration.ofSeconds(5))
:这就是那个标尺,它设定了管理员的最大耐心------5秒。Flink会自动推算水位线(Watermark
= 最大看到的时间戳 - 5秒)。- 当
水位线
超过10:00时,Flink就认为所有10:00
窗的数据都到了,于是触发口计算。
选择事件时间的好处是,无论数据何时到达,是否乱序,最终计算结果都是准确且可重现的,这是数据计算的黄金标准。
代价是,为了等待延迟的数据,输出结果会慢一些(通常几秒到几十秒),且需要更多资源。
适用场景是,几乎所有要求结果准确的场景,如每日销售额统计、用户行为分析、计费等。
摄入时间
摄入时间是工厂的收货章。
这是一种折中方案。产品进入工厂大门时,管理员在它身上盖一个收货章,记录入库时间。 之后所有的工序,都基于这个入库时间进行,不再关心出厂日期。
当然,它也有一套简单的规则来推进进度。
摄入时间的逻辑是:数据进入Flink时,给它打上一个时间戳,后续就基于这个时间来处理。
在新版Flink中,摄入时间概念逐渐被边缘化,可以看作是事件时间的一种特例(由Flink自动分配时间戳)。
scss
// 设置使用摄入时间
env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime);
// 无需调用.assignTimestampsAndWatermarks,Flink会自动帮你做
dataStream
.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.hours(1))) // 注意:这里仍用EventTime窗口
.sum(...);
摄入时间比处理时间可靠------解决了并行节点时间不一致问题
又比事件时间简单------无需用户自己提取时间戳
但它无法处理在进入Flink之前就已乱序的数据,实际使用场景不多。
小结
时间语义 | 核心API | 优点 | 缺点 |
---|---|---|---|
处理时间 | TumblingProcessingTimeWindows |
延迟极低,简单 | 结果不准确 |
事件时间 | .assignTimestampsAndWatermarks + TumblingEventTimeWindows |
结果准确 | 延迟较高,复杂 |
摄入时间 | 设置特性即可 | 自动处理,一致性好 | 灵活性差,较少使用 |
选择哪一种,取决于你的业务是更需要速度还是准确性。 对于绝大多数流式任务来说,事件时间功能最强,但也是最复杂的。
而想深入理解事件时间的本质,核心在于Flink的水位线机制。
水位线机制
选择了事件时间后,我们立刻会遇到一个现实难题:数据是乱序到达的。Flink 如何知道10:00-11:00
这个窗口的数据已经到齐?
如果永远等下去,所谓的实时处理,就没有意义。
水位线,就是 Flink 给出的解决方案,它平衡了准确性和延迟之间的矛盾。
什么是水位线?
想象你在玩一个拼图游戏,碎片(数据)不是按顺序给你的。 你聘请了一个助手(水位线),他的工作是观察已经收到的碎片。
他偶尔会向你报告:老板,我保证编号100之前的所有碎片都送来了!
这个编号100
就是一个水位线。
你收到这个报告以后,就可以放心地把编号100之前的拼图部分先拼好(触发窗口计算),而不用等所有碎片到齐。
在Flink中
- 水位线(Watermark) 是一个插入到数据流中的特殊信号对象。
- 它带有一个时间戳
T
。 Watermark(T)
的含义是:宣告事件时间已经推进到了时间点 T,并且理论上,不会再有事件时间 ≤ T 的数据到来了。
如何在代码中生成水位线?
生成水位线的策略,主要有两种,对应不同的数据有序程度。
- 有序流
如果数据基本没有乱序,水位线的生成非常简单,它只需要周期性地把当前最大的时间戳发出去。
scss
WatermarkStrategy<Event> strategy = WatermarkStrategy
// 使用'forMonotonousTimestamps',表示时间戳是单调递增的(基本有序)
.<Event>forMonotonousTimestamps()
.withTimestampAssigner((event, timestamp) -> event.getTimestamp());
forMonotonousTimestamps()
:这个策略适用于有序流。它生成的规则是Watermark = 当前最大时间戳 - 0ms
。- 这意味着,一旦收到一个时间戳为
11:00:00
的数据,Flink 就认为11:00:00
之前的数据全都到了。这种方式非常激进,延迟极低,但无法承受任何乱序。
- 乱序流
现实世界的数据总是乱序的。为此,Flink 提供了最常用、最核心的策略:允许固定乱序时间。
scss
WatermarkStrategy<Event> strategy = WatermarkStrategy
// 使用'forBoundedOutOfOrderness',允许有界的乱序,这里设置最大乱序时间为5秒
.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, timestamp) -> event.getTimestamp());
forBoundedOutOfOrderness(Duration.ofSeconds(5))
:这是理解水位线的关键。- 它生成的规则是
Watermark = 当前最大时间戳 - 5秒
。 - 如果当前收到的最大时间戳是
11:00:10
,那么当前的水位线就是11:00:05 (10s - 5s)
。 - 这个
5秒
就是你作为系统设计者,对数据乱序程度的预估。 - 相当于你告诉Flink:我认为数据最多延迟5秒,如果5秒后它还没到,我就当它丢了,不再等它。
水位线如何触发窗口计算?
现在,让我们把窗口机制和水位线联动起来。
scss
dataStream
.assignTimestampsAndWatermarks(strategy) // 分配水位线
.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.seconds(10))) // 10秒的事件时间窗口
.process(...);
假设我们有一个 [10:00:00, 10:00:10)
的窗口。 窗口触发过程如下:
- 数据不断流入,水位线随着当前最大时间戳(减掉5秒延迟)不断更新。
- 当一条数据携带时间戳
10:00:15
到达时,计算出的新水位线为10:00:10
(15s - 5s)。 - Flink 发现水位线
10:00:10
已经 >= 窗口的结束时间10:00:10
。 - Flink 立即判定:这个窗口,所有该来的数据都来了(或者说,我不打算再等更晚的了)。
- 窗口关闭,并开始计算窗口内的所有数据。
这个过程就像考试收卷:考试结束铃响(水位线到达窗口结束时间),老师就收卷(触发计算),无论是否还有考生在拼命写最后几个字(延迟数据)。
迟到数据的处理
那万一真的有数据迟到了(在水位线推进过后才到达),怎么办?
Flink 提供了一个延迟处理机制------Allowed Lateness。
scss
.window(...)
.allowedLateness(Duration.ofMinutes(1)) // 允许窗口在触发后,再额外保留1分钟
.process(...);
.allowedLateness(Duration.ofMinutes(1))
:这允许窗口在第一次被触发(水位线推动)后,并不立即销毁,而是继续保留1分钟
。- 在这1分钟内,任何属于该窗口的延迟数据到来,都会立即触发该窗口的一次额外计算(增量计算),更新结果。这非常适用于不断修正重要指标的场景。
- 1分钟后,窗口才会被真正销毁,之后到来的延迟数据就会被丢弃。
如何选择时间语义
三种时间语义已经介绍完毕,那么面对一个具体的流处理任务,如何做出最明智的选择?
我们可以通过一个简单的流程图来锁定答案。
面对一个新任务,你可以依次问自己以下三个问题:
- 我的计算结果需要完全准确的吗?
- 否 -> 选择处理时间。(例如:实时监控大屏,要求极低延迟)
- 是 -> 进入下一个问题。
- 我的数据是否存在乱序或延迟?
- 否 (极其罕见)-> 你可以使用事件时间 ,并配置有序流水位线策略(
forMonotonousTimestamps
)。 - 是 -> 进入下一个问题。
- 否 (极其罕见)-> 你可以使用事件时间 ,并配置有序流水位线策略(
- 我是否愿意编写额外代码来换取准确性?
- 否 -> 选择摄入时间。
- 是 -> 选择事件时间。
这个决策链的终点,绝大多数都是事件时间,因为大数据场景下,准确性和乱序是常态。
选择了事件时间,最重要的一步就是设置 forBoundedOutOfOrderness
的这个参数。
- 设置太小 (如
1秒
):水位线推进过快。可能会有很多数据被当成延迟数据丢弃,导致计算结果不准确。 - 设置太大 (如
10分钟
):水位线推进过慢。窗口会等待非常久才触发,失去了实时性的意义。 - 最佳实践 :通过观察数据源的最大延迟分布来确定。如果你发现99%的数据延迟都在5秒内,那么将参数设置为
5-10秒
即可。
结语
技术选型的本质,是理解不同工具背后的 trade-off。 Flink 的时间语义不仅仅是API,更是其分布式系统的核心架构理念。
比如水位线机制就是一个经典的工程学权衡问题。它既没有追求绝对正确,也没有追求绝对速度,而是通过一个可配置的参数,允许使用者根据业务场景,在延迟和完整性之间选择一个合适的平衡点。
这是需要我们学习和借鉴的设计理念。
全文完。
如果这篇文章对你有用的话,欢迎点个关注
或者点赞
支持一下,谢谢大家!
后续我将会继续更新大数据相关技术文档,下篇再见。