这是我参与「第四届青训营 」笔记创作活动的第4天
零、课程回顾
-
流式计算中的动态表:表的数据是实时动态变化的
-
Flink 中的 State 和 Checkpoint 基本原理:流式计算 Stateful 的基础。Flink 本身可以做到有状态的计算,其底层核心就是 State 和 Checkpoint
-
Retract 机制,以及算子如何产生和处理 Retract 数据:使得实时SQL的处理真正做到了实时输出并实时更新纠正之前结果的机制
-
Flink 中如何实现 Exactly-Once 语义:通常使用幂等写、两阶段提交实现端到端Exactly-Once 语义
一、概述
1. 流式计算 VS 批式计算
基本原则:实时性越高,数据价值越高
- 资源模型:批式/流式任务使用的计算资源模型。批式任务定时运行一下计算就结束了;而流式任务需要实时处理和输出,需要对资源长期持有。
2. 批处理
- T+1 架构(T+1 离线计算模型):批处理模型典型的数仓架构,即数据计算时天级别的,当天只能看到前一天的计算结果。
- Hive 或 Spark:通常使用的计算引擎。计算的时候,数据是完全 ready 的,输入和输出都是确定的。
3. 处理时间窗口
利用批计算用更短的周期去模拟实时计算,如果要做到小时级别或比小时更短,应该如何做到呢?—— 实时计算窗口
- 实时计算:处理时间窗口
- 数据实时流动,实时计算,窗口结束时直接发送结果,不需要周期调度任务
4. 处理时间 VS 事件时间
- 处理时间:【数据到达流式系统的时间】数据在流式计算系统中真正处理时所在机器的当前时间
- 事件时间:数据产生的时间,如客户端、传感器、后端代码等上报数据时的时间
在实际情况中,处理时间比事件时间有一定的延迟,因处理二者中间的gap就是难点
5. 事件时间窗口
利用事件时间真实地计算数据所处的窗口结果
- 实时计算:事件时间窗口
- 数据实时进入到真实事件发生的窗口中进行计算,可以有效地处理数据延迟和乱序
6. Watermark
在实时处理的事件时间过程中,如何表达系统中当前衡量的真实数据的时间? —— Watermark
-
在数据中插入一些 watermark 来表示当前的真实时间
- W(11) 的含义:在处理过程中,事件时间已经到了11时刻,如果后面还有比这个时间小的数据到来,可认为这个就是延迟数据,就不应该参与前面的计算。
-
在数据存在乱序时,watermark比较重要,它可用来在 平衡乱序容忍和实时性
- 插入的 W(11) 左边(后边)的数据中没有比11更小的数据。如果有,那么可认为这条数据是一个迟到的数据,进行特殊处理,其并不影响之前的窗口正常计算和结束。
二、Watermark
-
Watermark 定义 :系统认为的 当前真实的事件时间
-
Watermark 的产生:一般是从数据的事件时间来产生,产生策略可以灵活多样,最常见的包括使用当前事件时间的时间减去一个固定的delay,来表示可以可以容忍多长时间的乱序。
- SQL:从原始数据order_time减去5s作为Watermark的数值
CREATE TABLE Orders { user BIGINT, product STRING, order_time TIMESTAMP(3), WHATERMARK FOR order_time AS order_time - INTERVAL '5' SECOND } WITH (...); - DataStream:用事件时间减去固定的20s作为Watermark的数值
WatermarkStrategy .<Tuple2<Long, String>>forBoundedOutOfOrderness(Duration.ofSeconds(20)) .withTimestampAssigner((event, timestamp) -> event.f0);
- SQL:从原始数据order_time减去5s作为Watermark的数值
-
Watermark传递:
- 上下游task之间有数据传输关系的,上游就会将watermark传递给下游;
- 下游收到多个上游传递过来的watermark后,默认会取其中最小值来作为自身的watermark,同时它也会将自己watermark传递给它的下游;
- 经过整个传递过程,最终系统中每一个计算单元就都会实时的知道自身当前的watermark是多少。
NOTE:典型问题
-
怎么观察一个任务中的watermark是多少,是否是正常的
- 一般通过Flink Web UI上的信息来观察当前任务的watermark情况
- 这个问题是生产实践中最容易遇到的问题,大家在开发事件时间的窗口任务的时候,经常会忘记了设置watermark,或者数据太少,watermark没有及时的更新,导致窗口一直不能触发。
-
Per-partition / Per-subtask 生成watermark
- Per-subtask:早期版本的机制。典型问题是如果一个 source subtask 消费多给 partition,那么多个 partition 之间的数据读取可能会加剧乱序程度;
- Per-partition:新版本引入了基于每个 partition 单独的 watermark 生成机制,来避免上面的问题。
-
如果有部分partition/subtask会断流,应该如何处理
- 数据断流是很常见的问题,有时候是业务数据本身就有这种特点,比如白天有数据,晚上没有数据。在这种情况下,watermark默认是不会更新的,因为它要取上游subtask发来的watermark中的最小值。
- 解决方案:IDLE Source
- 我们可以用一种IDLE状态来标记这种subtask,被标记为这种状态的subtask,我们在计算watermark的时候,可以把它先排除在外。这样就可以保证有部分partition断流的时候,watermark仍然可以继续更新。
- 当某个 subtask 断流超过配置 idle 超时时间时,将当前 subtask 置为 idle,并下发一个 idle 的状态给下游。下游在计算自身 watermark 的时候,可以忽略掉当前是 idle 的 subtask
-
算子对于时间晚于watermark的数据的处理
- 对于迟到数据,不同的算子对于这种情况的处理可以有不同的实现,主要是根据算子本身的语义来决定的:
- window对于迟到的数据,默认就是丢弃;
- 双流join,对于迟到数据,可以认为是无法与之前正常数据join上;
- CEP,默认丢弃
- 对于迟到数据,不同的算子对于这种情况的处理可以有不同的实现,主要是根据算子本身的语义来决定的:
小结
- 含义:系统认为的当前真实时间
- 生成:可通过 Watermark Generator
- 传递:取上游所有 subtask 的最小值
- 部分数据断流:Idle Source
- 迟到数据处理:Window 算子是丢弃;Join 算子认为无法与之前正常数据join上
三、Window
1. 基本功能
(1)TUMBLE Window (滚动窗口)
- 根据数据的时间(可以是处理时间,也可以是事件时间)划分到它所属的窗口中
windowStart = timestamp - timestamp % windowSize,这条数据所属的window就是[windowStart, windowStart + windowSize) - 窗口划分:
- 每个key单独划分
- 每条数据只会属于一个窗口
- 窗口触发:Window 结束时间到达时一次性触发
(2)Sliding Window (滑动窗口)
- 窗口划分:
- 每个key单独划分
- 每条数据可能属于多个窗口
- 窗口触发:Window 结束时间到达时一次性触发
(3)Session Window (会话窗口)
前面两个窗口是固定窗口,即数据到来的时候,根据数据的时间就能直接确定它所属的窗口
- 窗口划分:
- 每个key单独划分
- 每条数据会单独划分为一个窗口,如果 window 之间有交集,则会对窗口进行 merge
- 窗口触发:Window 结束时间到达时一次性触发
会话窗口中,数据到来的时候,无法确定该数据最终所属的窗口,它是一个动态merge的过程。一般会设置一个会话的最大的gap,比如10min。
某个key下面来第一条数据的时候,它的window就是 [event_time, event_time + gap),当这个key后面有另一条数据的时候,它会立即产生一个窗口。若这个窗口与之前的窗口有重叠,则将两个窗口进行merge,变成一个更大的窗口,此时取消之前定义的timer,重新注册一个新的timer。
所以会话窗口要求所有的聚合函数都必须有实现merge。
(4)迟到数据处理
- window 中的迟到定义:一条数据到达后,会用 WindowAssigner 给它划分一个 window,一般时间窗口是一个时间区间,如 ,若 划分出来的 window end 比当前的 watermark 值还小,则说明该窗口已触发了计算,这条数据会被认为是迟到数据。
watermark驱动某个窗口触发输出之后,这个窗口如果后面又来了数据,那这种情况就属于是迟到的数据了。(注意,不是数据的时间晚于watermark就算是迟到,而是它所属的窗口已经被触发了,才算迟到)。
- 迟到数据的产生:只有事件时间下才会有迟到的数据。(处理时间中,数据来了直接计算,不会产生迟到数据)
迟到数据默认处理方式:丢弃
Allow lateness
-
这种方式需要设置一个允许迟到的时间。设置之后,窗口到时间后,不会马上清理状态,而是会多保留 allowLateness 的时长,这段时间内如果还有数据到来,这继续之前的状态进行计算。【其修正的过程,其实就是 Retract 的过程,保证一致性语义】
-
适用于:DataStream、SQL
Side Output
- 这种方式需要对迟到数据打一个 tag,把迟到的数据转变成一个单独的流,在 DataStream 上根据这个 tag 获取到迟到数据流,再由用户自己来决定如何处理这部分数据
- 适用于:DataStream
注意:side output 只有在 DataStream 的窗口中才可以用,在 SQL 中目前还没有这种语义,所以暂时只有drop这一个策略。
(5)增量计算 VS 全量计算
增量计算
- 每条数据到来后,直接参与计算(但是还不需要输出结果),window 只存储计算结果。比如计算 sum,状态中只需要存储sum的结果,不需要保存每条数据。
- reduce、aggregate等函数都是典型的增量计算
- SQL 中的聚合只有增量计算
全量计算
- 每条数据到来后,先放到一个buffer中,这个 buffer 会存储到状态里(window 的 state 中),直到窗口触发输出的时候,才把所有数据拿出来统一进行计算
- process 函数就是典型的全量计算
(6)EMIT 触发
- EMIT 触发可以把中间结果输出多次
- EMIT 输出:在 window 没有结束的时候,提前把 window 计算的部分结果输出出来
EMIT触发可以提前把窗口内容输出出来的一种机制。比如我们可以配置一个1天的窗口,每隔5s输出一次它的最新结果,那这样下游就可以更快的获取到窗口计算的结果。
实现
- DataStream 中可以通过自定义Trigger来实现,其结果可以是:
CONTINUEFIRE(触发计算,但是不清理)PURGEFIRE_AND_PURGE
- SQL 也可以通过配置使用:
table.exec.emit.early-fire.enable=truetable.exec.emit.early-fire.delay={time}
(7)Window Offset
window offset的功能就是可以在计算窗口的时候,可以让窗口有一个偏移。
计算window的公式:windowStart = timestamp - (timestamp - offset + windowSize) % windowSize
2. Window 高级优化
以下说的所有的高级优化,都只限于SQL中的window中。在DataStream中,用户需要自己通过代码来实现类似的能力
(1)Mini-batch 优化 => 解决频繁访问状态的问题
简单理解:让算子攒一小批数据,读一次状态就处理一小批数据,再输出,再写回状态
一般来讲,Flink的状态比较大一些都推荐使用rocksdb statebackend,这种情况下,每次的状态访问就都需要做一次序列化和反序列化,开销较大。
为了降低这种开销,可以通过降低状态访问频率的方式来解决,这就是mini-batch最主要解决的问题:即攒一小批数据再进行计算,这批数据每个key的state访问只有一次,这样在单个key的数据比较集中的情况下,对于状态访问可以有效的降低频率,最终提升性能。
每一个算子都需要攒一批自己的数据然后处理输出,那么上下游算子处理的延迟就是 总延迟。所以可利用 watermark 机制或 Checkpoint 中 Barrier 传递的机制,做一个全局的协调。
(2)Local-global => 解决倾斜问题
- 主要是可以降低数据shuffle的量,同时也可以缓解数据的倾斜。
- 将原本的聚合划分成两阶段:
- 第一阶段先做一个local的聚合,这个阶段不需要数据shuffle,是直接跟在上游算子之后进行处理;
- 第二个阶段是要对第一个阶段的结果做一个 merge(这里要求和session window的merge一样。如果存在没有实现merge的聚合函数,那么这个优化就不会生效)。
如上图所示,要对数据做一个 sum,同样颜色的数据表示相同的 group by 的key,这样可以在local agg阶段对它们做一个预聚合;然后到了 global 阶段数据倾斜就消除了。
(3)Distinct状态复用 => 降低状态量
- 一般批里面的引擎都是通过把它优化成aggregate的方式来处理,但是在流式window中不能直接这样优化,否则算子就变成会下发retract的数据
- 对于 count distinct,需要保存所有数据是否出现过这样子的一个映射
如上图所示,每条数据先进行 filter,再进行左边的聚合计算。如果每个指标都单独用一个map来记录每条数据是否出现过,那状态量是很大的。
【让一个指标的 filter 多个条件复用同一份状态】 可以把相同字段的distinct计算用一个map的key来存储,在map的value中,用一个bit vector来实现就可以把各个状态复用到一起了。比如一个bigint有64位,可以表示同一个字段的64个filter,这样整体状态量就可以节省很多了。如下图所示:
(4)滑动窗口 Pane 复用 => 降低滑动窗口的状态存储量
如图所示,在滑动窗口中,同一些数据属于两个窗口(window 1 和 window 2),此时可以开两个窗口,把数据计算两次,然后再把两个窗口的内容都进行计算和更新。但很多时候滑动的窗口和滑动的时间比例比较大(比如一天的窗口,一小时滑动),此时每个窗口需要参与的计算量和开销非常大。
优化思路:
- 不在数据到来的时候直接把最终窗口的结果直接计算出来,而是划分更小的粒度,这个粒度称为 "Pane"
- Pane 不是窗口,但最终能够组合成窗口
将窗口的状态划分成更小粒度的pane,比如3小时窗口、1小时滑动的情况,可以把pane设置为1h,这样每来一条数据,我们就只更新这条数据对应的pane的结果就可以了。当窗口需要输出结果的时候,只需要将这个窗口对应的pane的结果merge起来就可以了。
四、案例分析
1. 计算实时抖音DAU曲线
DAU(Daily Active User): 指的是每天的去重活跃用户数
输出: 每个5s更新一下当前的DAU数值,最终获得一天内的DAU变化曲线
要求: 通过窗口的功能以及相关的优化,开发一个Flink SQL任务,使得可以高效的计算出来上面要求的实时结果。
上述方法的问题:所有数据都需要在一个 subtask 中完成窗口计算,无法并行。
优化
通过两阶段聚合把数据打散,完成第一轮聚合,第二轮聚合只需要对各个分桶的结果求和,解决了倾斜问题。
2. 计算大数据任务的资源使用
问题描述: 大数据任务(特指离线任务)运行时通常会有多个container启动并运行,每个container在运行结束的时候,YARN会负责将它的资源使用(CPU、内存)情况上报。一般大数据任务运行时间从几分钟到几小时不等。
需求: 根据YARN上报的各个container的信息,在任务结束的时候,尽快的计算出一个任务运行所消耗的总的资源。假设前后两个container结束时间差不超过10min。
优化
可以通过会话窗口来讲数据划分到一个window 中,然后再将结果求和即可。