Flink状态编程

Flink处理机制的核心,就是“有状态的流式计算”,在简单聚合、窗口聚合、处理函数的应用,都会有状态的身影出现。在Flink这样的分布式系统中,我们不仅需要定义出状态在任务并行时的处理方式,还需要考虑如何持久化保存、以便发生故障时能正确地恢复,这就需要一套完整的管理机制来处理所有状态。

接下来,将从状态概念入手,详细介绍Flink中的状态分类、状态的使用、持久化及状态后端的配置

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)等多种结果,内部支持各种数据类型
  • 聚合、窗口等算子中内置的状态,也是托管状态
  • 在富函数类中通过上下文来自定义状态,也是托管状态

原始状态:

  • 自定义,Flin不会对状态进行任何自动操作,也不知道状态的具体数据类型,当做最原始的字节数组来存储
  • 需要花费大量的精力来处理状态的管理和维护,一般情况下不推荐使用
算子状态和按键分区状态

接下来我们的重点就是托管状态

在Flink中,一个算子任务会按照并行度分为多个并行子任务执行,而不同的子任务会占据不同的任务槽(task slot)。由于不同的slot在计算资源上是物理隔离的,所以Flink能管理的状态在并行任务间是无法共享的,每个状态只能针对当前子任务的实例有效。

很多有状态的操作(比如聚合、窗口)都是先做keyBy进行按键分区的。按键分区之后,任务所进行的所有计算都应该只针对当前key有效,所以状态也应该按照key彼此隔离,在这种情况下,状态的访问又会有所不同。

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

(1)算子状态

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

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

(2)按键分区状态

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

代码实现
整体介绍

在Flink中,状态始终是与特定算子相关联的,算子在使用状态前首先需要“注册”,告诉Flink当前上下文中定义状态的信息。

状态的注册,主要是通过状态描述器(StateDescriptor)来实现的,状态描述器中最重要的内容,就是状态的名称和类型,还可以传入一个用户自定义函数UDF,用来说明处理逻辑,比如前面提到的ReduceFunction、AggregateFunction。

以ValueState为例,可以定义值状态描述器如下:

ValueStateDescriptor<Long> descriptor = new ValueStateDescriptor<>(
    "my state", // 状态名称
    Types.LONG // 状态类型
);

定义了一个名称为“my state”的长整型ValueState的描述器

代码中完整的操作是,首先定义出状态描述器的,然后调用getRuntimeContext()方法获取运行时上下文,继而调用RuntimeContext的获取状态的方法,将状态描述器传入,就可得到对于的状态。

状态的访问需要获取运行时上下文,这只能通过富函数类得到,自定义的Keyed State只能在富函数中使用。底层的处理函数本身继承了AbstractRichFunction抽象类,所以也可以使用。

富函数中,调用getRuntimeContext()方法获取到运行时上下文之后,RuntimeContext有以下几个获取状态的方法:

ValueState<T> getState(ValueStateDescriptor<T>)
MapState<UK, UV> getMapState(MapStateDescriptor<UK, UV>)
ListState<T> getListState(ListStateDescriptor<T>)
ReducingState<T> getReducingState(ReducingStateDescriptor<T>)
AggregatingState<IN, OUT> getAggregatingState(AggregatingStateDescriptor<IN,ACC, OUT>)

对于不同结构类型的状态,只要传入对应的描述器,调用对应的方法就可以了。获取到状态对象之后,可以调用它们各自的方法进行读写操作,另外所有类型的状态都有一个clear()方法,用于清除当前状态

import org.apache.flink.api.common.functions.RichFlatMapFunction;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.util.Collector;

public class MyFlatMapFunction extends RichFlatMapFunction<Long,String > {
    //声明状态
    private transient ValueState<Long> state;

    @Override
    public void open(Configuration parameters) throws Exception {
        //在open生命周期方法中获取状态
        ValueStateDescriptor<Long> descriptor = new ValueStateDescriptor<Long>(
                "my state", //状态名称
                Types.LONG //状态类型
        );
        state = getRuntimeContext().getState(descriptor);
    }

    @Override
    public void flatMap(Long value, Collector<String> out) throws Exception {
        //访问状态
        Long currentState = state.value();
        currentState += 1; //状态数值 + 1
        //更新状态
        state.update(currentState);
        if (currentState >= 100){
            out.collect("state: " + currentState);
            state.clear(); //清空状态
        }

    }
}

因为RichFlatmapFunction中的flatmap()是每来一条数据都会调用一次,所以我们不应该在这里调用运行时上下文getState()方法,而是在生命周期方法open()中获取状态对象。另外,我们获取到的状态对象名称state不应该在open中声明,否则flatmap()例就访问不到了。故在外面将其定义为类得属性,就可以在不同的方法中通用,而在外部不能直接获取状态,因为编译时无法拿到运行时上下文。故最终方案变为:外部声明状态对象,open生命周期方法中通过运行时上下文获取状态

注意:这种方式定义的都是Keyed State,对于每个key都会保存一份状态实例。对状态进行读写操作时,获取到的状态跟当前输入数据的key有关;只有相同key的数据,才会操作同一个状态,不同key的数据访问到的状态值是不同的,而且clear()方法只会清除当前key对应的状态。

状态不一定存储在内存中,也可以放在磁盘或其他地方,具体的位置由一个可配置的组件管理,这个组件叫做状态后端(State Backend)

值状态(ValueState)

例子:使用用户id来进行分流,分别统计每个用户的pv数据,注册一个定时器,每隔一段时间发送pv的统计结果,这样对下游算子的压力不至于太大。定义一个用来保存定时器时间戳的值状态变量,当定时器触发并向下游发送数据以后,便清空存储定时器时间戳的状态变量,若新数据到来发现并没有定时器,便注册新的定时器,将定时器的时间戳保存在状态变量中

import com.yingzi.chapter05.Source.ClickSource;
import com.yingzi.chapter05.Source.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;

public class PeriodicPvExample {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override
                            public long extractTimestamp(Event element, long recordTimestamp) {
                                return element.timestamp;
                            }
                        }));
        stream.print("input");

        //统计每个用户的pv,隔10s输出一次结果
        stream.keyBy(data -> data.user)
                .process(new PeriodicPvResult())
                .print();

        env.execute();
    }

    public static class PeriodicPvResult extends KeyedProcessFunction<String, Event, String> {
        //定义两个状态,保存当前pv值,以及定时器时间戳
        ValueState<Long> countState;
        ValueState<Long> timerTsState;

        @Override
        public void open(Configuration parameters) throws Exception {
            countState = getRuntimeContext().getState(new ValueStateDescriptor<Long>("count", Long.class));
            timerTsState = getRuntimeContext().getState(new ValueStateDescriptor<Long>("timerTs", Long.class));
        }

        @Override
        public void processElement(Event value, Context ctx, Collector<String> out) throws Exception {
            //更新count值
            Long count = countState.value();
            if (count == null) {
                countState.update(1L);
            } else {
                countState.update(count + 1);
            }
            //注册定时器
            if (timerTsState.value() == null) {
                ctx.timerService().registerEventTimeTimer(value.timestamp + 10 * 1000L);
                timerTsState.update(value.timestamp + 10 * 1000L);
            }
        }

        @Override
        public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
            out.collect(ctx.getCurrentKey() + " pv: " + countState.value());

            timerTsState.clear();
        }
    }
}
列表状态(ListState)

在Flink SQL中,支持两条流的全量Join,语法如下:

SELECT * FROM A INNER JOIN B WHERE A.id = B.id;

这样一条SQL语句要慎用,因为Flink会将A流和B流的所有数据都保存下来,然后进行Join,此处我们可以用列表状态变量来实现一下这个SQL语句的功能。

import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.state.ListState;
import org.apache.flink.api.common.state.ListStateDescriptor;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.api.java.tuple.Tuple3;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.co.CoProcessFunction;
import org.apache.flink.util.Collector;

public class TwoStreamFullJoinExample {

    public static void main(String[] args) throws Exception {

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        SingleOutputStreamOperator<Tuple3<String, String, Long>> stream1 = env.fromElements(
                Tuple3.of("a", "stream-1", 1000L),
                Tuple3.of("b", "stream-1", 2000L)
        ).assignTimestampsAndWatermarks(WatermarkStrategy.<Tuple3<String, String, Long>>forMonotonousTimestamps()
                .withTimestampAssigner(new SerializableTimestampAssigner<Tuple3<String, String, Long>>() {
                    @Override
                    public long extractTimestamp(Tuple3<String, String, Long> element, long recordTimestamp) {
                        return element.f2;
                    }
                }));

        SingleOutputStreamOperator<Tuple3<String, String, Long>> stream2 = env.fromElements(
                Tuple3.of("a", "stream-2", 3000L),
                Tuple3.of("b", "stream-2", 4000L)
        ).assignTimestampsAndWatermarks(WatermarkStrategy.<Tuple3<String, String, Long>>forMonotonousTimestamps()
                .withTimestampAssigner(new SerializableTimestampAssigner<Tuple3<String, String, Long>>() {
                    @Override
                    public long extractTimestamp(Tuple3<String, String, Long> element, long recordTimestamp) {
                        return element.f2;
                    }
                }));

        stream1.keyBy(data -> data.f0)
                .connect(stream2.keyBy(data -> data.f0))
                .process(new CoProcessFunction<Tuple3<String, String, Long>, Tuple3<String, String, Long>, String>() {
                    private ListState<Tuple3<String ,String ,Long>> stream1ListStae;
                    private ListState<Tuple3<String ,String ,Long>> stream2ListStae;

                    @Override
                    public void open(Configuration parameters) throws Exception {
                        super.open(parameters);
                        stream1ListStae = getRuntimeContext().getListState(new ListStateDescriptor<Tuple3<String ,String ,Long>>("stream1-list", Types.TUPLE(Types.STRING,Types.STRING)));
                        stream2ListStae = getRuntimeContext().getListState(new ListStateDescriptor<Tuple3<String ,String ,Long>>("stream2-list", Types.TUPLE(Types.STRING,Types.STRING)));
                    }

                    @Override
                    public void processElement1(Tuple3<String, String, Long> value, Context ctx, Collector<String> out) throws Exception {
                        stream1ListStae.add(value);
                        for (Tuple3<String, String, Long> stream2 : stream2ListStae.get()) {
                            out.collect(value + " => " + stream2);
                        }
                    }

                    @Override
                    public void processElement2(Tuple3<String, String, Long> value, Context ctx, Collector<String> out) throws Exception {
                        for (Tuple3<String, String, Long> stream1 : stream1ListStae.get()) {
                            out.collect(stream1 + " => " + value);
                        }
                    }
                }).print();

        env.execute();
    }
}
映射状态(MapState)

映射状态的用法和Java中的HashMap很相似。在这里可以通过MapState的使用来探索一下窗口的底层实现,使用映射状态来完整模拟窗口的功能。

例子:此处模拟一个滚动窗口,计算每一个url在每一个窗口中的pv数据

import com.yingzi.chapter05.Source.ClickSource;
import com.yingzi.chapter05.Source.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.state.MapState;
import org.apache.flink.api.common.state.MapStateDescriptor;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;

import java.sql.Timestamp;

public class FakeWindowExample {

    public static void main(String[] args) throws Exception {

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override
                            public long extractTimestamp(Event element, long recordTimestamp) {
                                return element.timestamp;
                            }
                        }));

        //统计每10s窗口内,每个rul的pv
        stream.keyBy(data -> data.url)
                .process(new FakeWindowResult(1000L))
                .print();


        env.execute();
    }

    public static class FakeWindowResult extends KeyedProcessFunction<String, Event, String> {
        //定义属性,窗口长度
        private Long windowSize;

        public FakeWindowResult(Long windowSize) {
            this.windowSize = windowSize;
        }

        //声明状态,用map保存pv值(窗口start,count)
        MapState<Long, Long> windowPvMapState;

        @Override
        public void open(Configuration parameters) throws Exception {
            windowPvMapState = getRuntimeContext().getMapState(new MapStateDescriptor<Long, Long>("window-pv", Long.class, Long.class));
        }

        @Override
        public void processElement(Event value, Context ctx, Collector<String> out) throws Exception {
            //每来一条数据,就根据时间戳判断属于哪个窗口
            Long windowStart = value.timestamp / windowSize * windowSize;
            Long windowEnd = windowStart + windowSize;

            //注册 end - 1的定时器,窗口触发计算
            ctx.timerService().registerEventTimeTimer(windowEnd - 1);

            //更新状态中的pv值
            if (windowPvMapState.contains(windowStart)) {
                Long pv = windowPvMapState.get(windowStart);
                windowPvMapState.put(windowStart, pv + 1);
            } else {
                windowPvMapState.put(windowStart, 1L);
            }
        }
        //定时器触发,直接输出统计的pv结果

        @Override
        public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
            Long windowEnd = timestamp + 1;
            Long windowStart = windowEnd - windowSize;
            Long pv = windowPvMapState.get(windowStart);
            out.collect("url: " + ctx.getCurrentKey() + " 访问量 " + pv + " 窗口 " + new Timestamp(windowStart) + " ~ " + new Timestamp(windowEnd));

            //模拟窗口的销毁,清除map中的key
            windowPvMapState.remove(windowStart);
        }
    }
}
聚合状态(AggregatingState)

例子:对用户点击事件流每5个数据统计一次平均时间戳。这是一个类似计数窗口(CountWindow)求平均值的计算,这里可以使用一个又聚合状态的RichFlatMapFunction来实现

import com.yingzi.chapter05.Source.ClickSource;
import com.yingzi.chapter05.Source.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.AggregateFunction;
import org.apache.flink.api.common.functions.RichFlatMapFunction;
import org.apache.flink.api.common.state.AggregatingState;
import org.apache.flink.api.common.state.AggregatingStateDescriptor;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.util.Collector;

import java.sql.Timestamp;

public class AverageTimestampExample {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override
                            public long extractTimestamp(Event element, long
                                    recordTimestamp) {
                                return element.timestamp;
                            }
                        })
                );

        // 统计每个用户的点击频次,到达 5 次就输出统计结果
        stream.keyBy(data -> data.user)
                .flatMap(new AvgTsResult())
                .print();
        env.execute();
    }

    public static class AvgTsResult extends RichFlatMapFunction<Event, String> {
        // 定义聚合状态,用来计算平均时间戳
        AggregatingState<Event, Long> avgTsAggState;
        // 定义一个值状态,用来保存当前用户访问频次
        ValueState<Long> countState;

        @Override
        public void open(Configuration parameters) throws Exception {
            avgTsAggState = getRuntimeContext().getAggregatingState(new
                    AggregatingStateDescriptor<Event, Tuple2<Long, Long>, Long>(
                    "avg-ts",
                    new AggregateFunction<Event, Tuple2<Long, Long>, Long>() {
                        @Override
                        public Tuple2<Long, Long> createAccumulator() {
                            return Tuple2.of(0L, 0L);
                        }

                        @Override
                        public Tuple2<Long, Long> add(Event value, Tuple2<Long, Long> accumulator) {
                            return Tuple2.of(accumulator.f0 + value.timestamp, accumulator.f1 + 1);
                        }

                        @Override
                        public Long getResult(Tuple2<Long, Long> accumulator) {
                            return accumulator.f0 / accumulator.f1;
                        }

                        @Override
                        public Tuple2<Long, Long> merge(Tuple2<Long, Long> a, Tuple2<Long, Long> b) {
                            return null;
                        }
                    },
                    Types.TUPLE(Types.LONG, Types.LONG)
            ));
            countState = getRuntimeContext().getState(new ValueStateDescriptor<Long>("count", Long.class));
        }

        @Override
        public void flatMap(Event value, Collector<String> out) throws Exception {
            Long count = countState.value();
            if (count == null) {
                count = 1L;
            } else {
                count++;
            }
            countState.update(count);
            avgTsAggState.add(value);
            // 达到 5 次就输出结果,并清空状态
            if (count == 5) {
                out.collect(value.user + " 平均时间戳: " + new Timestamp(avgTsAggState.get()));
                countState.clear();
            }
        }
    }
}
状态生存时间(TTL)

在实际应用中,很多状态会随着时间的推移逐渐增长,如果不加以限制,最终就会导致存储空间的耗尽。一个优化的思路是直接在代码中调用clear()方法去清除状态,但是有时候我们的逻辑要求不能直接清除,这时就需要配置一个状态的生存时间(time-to-live,TTL),当状态在内存中存在的时间超出这个值时,就将它清除

具体实现上,如果用一个进程不停地扫描所有状态看是否过期,显然呼占用大量资源做无用功。状态的失效其实不需要立即删除,我们可以给状态附件一个属性,也就是状态的失效时间。状态创建的时候,设置 失效 = 当前时间 + TTL。之后如果有对状态的访问和修改,我们可以再对失效时间进行更新,当设置的清除条件被触发时(比如,状态被访问的时间,或者每隔一段时间扫描一次失效状态),就可以判断状态是否失效,从而进行清除。

配置状态的TTL时,需要创建一个StateTtlConfig配置对象,然后调用状态描述器的enableTimeToLive()方法启动TTL功能。

StateTtlConfig ttlConfig = StateTtlConfig
     .newBuilder(Time.seconds(10))
     .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
     .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
     .build();

ValueStateDescriptor<String> stateDescriptor = new ValueStateDescriptor<>("mystate", String.class);
stateDescriptor.enableTimeToLive(ttlConfig);
  • newBuilder():状态TTL配置的构造器方法,必须调用,返回一个Builder之后再调用build()方法就可以得到StateTtlConfig,方法需要传入一个Time作为参数,作为设定的状态生存空间
  • setUpdateType():设置更新类型,更新类型指定了什么时候更新状态失效时间,这里的OnCreateAndWrite表示只有创建状态和更改状态时更新失效时间。另一种类型OnReadAndWrite表示读写操作都会更新失效时间,配置默认为OnCreateAndWrite
  • setStateVisibility():设置状态的可见性。是因为清除操作并不是实时地,当状态过期之后还有可能基于存在,如果这时对它进行访问,能否正常读取到就是一个问题了。默认设置NeverReturnExpired,表示从不返回过期值,这在处理会话或者隐私数据时比较重要。配置ReturnExpiredDefNotCleanedUp,如果过期状态还存在,就返回它的值

TTL配置还可以设置保存检查点(checkpoint)时触发清除操作, 配置增量的清理(incremental cleanup),还可以针对RocksDB状态对后端使用过滤器(compaction filter)进行后台清理。

另外需要注意:目前TTL设置只支持处理时间。另外,所有集合类型的状态(例如ListState,MapState)在设置TTL时,都是针对每一项(per-entry)元素,也就是说一个列表状态中的每一个元素,都会以自己的失效时间来进行清理,而不是整个列表清理。

算子状态

除了按键分区状态之外,另一大类受控状态就是算子状态,算子状态是更底层的状态类型,因为它只针对当前算子并行任务有效,不需要考虑不同key的隔离。算子状态功能不如按键分区状态丰富,应用场景较少,它的调用方式也会有些区别

概念和特点

算子状态是一个算子并行实例上定义的状态,作用范围被限定为当前算子任务,算子状态跟数据的key无关,故不同key的数据只有被分布到同一个并行子任务,就会访问到同一个Operator State

算子状态的实际应用场景不如Keyed State多,一般用在Source或Sink等与外部系统连接的算子上,或者完全没有key定义的场景。Flink的Kafka连接器中,就用到了算子状态。在我们给Source算子设置并行度后,Kafka消费者的每一个并行实例,都会为对应的主题(topic)分区维护一个偏移量,作为算子状态保存起来。保证Flink应用精确一次(exactly-once)

当算子的并行度发生变化时,算子状态也支持在并行的算子任务实例之间做重组分配,根据状态的类型不同,重组分配的方案也会不同。

状态类型

算子状态也支持不同的结果类型,主要有三种:ListState、UnionListState、BroadcastState

列表状态(ListState)

这与Keyed State中的ListSttae一样,将状态表示为一组数据的列表

与Keyed State中的列表区别是:在算子状态的上下文,不会按键分别处理状态,每一个并行子任务上只会保留一个列表,也就是当前并行子任务上所有状态项的集合,列表中的状态项就是可以重新分配的最细粒度,彼此之间完全独立。

算子并行度进行放缩调整时,算子的列表状态中所有元素项会统一被收集起来,相当于把多个分区的列表合并成一个大列表,然后再均匀地分配给所有并行任务。这种均匀分配的具体方法就是轮询(round-robin),与之前介绍的rebanlance数据传输方式类似,是通过逐一“发牌”的方式将状态项平均分配的。这种方式也叫做平均分割重组(even-split-redistribution)

算子状态不会存在键组结构,故为了重组分配方便,便直接定义成列表。这就解释了算子状态中为什么没有最简单的值状态(ValueState)

联合列表状态(UnionListState)

算子并行度进行缩放调整时状态的分配方式不同。UnionListState的重点在于联合(union)。在并行度调整时,联合列表状态的算子会直接广播状态的完整列表。这样,并行度缩放之后的并行子就获取到了联合后完整的大列表,可以自行选择要使用的状态项和要丢弃的状态项,这种分配方式称为联合重组(union redistribution),如果列表中状态项数量太多,考虑到资源和效率一般不推荐使用联合重组。

广播状态(BroadcastState)

算子并行子任务都保存同一份全局状态,做统一的配置和规则设定。这时所有分区的所有数据都会访问到同一个状态,状态就像被广播到所有分区一样,这种特殊的算子状态称为广播状态

广播状态在每个并行子任务上的实例都一样,故在并行度调整的时候就比较简单,只要复制一份到新的并行任务就可以实现扩展;对于并行度缩小的情况,可将多余的并行子任务连同状态直接砍掉——因为状态都是复制出来的,不会丢失

在底层,广播状态是以类似映射结构(map)的键值对(key-value)来保存的,必须基于一个广播流来创建。

代码实现

状态在本质上来说就是算子并行子任务实例上的一个特殊本地变量。它的特殊之处就在于Flink会提供完整的管理机制,来保证它的持久化保存,以便发生故障时进行状态恢复,另外还可以针对不同的key保存独立的状态实例。按键分区状态(Keyed State)对这两个都要考虑;而算子状态(Operator State)并不考虑key的影响,主要任务是让Flink了解状态的信息、将状态数据持久化后保存到外部存储空间。

对于Keyed State:状态都是跟key相关的,相同key的数据不管发往哪个分区,总是会全部进入一个分区;于是只要将状态也按照key的哈希值计算处对应的分区,进行重组分配就可以了。恢复状态后继续处理数据,基于key找到之前的状态,保证了结果的一致性。Flink对Keyed State进行了非常完善的包装,我们不需要实现任何接口就可以直接使用。

对于Operator State,提供了接口,我们可以根据业务需求自行设计状态的快照保存恢复

CheckpointedFunction接口

在Flink中,对状态进行持久化保存的快照机制叫做检查点(Checkpoint),于是使用算子状态时,就需要对检查点的相关操作进行定义,实现一个CheckpointedFunction接口

public interface CheckpointedFunction {
    // 保存状态快照到检查点时,调用这个方法
    void snapshotState(FunctionSnapshotContext context) throws Exception
    // 初始化状态时调用这个方法,也会在恢复状态时调用
     void initializeState(FunctionInitializationContext context) throws Exception;
}

每次应用保存检查点做快照时,调用snapshotState()方法,将状态外部持久化。

算子任务进行初始化时,调用initializeState()方法,这又有两种情况

  • 一种是整个应用第一次运行,状态被初始化为一个默认值
  • 另一种是应用重启时,从检查点(checkpoint)或者保存点(savepoint)中读取之前状态的快照,并赋给本地状态

故接口中的snapshotState()方法定义了检查点的快照保存逻辑,而initializeState()方法定义了初始化逻辑和恢复逻辑。

snapshotState()方法拿到的是快照的上下文FunctionSnapshotContext,它可以提高检查点的相关信息,不过无法获取状态句柄;而initializeState()方法拿到的是FunctionInitializationContext,这时函数类进行初始化时的上下文,是真正的运行时上下文

FunctionInitializationContext中提供了算子状态存储按键分区状态存储,在这两个存储对象中可以非常方便地获取当前任务实例中的Operator State和Keyed State

ListStateDescriptor<String> descriptor = new ListStateDescriptor<>(
 "buffered-elements",
 Types.of(String));
ListState<String> checkpointedState = context.getOperatorStateStore().getListState(descriptor);

算子状态的注册和使用跟Keyed State非常相似,先定一个状态描述器,告诉Flink当前状态的名称和类型,然后从上下文中提供的算子状态存储器中获取对应的状态对象

例:算子状态的应用案例,自定义的SinkFunction会在CheckpointedFunction中进行数据缓存,然后统一发送到下游。

import com.yingzi.chapter05.Source.ClickSource;
import com.yingzi.chapter05.Source.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.state.ListState;
import org.apache.flink.api.common.state.ListStateDescriptor;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.runtime.state.FunctionInitializationContext;
import org.apache.flink.runtime.state.FunctionSnapshotContext;
import org.apache.flink.streaming.api.checkpoint.CheckpointedFunction;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.sink.SinkFunction;

import java.util.ArrayList;
import java.util.List;

public class BufferingSinkExample {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override
                            public long extractTimestamp(Event element, long recordTimestamp) {
                                return element.timestamp;
                            }
                        }));
        stream.print("input");

        //批量缓存输出
        stream.addSink(new BufferSink(10));


        env.execute();
    }

    public static class BufferSink implements SinkFunction<Event>, CheckpointedFunction {

        private final int threshold;
        private transient ListState<Event> checkpointedState;
        private List<Event> bufferedElements;

        public BufferSink(int threshold) {
            this.threshold = threshold;
            this.bufferedElements = new ArrayList<>();
        }

        @Override
        public void invoke(Event value, Context context) throws Exception {
            bufferedElements.add(value);
            if (bufferedElements.size() == threshold) {
                for (Event bufferedElement : bufferedElements) {
                    System.out.println(bufferedElement); //输出到外部系统,这里用控制台打印模拟
                }
            }
            System.out.println("========输出完毕========");
            bufferedElements.clear();
        }

        @Override
        public void snapshotState(FunctionSnapshotContext context) throws Exception {
            checkpointedState.clear();
            //把当前局部变量中的所有元素写入到检查点中
            for (Event bufferedElement : bufferedElements) {
                checkpointedState.add(bufferedElement);
            }
        }

        @Override
        public void initializeState(FunctionInitializationContext context) throws Exception {
            ListStateDescriptor<Event> descriptor = new ListStateDescriptor<Event>(
                    "buffered-elements",
                    Types.POJO(Event.class));

            checkpointedState = context.getOperatorStateStore().getListState(descriptor);
            //如果是从故障中恢复,就将ListState中的所有元素添加到局部变量中、
            if (context.isRestored()){
                for (Event event : checkpointedState.get()) {
                    bufferedElements.add(event);
                }
            }
        }
    }
}

当初始化好状态对象后,可以通过调用isRestored()方法判断是否从故障中恢复。在代码中BufferingSink初始化时,恢复出的ListState的所有元素会添加到一个局部变量bufferedElements中,以后进行检查点快照时就可以直接使用。在调用snapshotState()时,直接清空ListState,然后把当前局部变量中的所有元素写入到检查点中

对于不同类型的算子状态,需要调用不同的获取状态对象的接口,对应地也就会使用不同的状态分配重组算法。比如获取列表状态时,调用getListSttae()使用最简单的平均分割重组算法;而获取联合列表状态时,调用getUnionListState()使用联合重组算法

广播状态

状态广播除去,所有并行子任务的状态都是相同的,并行度调整时只要直接复制就可以了。

基本用法

在处理流数据时,有时会基于一下配置或者规则,简单的配置可以直接读取配置文件,一次加载,永久有效;但是数据流是连续不断的,如果配置随着时间推移还会动态变化,那么又该怎么办?

一个简单的想法是,定期扫描配置文件,发现改变就立即更新,但这样就需要另外启动一个扫描进程,如果扫描周期太长,配置更新不及时就会导致结果错误。如果扫描周期太短,又会耗费大量资源做无用功,解决的办法还是流处理的事件驱动思路——将动态地配置数据看作一条流,将这条和本身要处理的数据流进行连接,就可以实时地更新配置进行计算了

配置或者规则数据是全局有效的,我们需要将其广播给所有的并行子任务,子任务将其作为一个算子状态保存,以保证故障恢复后处理结果时一致的,这时的状态就是一个典型的广播状态。

广播状态的底层是以键值对形式存储,是一个映射状态。在代码上直接调用DataStream的broadcast()方法,传入一个映射状态描述器说明状态的名称和类型,就可得到一个广播流,将其与要处理的数据流连接就会得到广播连击流。注意:广播状态只能用在广播连接流中。

MapStateDescriptor<String, Rule> ruleStateDescriptor = new MapStateDescriptor<>(...);
BroadcastStream<Rule> ruleBroadcastStream = ruleStream.broadcast(ruleStateDescriptor);

DataStream<String> output = stream
 .connect(ruleBroadcastStream)
 .process( new BroadcastProcessFunction<>() {...} );

这里定义了一个规则流ruleStream,里面的数据表示数据流stream处理的规则,规则的数据类型定义为Rule。定义一个MapStateDescirptor来描述广播状态,然后传入ruleStream.broadcast()得到广播流,接着用stream和广播流进行连接。这里的状态描述器中的key类型为String,就是为了区分不同的状态值而给点的key名称

对于广播连接流调用process()方法,可以传入广播处理函数KeyedBroadcastProcessFunction或者BroadcastProcessFunction来进行处理计算。

public abstract class BroadcastProcessFunction<IN1, IN2, OUT> extends BaseBroadcastProcessFunction {
    ...
     public abstract void processElement(IN1 value, ReadOnlyContext ctx,Collector<OUT> out) throws Exception;
     public abstract void processBroadcastElement(IN2 value, Context ctx,Collector<OUT> out) throws Exception;
    ...
}

processElement()处理正常的数据流,processBroadcastElement()处理广播流。区别在前面方法的上下文是只读的(ReadOnly),因此获取到的广播状态也只能读取不能更改,而后面的方法里的Context没有限制,可以根据当前广播流中的数据更新状态

Rule rule = ctx.getBroadcastState( new MapStateDescriptor<>("rules", Types.String,
                                                            Types.POJO(Rule.class))).get("my rule");

通过调用ctx.getBroadcastState()方法,传入一个MapStateDescriptor,就可以得到当前的叫做rules的广播状态,调用它的get()方法,就可以取出其中“my rule”对应的值进行计算处理

代码实现

例子:考虑在电商应用时,往往需要判断用户先后发生行为的组合模式,比如登录-下单或者登录-支付,检测出这些连续的行为进行统计,就可以了解平台的运用状况以及用户的行为习惯。

import org.apache.flink.api.common.state.BroadcastState;
import org.apache.flink.api.common.state.MapStateDescriptor;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.datastream.BroadcastStream;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.co.KeyedBroadcastProcessFunction;
import org.apache.flink.util.Collector;

public class BroadcastStateExample {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        // 读取用户行为事件流
        DataStreamSource<Action> actionStream = env.fromElements(
                new Action("Alice", "login"),
                new Action("Alice", "pay"),
                new Action("Bob", "login"),
                new Action("Bob", "buy")
        );
        // 定义行为模式流,代表了要检测的标准
        DataStreamSource<Pattern> patternStream = env.fromElements(
                new Pattern("login", "pay"),
                new Pattern("login", "buy")
        );
        // 定义广播状态的描述器,创建广播流
        MapStateDescriptor<Void, Pattern> bcStateDescriptor = new MapStateDescriptor<>(
                "patterns", Types.VOID, Types.POJO(Pattern.class));
        BroadcastStream<Pattern> bcPatterns = patternStream.broadcast(bcStateDescriptor);
        // 将事件流和广播流连接起来,进行处理
        DataStream<Tuple2<String, Pattern>> matches = actionStream
                .keyBy(data -> data.userId)
                .connect(bcPatterns)
                .process(new PatternEvaluator());
        matches.print();
        env.execute();
    }

    public static class PatternEvaluator
            extends KeyedBroadcastProcessFunction<String, Action, Pattern, Tuple2<String, Pattern>> {
        // 定义一个值状态,保存上一次用户行为
        ValueState<String> prevActionState;

        @Override
        public void open(Configuration conf) {
            prevActionState = getRuntimeContext().getState(new ValueStateDescriptor<>("lastAction", Types.STRING));
        }

        @Override
        public void processBroadcastElement(Pattern pattern, Context ctx, Collector<Tuple2<String, Pattern>> out) throws Exception {

            BroadcastState<Void, Pattern> bcState = ctx.getBroadcastState(new MapStateDescriptor<>("patterns", Types.VOID, Types.POJO(Pattern.class)));
            // 将广播状态更新为当前的 pattern
            bcState.put(null, pattern);
        }

        @Override
        public void processElement(Action action, ReadOnlyContext ctx, Collector<Tuple2<String, Pattern>> out) throws Exception {
            Pattern pattern = ctx.getBroadcastState(new MapStateDescriptor<>("patterns", Types.VOID, Types.POJO(Pattern.class))).get(null);
            String prevAction = prevActionState.value();
            if (pattern != null && prevAction != null) {
                // 如果前后两次行为都符合模式定义,输出一组匹配
                if (pattern.action1.equals(prevAction) && pattern.action2.equals(action.action)) {
                    out.collect(new Tuple2<>(ctx.getCurrentKey(), pattern));
                }
            }
            // 更新状态
            prevActionState.update(action.action);
        }
    }

    // 定义用户行为事件 POJO 类
    public static class Action {
        public String userId;
        public String action;

        public Action() {
        }

        public Action(String userId, String action) {
            this.userId = userId;
            this.action = action;
        }

        @Override
        public String toString() {
            return "Action{" +
                    "userId=" + userId +
                    ", action='" + action + '\'' +
                    '}';
        }
    }

    // 定义行为模式 POJO 类,包含先后发生的两个行为
    public static class Pattern {
        public String action1;
        public String action2;

        public Pattern() {
        }

        public Pattern(String action1, String action2) {
            this.action1 = action1;
            this.action2 = action2;
        }

        @Override
        public String toString() {
            return "Pattern{" +
                    "action1='" + action1 + '\'' +
                    ", action2='" + action2 + '\'' +
                    '}';
        }
    }
}

这里将检测的行为模式定义为POJO类Pattern,里面包含了连续的两个行为。由于广播状态只保存一个Pattern,并不关系MapState中的key,所以也可以直接将key的类型指定为Void,具体值就是null。在具体操作中,将广播流中的Pattern数据保存为广播变量;在行为数据Action到来之后读取当前广播变量,确定行为模式,并将之前的一次行为保存为一个ValueState——针对当前用的状态保存,用到了Keyed State。检测到如果前一次行为与 Pattern 中的 action1 相同,而当前行为与 action2 相同,则发现了匹配模 式的一组行为,输出检测结果

状态持久化和状态后端

在Flink的状态管理机制中,很重要的一个功能就是对状态进行持久化保存,这样就可以在故障后进行重启恢复。Flink对状态进行持久化的方式,就是将当前所有分布式状态进行快照保存,写入一个检查点或者保存点保存到外部存储系统中。具体的存储介质一般是分布式文件系统

检查点

有状态流应用中的检查点,其实就是所有任务的状态在某个时间点的一个快照,也就是一次存盘,让我们之前处理数据的进度不要丢掉。在一个流应用程序运行时,Flink会定期保存检查点,在检查点中记录每个算子的id和状态;如果发生故障,Flink就会用最近一次成功保存的检查点来恢复应用的状态,重新启动处理流程,如同读档一样

如果保存检查点之后又处理一些数据,然后发生了故障,那么重启恢复状态之后这些数据带来的状态改变会丢失,为了让最终处理结果正确,我们还需要让源算子重新读取这些数据,再次处理一遍。这就要求流的数据源具有数据重放的能力,一个典型的例子是Kafka,我们可以通过保存消费数据的偏移量、故障重启后重新提交来实现数据的重放。这是对至少一次(at least once)状态一致性的保证,如果希望实现精确一次(exactly once)的一致性,还需要数据写入外边系统时的相关保证。

默认情况下,检查点时被禁用的,需要在代码中手动开启。

StreamExecutionEnvironment env = StreamExecutionEnvironment.getEnvironment();
env.enableCheckpointing(1000);

传入的参数是检查点的间隔数据,单位为毫秒

Flink还提供了保存点的功能,保存点在原理和形式上跟检查点完全一样,也是状态持久化保存的一个快照;区别在于,保存点是自定义的镜像保存,所以不会由Flink自动创建,而需要用户手动地触发,这在有计划地停止、重启应用时非常有用

状态后端(State Backends)

  • 1、在应用进行检查点保存时,首先会由JobManager向所有TaskManager发出触发检查点的命令
  • 2、TaskManager收到之后,将当前任务的所有庄园进行快照保存,持久化到远程的存储介质中
  • 3、完成之后向JobManager返回确认信息,过程是分布式的,当JobManager收到所有TaskManager的返回信息后,就会确认当前检查点成功保存

在Flink中,状态的存储、访问以及维护,都由一个可插拔的组件决定的,这个组件称为状态后端(state backend),其主要负责两件事:1、本地的状态管理,2、检查点写入远程的持久化存储

状态后端的分类

状态后端是一个开箱即用的组件,可在不改变应用程序逻辑的情况下独立配置,Flink中提供了两类不同的状态后端:

哈希表状态后端(HashMapStateBackend)、内嵌RocksDB状态后端(EmbeddedRocksDBStateBackend)

哈希表状态后端(HashMapStateBackend)

系统默认配置。把状态当做对象保存在内存里,保存在Taskmanager的JVM堆上。普通的状态,以及窗口中收集的数据和触发器,都会以键值对的形式存储起来,底层是一个哈希表。

对于检查点的保存,一般是放在持久化的分布式文件系统中,也可以通过配置检查点存储来另外指定。

HashMapStateBackend将本地状态全部放入内存,这样就可以获得最快的读写速度,使计算性能达到最佳,代价则是内存的占用,它适用于大状态、长窗口、大键值状态的作业,对所有高可用性设置也是有效

(2)内嵌RocksDB状态后端(EmbeddedRocksDBStateBackend)

RocksDB是一种内嵌的key-value存储介质,会将处理中的数据全部放入RocksDB数据库中,RocksDB默认存储在TaskManager的本地数据目录里。数据被存储为序列化的字节数组,读写操作需要序列化/反序列化,因此状态的访问性能要差一些。另外,因为做了序列化,key的比较也会按照字节进行,而不是直接调用hashCode()和equals()方法

对于检查点,同样会写入到远程的持久化文件系统中。EmbeddedRocksDBStateBackend始终执行的是异步快照,也就是不会因为保存检查点而阻塞数据的处理,而且它还提供了增量式保存检查点的机制,这在很多情况下可以大大提升保存效率。

它会把状态数据落盘,而且支持增量化的检查点,故在状态非常大、窗口非常长、键/值状态很大的应用场景中是一个好选择,对所有高可用性设置有效。

如何选择正确的状态后端

HashMapStateBackend是内存计算,读写速度非常快,但是状态的大小会受到集群可用内存的限制,如果应用的状态随着时间不停的增长,就会耗尽内存资源

RocksDB是硬盘存储,可以根据可以的磁盘空间进行扩展,而且是唯一支持增量检查点的状态后端,它非常适合于超级海量状态的存储,不过每个状态的读写都需要做序列化/反序列化,而且可能需要直接从磁盘读取数据,这就会导致性能的降低,平均读写性能要比HashMapStateBackend慢一个数量级

状态后端的配置

未做配置时,应用程序使用的默认状态后端是集群配置文件flink-conf.yaml指定,配置键名称为state.backend,对集群上运行的所有作业都有效。另外,我们还可以在代码中为当前作业单独配置状态后端,覆盖集群配置文件的默认值。

(1)配置默认的状态后端

在flink-conf.yaml中,使用state.backend来配置默认状态后端,hashmap -> HashMapStateBackend;rocksdb -> EmbeddedRocksDBStateBackend。另外,也可以是一个实现了状态后端工厂StateBackendFactory的类的完全限定类名

# 默认状态后端
state.backend: hashmap
# 存放检查点的文件路径
state.checkpoints.dir: hdfs://namenode:40010/flink/checkpoints

上面是一个配置HashMapStateBackend的例子,这里state.checkpoints.dir配置项,定义了状态后端将检查点和元数据写入的目录

(2)为每个作业单独配置状态后端

每个作业独立的状态后端,可以在代码中,基于作业的执行环境直接设置:

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStateBackend(new HashMapStateBackend());StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStateBackend(new EmbeddedRocksDBStateBackend());    

注意:如果想在IDE中使用EmbeddedRocksDBStateBackend,需要为Flink项目添加依赖

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值