一、Flink的状态简介

在流处理中,数据是连续不断到来和处理的,每个任务进行计算处理时,可以基于当前数据直接转换得到输出结果;也可以依赖一些其他数据,这些由一个任务维护,并且用来计算输出结果的所有数据,就叫作任务的状态。

状态算子分类

在Flink中,算子任务可以分为无状态和有状态两种情况

无状态算子

flink java api提交任务到yarn flink任务状态_java


无状态的算子任务只需要观察每个独立事件,根据当前输入的数据直接转换输出结果。可以将一个字符串类型的数据拆分开作为元组的输出,也可以将数据做一些计算,比如每个代表数量的字段+1。我们之前讲到的基本转换算子,如map、filter、flatMap,计算时不依赖其他数据,就都属于无状态的算子。

有状态算子

flink java api提交任务到yarn flink任务状态_大数据_02


有状态的算子任务,除当前数据之外,还需要一些其他数据来得到计算结果。这里的其他数据就是所谓的状态,最常见的就是之前到达的数据,或者由之前数据计算出的某个结果。比如,做求和计算时,需要保存之前所有数据的和,这就是状态;窗口算子中会保存已经到达的所有数据,这些也都是它的状态。另外,如果我们希望检索到某种事件模式(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彼此隔离,在这种情况下,状态的访问又会有所不同。

基于上述的想法,我们可以将托管状态分为两类:算子状态和按键分区状态

算子状态

flink java api提交任务到yarn flink任务状态_大数据_03


状态作用范围限定为当前的算子任务实例,也就是只对当前并行子任务实例有效。这就意味着对于一个并行子任务,占据了一个分区,它所处理的所有数据都会访问到相同的状态,该状态对于同一任务而言是共享的。

算子状态可以用在所有算子上,使用的时候起始就跟一个本地变量没什么区别——因为本地变量的作用域也是当前任务实例。在使用时,我们还需进一步实现CheckpointedFunction接口。

按键分区状态

flink java api提交任务到yarn flink任务状态_大数据_04


状态是根据输入流中定义的键(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功能。