流式计算分为无状态和有状态两种情况。
1)无状态的计算观察每个独立事件,并根据最后一个事件输出结果。例如,流处理应用程序从传感器接收温度读
数,并在温度超过 90 度时发出警告。
2)有状态的计算则会基于多个事件输出结果。
以下是一些例子。
⚫ 所有类型的窗口。例如,计算过去一小时的平均温度,就是有状态的计算。
⚫ 所有用于复杂事件处理的状态机。例如,若在一分钟内收到两个相差 20 度以上的温度读数,则发出警告,这
是有状态的计算。
⚫ 流与流之间的所有关联操作,以及流与静态表或动态表之间的关联操作,都是有状态的计算。
下图展示了无状态流处理和有状态流处理的主要区别。无状态流处理分别接收每条数据记录(图中的黑条),然
后根据最新输入的数据生成输出数据(白条)。
有状态流处理会维护状态(根据每条输入记录进行更新),并基于最新输入的记录和当前的状态值生成输出记录
(灰条)。
上图中输入数据由黑条表示。无状态流处理每次只转换一条输入记录,并且仅根据最新的输入记录输出结果
(白条)。有状态流处理维护所有已处理记录的状态值,并根据每条新输入的记录更新状态,因此输出记录(灰条)反
映的是综合考虑多个事件之后的结果。
尽管无状态的计算很重要,但是流处理对有状态的计算更感兴趣。事实上,正确地实现有状态的计算比实现
无状态的计算难得多。旧的流处理系统并不支持有状态的计算,而新一代的流处理系统则将状态及其正确性
视为重中之重。
Flink 内置的很多算子,数据源 source,数据存储 sink 都是有状态的,流中的数据都是 buffer records,
会保存一定的元素或者元数据。例如: ProcessWindowFunction 会缓存输入流的数据,ProcessFunction 会保
存设置的定时器信息等等。
在 Flink 中,状态始终与特定算子相关联。总的来说,有两种类型的状态:
⚫ 算子状态(operator state)
⚫ 键控状态(keyed state)
算子状态的作用范围限定为算子任务。这意味着由同一并行任务所处理的所有数据都可以访问到相同的状
态,状态对于同一任务而言是共享的。
算子状态不能由相同或不同算子的另一个任务访问。
Flink 为算子状态提供三种基本数据结构:
⚫ 列表状态(List state)
将状态表示为一组数据的列表。
⚫ 联合列表状态(Union list state)
也将状态表示为数据的列表。它与常规列表状态的区别在于,在发生故障时,或者从保存点(savepoint)启
动应用程序时如何恢复。
⚫ 广播状态(Broadcast state)
如果一个算子有多项任务,而它的每项任务状态又都相同,那么这种特殊情况最适合应用广播状态。
键控状态是根据输入数据流中定义的键(key)来维护和访问的。Flink 为每个键值维护一个状态实例,并将
具有相同键的所有数据,都分区到同一个算子任务中,这个任务会维护和处理这个 key 对应的状态。当任务处理
一条数据时,它会自动将状态的访问范围限定为当前数据的 key。因此,具有相同 key 的所有数据都会访问相同的
状态。
Keyed State 很类似于一个分布式的 key-value map 数据结构,只能用于 KeyedStream(keyBy 算子处理
之后)。
Flink 的 Keyed State 支持以下数据类型:
⚫ ValueState
get 操作: ValueState.value()
set 操作: ValueState.update(T value)
⚫ ListState
ListState.add(T value)
ListState.addAll(List
ListState.get()返回 Iterable
ListState.update(List
⚫ MapState
MapState.get(UK key)
MapState.put(UK key, UV value)
MapState.contains(UK key)
MapState.remove(UK key)
⚫ ReducingState
⚫ AggregatingState
State.clear()是清空操作。
我们可以利用 Keyed State,实现这样一个需求:
检测传感器的温度值,如果连续的两个温度差值超过 10 度,就输出报警。
DataStream> warningStream = dataStream .keyBy("id") .flatMap(new TempIncreaseWarning(10.0));
这里需要实现一个自定义的 RichFlatMapFuction,具体实现如下:
public static class TempIncreaseWarning extends RichFlatMapFunction>{ private Double threshold; TempIncreaseWarning(Double threshold) { this.threshold = threshold; } private ValueState lastTempState; @Override public void open(Configuration parameters) throws Exception { lastTempState = getRuntimeContext().getState(new ValueStateDescriptor ("last-temp", Double.class, Double.MIN_VALUE)); } @Override public void flatMap(SensorReading value, Collector > out) throws Exception { Double lastTemp = lastTempState.value(); lastTempState.update(value.getTemperature()); if( lastTemp != Double.MIN_VALUE ) {// 跟最新的温度值计算差值,如果大于阈值,那么输出报警 Double diff = Math.abs(value.getTemperature() - lastTemp); if (diff > threshold) out.collect( new Tuple3<>(value.getId(), lastTemp, value.getTemperature()) ); } } }
➢ 通过 RuntimeContext 注册 StateDescriptor。
➢ StateDescriptor 以状态 state 的名字和存储的数据类型为参数。
➢ 在 open()方法中创建 state 变量。注意复习之前的 RichFunction 相关知识。
当在分布式系统中引入状态时,自然也引入了一致性问题。一致性实际上是"正确性级别"的另一种说法,也就
是说在成功处理故障并恢复之后得到的结果,与没有发生任何故障时得到的结果相比,前者到底有多正确?举例来
说,假设要对最近一小时登录的用户计数。在系统经历故障之后,计数结果是多少?如果有偏差,是有漏掉的计数
还是重复计数?
在flink中对有状态的流处理做了以下特点说明:
1)有状态的流处理,内部每个[算子]任务都可以有自己的状态。
2)对于流处理器内部来说,所谓的状态一致性,其实就是我们所说的计算结果要保证准确。
3)一条数据不应该丢失,也不应该重复计算。
4) 在遇到故障时可以恢复状态,恢复以后的重新计算,结果应该也是完全正确的。
如图:数据重做后,仍然要保持状态的一致性
在流处理中,一致性可以分为 3 个级别:
⚫ at-most-once (最多一次)
当任务故障时,最简单的做法是什么都不干,既不恢复丢失的状态,也不重播丢失的数据。 这其实是没有正
确性保障的委婉说法——故障发生之后,计数结果可能丢失。
⚫ at-least-once (至少一次)
在大多数的真实应用场景,我们希望不丢失事件。这种类型的保障称为 at-least-once,意思是所有的事件都
得到了处理,而一些事件还可能被处理多次。 这表示计数结果可能大于正确值,但绝不会小于正确值。也就是
说,计数程序在发生故障后可能多算,但是绝不会少算。
⚫ exactly-once (精确一次)
恰好处理一次是最严格的保证,也是最难实现的。恰好处理一次语义不仅仅意味着没有事件丢失,还意味着
针对每一个数据,内部状态仅仅更新一次。
曾经,at-least-once 非常流行。第一代流处理器(如 Storm 和 Samza)刚问世时只保证 at-least-once,原因有二:
⚫ 保证 exactly-once 的系统实现起来更复杂。这在基础架构层(决定什么代表正确,以及 exactly-once 的范围是什么)和实现层都很有挑战性。
⚫ 流处理系统的早期用户愿意接受框架的局限性,并在应用层想办法弥补(例如使应用程序具有幂等性,或者用批量计算层再做一遍计算)。
最先保证 exactly-once 的系统(Storm Trident 和 Spark Streaming)在性能和表现力这两个方面付出了很
大的代价。为了保证 exactly-once,这些系统无法单独地对每条记录运用应用逻辑,而是同时处理多条(一批)记
录,保证对每一批的处理要么全部成功,要么全部失败。这就导致在得到结果前,必须等待一批记录处理结束。因
此,用户经常不得不使用两个流处理框架(一个用来保证 exactly-once,另一个用来对每个元素做低延迟处理),
结果使基础设施更加复杂。
曾经,用户不得不在保证 exactly-once 与获得低延迟和效率之间权衡利弊。Flink 避免了这种权衡。 Flink 的
一个重大价值在于,它既保证了 exactly-once,也具有低延迟和高吞吐的处理能力。
从根本上说,Flink 通过使自身满足所有需求来避免权衡,它是业界的一次意义重大的技术飞跃。尽管这在外
行看来很神奇,但是一旦了解,就会恍然大悟。
目前我们看到的一致性保证都是由流处理器实现的,也就是说都是在 Flink 流处理器内部保证的;而在真实应
用中,流处理应用除了流处理器以外还包含了数据源(例如 Kafka)和输出到持久化系统。
端到端的一致性保证,意味着结果的正确性贯穿了整个流处理应用的始终;每一个组件都保证了它自己的一
致性,整个端到端的一致性级别取决于所有组件中一致性最弱的组件。具体可以划分如下:
⚫ 内部保证 —— 依赖 checkpoint
⚫ source 端 —— 需要外部源可重设数据的读取位置
⚫ sink 端 —— 需要保证从故障恢复时,数据不会重复写入外部系统
而对于 sink 端,又有两种具体的实现方式:幂等(Idempotent)写入和事务性(Transactional)写入。
⚫ 幂等写入
所谓幂等操作,是说一个操作,可以重复执行很多次,但只导致一次结果更改, 也就是说,后面再重复执行
就不起作用了。
⚫ 事务写入
需要构建事务来写入外部系统,构建的事务对应着 checkpoint,等到 checkpoint 真正完成的时候,才把所
有对应的结果写入 sink 系统中。
对于事务性写入,具体又有两种实现方式:预写日志(WAL)和两阶段提交(2PC)。DataStream API 提供
了 GenericWriteAheadSink 模板类和 TwoPhaseCommitSinkFunction 接口,可以方便地实现这两种方式的
事务性写入。
1) 预写日志(Write-Ahead-Log,WAL)
➢ 把结果数据先当成状态保存,然后在收到 checkpoint 完成的通知时, 一次性写入 sink 系统
➢ 简单易于实现,由于数据提前在状态后端中做了缓存,所以无论什么sink 系统,都能用这种方式一批搞定
➢ DataStream API 提供了一个模板类:GenericWriteAheadSink,来实现这种事务性 sink
2) 两阶段提交(Two-Phase-Commit,2PC)
➢ 对于每个 checkpoint,sink 任务会启动一个事务,并将接下来所有接收的数据添加到事务里
➢ 然后将这些数据写入外部 sink 系统,但不提交它们 —— 这时只是“预提交”
➢ 当它收到 checkpoint 完成的通知时,它才正式提交事务,实现结果的真正写入
2PC这种方式真正实现了exactly-once,它需要一个提供事务支持的外部 sink 系统。Flink 提供了
TwoPhaseCommitSinkFunction 接口。
1)外部 sink 系统必须提供事务支持,或者 sink 任务必须能够模拟外部系统上的事务
2)在 checkpoint 的间隔期间里,必须能够开启一个事务并接受数据写入
3)在收到 checkpoint 完成的通知之前,事务必须是“等待提交”的状态。 在故障恢复的情况下,这可能需要一些时间。如果这个时候sink系统关闭事务(例如超时了),那么未提交的数据就会丢失
4)sink 任务必须能够在进程失败后恢复事务
5)提交事务必须是幂等操作
不同 Source 和 Sink 的一致性保证可以用下表说明:
Flink 具体如何保证 exactly-once 呢? 它使用一种 种轻量级快照机制 被称为"检查点"(checkpoint)的特
性,在出现故障时将系统重置回正确状态。下面通过简单的类比来解释检查点的作用。
假设你和两位朋友正在数项链上有多少颗珠子,如下图所示。你捏住珠子,边数边拨,每拨过一颗珠子就给
总数加一。你的朋友也这样数他们手中的珠子。当你分神忘记数到哪里时,怎么办呢? 如果项链上有很多珠子,你
显然不想从头再数一遍,尤其是当三人的速度不一样却又试图合作的时候,更是如此(比如想记录前一分钟三人一
共数了多少颗珠子,回想一下一分钟滚动窗口)。
于是,你想了一个更好的办法:在项链上每隔一段就松松地系上一根有色皮筋, 将珠子分隔开; 当珠子被拨动
的时候,皮筋也可以被拨动; 然后,你安排一个助手,让他在你和朋友拨到皮筋时记录总数。用这种方法,当有人
数错时,就不必从头开 始数。相反,你向其他人发出错误警示,然后你们都从上一根皮筋处开始重数,助 手则会
告诉每个人重数时的起始数值,例如在粉色皮筋处的数值是多少。
Flink 检查点的作用就类似于皮筋标记。数珠子这个类比的关键点是: 对于指定的皮筋而言,珠子的相对位置
是确定的; 这让皮筋成为重新计数的参考点。总状态(珠子的总数)在每颗珠子被拨动之后更新一次,助手则会保存
与每根皮筋对应的检查点状态,如当遇到粉色皮筋时一共数了多少珠子,当遇到橙色皮筋时又是多少。 当问题出
现时,这种方法使得重新计数变得简单。
总结:
状态流应用的一致检查点,其实就是:
1)所有任务的状态,在某个时间点的一份拷贝(一份快照)。而这个时间点,应该是所有任务都恰好处理完一个相同的输入数据的时候。
有状态流应用的一致检查点,其实就是:所有任务的状态,在某个时间点的一份拷贝(一份快照)。而这个时间点,应该是所有任务都恰好处理完一个相同的输入数据的时候。
2)应用状态的一致检查点,是 Flink 故障恢复机制的核心
Flink 检查点的核心作用是确保状态正确,即使遇到程序中断,也要正确。记住这一基本点之后,我们用一个
例子来看检查点是如何运行的。Flink 为用户提供了用来定义状态的工具。
下图表示程序的初始状态: 输入流中的 6 条记录被检查点分割线(checkpoint barrier)隔开,所有的 map 算
子状态均为 0(计数还未开始),按key分组,数据采用累加方式进行最后输出。
所有 key 为 a 的记录将被顶层的 map 算子处理,所有 key 为 b的记录将被中间层的 map 算子处理,所有
key 为 c 的记录则将被底层的 map 算子处理。
上图是程序的初始状态。注意,a、b、c 三组的初始计数状态都是 0,即三个圆柱上的值。ckpt 表示检查点
分割线(checkpoint barriers)。每条记录在处理顺序上严格地遵守在检查点之前或之后的规定,例如
["b",2]在检查点之前被处理,["a",2]则在检查点之后被处理。
当该程序处理输入流中的 6 条记录时,涉及的操作遍布 3 个并行实例(节点、CPU内核等)。那么,检查点该如
何保证 exactly-once 呢?
检查点分割线和普通数据记录类似。它们由算子处理,但并不参与计算,而是会触发与检查点相关的行为。
当读取输入流的数据源(在本例中与 keyBy 算子内联) 遇到检查点屏障时,它将其在输入流中的位置保存到持久化
存储中。
如果输入流来自消息传输系统(Kafka),这个位置就是偏移量。Flink 的存储机制是插件化的,持久化存储可以是分布式文件系统,如 HDFS。下图展示了这个过程。
当 Flink 数据源(在本例中与 keyBy 算子内联)遇到检查点分界线(barrier)时, 它会将其在输入流中的位置
保存到持久化存储中。这让 Flink 可以根据该位置重启。
检查点像普通数据记录一样在算子之间流动。当 map 算子处理完前 3 条数据并收到检查点分界线时,它们会
将状态以异步的方式写入持久化存储,如下图所示。
位于检查点之前的所有记录(["b",2]、["b",3]和["c",1])被 map 算子处理之后的情 况。
此时,持久化存储已经备份了检查点分界线在输入流中的位置(备份操作发生在barrier 被输入算子处理的时
候)。map 算子接着开始处理检查点分界线,并触发将状 态异步备份到稳定存储中这个动作。
当 map 算子的状态备份和检查点分界线的位置备份被确认之后,该检查点操作就可以被标记为完成,如下图
所示。
我们在无须停止或者阻断计算的条件下,在一个逻辑时间点(对应检查点屏障在输入流中的位置)为计算状态拍
了快照。通过确保备份的状态和位置指向同一个逻辑时间点,后文将解释如何基于备份恢复计算,从 而保证
exactly-once。值得注意的是,当没有出现故障时,Flink 检查点的开销极小, 检查点操作的速度由持久化存储的
可用带宽决定。
回顾数珠子的例子: 除了因为数错而需要用到皮筋之外,皮筋会被很快地拨过。
检查点操作完成,状态和位置均已备份到稳定存储中。
输入流中的所有数据记录都已处理完成。值得注意的是,备份的状态值与实际的状态值是不同的。备份反
映的是检查点的状态。
如果检查点操作失败,Flink 可以丢弃该检查点并继续正常执行,因为之后的某一个检查点可能会成功。虽然
恢复时间可能更长,但是对于状态的保证依旧很有力。只有在一系列连续的检查点操作失败之后,Flink 才会抛出
错误,因为这通常预示着发生了严重且持久的错误。
现在来看看下图所示的情况: 检查点操作已经完成,但故障紧随其后。
在这种情况下,Flink 会重新拓扑(可能会获取新的执行资源),将输入流倒回到上一个检查点,然后恢复状态
值并从该处开始继续计算。在本例中,["a",2]、["a",2] 和["c",2]这几条记录将被重播。
下图展示了这一重新处理过程。从上一个检查点开始重新计算,可以保证在剩下的记录被处理之后,得到的
map 算子的状态值与没有发生故障时的状态值一致。
Flink 将输入流倒回到上一个检查点屏障的位置,同时恢复 map 算子的状态值。然后,Flink 从此处开始重新
处理。这样做保证了在记录被处理之后,map 算子的状态值与没有发生故障时的一致。
Flink 检查点算法的正式名称是异步分界线快照(asynchronous barrier snapshotting)。该算法大致基于
Chandy-Lamport 分布式快照算法。
检查点是 Flink 最有价值的创新之一,因为它使 Flink 可以保证 exactly-once, 并且不需要牺牲性能。
3.1示例中讲到异常处理步骤,如何进行重置,我们再来聊聊。
1)重启应用
遇到故障之后,第一步就是重启应用
2)状态重置
⚫ 从 checkpoint 中读取状态,将状态重置
⚫ 从检查点重新启动应用程序后,其内部状态与检查点完成时的状态完全相同
3)数据恢复
⚫ 开始消费并处理检查点到发生故障之间的所有数据
⚫ 这种检查点的保存和恢复机制可以为应用程序状态提供“精确一次” (exactly-once)的一致性,因为所有
算子都会保存检查点并恢复其所有状 态,这样一来所有的输入流就都会被重置到检查点完成时的位置
1)Flink 的检查点算法用到了一种称为分界线(barrier)的特殊数据形式,用来把一条流上数据按照不同的检查
点分开
2)分界线之前到来的数据导致的状态更改,都会被包含在当前分界线所属 的检查点中;而基于分界线之后的数据
导致的所有更改,就会被包含在 之后的检查点中
图例:
A)有两个输入流的应用程序,用并行的两个 Source 任务来读取
B)JobManager 会向每个 source 任务发送一条带有新检查点 ID 的消息,通过这 种方式来启动检查点
C)数据源将它们的状态写入检查点,并发出一个检查点 barrier
D)状态后端在状态存入检查点之后,会返回通知给 source 任务,source 任务就会向 JobManager 确认检查点完成
1)开始对齐
• 分界线对齐:barrier 向下游传递,sum 任务会等待所有输入分区的 barrier 到达
• 对于barrier已经到达的分区,继续到达的数据会被缓存
• 而barrier尚未到达的分区,数据会被正常处理
2)结束对齐
当收到所有输入分区的 barrier 时,任务就将其状态保存到状态后端的检查点中, 然后将 barrier 继续向下游转发
3)下游继续传递
向下游转发检查点 barrier 后,任务继续正常的数据处理
4)Sink确认
• Sink 任务向 JobManager 确认状态保存到 checkpoint 完毕
• 当所有任务都确认已成功将状态保存到检查点时,检查点就真正完成了
我们知道,端到端的状态一致性的实现,需要每一个组件都实现,对于 Flink + Kafka 的数据管道系统
(Kafka 进、Kafka 出)而言,各组件怎样保证 exactly-once 语义呢?
⚫ 内部 —— 利用 checkpoint 机制,把状态存盘,发生故障的时候可以恢复, 保证内部的状态一致性
⚫ source —— kafka consumer 作为 source,可以将偏移量保存下来,如果后续任务出现了故障,恢复的时候
可以由连接器重置偏移量,重新消费数据, 保证一致性
⚫ sink —— kafka producer 作为 sink,采用两阶段提交 sink,需要实现一个TwoPhaseCommitSinkFunction
内部的 checkpoint 机制我们已经有了了解,那 source 和 sink 具体又是怎样运行的呢?接下来我们逐步做一
个分析。
我们知道 Flink 由 JobManager 协调各个 TaskManager 进行 checkpoint 存储, checkpoint 保存在
StateBackend 中,默认 StateBackend 是内存级的,也可以改为文件级的进行持久化保存。
⚫ 第一条数据来了之后,开启一个 kafka 的事务(transaction),正常写入kafka 分区日志但标记为未提交,这
就是“预提交”
⚫ jobmanager 触发 checkpoint 操作,barrier 从 source 开始向下传递,遇到barrier 的算子将状态存入状态后
端,并通知 jobmanager
⚫ sink 连接器收到 barrier,保存当前状态,存入 checkpoint,通知 jobmanager,并开启下一阶段的事务,用
于提交下个检查点的数据
⚫ jobmanager 收到所有任务的通知,发出确认信息,表示 checkpoint 完成
⚫ sink 任务收到 jobmanager 的确认信息,正式提交这段时间的数据
⚫ 外部 kafka 关闭事务,提交的数据可以正常消费了。
所以我们也可以看到,如果宕机需要通过 StateBackend 进行恢复,只能恢复所有确认提交的操作。
1)Flink 还提供了可以自定义的镜像保存功能,就是保存点(savepoints)
2)原则上,创建保存点使用的算法与检查点完全相同,因此保存点可以认为就是具有一些额外元数据的检查点
3)Flink不会自动创建保存点,因此用户(或者外部调度程序)必须明确地触发创建操作
4)保存点是一个强大的功能。除了故障恢复外,保存点可以用于:有计划 的手动备份,更新应用程序,版本迁移,暂停和重启应用,等等
⚫ MemoryStateBackend
内存级的状态后端,会将键控状态作为内存中的对象进行管理,将它们存储在 TaskManager 的 JVM 堆上;
而将 checkpoint 存储在 JobManager 的内存中。
⚫ FsStateBackend
将 checkpoint 存到远程的持久化文件系统(FileSystem)上。而对于本地状态,跟 MemoryStateBackend
一样,也会存在 TaskManager 的 JVM 堆上。
⚫ RocksDBStateBackend
将所有状态序列化后,存入本地的 RocksDB 中存储。
注意:RocksDB 的支持并不直接包含在 flink 中,需要引入依赖:
org.apache.flink flink-statebackend-rocksdb_2.12 1.10.1
设置状态后端为 FsStateBackend,并配置检查点和重启策略:
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); // 1. 状态后端配置 env.setStateBackend(new FsStateBackend("")); // 2. 检查点配置 env.enableCheckpointing(1000); env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE); env.getCheckpointConfig().setCheckpointTimeout(60000); env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500); env.getCheckpointConfig().setMaxConcurrentCheckpoints(1); env.getCheckpointConfig().setPreferCheckpointForRecovery(false); env.getCheckpointConfig().setTolerableCheckpointFailureNumber(0); // 3. 重启策略配置 // 固定延迟重启(隔一段时间尝试重启一次) env.setRestartStrategy(RestartStrategies.fixedDelayRestart( 3, // 尝试重启次数 100000 // 尝试重启的时间间隔,也可 org.apache.flink.api.common.time.Time ));