一、Flink的状态简介
在流处理中,数据是连续不断到来和处理的,每个任务进行计算处理时,可以基于当前数据直接转换得到输出结果;也可以依赖一些其他数据,这些由一个任务维护,并且用来计算输出结果的所有数据,就叫作任务的状态。
状态算子分类
在Flink中,算子任务可以分为无状态和有状态两种情况
无状态算子
无状态的算子任务只需要观察每个独立事件,根据当前输入的数据直接转换输出结果。可以将一个字符串类型的数据拆分开作为元组的输出,也可以将数据做一些计算,比如每个代表数量的字段+1。我们之前讲到的基本转换算子,如map、filter、flatMap,计算时不依赖其他数据,就都属于无状态的算子。
有状态算子
有状态的算子任务,除当前数据之外,还需要一些其他数据来得到计算结果。这里的其他数据就是所谓的状态,最常见的就是之前到达的数据,或者由之前数据计算出的某个结果。比如,做求和计算时,需要保存之前所有数据的和,这就是状态;窗口算子中会保存已经到达的所有数据,这些也都是它的状态。另外,如果我们希望检索到某种事件模式(event pattern),比如“先有下单行为,后有支付行为”,那么也应该把之前的行为保存下来,这同样属于状态。聚合算子、窗口算子都属于有状态的算子。
有状态算子的一般处理流程为:
- 算子任务接受到上游发来的数据
- 获取当前状态
- 根据业务逻辑进行计算,更新状态
- 得到计算结果,输出发送到下游任务
状态的管理
传统的事务型处理架构中,这种额外的状态数据是保存在数据库中的。而对于实时流处理来说,这样做需要频繁读写外部数据库,如果数据规模非常大肯定就达不到性能要求了。在Flink中的解决方案是,将状态直接保存在内存中保证性能,并通过分布式扩展来提供吞吐量。
在Flink中,每一个算子任务都可以设置并行度,从而可以在不同的slot上并行运行多个实例,我们把它叫做并行子任务。状态既然在内存中,那么就可以认为是子任务实例上的一个本地变量,能够被任务的业务逻辑访问和修改。
在大数据的场景下,我们必须使用分布式框架来做扩展,在低延迟、高吞吐的基础上还有保证容错性,但随之而来会出现一系列问题:
- 状态的访问权限:在Flink上的聚合窗口操作,一般是基于KeyedStream的,数据会按照key的哈希值进行分区,聚合处理的结果也应该是只对当前key有效。然而同一个分区上执行的任务实例,可能会包含多个key的数据,它们同时访问和更改本地变量,就会导致计算结果错误。这时状态并不是单纯的本地变量
- 容错性:故障后的恢复。状态只保存在内存中显然是不够稳定的,我们需要将它持久化保存,做一个备份;在发生故障后可以从这个备份中恢复状态。
- 考虑分布式应用的横向扩展性。当处理的数据量增大时,应该相对地对计算资源扩容,调大并行度,这时就涉及冬奥了状态的重组调整。
Flink作为有状态的大数据流式处理框架,已经帮我们搞定了这一切。Flink有一套完整的状态管理机制,将底层一些核心功能全部封装起来,包括状态的高效存储、访问、持久化保存、故障恢复、资源扩展的调整。我们只需要调用相应的API就可以方便地使用状态,或对应用的容错机制进行配置,从而将更多的精力放在业务逻辑的开发上。
状态的分类
托管状态和原始状态
托管状态是由Flink统一管理的,状态的存储访问、故障恢复、重组等一系列问题都有Flink实现,我们只需要调接口就可以了
原始状态是自定义的,相当于开辟了一块内存,需要我们自己管理,实现状态的序列化和故障恢复
托管状态:
- 由Flink的运行时(Runtime)来托管的
- 在配置容错机制后,状态会自动持久化保存,并在发生故障时自动恢复
- 应用发生横向扩展时,状态也会自动从组分配到所有的字任务实例上
- 对于具体的状态内容,Flink提供了值状态(ValueState)、列表状态(ListState)、映射状态(MapState)、聚合状态(AggregateState)等多种结果,内部支持各种数据类型
- 聚合、窗口等算子中内置的状态,也是托管状态
- 在富函数类中通过上下文来自定义状态,也是托管状态
原始状态:
- 自定义,Flink不会对状态进行任何自动操作,也不知道状态的具体数据类型,当做最原始的字节数组来存储
- 需要花费大量的精力来处理状态的管理和维护,一般情况下不推荐使用
算子状态和按键分区状态
接下来我们的重点就是托管状态
在Flink中,一个算子任务会按照并行度分为多个并行子任务执行,而不同的子任务会占据不同的任务槽(task slot)。由于不同的slot在计算资源上是物理隔离的,所以Flink能管理的状态在并行任务间是无法共享的,每个状态只能针对当前子任务的实例有效。
很多有状态的操作(比如聚合、窗口)都是先做keyBy进行按键分区的。按键分区之后,任务所进行的所有计算都应该只针对当前key有效,所以状态也应该按照key彼此隔离,在这种情况下,状态的访问又会有所不同。
基于上述的想法,我们可以将托管状态分为两类:算子状态和按键分区状态
算子状态
状态作用范围限定为当前的算子任务实例,也就是只对当前并行子任务实例有效。这就意味着对于一个并行子任务,占据了一个分区,它所处理的所有数据都会访问到相同的状态,该状态对于同一任务而言是共享的。
算子状态可以用在所有算子上,使用的时候起始就跟一个本地变量没什么区别——因为本地变量的作用域也是当前任务实例。在使用时,我们还需进一步实现CheckpointedFunction接口。
按键分区状态
状态是根据输入流中定义的键(key)来维护和访问的,所以只能定义在按键分区流(KeyedStream)中,也就是keyBy之后才可以使用
聚合算子必须在keyBy之后才能使用,就是因为聚合的几个是以Keyed State的形式保存的。另外,也可以通过富函数类来自定义Keyed State,故只要提供了富函数类接口的算子,都可以使用Keyed state,即使是map、filter这样无状态的基本转换算子,也可以通过富函数类给它们追加Keyed State,或者实现CheckpointedFunction接口来定义Operator State。从这个角度来讲,Flink中所有的算子都可以是有状态的。
无论是Keyed State还是Operator State,都是在本地实例上维护的,也就是说每个并行子任务维护着对应的状态,算子的子任务状态不共享。
二、状态的使用
按键分区状态
在实际应用中,一般都需要将数据按照某个key进行分区,然后再进行计算处理;所以最为常见的状态类型就是Keyed State。之前介绍到keyBy之后的聚合、窗口计算,算子持有的状态,都是Keyed State.
另外,我们还可以通过富函数类对转换算子进行扩展、实现自定义功能,比如RichMapFunction、RichFilterFunction。在富函数中,我们可以调用getRuntimeContext()获取当前的运行时上下文(RuntimeContext),进而获取到访问状态的句柄,这种富函数中自定义的状态也是Keyed State。
基本概念和特点
按键分区状态(Keyed State)是任务按照键(key)来访问和维护的状态,以key为作用范围进行隔离。
在进行按键分区(keyBy)之后,具有相同键的所有数据,都会分配到同一个并行子任务中;如果当前任务定义了状态,Flink就会在当前并行子任务实例中,为每个键值维护一个状态的实例。于是当前任务就会为分配来的所有数据,按照key维护和处理对应的状态。
一个并行子任务可能会处理多个key的数据,所以Flink需要对Keyed State进行一些特殊优化。在底层,Keyed State类似于一个分布式的映射(map)数据结构,所有的状态会根据key保存成键值对(key-value)的形式。当一条数据到来时,任务就会自动将状态的访问范围限定为当前数据的key,从map存储中读取出对应的状态值。故具有相同key的所有数据都会到访问相同的状态,而不同key的状态之间食彼此隔离的。
这种将状态绑定到key上的方式,相当于使得状态和流的逻辑一一对应了:不会有别的key的数据来访问当前状态,而当前状态对应key的数据也只会访问这一个状态,不会分发到其他分区去,这就保证了对状态的操作都是本地进行的,对数据流和状态的处理做道了分区的一致性。
在应用的并行度改变时,状态也需要进行重组。不同key对应的Keyed State可以进一步组成所谓的键组(key groups),每一组都对应着一个并行子任务,键组是Flink重新分配Keyed State的单元,键组的数量就等于定义的最大并行度。当算子并行度发生改变时,Keyed State就会按照当前的并行度重新平均分配,保证运行时各个子任务的负载相同。
注意:使用Keyed State必须基于KeyedStream,没有进行keyBy分区的DataStream,即使转换算子实现了对应的富函数类,也不能通过运行时上下文访问Keyed State
支持的结构类型
实际应用中,需要保存为状态的数据会有各种各样的类型,有时还需要复杂的集合类型,比如列表(List)和映射(Map)。对于这些常见的用法,Flink的按键分区状态(Keyed State)提供了足够的支持
支持的结构类型
值状态(ValueState)
状态中只保存一个值(value),ValueState本身是一个接口,源码中定义如下
public interface ValueState<T> extends State {
T value() throws IOException;
void update(T value) throws IOException;
}
T是泛型,表示状态的数据内容可以适任何具体的数据类型。
- T value():获取当前状态的值
- update(T value):对状态进行更新,传入的参数value就是要覆写的状态值
在具体使用时,为了让运行时上下文清楚到底是哪个状态,我们还需要创建一个状态描述器(StateDescriptor)来提供状态的基本信息。
public ValueStateDescriptor(String name, Class<T> typeClass) {
super(name, typeClass, null);
}
这里需要传入状态的名称和类型,有了这个描述器,运行时环境就可以获取到状态的控制句柄(handler)
列表状态(ListState)
将需要保存的数据,以列表(List)的形式组织起来。在ListState接口中同样有一个类型参数T,表示列表中数据的类型。ListState也提供了一系列的方法来操作状态,使用方式与一般的List非常相似
- Iterableget():获取当前的列表状态,返回的是一个可迭代类型Iterable
- update(Listvalues):传入一个列表values,直接对状态进行覆盖
- add(T value):在状态列表中添加一个元素value
- addAll(List values):向列表中添加多个元素,以列表values形式传入
类似地,ListState的状态描述器就叫做ListStateDescriptor,用法跟ValueStateDescriptor完全一致
映射状态(MapState)
将一些键值对(key-value)作为状态整体保存起来,可以认为就是一组key-value映射的列表。对应的MapState<UK,UV>接口中,就会有UK、UV两个泛型,分别表示保存的key、value类型。同样,MapState提供了操作映射状态的方法,查询对应的value值:
- UV get(UK key):传入一个key作为参数,查询对应的value值
- put(UK key,UV value):传入一个键值对,更新key对应的value值
- putAll(Map<UK,UV>map):将传入的映射map中所有的键值对,全部添加到映射状态中
- remove(UK key):将指定key对应的键值对删除
- boolean contains(UK key):判断是否存在指定的key,返回一个boolean值,另外,MapState也提供了获取整个映射相关信息的方法
- Iterable<Map,Entry<UK,UV>>entries():获取映射状态中所有的键值对
- Iterablekeys():获取映射状态中所有的键(key),返回一个可迭代Iterable类型
- Iterablevalues():获取映射状态中所有的值(value),返回一个可迭代Iterable类型
- boolean isEmpty():判断映射是否为空,返回一个boolean值
归约状态(ReducingState)
类似于值状态(Value),需要对添加进来的所有数据进行归约,将归约聚合之后的值作为状态保存下来。ReducintState这个接口调用的方法类似于ListState,只不过它保存的只是一个聚合值,当调用add()方法时,直接把新数据和之前的状态进行归约,并用得到的结果更新状态
归约的逻辑定义,是在归约状态描述器(ReducingStateDescriptor)中,通过传入一个归约函数(ReduceFunction)来实现的。
public ReducingStateDescriptor(
String name, ReduceFunction<T> reduceFunction, Class<T> typeClass) {...}
聚合状态(AggregatingState)
聚合状态也是一个值,用来保存添加进来的所有数据的聚合结果,调用add()方法添加元素时,会直接使用指定的AggregateFunction进行聚合并更新状态。聚合逻辑是在描述器中传入一个更加一般化的聚合函数(AggregateFunction)
三、状态的实现
状态(State)
在Flink中,状态始终是与特定算子相关联的,算子在使用状态前首先需要“注册”,告诉Flink当前上下文中定义状态的信息。
状态的注册,主要是通过状态描述器(StateDescriptor)来实现的,状态描述器中最重要的内容,就是状态的名称和类型,还可以传入一个用户自定义函数UDF,用来说明处理逻辑,比如前面提到的ReduceFunction、AggregateFunction。
public class StateTest {
public static void main(String[] args) throws Exception {
// 1、创建流式执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
.assignTimestampsAndWatermarks((AssignerWithPeriodicWatermarks<Event>) WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(10))
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return 0;
}
}));
stream.keyBy(data -> data.user)
.flatMap(new MyFlatMap()).print();
env.execute();
}
// 实现自定义的FlatMapfunction、
public static class MyFlatMap extends RichFlatMapFunction<Event,String> {
// 定义状态
private transient ValueState<Event> myValueState;
private transient ListState<Event> myListState;
private transient MapState<String,Long> myMapState;
private transient ReducingState<Event> myReducingState;
private transient AggregatingState<Event,String> myAggregatingState;
@Override
public void open(Configuration parameters) throws Exception {
myValueState = getRuntimeContext().getState(new ValueStateDescriptor<Event>("my-value",Event.class));
myListState = getRuntimeContext().getListState(new ListStateDescriptor<Event>("my-list",Event.class));
myMapState = getRuntimeContext().getMapState(new MapStateDescriptor<String, Long>("my-value",String.class,Long.class));
myReducingState = getRuntimeContext().getReducingState(new ReducingStateDescriptor<Event>("my-value", new ReduceFunction<Event>() {
@Override
public Event reduce(Event value1, Event value2) throws Exception {
return new Event(value1.user,value1.url,value2.timeStamp);
}
}, Event.class));
myAggregatingState = getRuntimeContext().getAggregatingState(new AggregatingStateDescriptor<Event, Long, String>("my-value", new AggregateFunction<Event, Long, String>() {
@Override
public Long createAccumulator() {
return 0L;
}
@Override
public Long add(Event value, Long accumulator) {
return accumulator + 1;
}
@Override
public String getResult(Long accumulator) {
return "count: " + accumulator;
}
@Override
public Long merge(Long a, Long b) {
return a + b;
}
}, Long.class));
}
@Override
public void flatMap(Event value, Collector<String> out) throws Exception {
if(myValueState.value() == null){
myValueState.update(value);
}
myListState.add(value);
myMapState.put(value.user,myMapState.get(value.user) == null ? 0 : myMapState.get(value.user) + 1);
myReducingState.add(value);
myAggregatingState.add(value);
}
@Override
public void close() throws Exception {
myValueState.clear();
}
}
}
状态生存时间(TTL)
在实际应用中,很多状态会随着时间的推移逐渐增长,如果不加以限制,最终就会导致存储空间的耗尽。一个优化的思路是直接在代码中调用clear()方法去清除状态,但是有时候我们的逻辑要求不能直接清除,这时就需要配置一个状态的生存时间(time-to-live,TTL),当状态在内存中存在的时间超出这个值时,就将它清除
具体实现上,如果用一个进程不停地扫描所有状态看是否过期,显然呼占用大量资源做无用功。状态的失效其实不需要立即删除,我们可以给状态附件一个属性,也就是状态的失效时间。状态创建的时候,设置 失效 = 当前时间 + TTL。之后如果有对状态的访问和修改,我们可以再对失效时间进行更新,当设置的清除条件被触发时(比如,状态被访问的时间,或者每隔一段时间扫描一次失效状态),就可以判断状态是否失效,从而进行清除。
配置状态的TTL时,需要创建一个StateTtlConfig配置对象,然后调用状态描述器的enableTimeToLive()方法启动TTL功能。