有状态的计算是流处理框架要实现的重要功能,因为稍复杂的流处理场景都需要记录状态,然后在新流入数据的基础上不断更新状态。 例如以下状态都需要使用流处理的状态功能:
在Flink任务中,Flink的一个算子有多个子任务,每个子任务分布在不同实例上,可以把状态理解为某个算子子任务在其当前实例上的一个变量,变量记录了数据流的历史信息。当新数据流入时,可以结合历史信息来进行计算。实际上,Flink的状态是由算子的子任务来创建和管理的。一个状态更新和获取的流程如下图所示,一个算子子任务接收输入流,获取对应的状态,根据新的计算结果更新状态。一个简单的例子是对一个时间窗口内输入流的某个整数字段求和,那么当算子子任务接收到新元素时,会获取已经存储在状态中的数值,然后将当前输入加到状态上,并将状态数据更新。
对于获取和更新状态数据的逻辑不复杂,但是对于流处理框架还需要解决以下问题:
基于上述要求, 不能将状态直接交由内存管理,因为内存的容量是有限制的,当状态数据稍微大一些时,就会出现OOM问题。 作为一个计算框架,Flink提供了有状态的计算,封装了一些底层的实现,比如状态的高效存储、Checkpoint和Savepoint持久化备份机制、计算资源扩缩容等问题。因为Flink接管了这些问题,开发者只需调用Flink API,这样可以更加专注于业务逻辑。
总而言之,Flink中的状态:
由一个任务维护,并且用来计算某个结果的所有数据,就属于这个任务的状态。
状态可以理解为一个本地变量,可以被任何业务逻辑访问。
当任务失败时,可以使用状态恢复数据。
状态始终与特定的算子相关联。
算子需要预先注册其状态,以便Flink在运行时能够了解算子状态。
StateDescriptor 是所有状态描述符的基类。 Flink 通过 StateDescriptor 定义状态,包括状态的名称,存储数据的类型,序列化器等基础信息。
Flink 中提供了 ListStateDescriptor、MapStateDescriptor、ValueStateDescriptor、AggregatingStateDescriptor、ReducingStateDescriptor 、FoldingStateDescriptor(废弃) 状态描述符供使用。
Flink的状态有两种:托管状态(Managed State)和原始状态(Raw State)。托管状态就是由Flink统一管理的,状态的存储访问、故障恢复和重组等一系列问题都由Flink实现,作为开发人员只要调接口就可以;而原始状态则是自定义的,相当于就是开辟了一块内存,需要开发人员管理,实现状态的序列化和故障恢复。
Managed State | Raw State | |
---|---|---|
状态管理方式 | Flink Runtime托管,自动存储、自动恢复、自动伸缩 | 用户管理 |
状态数据结构 | Flink提供常见的数据结构,如ValueState、ListValue、MapState等 | 字节数据组:byte[] |
应用场景 | 绝大多数Flink算子(通过继承Rich函数或者其他提供的接口类) | 用户自定义算子 |
在Flink任务中,一个算子任务会按照并行度分为多个并行子任务执行,而不同的子任务会占据不同的任务槽(task slot)。由于不同的slot在计算资源上是物理隔离的,所以Flink能管理的状态在并行任务间是无法共享的,每个状态只能针对当前子任务的实例有效。而很多有状态的操作(比如聚合、窗口)都是要先做keyBy进行按键分区的。按键分区之后,任务所进行的所有计算都应该只针对当前key有效,所以状态也应该按照key彼此隔离。在这种情况下,状态的访问方式又会有所不同。基于上述情况,托管状态可分为两类:算子状态和按键分区状态。
状态作用范围限定为当前的算子任务实例,也就是只对当前并行子任务实例有效。对于一个并行子任务,它所处理的所有数据都会访问到相同的状态,状态对于同一任务而言是共享的,如图所示:
算子状态可以用在所有算子上,使用时其实就跟一个本地变量没什么区别——因为本地变量的作用域也是当前任务实例。在使用时,还需进一步实现CheckpointedFunction接口。
按键分区状态(Keyed State)顾名思义,是任务按照键(key)来访问和维护的状态。它的特点非常鲜明,就是以key为作用范围进行隔离。在进行按键分区(keyBy)之后,具有相同键的所有数据,都会分配到同一个并行子任务中;所以如果当前任务定义了状态,Flink就会在当前并行子任务实例中,为每个键值维护一个状态的实例。
一个并行子任务可能会处理多个key的数据,在底层,Keyed State类似于一个分布式的映射(map)数据结构,所有的状态会根据key保存成键值对(key-value)的形式。
当一条数据到来时,任务就会自动将状态的访问范围限定为当前数据的key,从map存储中读取出对应的状态值。所以具有相同key的所有数据都会到访问相同的状态,而不同key的状态之间是彼此隔离的。这种将状态绑定到key上的方式,相当于使得状态和流的逻辑分区一一对应了,不会有别的key的数据来访问当前状态;而当前状态对应key的数据也只会访问这一个状态,不会分发到其他分区去。这就保证了对状态的操作都是本地进行的,对数据流和状态的处理做到了分区一致性。
Keyed State是KeyedStream
上的状态。 状态是根据输入流中定义的键(key)来维护和访问的,所以只能定义在按键分区流(KeyedStream)中,也就keyBy之后才可以使用,如图所示:
无论是Keyed State还是Operator State,Flink的状态都是基于本地的,即每个算子子任务维护着这个算子子任务对应的状态存储,算子子任务之间的状态不能相互访问。
Operator State | Keyed State | |
---|---|---|
适用算子类型 | 可以适用所有算子 | 只适用于KeyedStream上的算子 |
状态分配 | 一个算子子任务对应一个状态 | 每个Key对应一个状态 |
创建和访问方式 | 实现CheckpointedFunction等借口 | 重写Rich Function,通过里面的RuntimeContext访问 |
横向扩展 | 有多种状态重新分配的方式 | 状态随着Key自动在多个算子子任务上迁移 |
支持数据结构 | ListState、BroadcastState等 | ValueState、ListValue、MapState等 |
在 Flink 中,状态始终是与特定算子相关联的;算子在使用状态前首先需要“注册”,其实就是告知Flink当前上下文中定义状态的信息,这样运行时的Flink才能知道算子有哪些状态。
状态的注册,主要是通过“状态描述器”(StateDescriptor)来实现的。状态描述器中最重要的内容,就是状态的名称(name)和类型(type。状态描述器中还可能需要传入一个用户自定义函数(user-defined-function,UDF),用来说明处理逻辑,比如ReduceFunction和AggregateFunction。
值状态(ValueState)
顾名思义,状态中只保存一个“值”(value)。
源码如下:
//接口 T表示泛型,value表示可以是任何具体的数据类型。
public interface ValueState<T> extends State {
T value() throws IOException; //获取当前状态的值
void update(T value) throws IOException;//更新当前状态的值(value即为新的值)
}
在使用时,为了让运行时上下文清楚到底是哪个状态,需要创建一个"状态描述器"提供基本信息(StateDescriptor)
映射状态(MapState)
列表状态(ListState)
归约状态(ReducingState)
聚合状态(AggregatingState)
列表状态(ListState)
联合列表状态(UnionState)
广播状态(BroadcastState)
Flink状态:
Flink状态分类:托管状态和原始状态
Flink中的托管状态(Manage)
算子状态(OperatorState)与按键分区状态(KeyedState)的区别:
算子状态分为:列表状态(ListState)、广播状态(BroadcastState)、联合列表状态(UnionState)。
按键分区状态分为: 值状态(ValueState)、列表状态(ListState)、映射状态MapState)、
归约状态(ReducingState)、聚合状态(AggregatingState)。