Flink 中的状态管理

与 Keyed State 中的 ListState 一样,将状态表示为一组数据的列表。
与 Keyed State 中的列表状态的区别是:在算子状态的上下文中,不会按键(key)分别
处理状态,所以每一个并行子任务上只会保留一个“列表”(list),也就是当前并行子任务上
所有状态项的集合。列表中的状态项就是可以重新分配的最细粒度,彼此之间完全独立。
当算子并行度进行缩放调整时,算子的列表状态中的所有元素项会被统一收集起来,相
当于把多个分区的列表合并成了一个“大列表”,然后再均匀地分配给所有并行任务。这种
“均匀分配”的具体方法就是“轮询”(round-robin),与之前介绍的 rebanlance 数据传输方式
类似,是通过逐一“发牌”的方式将状态项平均分配的。这种方式也叫作“平均分割重组”
(even-split redistribution)。
算子状态中不会存在“键组”(key group)这样的结构,所以为了方便重组分配,就把它
直接定义成了“列表”(list)。这也就解释了,为什么算子状态中没有最简单的值状态
(ValueState)。

1.概述

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

无状态的算子任务只需要观察每个独立事件,根据当前输入的数据直 接转换输出结果。我们之前讲到的基本转换算子,如map、filter、flatMap,计算时不依赖其他数据,就都属于无状态的算子。

而有状态的算子任务,则除当前数据之外,还需要一些其他数据来得到计算结果。这里的“其他数据”,就是所谓的状态(state)。算子中,聚合算子、窗口算子都属于有状态的算子。

有状态算子的一般处理流程,具体步骤如下。

  1. 算子任务接收到上游发来的数据;
  2. 获取当前状态;
  3. 根据业务逻辑进行计算,更新状态;
  4. 得到计算结果,输出发送到下游任务。

2.状态的分类

1.托管状态(Managed State)和原始状态(Raw State)

Flink 的状态有两种:托管状态(Managed State)和原始状态(Raw State)。托管状态就是由 Flink 统一管理的,状态的存储访问、故障恢复和重组等一系列问题都由 Flink 实现,我们只要调接口就可以;而原始状态则是自定义的,相当于就是开辟了一块内存,需要我们自己管理,实现状态的序列化和故障恢复。

通常我们采用 Flink 托管状态来实现需求。

2.算子状态(Operator State)和按键分区状态(Keyed State)

重点是托管状态(Managed State)。

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

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

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

1.算子状态

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

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

FLIP-27的新Source架构,则是需要继承SourceReaderBase抽象类。

2.按键分区状态

状态是根据输入流中定义的键 (key)来维护和访问的,所以只能定义在按键分区流(KeyedStream)中,也就keyBy之后才可以使用。

按键分区状态应用非常广泛。之前讲到的聚合算子必须在keyBy之后才能使用,就是因为聚合的结果是以Keyed State的形式保存的。

另外,也可以通过富函数类(Rich Function)来自定义 Keyed State,所以只要提供了富函数类接口的算子,也都可以使用 Keyed State。所以即使是 map、filter 这样无状态的基本转换算子,我们也可以通过富函数类给它们“追加”Keyed State。比如 RichMapFunction、RichFilterFunction。在富函数中,我们可以调用.getRuntimeContext()获取当前的运行时上下文(RuntimeContext),进而获取到访问状态的句柄;这种富函数中自定义的状态也是 Keyed State。从这个角度讲,Flink 中所有的算子都可以是有状态的。

无论是 Keyed State 还是 Operator State,它们都是在本地实例上维护的,也就是说每个并行子任务维护着对应的状态,算子的子任务之间状态不共享。

2.按键分区状态(Keyed State)

按键分区状态(Keyed State)顾名思义,是任务按照键(key)来访问和维护的状态。它的特点非常鲜明,就是以 key 为作用范围进行隔离。

需要注意,使用Keyed State必须基于KeyedStream。没有进行keyBy分区的DataStream,即使转换算子实现了对应的富函数类,也不能通过运行时上下文访问 Keyed State。

1.值状态(ValueState)

顾名思义,状态中只保存一个“值”(value)。ValueState<T>本身是一个接口,源码中定义如下:

    public interface ValueState<T> extends State {
        
        T value() throws IOException;
        
        void update(T value) throws IOException;
    }

这里的 T 是泛型,表示状态的数据内容可以是任何具体的数据类型。如果想要保存一个长整型值作为状态,那么类型就是 ValueState<Long>。

我们可以在代码中读写值状态,实现对于状态的访问和更新。

  • T value():获取当前状态的值;
  • update(T value):对状态进行更新,传入的参数 value 就是要覆写的状态值。

在具体使用时,为了让运行时上下文清楚到底是哪个状态,我们还需要创建一个“状态描述器”(StateDescriptor)来提供状态的基本信息。例如源码中,ValueState 的状态描述器构造方法如下:

    public ValueStateDescriptor(String name, Class<T> typeClass) {
        super(name, typeClass, null);
    }

这里需要传入状态的名称和类型——这跟我们声明一个变量时做的事情完全一样。

案例需求:检测每种传感器的水位值,如果连续的两个水位值超过 10,就输出报警。

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        SingleOutputStreamOperator<WaterSensor> sensorDS = env
                .socketTextStream("localhost", 7777)
                .map(new WaterSensorMapFunction())
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<WaterSensor>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                                .withTimestampAssigner((element, ts) -> element.getTs() * 1000L)
                );
        sensorDS.keyBy(r -> r.getId())
                .process(
                        new KeyedProcessFunction<String, WaterSensor, String>() {
                            
                            // TODO 1.定义状态
                            ValueState<Integer> lastVcState;

                            @Override
                            public void open(Configuration parameters) throws Exception {
                                super.open(parameters);
                                // TODO 2.在 open 方法中,初始化状态
                                // 状态描述器两个参数:第一个参数,起个名字,不重复;第二个参数,存储的类型
                                lastVcState = getRuntimeContext()
                                        .getState(new ValueStateDescriptor<Integer>("lastVcState", Types.INT));
                            }

                            @Override
                            public void processElement(WaterSensor value, KeyedProcessFunction<String, WaterSensor, String>.Context ctx, Collector<String> out) throws Exception {
                                // lastVcState.value(); // 取出 本组值状态 的数据
                                // lastVcState.update(); // 更新 本组值状态 的数据
                                // lastVcState.clear(); // 清除 本组值状态 的数据
                                
                                // 1. 取出上一条数据的水位值(Integer 默认值是 null,判断)
                                int lastVc = lastVcState.value() ==
                                        null ? 0 : lastVcState.value();
                                // 2. 求差值的绝对值,判断是否超过 10
                                Integer vc = value.getVc();
                                
                                if (Math.abs(vc - lastVc) > 10) {
                                    out.collect("传感器=" +
                                            value.getId() + "==>当前水位值=" + vc + ",与上一条水位值=" + lastVc +
                                            ",相差超过 10!!!!");
                                }
                                // 3. 更新状态里的水位值
                                lastVcState.update(vc);
                            }
                        }
                )
                .print();
        env.execute();

2.列表状态(ListState)

将需要保存的数据,以列表(List)的形式组织起来。在 ListState<T>接口中同样有一个类型参数 T,表示列表中数据的类型。ListState也提供了一系列的方法来操作状态,使用方式与一般的 List 非常相似。

  • Iterable<T> get():获取当前的列表状态,返回的是一个可迭代类型 Iterable<T>;
  • update(List<T> values):传入一个列表 values,直接对状态进行覆盖;
  • add(T value):在状态列表中添加一个元素 value;
  • addAll(List<T> values):向列表中添加多个元素,以列表 values 形式传入。

类似地,ListState的状态描述器就叫作 ListStateDescriptor,用法跟 ValueStateDescriptor完全一致

案例:针对每种传感器输出最高的 3 个水位值

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        SingleOutputStreamOperator<WaterSensor> sensorDS = env
                .socketTextStream("localhost", 7777)
                .map(new WaterSensorMapFunction())
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<WaterSensor>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                                .withTimestampAssigner((element,ts) -> element.getTs() * 1000L)
                );
        sensorDS.keyBy(r -> r.getId())
                .process(
                        new KeyedProcessFunction<String, WaterSensor, String>() {
                            ListState<Integer> vcListState;
                            @Override
                            public void open(Configuration parameters) throws Exception {
                                super.open(parameters);
                                vcListState = getRuntimeContext()
                                        .getListState(new ListStateDescriptor<Integer>("vcListState", Types.INT));
                            }
                            @Override
                            public void processElement(WaterSensor value, Context ctx, Collector<String> out) throws Exception {
                                // 1.来一条,存到 list 状态里
                                vcListState.add(value.getVc());
                                // 2.从 list 状态拿出来(Iterable), 拷贝到一个 List 中,排序, 只留 3 个最大的
                                Iterable<Integer> vcListIt = vcListState.get();
                                // 2.1 拷贝到 List 中
                                List<Integer> vcList = new ArrayList<>();
                                for (Integer vc : vcListIt) {
                                    vcList.add(vc);}
                                // 2.2 对 List 进行降序排序
                                vcList.sort((o1, o2) -> o2 - o1);
                                // 2.3 只保留最大的 3 个(list 中的个数一定是连续变大,一超过 3 就立即清理即可)
                                if (vcList.size() > 3) {
                                    // 将最后一个元素清除(第 4 个)
                                    vcList.remove(3);
                                }
                                out.collect("传感器 id 为" +
                                        value.getId() + ",最大的 3 个水位值=" + vcList.toString());
                                // 3.更新 list 状态
                                vcListState.update(vcList);
                                // vcListState.get(); //取出 list 状态 本组的数据,是一个 Iterable
                                // vcListState.add(); //向 list 状态 本组 添加一个元素
                                // vcListState.addAll(); //向 list 状态 本组 添加多个元素
                                // vcListState.update(); //更新 list 状态 本组数据(覆盖)
                                // vcListState.clear(); //清空 List 状态 本组数据
                            }
                        }
                )
                .print();
        env.execute();

3.Map 状态(MapState)

把一些键值对(key-value)作为状态整体保存起来,可以认为就是一组 keyvalue 映射的列表。对应的 MapState<UK, UV>接口中,就会有 UK、UV两个泛型,分别表示保存的 key和value 的类型。同样,MapState 提供了操作映射状态的方法,与 Map 的使用非常类似。

  • 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():获取映射状态中所有的键值对;
  • Iterable<UK> keys():获取映射状态中所有的键(key),返回一个可迭代 terable 类型;
  • Iterable<UV> values():获取映射状态中所有的值(value),返回一个可迭代 Iterable类型;
  • boolean isEmpty():判断映射是否为空,返回一个 boolean 值。

案例需求:统计每种传感器每种水位值出现的次数。

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        SingleOutputStreamOperator<WaterSensor> sensorDS = env
                .socketTextStream("localhost", 7777)
                .map(new WaterSensorMapFunction())
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<WaterSensor>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                                .withTimestampAssigner((element, ts) -> element.getTs() * 1000L)
                );

        sensorDS.keyBy(r -> r.getId())
                .process(
                        new KeyedProcessFunction<String, WaterSensor, String>() {
                            MapState<Integer, Integer> vcCountMapState;

                            @Override
                            public void open(Configuration parameters) throws Exception {
                                super.open(parameters);
                                vcCountMapState = getRuntimeContext().
                                        getMapState(new MapStateDescriptor<Integer, Integer>("vcCountMapState", Types.INT, Types.INT));
                            }
                            @Override
                            public void processElement(WaterSensor value, Context ctx, Collector<String> out) throws Exception {
                                // 1.判断是否存在 vc 对应的 key
                                Integer vc = value.getVc();
                                if (vcCountMapState.contains(vc)) {
                                    // 1.1 如果包含这个 vc 的 key,直接对 value+1
                                    Integer count = vcCountMapState.get(vc);
                                    vcCountMapState.put(vc, ++count);
                                } else {
                                    // 1.2 如果不包含这个 vc 的 key,初始化 put 进去
                                    vcCountMapState.put(vc, 1);
                                }
                                // 2.遍历 Map 状态,输出每个 k-v 的值
                                StringBuilder outStr = new StringBuilder();
                                outStr.append("======================================\n");
                                outStr.append(" 传 感 器 id 为 " + value.getId() + "\n");
                                for (Map.Entry<Integer, Integer> vcCount : vcCountMapState.entries()) {
                                    outStr.append(vcCount.toString() + "\n");
                                }
                                outStr.append("======================================\n");
                                out.collect(outStr.toString());

                                // vcCountMapState.get(); //对本组的 Map 状态,根据 key,获取 value
                                // vcCountMapState.contains(); //对本组的 Map 状态,判断 key 是否存在
                                // vcCountMapState.put(, ); //对本组的 Map 状态,添加一个 键值对
                                // vcCountMapState.putAll(); // 对本组的 Map 状态,添加多个 键值对
                                // vcCountMapState.entries(); //对本组的 Map 状态,获取所有键值对
                                // vcCountMapState.keys(); //对本组的 Map 状态,获取所有键
                                // vcCountMapState.values(); //对本组的 Map 状态,获取所有值
                                // vcCountMapState.remove(); // 对本组的 Map 状态,根据指定 key,移除键值对
                                // vcCountMapState.isEmpty(); //对本组的 Map 状态,判断是否为空
                                // vcCountMapState.iterator(); //对本组的 Map 状态,获取迭代器
                                // vcCountMapState.clear(); //对本组的 Map 状态,清空
                                
                            }
                        }
                )
                .print();
        env.execute();

4.归约状态(ReducingState)

类似于值状态(Value),不过需要对添加进来的所有数据进行归约,将归约聚合之后的值作为状态保存下来。ReducingState<T>这个接口调用的方法类似于 ListState,只不过它保存的只是一个聚合值,所以调用.add()方法时,不是在状态列表里添加元素,而是直接把新数据和之前的状态进行归约,并用得到的结果更新状态。

归约逻辑的定义,是在归约状态描述器(ReducingStateDescriptor)中,通过传入一个归约函数(ReduceFunction)来实现的。这里的归约函数,就是我们之前介绍reduce聚合算子时讲到的 ReduceFunction,所以状态类型跟输入的数据类型是一样的。

    public ReducingStateDescriptor(
            String name, ReduceFunction<T> reduceFunction, Class<T> typeClass) {
			...
        }
    }

这里的描述器有三个参数,其中第二个参数就是定义了归约聚合逻辑的 ReduceFunction,另外两个参数则是状态的名称和类型。

案例:计算每种传感器的水位和

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        SingleOutputStreamOperator<WaterSensor> sensorDS = env
                .socketTextStream("localhost", 7777)
                .map(new WaterSensorMapFunction())
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<WaterSensor>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                                .withTimestampAssigner((element, ts) -> element.getTs() * 1000L)
                );

        sensorDS.keyBy(r -> r.getId())
                .process(
                        new KeyedProcessFunction<String, WaterSensor, String>() {
                            private ReducingState<Integer> sumVcState;

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

                            @Override
                            public void processElement(WaterSensor value, Context ctx, Collector<String> out) throws Exception {
                                // 来一条数据 添加到 reducing状态里
                                sumVcState.add(value.getVc());
                                Integer vcSum = sumVcState.get();
                                out.collect("传感器id为" + value.getId() + ",水位值总和=" + vcSum);

//                                sumVcState.get();   //对本组的Reducing状态,获取结果
//                                sumVcState.add();   //对本组的Reducing状态,添加数据
//                                sumVcState.clear(); //对本组的Reducing状态,清空数据
                            }
                        }
                )
                .print();
        env.execute();

5.聚合状态(AggregatingState)

与归约状态非常类似,聚合状态也是一个值,用来保存添加进来的所有数据的聚合结果。与 ReducingState 不同的是,它的聚合逻辑是由在描述器中传入一个更加一般化的聚合函数(AggregateFunction)来定义的;这也就是之前我们讲过的 AggregateFunction,里面通过一个累加器(Accumulator)来表示状态,所以聚合的状态类型可以跟添加进来的数据类型完全不同,使用更加灵活。

同样地,AggregatingState 接口调用方法也与 ReducingState 相同,调用.add()方法添加元素时,会直接使用指定的 AggregateFunction 进行聚合并更新状态。

案例需求:计算每种传感器的平均水位

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        SingleOutputStreamOperator<WaterSensor> sensorDS = env
                .socketTextStream("hadoop102", 7777)
                .map(new WaterSensorMapFunction())
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<WaterSensor>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                                .withTimestampAssigner((element, ts) -> element.getTs() * 1000L)
                );

        sensorDS.keyBy(r -> r.getId())
                .process(
                        new KeyedProcessFunction<String, WaterSensor, String>() {
                            AggregatingState<Integer, Double> vcAvgAggregatingState;

                            @Override
                            public void open(Configuration parameters) throws Exception {
                                super.open(parameters);
                                vcAvgAggregatingState =
                                        getRuntimeContext()
                                                .getAggregatingState(
                                                        new AggregatingStateDescriptor<Integer, Tuple2<Integer, Integer>, Double>(
                                                                "vcAvgAggregatingState",
                                                                new AggregateFunction<Integer, Tuple2<Integer, Integer>, Double>() {
                                                                    @Override
                                                                    public Tuple2<Integer, Integer> createAccumulator() {
                                                                        return Tuple2.of(0, 0);
                                                                    }

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

                                                                    @Override
                                                                    public Double getResult(Tuple2<Integer, Integer> accumulator) {
                                                                        return accumulator.f0 * 1D / accumulator.f1;
                                                                    }

                                                                    @Override
                                                                    public Tuple2<Integer, Integer> merge(Tuple2<Integer, Integer> a, Tuple2<Integer, Integer> b) {
                                                                        // return Tuple2.of(a.f0 + b.f0, a.f1 + b.f1);
                                                                        return null;
                                                                    }
                                                                },
                                                                Types.TUPLE(Types.INT, Types.INT))
                                                );
                            }

                            @Override
                            public void processElement(WaterSensor value, Context ctx, Collector<String> out) throws Exception {
                                // 将 水位值 添加到 聚合状态中
                                vcAvgAggregatingState.add(value.getVc());
                                // 从 聚合状态中 获取结果
                                Double vcAvg = vcAvgAggregatingState.get();
                                out.collect(" 传 感 器 id 为 " + value.getId() + ",平均水位值=" + vcAvg);
                                // vcAvgAggregatingState.get(); //对 本组的聚合状态 获取结果
                                // vcAvgAggregatingState.add(); //对 本组的聚合状态 添加数据,会自动进行聚合
                                // vcAvgAggregatingState.clear(); //对 本组的聚合状态 清空数据
                            }
                        }
                )
                .print();
        env.execute();

6.状态生存时间(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<>("my state", String.class);
        stateDescriptor.enableTimeToLive(ttlConfig);

这里用到了几个配置项:

  • .newBuilder()
    状态 TTL配置的构造器方法,必须调用,返回一个 Builder之后再调用.build()方法就可以得到 StateTtlConfig 了。方法需要传入一个 Time 作为参数,这就是设定的状态生存时间。

  • setUpdateType()
    设置更新类型。更新类型指定了什么时候更新状态失效时间,这里的 OnCreateAndWrite表示只有创建状态和更改状态(写操作)时更新失效时间。另一种类型OnReadAndWrite则表示无论读写操作都会更新失效时间,也就是只要对状态进行了访问,就表明它是活跃的,从而延长生存时间。这个配置默认为 OnCreateAndWrite。

  • .setStateVisibility()
    设置状态的可见性。所谓的“状态可见性”,是指因为清除操作并不是实时的,所以当状态过期之后还有可能继续存在,这时如果对它进行访问,能否正常读取到就是一个问题了。这里设置的 NeverReturnExpired 是默认行为,表示从不返回过期值,也就是只要过期就认为它已经被清除了,应用不能继续读取;这在处理会话或者隐私数据时比较重要。对应的另一种配置是 ReturnExpireDefNotCleanedUp,就是如果过期状态还存在,就返回它的值。

除此之外,TTL 配置还可以设置在保存检查点(checkpoint)时触发清除操作,或者配置
增 量的清理 (incremental cleanup),还 可以针对 RocksDB 状态后 端使 用压缩过 滤器
(compaction filter)进行后台清理。这里需要注意,目前的 TTL 设置只支持处理时间。

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        SingleOutputStreamOperator<WaterSensor> sensorDS = env
                .socketTextStream("localhost", 7777)
                .map(new WaterSensorMapFunction())
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<WaterSensor>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                                .withTimestampAssigner((element, ts) -> element.getTs() * 1000L)
                );

        sensorDS.keyBy(r -> r.getId())
                .process(
                        new KeyedProcessFunction<String, WaterSensor, String>() {
                            ValueState<Integer> lastVcState;

                            @Override
                            public void open(Configuration parameters) throws Exception {
                                super.open(parameters);
                                // TODO 1.创建 StateTtlConfig
                                StateTtlConfig stateTtlConfig =
                                        StateTtlConfig
                                                .newBuilder(Time.seconds(5))
                                                // 过期时间 5s
                                                // .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) // 状态 创建和写入(更新) 更新 过期时间
                                                .setUpdateType(StateTtlConfig.UpdateType.OnReadAndWrite) // 状态 读取、创建和写入(更新) 更新 过期时 间
                                                .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) // 不返回过期的状态值
                                                .build();

                                // TODO 2.状态描述器 启用 TTL
                                ValueStateDescriptor<Integer>
                                        stateDescriptor = new ValueStateDescriptor<>("lastVcState",
                                        Types.INT);

                                stateDescriptor.enableTimeToLive(stateTtlConfig);
                                this.lastVcState = getRuntimeContext().getState(stateDescriptor);
                            }

                            @Override
                            public void processElement(WaterSensor value, Context ctx, Collector<String> out) throws Exception {
                                // 先获取状态值,打印 ==》 读取状态
                                Integer lastVc = lastVcState.value();
                                out.collect("key=" + value.getId() + ",状态值=" + lastVc);
                                // 如果水位大于 10,更新状态值 ===》 写入状态
                                if (value.getVc() > 10) {
                                    lastVcState.update(value.getVc());
                                }
                            }
                        }
                )
                .print();
        env.execute();

3.算子状态(Operator State)

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

算子状态的实际应用场景不如 Keyed State 多,一般用在 Source 或 Sink 等与外部系统连接的算子上,或者完全没有 key 定义的场景。比如 Flink 的 Kafka 连接器中,就用到了算子状态。

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

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

1.列表状态(ListState)

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

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

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

算子状态中不会存在“键组”(key group)这样的结构,所以为了方便重组分配,就把它直接定义成了“列表”(list)。这也就解释了,为什么算子状态中没有最简单的值状态(ValueState)。

案例实操:在 map 算子中计算数据的个数。

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        
        env.setParallelism(2);
        
        env.socketTextStream("localhost", 7777)
                .map(new MyCountMapFunction())
                .print();
        
        env.execute();
    }

    // TODO 1.实现 CheckpointedFunction 接口
    public static class MyCountMapFunction implements MapFunction<String, Long>, CheckpointedFunction {
        private Long count = 0L;
        private ListState<Long> state;
        @Override
        public Long map(String value) throws Exception {
            return ++count;
        }
        
        /**
         * TODO 2.本地变量持久化:将 本地变量 拷贝到 算子状态中,开启
         checkpoint 时才会调用
         *
         * @param context
         * @throws Exception
         */
        @Override
        public void snapshotState(FunctionSnapshotContext context)
                throws Exception {
            System.out.println("snapshotState...");
            // 2.1 清空算子状态
            state.clear();
            // 2.2 将 本地变量 添加到 算子状态 中
            state.add(count);
        }
        
        /**
         * TODO 3.初始化本地变量:程序启动和恢复时, 从状态中 把数据添加到 本
         地变量,每个子任务调用一次
         *
         * @param context
         * @throws Exception
         */
        @Override
        public void initializeState(FunctionInitializationContext context) throws Exception {
            System.out.println("initializeState...");
            // 3.1 从 上下文 初始化 算子状态
            state = context
                    .getOperatorStateStore()
                    .getListState(new ListStateDescriptor<Long>("state", Types.LONG));
            // 3.2 从 算子状态中 把数据 拷贝到 本地变量
            if (context.isRestored()) {
                for (Long c : state.get()) {
                    count += c;
                }
            }
        }
    }

2.联合列表状态(UnionListState)

与 ListState 类似,联合列表状态也会将状态表示为一个列表。它与常规列表状态的区别在于,算子并行度进行缩放调整时对于状态的分配方式不同。

UnionListState 的重点就在于“联合”(union)。在并行度调整时,常规列表状态是轮询分配状态项,而联合列表状态的算子则会直接广播状态的完整列表。这样,并行度缩放之后的并行子任务就获取到了联合后完整的“大列表”,可以自行选择要使用的状态项和要丢弃的状态项。这种分配也叫作“联合重组”(union redistribution)。如果列表中状态项数量太多,为资源和效率考虑一般不建议使用联合重组的方式。

使用方式同 ListState,区别在如下标红部分:

state = context
 .getOperatorStateStore()
 .getUnionListState(new ListStateDescriptor<Long>("union-state", Types.LONG));

3.广播状态(BroadcastState)

有时我们希望算子并行子任务都保持同一份“全局”状态,用来做统一的配置和规则设定。这时所有分区的所有数据都会访问到同一个状态,状态就像被“广播”到所有分区一样,这种特殊的算子状态,就叫作广播状态(BroadcastState)。

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

案例实操:水位超过指定的阈值发送告警,阈值可以动态修改。

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        env.setParallelism(2);
        // 数据流
        SingleOutputStreamOperator<WaterSensor> sensorDS = env
                .socketTextStream("localhost", 7777)
                .map(new WaterSensorMapFunction());

        // 配置流(用来广播配置)
        DataStreamSource<String> configDS = env.socketTextStream("localhost", 8888);

        // TODO 1. 将 配置流 广播
        MapStateDescriptor<String, Integer> broadcastMapState = new
                MapStateDescriptor<>("broadcast-state", Types.STRING, Types.INT);

        BroadcastStream<String> configBS = configDS.broadcast(broadcastMapState);

        // TODO 2.把 数据流 和 广播后的配置流 connect
        BroadcastConnectedStream<WaterSensor, String> sensorBCS = sensorDS.connect(configBS);

        // TODO 3.调用 process
        sensorBCS
                .process(
                        new BroadcastProcessFunction<WaterSensor, String, String>() {
                            /**
                             * 数据流的处理方法: 数据流 只能 读取 广播状
                             * 态,不能修改
                             *
                             * @param value
                             * @param ctx
                             * @param out
                             * @throws Exception
                             */
                            @Override
                            public void processElement(WaterSensor value, ReadOnlyContext ctx, Collector<String> out) throws Exception {
                                // TODO 5.通过上下文获取广播状态,取出里面的值(只读,不能修改)
                                ReadOnlyBroadcastState<String, Integer> broadcastState = ctx.getBroadcastState(broadcastMapState);
                                Integer threshold = broadcastState.get("threshold");
                                // 判断广播状态里是否有数据,因为刚启动时,可能是数据流的第一条数据先来
                                threshold = (threshold == null ? 0 : threshold);
                                if (value.getVc() > threshold) {
                                    out.collect(value + ",水位超过指 定的阈值:" + threshold + " !!!");
                                }
                            }

                            /**
                             * 广播后的配置流的处理方法: 只有广播流才能修
                             * 改 广播状态
                             *
                             * @param value
                             * @param ctx
                             * @param out
                             * @throws Exception
                             */
                            @Override
                            public void
                            processBroadcastElement(String value, Context ctx, Collector<String> out) throws Exception {
                                // TODO 4. 通过上下文获取广播状态,往里面写数据
                                BroadcastState<String, Integer> broadcastState = ctx.getBroadcastState(broadcastMapState);
                                broadcastState.put("threshold", Integer.valueOf(value));
                            }
                        }
                )
                .print();
        env.execute();

4.状态后端(State Backends)

在 Flink 中,状态的存储、访问以及维护,都是由一个可插拔的组件决定的,这个组件就叫作状态后端(state backend)。状态后端主要负责管理本地状态的存储方式和位置。

1.状态后端的分类(HashMapStateBackend/RocksDB)

状态后端是一个“开箱即用”的组件,可以在不改变应用程序逻辑的情况下独立配置。Flink 中提供了两类不同的状态后端,一种是“哈希表状态后端”(HashMapStateBackend),另一种是“内嵌 RocksDB 状态后端”(EmbeddedRocksDBStateBackend)。如果没有特别配置,系统默认的状态后端是 HashMapStateBackend。

1.哈希表状态后端(HashMapStateBackend)

HashMapStateBackend是把状态存放在内存里。具体实现上,哈希表状态后端在内部会直接把状态当作对象(objects),保存在 Taskmanager 的 JVM 堆上。普通的状态,以及窗口中收集的数据和触发器,都会以键值对的形式存储起来,所以底层是一个哈希表(HashMap),这种状态后端也因此得名。

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

RocksDB 是一种内嵌的 key-value 存储介质,可以把数据持久化到本地硬盘。配置EmbeddedRocksDBStateBackend 后,会将处理中的数据全部放入 RocksDB 数据库中,RocksDB 默认存储在 TaskManager 的本地数据目录里。

RocksDB 的状态数据被存储为序列化的字节数组,读写操作需要序列化/反序列化,因此状态的访问性能要差一些。另外,因为做了序列化,key 的比较也会按照字节进行,而不是直接调用.hashCode()和.equals()方法。

EmbeddedRocksDBStateBackend 始终执行的是异步快照,所以不会因为保存检查点而阻塞数据的处理;而且它还提供了增量式保存检查点的机制,这在很多情况下可以大大提升保存效率。

2.如何选择正确的状态后端

HashMap 和 RocksDB 两种状态后端最大的区别,就在于本地状态存放在哪里。

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

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

3.状态后端的配置

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

1.配置默认的状态后端

在 flink-conf.yaml 中,可以使用 state.backend 来配置默认状态后端。

配置项的可能值为 hashmap,这样配置的就是 HashMapStateBackend;如果配置项的值是rocksdb,这样配置的就是 EmbeddedRocksDBStateBackend。

下面是一个配置 HashMapStateBackend 的例子:

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

这里的 state.checkpoints.dir 配置项,定义了检查点和元数据写入的目录。

2.为每个作业(Per-job/Application)单独配置状态后端

  1. 通过执行环境设置,HashMapStateBackend。
        StreamExecutionEnvironment env =
                StreamExecutionEnvironment.getExecutionEnvironment();
        env.setStateBackend(new HashMapStateBackend());
  1. 通过执行环境设置,EmbeddedRocksDBStateBackend。
    添加POM依赖
        <dependency>
            <groupId>org.apache.flink</groupId>
            <artifactId>flink-statebackend-rocksdb</artifactId>
            <version>1.18.0</version>
        </dependency>
        StreamExecutionEnvironment env =
                StreamExecutionEnvironment.getExecutionEnvironment();
        env.setStateBackend(new EmbeddedRocksDBStateBackend());

由于 Flink发行版中默认就包含了 RocksDB(服务器上解压的 Flink),所以只要我们的代码中没有使用 RocksDB 的相关内容,就不需要引入这个依赖。

posted @ 2024-01-28 21:05  SpringCore  阅读(154)  评论(0编辑  收藏  举报