Flink从入门到放弃(四)-State初体验

449 阅读9分钟

一、回顾

经过前面几篇文章的整理,我们已经学习到了Flink框架的特性以及数据流处理模型中的前两个环节(source、transformation)。在transformation环节中,基于无界流的场景下,我们引入了窗口的概念,并从完整的生命周期全貌进行了剖析,随之又引出了乱序数据场景下的解决方案,即watermark的定义和计算规则以及一些高级的问题抛出。前几篇的文章是从数据接入和数据计算的角度来学习flink,那么这里我们可能需要思考一个常问到的问题:**对于Flink来说,是如何做到数据一致性(Exactly-Once)的?**接下来几篇文章我们将针对这个高频面试题问题来详细展开讲解。首先在第一篇Flink框架介绍中,提到了Flink支持状态计算的概念。这个词汇对于老手们来讲再熟悉不过了,那么本篇将从以下几个方面来展开:

  1. 怎么理解状态?状态的作用是什么?
  2. 在Flink中状态类型有哪几种?
  3. 在实际的场景中如何使用状态?

二、状态介绍

What is State?

While many operations in a dataflow simply look at one individual event at a time (for example an event parser), some operations remember information across multiple events (for example window operators). These operations are called stateful

这段英文来自于官网对状态的介绍。以上的英文直译的大概意思就是说:状态在Flink中叫做State,通常很多operator仅此一次对事件处理,但是有些operator会记录下多个事件的信息,那么这些operator就是有状态的。大白话来讲在应用程序中代表某个时刻某个task或者operator的状态,通俗来讲就是用来保存中间计算结果或者用来缓存数据的

那么根据是否需要保存中间结果,又分为有状态计算和无状态计算。

  • 对于流计算而言,如果每次计算都是相互独立的,不依赖上下游的事件,则是无状态计算
  • 如果计算需要依赖之前或者后续的事件,那么就是有状态的计算

那么在这里举两个案例来解释下State的应用场景:

  1. 数据流中有重复数据,我们需要做去重处理,那么就需要知道之前接收到哪些数据并保存下来,这样每收到一条新的数据,都要与已经收到的数据比较是否重复
  2. 基于前面讲到的窗口统计求和场景,一般思路是将事件缓存起来,然后窗口触发计算进行求和,但是这种效率比较低,需要占用大量的内存。如果使用增量计算,每次只需要保s存当前所有值的总和,这样当新的事件到来时,只需要在中间结果基础上进行求和即可

三、状态类型

在算子层面,State根据是否在DataStream上指定Key划分为Keyed State和Operator State两大类型。

在流层面,State提供了BroadcastState在广播模式下使用,即将一个流的数据广播到所有的下游任务。它是Operator State的一种特殊类型。

在开发层面,State分为原生(Raw)和托管(Managed)。那托管的意思就是说由Flink来管理State,并对其进行优化、存储和恢复;而原生由用户进行管理、自行序列化,底层以字节数组的形式存储。

按照数据结构的不同,又可以将KeyedState和OperatorState进一步细分:

按是否有Key划分支持的State作用
KeyedStateValueState<T>保存一个可以更新和检索的值,这个值和对应的Key绑定,是最简单的状态。可以通过update()方法更新状态值,通过value()方法获取状态值
KeyedStateListState<T>保存一个元素的列表,即key上的状态值为一个列表。可以通过add()方法进行增加,通过get()方法返回一个Iterable<T>来遍历状态值
KeyedStateReducingState<T>保存一个单值,通过用户传递的ReduceFunction,每次调用add方法增加值,会调用reduceFunction,最后合并到一个单一的状态值
KeyedStateAggregatingState<IN, OUT>保留一个单值,和ReducingState<T>不同的是这里聚合的类型可以是不同的元素类型,使用add(IN)来加入元素,并使用AggregateFunction函数计算聚合结果
KeyedStateMapState<UK, UV>使用Map存储Key-Value时,通过put<UK,UV>或者putAll(Map<UK,UV>)来添加,使用get(UK)来获取
OperatorStateListState<T>同KeyedState的ListState<T>

以上所有类型的State都会有一个clear()方法,用于清除当前key下的状态数据,也就是当前输入元素的key.

四、状态使用

实际开发中,对状态的使用总结有以下几个步骤:

  1. 先定义一个StateDescriptor,指定State名称和值类型
  2. 通过RuntimeContext获取State
  3. 对数据进行处理后进行更新

接下来将分别针对三种类型的State提供开发Demo:

State类型State结构需求实现
KeyedStateValueState<T>实现简单的单词统计,且支持容错
KeyedStateListState<T>实现去重功能
KeyedStateReducingState<T>统计某网站每日总PV
KeyedStateMapState<UK, UV>统计某网站每日UV以及每个用户的访问频次
OperatorStateListState<T>可容错的source/sink实现
BroadcastStateBroadcastState<K, V>双流关联,实现简单拼接

4.1 Keyed State

ValueState<T>

需求:使用ValueState来实现简单的单词统计,且支持容错;即当任务停止重启之后,计算值不丢失;具体代码见:ValueStateDemo.java

socketTextStream.map(t -> Tuple2.of(t.split(",")[0], Integer.parseInt(t.split(",")[1])))
  .returns(TupleTypeInfo.getBasicTupleTypeInfo(String.class, Integer.class))
  .keyBy(t -> t.f0)
  .map(new RichMapFunction<Tuple2<String, Integer>, Tuple2<String, Integer>>() {

    private transient ValueState<Integer> state;

    @Override
    public void open(Configuration parameters) throws Exception {

      //1.定义状态描述器
      ValueStateDescriptor<Integer> valueStateDescriptor = new ValueStateDescriptor<>("wc-count", Integer.class);

      //2.获取状态
      state = getRuntimeContext().getState(valueStateDescriptor);

      super.open(parameters);
    }

    @Override
    public Tuple2<String, Integer> map(Tuple2<String, Integer> value) throws Exception {

      if ("error".equalsIgnoreCase(value.f0)) {
        return Tuple2.of(value.f0, 1 / 0);
      }
      if (state.value() != null) {
        state.update(state.value() + value.f1);
      } else {
        state.update(value.f1);
      }
      return Tuple2.of(value.f0, state.value());
    }
  }).print()
ListState<T>

需求:实现去重功能;具体代码见:ListStateDemo.java

socketTextStream.keyBy(t -> t.split("&")[0])
  .flatMap(new RichFlatMapFunction<String, String>() {
    private transient ListState<String> state;

    @Override
    public void open(Configuration parameters) throws Exception {
      state = getRuntimeContext().getListState(new ListStateDescriptor<String>("distinct", String.class));
      super.open(parameters);
    }
    @Override
    public void flatMap(String value, Collector<String> out) throws Exception {
      String str = value.split("&")[1];
      StringBuilder stringBuilder = new StringBuilder();
      Iterator<String> iterator = state.get().iterator();
      while (iterator.hasNext()) {
        String stateValue = iterator.next();
        stringBuilder.append(stateValue).append("\t");
      }

      String stateStr = stringBuilder.toString().trim();
      if ((stateStr.length() > 0 && !stateStr.contains(str)) || stateStr.length() == 0) {
        state.add(str);
        stateStr += "\t" + str;
      }
      out.collect("当前Key为:" + value.split("&")[0] + " 中间状态值为--->" + stateStr);
    }

  }).print();
ReducingState<T>

需求:统计某网站每日总PV;具体代码见ReducingStateDemo.java

socketTextStream.keyBy(t -> t.split("&")[0])
  .process(new KeyedProcessFunction<String, String, Tuple2<String, Integer>>() {
    private transient ReducingState<Integer> reducingState;

    @Override
    public void open(Configuration parameters) throws Exception {
      reducingState = getRuntimeContext().getReducingState(new ReducingStateDescriptor<Integer>("reduce", new ReduceFunction<Integer>() {
        @Override
        public Integer reduce(Integer value1, Integer value2) throws Exception {
          return value1 + value2;
        }
      }, Integer.class));
      super.open(parameters);
    }

    @Override
    public void processElement(String value, Context ctx, Collector<Tuple2<String, Integer>> out) throws Exception {
      Integer cnt = Integer.valueOf(value.split("&")[1].split("\\|\\|")[1]);
      reducingState.add(cnt);
      out.collect(Tuple2.of(value.split("&")[0], reducingState.get()));
    }
  }).print();
MapState<UK, UV>

需求:统计某网站每日UV以及每个用户的访问频次;具体代码见MapStateDemo.java

socketTextStream.keyBy(t -> t.split("&")[0])
  .process(new KeyedProcessFunction<String, String, Tuple2<String, Integer>>() {
    private static final long serialVersionUID = 6805407325918189102L;
    //MapState用来统计UK出现的次数
    private transient MapState<String, Integer> mapState;

    //ValueState用来统计去重后的UK个数
    private transient ValueState<Integer> valueState;

    @Override
    public void open(Configuration parameters) throws Exception {
      mapState = getRuntimeContext().getMapState(new MapStateDescriptor<String, Integer>("mapState", String.class, Integer.class));
      valueState = getRuntimeContext().getState(new ValueStateDescriptor<Integer>("distinctState", Integer.class));
      super.open(parameters);
    }

    @Override
    public void processElement(String value, Context ctx, Collector<Tuple2<String, Integer>> out) throws Exception {
      String str = value.split("&")[1];
      String uK = str.split("\\|\\|")[0];
      if (mapState.contains(uK)){
        mapState.put(uK,mapState.get(uK)+1);
      }else{
        mapState.put(uK,1);
      }
      List list = IteratorUtils.toList(mapState.keys().iterator());
      valueState.update(list.size());
      System.out.println("当前key:" + ctx.getCurrentKey() + "  去重UK统计个数为-->" + valueState.value() + " 当前UK出现的次数--->" + mapState.get(uK));
      out.collect(Tuple2.of(ctx.getCurrentKey(), valueState.value()));
    }

  })
  .print();

4.2 Operator State

Operator State从逻辑上理解,相当于一个并行度算子实例保存一份状态数据,没有key的概念,跟key无关。也就是说流中的元素会根据分区策略分配到不同的算子实例上,那么考虑到当应用重启时,不能保证流中元素和上一次的一样,还能经过该算子,所以在使用Operator State的时候需要实现 CheckpointedFunction接口,重构以下两个方法来设计snapshot和restore的逻辑,以此来保证容错:

//当执行checkpoint的时候,该方法会被调用;至于checkpoint相关知识点下篇文章讲解
void snapshotState(FunctionSnapshotContext context) throws Exception;

//当用户自定义函数初始化时,该方法会被调用;该方法不仅包含第一次初始化函数也包含从checkpoint中恢复时的一些逻辑
void initializeState(FunctionInitializationContext context) throws Exception;

Operator State都是以List形式存储,算子和算子之间的状态数据相互独立。和Keyed State不同的是,Operator State支持当算子并行度发生变化时自动重新分配状态数据。目前有两种分配策略:

  1. 均匀再分配(Even-split redistribution)

    每一个算子返回一个List形式的状态数据。从逻辑上讲,所有算子对应的List形式State数据合并后为整个作业的状态数据。也就是说每个算子都只是包含部分状态数据,当进行恢复或者重分区的时候,就会把整个状态均匀划分多个List。例如:当并行度为1时,状态数据包含元素1和元素2。当把并行度调整为2时,那么元素1就会被分配到算子1上,元素2分配到算子2上。

  1. 合并重新分配(Union redistribution)

    即每个算子实例都会包含整个状态数据,当发生恢复或者重分区时,每个Operator都会获取状态元素的完整数据。当作业存储的状态数据非常大时,不要使用该种策略,否则会出现OOM的情况

具体代码见OperatorStateMain.java

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(3000L);
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
Calendar calendar = Calendar.getInstance();
env.addSource(new CounterSource())
  .map(t -> Tuple2.of(simpleDateFormat.format(calendar.getTime()), Integer.valueOf(Math.toIntExact(t))))
  .returns(TupleTypeInfo.getBasicTupleTypeInfo(String.class, Integer.class))
  .addSink(new BufferingSink(10));

env.execute();

4.3 Broadcast State

Broadcast State是一种特殊的Operator State。

引入它是为了支持将一个流的记录广播到所有下游任务,而且这些记录在所有的sub task之间都会保持相同的状态,同时也可以被第二个流进行访问。那么和Operator State的区别在于:

  1. 它内部通过一个Map来维护
  2. 只适用于有广播流,而且可以有多个不同的broadcast state,通过名称来区分。这也就是说明为什么内部通过map来维护

另需要注意的是:

  1. 在Flink中,task之间不能通信,为了保证多并行度下算子获取broadcast state的一致性,只有广播流才能对broadcast state进行读写,而普通流只能读broadcast state。
  2. 在Flink中,所有的task都会对broadcast state进行checkpoint,尽管这么设计会将checkpoint的存储大小扩大了p倍(p代表了并行度),但这样可以保证在还原任务的时候可以快速从同一个文件中读取,避免热点问题
  3. 在Flink中,broadcast state通常存储在内存中(当然所有的Operator State都是存储于内存中的),所以尽量避免广播状态数据过大,导致OOM

需求:双流关联,实现拼接;具体代码见:BroadCastStateDemo.java

env.addSource(new MyWordSource(demoList)).connect(broadcast).process(new BroadcastProcessFunction<String, String, String>() {
  @Override
  public void processElement(String value, ReadOnlyContext ctx, Collector<String> out) throws Exception {
    out.collect(value + "---->" + ctx.getBroadcastState(BROADCAST).get("broadcastKey"));
  }

  @Override
  public void processBroadcastElement(String value, Context ctx, Collector<String> out) throws Exception {
    ctx.getBroadcastState(BROADCAST).put("broadcastKey", "广播变量:" + value + " 附加随机数:" + Math.random());
  }
}).map(new RichMapFunction<String, String>() {
  @Override
  public String map(String value) throws Exception {
    int indexOfThisSubtask = getRuntimeContext().getIndexOfThisSubtask();
    return indexOfThisSubtask + "---->" + value;
  }
}).print();