day4_Flink基础

文章目录

Flink基础

今日课程内容介绍

  • Flink中的多流转换
    • 分流
    • 基础合流操作
    • 基于时间的合流操作-双流连接
  • Flink 中的状态
    • 状态的概念
    • 状态的分类
    • 按键分区状态(Keyed State)
    • 算子状态(Operator State)
    • 广播状态(Broadcast State)
    • 状态持久化和状态后端

Flink的多流转换

分流

侧输出流分流

典型的使用场景如下:

  • 异常数据处理

    • 场景描述:在数据处理过程中,经常会遇到不符合预期格式或包含错误的数据。例如,在一个日志分析系统中,正常的日志数据可能是按照特定的格式(如包含时间戳、日志级别、消息内容等字段)进行记录的。但由于各种原因,如网络故障、软件漏洞等,可能会产生格式错误的日志。
    • 侧输出流的作用 :可以将这些异常数据通过侧输出流分流出来,单独进行处理。这样可以保证主流的数据处理逻辑不受异常数据的干扰,同时也能够对异常数据进行针对性的分析,比如记录错误信息、统计错误类型的频率等。在 Flink 中,可以定义一个 OutputTag 来标记异常数据的侧输出流,当遇到不符合正常数据处理规则的日志时,将其发送到这个侧输出流中。
  • 多类型数据分类

    • 场景描述:当输入的数据流包含多种不同类型的数据,并且每种类型的数据需要进行不同的处理时。以一个物联网应用场景为例,传感器可能会同时传输温度数据、湿度数据和设备状态数据。
    • 侧输出流的作用:使用侧输出流可以根据数据的类型(如根据数据中的类型标识符字段)将不同类型的数据分流到不同的侧输出流中。对于温度数据,可以在一个侧输出流中进行统计分析,如计算平均值、最大值等;对于湿度数据,可以在另一个侧输出流中进行阈值判断等操作;而设备状态数据则可以在第三个侧输出流中用于设备监控和故障预警。这种方式使得数据处理更加灵活和高效,能够满足复杂的业务需求。

下面是一个从 ProcessFunction 发出侧输出数据的例子,将数据集中的负数挑出来,输出侧输出中:

java 复制代码
/**
 * @desc : 侧输出示例:将数据集中的负数挑出来,输出到侧输出中
 **/
public class SideOutputDemo {
    public static void main(String[] args) throws Exception {
        // 设置流执行环境
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        // 侧输出
        DataStream<Integer> ds = env.fromElements(1, 2, -3, 4, 5, -6, 7, 8, -9, 10);
        // 定义用来标记侧输出的标签,注意泛型参数是侧输出数据的类型
        final OutputTag<Integer> outputTag = new OutputTag<Integer>("side-output") { };
        // 分流处理
        SingleOutputStreamOperator<Integer> mainDataStream = ds
                .process(new ProcessFunction<Integer, Integer>() {
                    @Override
                    public void processElement(Integer value, Context ctx, Collector<Integer> out) throws Exception {
                        if (value > 0) {
                            out.collect(value);    // 将数据发送到常规输出
                        } else {
                            ctx.output(outputTag, value);    // 向侧输出发送负数
                        }
                    }
                });
        // 获取侧输出结果
        DataStream<Integer> sideOutputStream = mainDataStream.getSideOutput(outputTag);
        // 打印主输出流
        // mainDataStream.print("主数据流");
        // 打印侧输出流
        sideOutputStream.print("侧输出流");
        // 执行
        env.execute("flink transformatiion");
    }
}

基础合流操作

既然一条流可以分开,自然多条流就可以合并。在实际应用中,我们经常会遇到来源不同的多条流,需要将它们的数据进行联合处理。所以 Flink 中合流的操作会更加普遍,对应的API 也更加丰富。

联合(Union)

最简单的合流操作,就是直接将多条流合在一起,叫作流的"联合 "(union),联合操作要求必须流中的数据类型必须相同 ,合并之后的新流会包括所有流中的元素,数据类型不变。这种合流方式非常简单粗暴,就像公路上多个车道汇在一起一样。

在代码中,我们只要基于 DataStream 直接调用.union()方法,传入其他 DataStream 作为参数,就可以实现流的联合了;得到的依然是一个 DataStream:

java 复制代码
stream1.union(stream2, stream3, ...)

注意:

  • 可以union多个流

  • 要求数据结构一样

  • 这里涉及到多个流合并,肯定会存在每个流水位线不一致的情况,当union时,用最小的水位线输出到下游。

定义JavaBean:

java 复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Event {
    // 事件的属性
    public String username;
    public String url;
    public Long ts;
} 

示例代码:

java 复制代码
public class UnionDemo {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        SingleOutputStreamOperator<Event> stream1 = env.socketTextStream("node1", 8888)
                .map(data -> {
                    String[] field = data.split(",");
                    return new Event(field[0].trim(), field[1].trim(), Long.valueOf(field[2].trim()));
                })
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(2))
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override
                            public long extractTimestamp(Event element, long recordTimestamp) {
                                return element.getTs();
                            }
                        })
                );
        stream1.print("stream1");

        SingleOutputStreamOperator<Event> stream2 = env.socketTextStream("node1", 9999)
                .map(data -> {
                    String[] field = data.split(",");
                    return new Event(field[0].trim(), field[1].trim(), Long.valueOf(field[2].trim()));
                })
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override
                            public long extractTimestamp(Event element, long recordTimestamp) {
                                return element.getTs();
                            }
                        })
                );
        stream2.print("stream2");

        // 合并两条流
        stream1.union(stream2)
                .process(new ProcessFunction<Event, String>() {
                    @Override
                    public void processElement(Event value, Context ctx, Collector<String> out) throws Exception {
                        out.collect(" 水 位 线 : " + ctx.timerService().currentWatermark());
                    }
                }).print();
        env.execute();
    }
}
连接(Connect)

流的联合虽然简单,不过受限于数据类型不能改变,灵活性大打折扣,所以实际应用较少出现。除了联合(union),Flink 还提供了另外一种方便的合流操作------连接(connect)。顾名思义,这种操作就是直接把两条流像接线一样对接起来。

连接流(ConnectedStreams)

连接得到的并不是 DataStream ,而是一个"连接流 " 。连接流可以看成是两条流形式上的"统一 ",被放在了一个同一个流中; 事实上内部仍保持各自的数据形式不变,彼此之间是相互独立的。要想得到新的 DataStream , 还需要进一步定义一个"同处理"(co-process )转换操作,用来说明对于不同来源、不同类型的数据,怎样分别进行处理转换、得到统一的输出类型。

java 复制代码
public class CoMapExample {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        DataStream<Integer> stream1 = env.fromElements(1, 2, 3);
        DataStream<Long> stream2 = env.fromElements(1L, 2L, 3L);
        ConnectedStreams<Integer, Long> connectedStreams = stream1.connect(stream2);
        SingleOutputStreamOperator<String> result = connectedStreams.map(new CoMapFunction<Integer, Long, String>() {
            @Override
            public String map1(Integer value) {
                return "Integer: " + value;
            }

            @Override
            public String map2(Long value) {
                return "Long: " + value;
            }
        });
        result.print();
        env.execute();
    }
}

两条流的连接( connect ),与联合( union )操作相比,最大的优势就是可以处理不同类型的流的合并,使用更灵活、应用更广泛。当然它也有限制,就是合并流的数量只能是 2 ,而 union 可以同时进行多条流的合并。

CoProcessFunction

我们可以实现一个实时对账的需求,也就是app 的支付操作和第三方的支付操作的一个双流 Join。App 的支付事件和第三方的支付事件将会互相等待 5 秒钟,如果等不来对应的支付事件,那么就输出报警信息。

java 复制代码
/** 
 * @desc : 实时对账
 **/
public class BillCheckExample {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        // 来自 app 的支付日志
        SingleOutputStreamOperator<Tuple3<String, String, Long>> appStream = env.fromElements(
                Tuple3.of("order-1", "app", 1000L),
                Tuple3.of("order-2", "app", 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<Tuple4<String, String, String, Long>> thirdpartStream = env.fromElements(
                Tuple4.of("order-1", "third-party", "success", 3000L),
                Tuple4.of("order-3", "third-party", "success", 4000L)
        ).assignTimestampsAndWatermarks(WatermarkStrategy.<Tuple4<String, String, String, Long>>forMonotonousTimestamps()
                .withTimestampAssigner(new SerializableTimestampAssigner<Tuple4<String, String, String, Long>>() {
                    @Override
                    public long extractTimestamp(Tuple4<String, String, String, Long> element, long recordTimestamp) {
                        return element.f3;
                    }
                })
        );

        // 检测同一支付单在两条流中是否匹配,不匹配就报警
        appStream.connect(thirdpartStream)
                .keyBy(data -> data.f0, data -> data.f0)
                .process(new OrderMatchResult())
                .print();
        env.execute();
    }

    // 自定义实现 CoProcessFunction
    public static class OrderMatchResult extends CoProcessFunction<Tuple3<String, String, Long>, Tuple4<String, String, String, Long>, String> {
        // 定义状态变量,用来保存已经到达的事件
        private ValueState<Tuple3<String, String, Long>> appEventState;
        private ValueState<Tuple4<String, String, String, Long>> thirdPartyEventState;

        @Override
        public void open(Configuration parameters) throws Exception {
            appEventState = getRuntimeContext().getState(new ValueStateDescriptor<Tuple3<String, String, Long>>("app-event", Types.TUPLE(Types.STRING, Types.STRING, Types.LONG)));
            thirdPartyEventState = getRuntimeContext().getState(new ValueStateDescriptor<Tuple4<String, String, String, Long>>("thirdparty-event", Types.TUPLE(Types.STRING, Types.STRING, Types.STRING, Types.LONG)));
        }

        @Override
        public void processElement1(Tuple3<String, String, Long> value, Context ctx, Collector<String> out) throws Exception {
            // 看另一条流中事件是否来过
            if (thirdPartyEventState.value() != null) {
                out.collect(" 对 账 成 功 : " + value + " " + thirdPartyEventState.value());
                // 清空状态
                thirdPartyEventState.clear();
            } else {
                // 更新状态
                appEventState.update(value);
                // 注册一个 5 秒后的定时器,开始等待另一条流的事件
                ctx.timerService().registerEventTimeTimer(value.f2 + 5000L);
            }
        }

        @Override
        public void processElement2(Tuple4<String, String, String, Long> value, Context ctx, Collector<String> out) throws Exception {
            if (appEventState.value() != null) {
                out.collect("对账成功:" + appEventState.value() + " " + value);
                // 清空状态
                appEventState.clear();
            } else {
                // 更新状态
                thirdPartyEventState.update(value);
                // 注册一个 5 秒后的定时器,开始等待另一条流的事件
                ctx.timerService().registerEventTimeTimer(value.f3 + 5000L);
            }
        }

        @Override
        public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
            // 定时器触发,判断状态,如果某个状态不为空,说明另一条流中事件没来
            if (appEventState.value() != null) {
                out.collect("对账失败:" + appEventState.value() + " " + "第三方支付 平台信息未到");
            }
            if (thirdPartyEventState.value() != null) {
                out.collect("对账失败:" + thirdPartyEventState.value() + " " + "app 信息未到");
            }
            appEventState.clear();
            thirdPartyEventState.clear();
        }
    }
}
广播连接流(BroadcastConnectedStream)

Broadcast State 是 Flink 1.5 引入的新特性。

在开发过程中,如果遇到需要下发/广播配置、规则等低吞吐事件流到下游所有task 时,就可以使用 Broadcast State 特性。下游的 task 接收这些配置、规则并保存为 BroadcastState, 将这些配置应用到另一个数据流的计算中 。

API介绍, 核心要点:

  • 将需要广播出去的流,调用broadcast方法进行广播转换,得到广播流BroadCastStream

  • 然后在主流上调用connect算子,来连接广播流(以实现广播状态的共享处理)

  • 在连接流上调用process算子,就会在同一个ProcessFunciton中提供两个方法分别对两个流进行处理,并在这个ProcessFunction内实现"广播状态"的共享

java 复制代码
/** 
 * @desc : 广播流及广播状态的使用示例
 **/
public class BroadCastDemo {
    public static void main(String[] args) throws Exception {
        Configuration configuration = new Configuration();
        configuration.setInteger("rest.port", 8822);
        StreamExecutionEnvironment env = StreamExecutionEnvironment.createLocalEnvironmentWithWebUI(configuration);
        env.setParallelism(1);

        // id,eventId
        DataStreamSource<String> stream1 = env.socketTextStream("node1", 8888);

        SingleOutputStreamOperator<Tuple2<String, String>> s1 = stream1.map(s -> {
            String[] arr = s.split(",");
            return Tuple2.of(arr[0], arr[1]);
        }).returns(new TypeHint<Tuple2<String, String>>() {
        });

        // id,age,city
        DataStreamSource<String> stream2 = env.socketTextStream("node1", 9999);

        SingleOutputStreamOperator<Tuple3<String, String, String>> s2 = stream2.map(s -> {
            String[] arr = s.split(",");
            return Tuple3.of(arr[0], arr[1], arr[2]);
        }).returns(new TypeHint<Tuple3<String, String, String>>() {
        });


        /**
         * 案例背景:
         *    流 1:  用户行为事件流(持续不断,同一个人也会反复出现,出现次数不定
         *    流 2:  用户维度信息(年龄,城市),同一个人的数据只会来一次,来的时间也不定 (作为广播流)
         *
         *    需要加工流1,把用户的维度信息填充好,利用广播流来实现
         */

        // 将字典数据所在流: s2  ,  转成 广播流
        MapStateDescriptor<String, Tuple2<String, String>> userInfoStateDesc = new MapStateDescriptor<>("userInfoStateDesc", TypeInformation.of(String.class), TypeInformation.of(new TypeHint<Tuple2<String, String>>() {}));
        BroadcastStream<Tuple3<String, String, String>> s2BroadcastStream = s2.broadcast(userInfoStateDesc);

        // 哪个流处理中需要用到广播状态数据,就要 去  连接 connect  这个广播流
        BroadcastConnectedStream<Tuple2<String, String>, Tuple3<String, String, String>> connected = s1.connect(s2BroadcastStream);


        /**
         *   对 连接了广播流之后的 "连接流" 进行处理
         *   核心思想:
         *      在processBroadcastElement方法中,把获取到的广播流中的数据,插入到 "广播状态"中
         *      在processElement方法中,对取到的主流数据进行处理(从广播状态中获取要拼接的数据,拼接后输出)
         */
        SingleOutputStreamOperator<String> resultStream = connected.process(new BroadcastProcessFunction<Tuple2<String, String>, Tuple3<String, String, String>, String>() {

            /*BroadcastState<String, Tuple2<String, String>> broadcastState;*/

            /**
             * 本方法,是用来处理 主流中的数据(每来一条,调用一次)
             * @param element  左流(主流)中的一条数据
             * @param ctx  上下文
             * @param out  输出器
             * @throws Exception
             */
            @Override
            public void processElement(Tuple2<String, String> element, BroadcastProcessFunction<Tuple2<String, String>, Tuple3<String, String, String>, String>.ReadOnlyContext ctx, Collector<String> out) throws Exception {

                // 通过 ReadOnlyContext ctx 取到的广播状态对象,是一个 "只读 " 的对象;
                ReadOnlyBroadcastState<String, Tuple2<String, String>> broadcastState = ctx.getBroadcastState(userInfoStateDesc);

                if (broadcastState != null) {
                    Tuple2<String, String> userInfo = broadcastState.get(element.f0);
                    out.collect(element.f0 + "," + element.f1 + "," + (userInfo == null ? null : userInfo.f0) + "," + (userInfo == null ? null : userInfo.f1));
                } else {
                    out.collect(element.f0 + "," + element.f1 + "," + null + "," + null);
                }

            }

            /**
             *
             * @param element  广播流中的一条数据
             * @param ctx  上下文
             * @param out 输出器
             * @throws Exception
             */
            @Override
            public void processBroadcastElement(Tuple3<String, String, String> element, BroadcastProcessFunction<Tuple2<String, String>, Tuple3<String, String, String>, String>.Context ctx, Collector<String> out) throws Exception {

                // 从上下文中,获取广播状态对象(可读可写的状态对象)
                BroadcastState<String, Tuple2<String, String>> broadcastState = ctx.getBroadcastState(userInfoStateDesc);

                // 然后将获得的这条  广播流数据, 拆分后,装入广播状态
                broadcastState.put(element.f0, Tuple2.of(element.f1, element.f2));
            }
        });
        resultStream.print();
        env.execute();
    }
}

基于时间的合流操作-双流连接

对于两条流的合并,很多情况我们并不是简单地将所有数据放在一起,而是希望根据某个字段的值将它们联结起来,"配对"去做处理。

窗口联结(Window Join)

基于时间的操作,最基本的当然就是时间窗口了。我们之前已经介绍过 Window API 的用法,主要是针对单一数据流在某些时间段内的处理计算。那如果我们希望将两条流的数据进行合并、且同样针对某段时间进行处理和统计,又该怎么做呢?

Flink 为这种场景专门提供了一个窗口联结(window join)算子,可以定义时间窗口,并将两条流中共享一个公共键(key)的数据放在窗口中进行配对处理。

窗口联结的调用

窗口联结在代码中的实现,首先需要调用 DataStream 的**.join()方法来合并两条流,得到一个 JoinedStreams;接着通过 .where() .equalTo()方法指定两条流中联结的 key;然后通过.window()**开窗口,并调用.apply()传入联结窗口函数进行处理计算。通用调用形式如下:

java 复制代码
stream1.join(stream2)
        .where(<KeySelector>)
        .equalTo(<KeySelector>)
        .window(<WindowAssigner>)
        .apply(<JoinFunction>)

上面代码中**.where()**的参数是键选择器(KeySelector),用来指定第一条流中的 key; 而.equalTo()传入的 KeySelector 则指定了第二条流中的 key。两者相同的元素,如果在同一窗口中,就可以匹配起来,并通过一个"联结函数"(JoinFunction)进行处理了。

窗口联结的处理流程

两条流的数据到来之后,首先会按照 key 分组、进入对应的窗口中存储;当到达窗口结束时间时,算子会先统计出窗口内两条流的数据的所有组合,也就是对两条流中的数据做一个笛卡尔积(相当于表的交叉连接,cross join),然后进行遍历,把每一对匹配的数据,作为参数(first,second)传入 JoinFunction 的.join()方法进行计算处理,得到的结果直接输出如图所示。所以窗口中每有一对数据成功联结匹配,JoinFunction 的.join()方法就会被调用一次,并输出一个结果。

窗口 join 的调用语法和我们熟悉的 SQL 中表的 join 非常相似:

sql 复制代码
SELECT * FROM table1 t1, table2 t2 WHERE t1.id = t2.id;
窗口联结实例

在电商网站中,往往需要统计用户不同行为之间的转化,这就需要对不同的行为数据流,按照用户 ID 进行分组后再合并,以分析它们之间的关联。如果这些是以固定时间周期(比如1 小时)来统计的,那我们就可以使用窗口 join 来实现这样的需求。

java 复制代码
/** 
 * @desc :
 **/
public class WindowJoinExample {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        DataStream<Tuple2<String, Long>> stream1 = env.fromElements(
                Tuple2.of("a", 1000L),
                Tuple2.of("b", 1000L),
                Tuple2.of("a", 2000L),
                Tuple2.of("b", 2000L)
        ).assignTimestampsAndWatermarks(
                WatermarkStrategy.<Tuple2<String, Long>>forMonotonousTimestamps().withTimestampAssigner(
                        new SerializableTimestampAssigner<Tuple2<String, Long>>() {
                            @Override
                            public long extractTimestamp(Tuple2<String, Long> stringLongTuple2, long l) {
                                return stringLongTuple2.f1;
                            }
                        })
        );

        DataStream<Tuple2<String, Long>> stream2 = env.fromElements(
                Tuple2.of("a", 3000L),
                Tuple2.of("b", 3000L),
                Tuple2.of("a", 4000L),
                Tuple2.of("b", 4000L)
        ).assignTimestampsAndWatermarks(WatermarkStrategy.<Tuple2<String, Long>>forMonotonousTimestamps().withTimestampAssigner(
                        new SerializableTimestampAssigner<Tuple2<String, Long>>() {
                            @Override
                            public long extractTimestamp(Tuple2<String, Long> stringLongTuple2, long l) {
                                return stringLongTuple2.f1;
                            }
                        }
                )
        );

        stream1
                .join(stream2)
                .where(r -> r.f0)
                .equalTo(r -> r.f0)
                .window(TumblingEventTimeWindows.of(Time.seconds(5)))
                .apply(new JoinFunction<Tuple2<String, Long>, Tuple2<String, Long>, String>() {
                    @Override
                    public String join(Tuple2<String, Long> left, Tuple2<String, Long> right) throws Exception {
                        return left + "=>" + right;
                    }
                })
                .print();
        env.execute();
    }
}
间隔联结(Interval Join)

在有些场景下,要处理的时间间隔可能并不是固定的。比如,在电商购物订单系统中,会同时向订单表和订单明细表写入数据,两个表的数据来源不同,但是数据的时间戳相差并不大,所以只统计一段时间内是否有订单和订单明细的数据匹配。这时显然不应该用滚动窗口或滑动窗口来处理,因为匹配的两个数据有可能刚好"卡在"窗口边缘两侧,于是窗口内就都没有匹配了;会话窗口虽然时间不固定,但也明显不适合这个场景。 基于时间的窗口联结已经无能为力了。

为了应对这样的需求,Flink 提供了一种叫作"间隔联结 "(interval join)的合流操作。顾名思义,间隔联结的思路就是针对一条流的每个数据,开辟出其时间戳前后的一段时间间隔,看这期间是否有来自另一条流的数据匹配。

间隔联结的原理

间隔联结具体的定义方式是,给定两个时间点,分别叫作间隔的"上界 "(upperBound)和"下界 "(lowerBound);对于一条流(不妨叫作 A)中的任意一个数据元素 a,就可以开辟一段时间间隔:[a.timestamp + lowerBound, a.timestamp + upperBound],即以 a 的时间戳为中心,下至下界点、上至上界点的一个闭区间:就把这段时间作为可以匹配另一条流数据的"窗口"范围。所以对于另一条流(不妨叫B)中的数据元素 b,如果它的时间戳落在了这个区间范围内,a 和b 就可以成功配对,进而进行计算输出结果。所以匹配的条件为:

sql 复制代码
a.timestamp + lowerBound <= b.timestamp <= a.timestamp + upperBound

这里需要注意,做间隔联结的两条流 A 和 B,也必须基于相同的 key ;下界lowerBound应该小于等于上界upperBound,两者都可正可负;间隔联结目前只支持事件时间语义。可以清楚地看到间隔联结的方式:

间隔联结的调用

通用调用形式如下:

java 复制代码
stream1
        .keyBy(<KeySelector>)
        .intervalJoin(stream2.keyBy(<KeySelector>))
        .between(Time.milliseconds(-2), Time.milliseconds(1))
        .process (new ProcessJoinFunction<Integer, Integer, String(){
            @Override
            public void processElement(Integer left, Integer right, Context ctx, Collector<String> out) {
                out.collect(left + "," + right);
            }
        });
间隔联结实例

在电商网站中,某些用户行为往往会有短时间内的强关联。举一个例子,有两条流,一条是下订单的流,一条是订单明细流(一个订单可以包含多个商品)。可以针对同一个用户,来做这样一个联结。也就是使用一个用户的下订单的事件和这个用户的最近十分钟的订单明细数据进行一个联结查询。

下面是示例代码:

java 复制代码
/** 
 * @desc :
 **/
public class IntervalJoinExample {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        SingleOutputStreamOperator<Tuple3<String, String, Long>> orderStream = env.fromElements(
                Tuple3.of("zhangsan", "order-1", 5000L),
                Tuple3.of("lisi", "order-2", 5000L),
                Tuple3.of("wangwu", "order-3", 20000L),
                Tuple3.of("lisi", "order-4", 20000L),
                Tuple3.of("zhaoliu", "order-5", 51000L)
        ).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>> orderDetailStream = env.fromElements(
                Tuple3.of("wangwu", "OPPO A1 Pro", 2000L),
                Tuple3.of("lisi", "OPPO Reno9", 3000L),
                Tuple3.of("lisi", "vivo iQOO", 3500L),
                Tuple3.of("wangwu", "Apple iPhone 13", 2500L),
                Tuple3.of("lisi", "OPPO Reno9 Pro", 36000L),
                Tuple3.of("wangwu", "小米MIX Fold2", 30000L),
                Tuple3.of("wangwu", "HUAWEI Mate 50", 23000L),
                Tuple3.of("wangwu", "Redmi Note12Pro+", 33000L)
        ).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;
                    }
                })
        );

        orderStream.keyBy(data -> data.f0)
                .intervalJoin(orderDetailStream.keyBy(data -> data.f0))
                .between(Time.seconds(-5), Time.seconds(10))
                .process(new ProcessJoinFunction<Tuple3<String, String, Long>, Tuple3<String, String, Long>, String>() {
                    @Override
                    public void processElement(Tuple3<String, String, Long> left, Tuple3<String, String, Long> right, ProcessJoinFunction<Tuple3<String, String, Long>, Tuple3<String, String, Long>, String>.Context ctx, Collector<String> out) throws Exception {
                        out.collect("order:" + left + " ===> orderDetail:" + right);
                    }
                })
                .print();

        env.execute();
    }
}

结果分析

order中的一条数据匹配orderDetail流中的一段时间,时间以order为准,代码中下界为-5,上界为10,所以间隔为15 order第一条数据zhangsan时间为5000,那么间隔为***[0,20000],而在clorderDetail中没有匹配到zhangsan数据,没有打印 order第二条数据lisi时间为5000,间隔同上[0,20000]**,orderDetail有俩条数据匹配:

复制代码
Tuple3.of("lisi", "OPPO Reno9", 3000L), 
Tuple3.of("lisi", "vivo iQOO", 3500L),

order第三条数据wangwu时间为20000L,间隔为**[15000, 30000]**,orderDetail有俩条匹配:

复制代码
Tuple3.of("wangwu", "小米MIX Fold2", 30000L), 
Tuple3.of("wangwu", "HUAWEI Mate 50", 23000L),

order第四条数据lisi时间为20000,间隔为**[15000,30000], orderDetail中没有间隔时间内的lisi数据,order第五条数据zhaoliu时间为51000,间隔为[46000, 61000]**, orderDetail中没有zhaoliu数据,而且时间间隔内没有数据。

综上所述,总共匹配到四条数据:

复制代码
order:(lisi,order-2,5000) ===> orderDetail:(lisi,OPPO Reno9,3000)
order:(lisi,order-2,5000) ===> orderDetail:(lisi,vivo iQOO,3500)
order:(wangwu,order-3,20000) ===> orderDetail:(wangwu,小米MIX Fold2,30000)
order:(wangwu,order-3,20000) ===> orderDetail:(wangwu,HUAWEI Mate 50,23000)
窗口同组联结(Window CoGroup)
窗口同组联结的调用

除窗口联结和间隔联结之外,Flink 还提供了一个"窗口同组联结 "(window coGroup)操作。它的用法跟window join 非常类似,也是将两条流合并之后开窗处理匹配的元素,调用时只需要将**.join()换为.coGroup()**就可以了。

复制代码
stream1.coGroup(stream2)
        .where(<KeySelector>)
        .equalTo(<KeySelector>)
        .window(TumblingEventTimeWindows.of(Time.hours(1)))
        .apply(<CoGroupFunction>)
窗口同组联结实例

coGroup 操作比窗口的 join 更加通用,不仅可以实现类似 SQL 中的"内连接"(inner join),也可以实现左外连接(left outer join)、右外连接(right outer join)和全外连接(full outer join)。事实上,窗口 join 的底层,也是通过 coGroup 来实现的。

下面是coGroup 的示例代码:

java 复制代码
/** 
 * @desc : 基于窗口的join
 **/
public class CoGroupExample {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        DataStream<Tuple2<String, Long>> stream1 = env
                .fromElements(
                        Tuple2.of("a", 1000L),
                        Tuple2.of("b", 1000L),
                        Tuple2.of("a", 2000L),
                        Tuple2.of("b", 2000L)
                )
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<Tuple2<String, Long>>forMonotonousTimestamps()
                                .withTimestampAssigner(new SerializableTimestampAssigner<Tuple2<String, Long>>() {
                                        @Override
                                        public long extractTimestamp(Tuple2<String, Long> stringLongTuple2, long l) {
                                            return stringLongTuple2.f1;
                                        }
                                    }
                                )
                );
        
        DataStream<Tuple2<String, Long>> stream2 = env
                .fromElements(
                        Tuple2.of("a", 3000L),
                        Tuple2.of("b", 3000L),
                        Tuple2.of("a", 4000L),
                        Tuple2.of("b", 4000L)
                )
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<Tuple2<String, Long>>forMonotonousTimestamps()
                                .withTimestampAssigner(new SerializableTimestampAssigner<Tuple2<String, Long>>() {
                                        @Override
                                        public long extractTimestamp(Tuple2<String, Long> stringLongTuple2, long l) {
                                            return stringLongTuple2.f1;
                                        }
                                    }
                                )
                );
        stream1
                .coGroup(stream2)
                .where(r -> r.f0)
                .equalTo(r -> r.f0)
                .window(TumblingEventTimeWindows.of(Time.seconds(5)))
                .apply(new CoGroupFunction<Tuple2<String, Long>, Tuple2<String, Long>, String>() {
                    @Override
                    public void coGroup(Iterable<Tuple2<String, Long>> iter1, Iterable<Tuple2<String, Long>> iter2, Collector<String> collector) throws Exception {
                        collector.collect(iter1 + "=>" + iter2);
                    }
                })
                .print();
        env.execute();
    }
}

输出结果是:

复制代码
[(a,1000), (a,2000)]=>[(a,3000), (a,4000)]
[(b,1000), (b,2000)]=>[(b,3000), (b,4000)]

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

如上图所示有状态算子的一般处理流程,具体步骤如下:

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

可以将state(状态)理解为存储数据的数据库

状态的概念

在传统的事务型处理架构中,这种额外的状态数据是保存在数据库中的。而对于实时流处理来说,这样做需要频繁读写外部数据库,如果数据规模非常大肯定就达不到性能要求了。所以 Flink 的解决方案是,将状态直接保存在内存中来保证性能,并通过分布式扩展来提高吞吐量

什么是状态(raw状态)

在稍复杂的流式计算逻辑中,基本都需要记录和利用一些历史累积信息;

例如,对整数流进行全局累计求和的逻辑:

① 我们在算子中定义一个变量来记录累计到当前的总和;

② 在一条新数据到达时,就将新数据累加到总和变量中;

③ 然后输出结果;

由上可知:状态就是用户在程序逻辑中用于记录信息的变量

(当然,依据不同的需求,状态数据可多可少,可简单可复杂)!

如上所述,state只不过是用户编程时自定义的"变量",跟flink又有何关系呢?

因为状态(状态中记录的数据),需要容错!!!程序一旦在运行中突然失败,则用户自定义的状态所记录的数据会丢失,因而无法实现失败后重启的接续!

flink托管状态

flink提供了内置的状态数据管理机制(简称状态机制);

flink会进行状态数据管理:包括故障发生后的状态一致性维护、以及状态数据的高效存储和访问,

用户借由flink所提供的的状态管理机制来托管自己的状态数据,则不用担心状态数据在程序失败及恢复时所引入的一系列问题;从而使得开发人员可以专注于应用程序的逻辑开发;

状态的分类

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

原始状态,即用户自定义的 State。Flink 在做快照的时候,把整个 State 当做一个整体,需要开发者自己管理,使用 byte 数组来读写状态内容。

  • 托管状态

托管状态是由 Flink 框架管理的 State,如 ValueState、ListState 等,其序列化和反序列化由 Flink 框架提供支持,无需用户感知、干预。

通常在 DataStream 上的状态,推荐使用托管状态,一般情况下,在实现自定义算子时,才会使用到原始状态。

接下来的重点就是托管状态(Managed State)

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

    Keyed State是KeyedStream上的状态。假如输入流按照id为Key进行了keyBy分组,形成一个KeyedStream,数据流中所有id为1的数据共享一个状态,可以访问和更新这个状态,以此类推,每个Key对应一个自己的状态。下图展示了Keyed State,因为一个算子子任务可以处理一到多个Key,算子子任务1处理了两种Key,两种Key分别对应自己的状态。

  • 算子状态(Operator State)

    Operator State可以用在所有算子上,每个算子子任务或者说每个算子实例共享一个状态,流入这个算子子任务的数据可以访问和更新这个状态。下图展示了Operator State,算子子任务1上的所有数据可以共享第一个Operator State,以此类推,每个算子子任务上的数据共享自己的状态。

两者的区别

Keyed State Operator State
适用算子类型 只适用于KeyedStream上的算子 可以用于所有算子
状态分配 每个Key对应一个状态 一个算子子任务对应一个状态
创建和访问方式 重写Rich Function,通过里面的RuntimeContext访问 实现CheckpointFunction等接口
横向扩展 状态随着key自动在多个算子子任务上迁移 有多种状态重新分配的方式
支持的数据结构 ValueState、ListState、MapState等 ListState、BroadcastState等

按键分区状态(Keyed State)

支持的结构类型

对于Keyed State,Flink提供了几种现成的数据结构可以使用,包括ValueState、ListState等,他们的继承关系如下图所示。首先,State主要有三种实现,分别为ValueStateMapStateAppendingState ,AppendingState又可以细分为ListStateReducingStateAggregatingState

值状态(ValueState)

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

java 复制代码
public interface ValueState<T> extends State {
    T value() throws IOException;
    void update(T value) throws IOException;
}

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

  • T value():获取当前状态的值;

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

在具体使用时,为了让运行时上下文清楚到底是哪个状态,还需要创建一个"状态描述器"(StateDescriptor)来提供状态的基本信息。

如果将state(状态)描述为表的话,创建状态描述器,相当于定义表结构的过程。

例如源码中,ValueState 的状态描述器构造方法如下:

java 复制代码
public ValueStateDescriptor(String name, Class<T> typeClass) {
    super(name, typeClass, null);
}

需求:使用Flink的ValueState编程API实现WordCount的功能

java 复制代码
public class ValueStateExample {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        DataStreamSource<String> lines = env.socketTextStream("node1", 9999);

        SingleOutputStreamOperator<Tuple2<String, Integer>> wordAndOne = lines.flatMap(new FlatMapFunction<String, Tuple2<String, Integer>>() {
            @Override
            public void flatMap(String line, Collector<Tuple2<String, Integer>> out) throws Exception {
                String[] words = line.split(" ");
                for (String word : words) {
                    out.collect(Tuple2.of(word, 1));
                }
            }
        });

        KeyedStream<Tuple2<String, Integer>, String> keyedStream = wordAndOne.keyBy(t -> t.f0);

        SingleOutputStreamOperator<Tuple2<String, Integer>> res = keyedStream.map(new RichMapFunction<Tuple2<String, Integer>, Tuple2<String, Integer>>() {
            private ValueState<Integer> valueState;//valueState不用管key,与key相关的操作都在内部实现了
            //前边代码块使用一个map来存数据,这里却只有一个integer,感觉上没有和key绑定在一起,但是实际上由于使用了keyedState,存取数据都会和对应的key绑定
            //在open方法中初始化状态或恢复状态
            @Override
            public void open(Configuration parameters) throws Exception {
                //定义状态描述器(描述状态的类型、名称)
                ValueStateDescriptor<Integer> stateDescriptor = new ValueStateDescriptor<>("wc-state", Integer.class);//如果这里包含泛型,那就需要使用typeInformation.of(new type)
                //初始化或恢复状态(在状态存储的地方读状态)
                valueState = getRuntimeContext().getState(stateDescriptor);
            }

            @Override
            public Tuple2<String, Integer> map(Tuple2<String, Integer> input) throws Exception {
                //String word = input.f0; //这个key没有用处,update内部能直接获取key
                Integer current = input.f1;
                //看似没有根据key来取,实际上内部会获取当前的key,根据当前的key取出对应的value
                Integer history = valueState.value();
                if (history == null) {
                    history = 0;
                }
                current += history;
                //更新状态
                valueState.update(current);
                //输出数据
                input.f1 = current;
                return input;
            }
        });

        res.print();

        env.execute();
    }
}
列表状态(ListState)

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

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

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

需求:将同一个用户,最近的10个行为保存起来

java 复制代码
public class ListStateExample {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        //将同一个用户,最近的10个行为保存起来
        //u001,view
        //u001,pay
        //u002,view
        //u002,view
        DataStreamSource<String> lines = env.socketTextStream("node1", 9999, "\n", 5);

        SingleOutputStreamOperator<Tuple2<String, String>> tpStream = lines.map(new MapFunction<String, Tuple2<String, String>>() {
            @Override
            public Tuple2<String, String> map(String value) throws Exception {
                String[] fields = value.split(",");
                String uid = fields[0];
                String event = fields[1];
                return Tuple2.of(uid, event);
            }
        });

        KeyedStream<Tuple2<String, String>, String> keyedStream = tpStream.keyBy(t -> t.f0);

        //将用一个用户的行为数据按照先后顺序保存起来
        SingleOutputStreamOperator<Tuple2<String, List<String>>> res = keyedStream.map(new UserEventFunction());

        res.print();

        env.execute();
    }

    private static class UserEventFunction extends RichMapFunction<Tuple2<String, String>, Tuple2<String, List<String>>> {

        private ListState<String> listState;

        @Override
        public void open(Configuration parameters) throws Exception {
            //定义状态描述器
            ListStateDescriptor<String> stateDescriptor = new ListStateDescriptor<>("event-state", String.class);
            //初始化或恢复状态
            listState = getRuntimeContext().getListState(stateDescriptor);
        }

        @Override
        public Tuple2<String, List<String>> map(Tuple2<String, String> input) throws Exception {
            String event = input.f1;
            listState.add(event);
            ArrayList<String> events = (ArrayList<String>) listState.get();
            if (events.size() > 10) {
                events.remove(0);
            }
            return Tuple2.of(input.f0, events);
        }
    }
}
映射状态(MapState)

把一些键值对(key-value)作为状态整体保存起来,可以认为就是一组 key-value 映射的列表。对应的 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 keys():获取映射状态中所有的键(key),返回一个可迭代 Iterable 类型;
  • Iterable values():获取映射状态中所有的值(value),返回一个可迭代 Iterable类型;
  • boolean isEmpty():判断映射是否为空,返回一个 boolean 值。

需求:按照省份进行keyBy,将同一个省份的数据分到同一个分区中,并且按照城市累加金额

java 复制代码
public class MapStateExample {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        //辽宁省,沈阳市,3000
        //辽宁省,大连市,4000
        //辽宁省,鞍山市,4000
        //河北省,廊坊市,2000
        //河北省,邢台市,3000
        //河北省,石家庄市,2000
        DataStreamSource<String> lines = env.socketTextStream("node1", 9999, "\n", 5);

        //对数据进行整理
        SingleOutputStreamOperator<Tuple3<String, String, Integer>> tpStream = lines.map(new MapFunction<String, Tuple3<String, String, Integer>>() {
            @Override
            public Tuple3<String, String, Integer> map(String line) throws Exception {
                String[] fields = line.split(",");
                String province = fields[0];
                String city = fields[1];
                int money = Integer.parseInt(fields[2]);
                return Tuple3.of(province, city, money);
            }
        });

        //按照省份进行keyBy,将同一个省份的数据分到同一个分区中,并且按照城市累加金额
        //按照(省份,城市)keyBy的话,(省份,城市)可能导致同省份进入到不同的分区里
        KeyedStream<Tuple3<String, String, Integer>, String> keyedStream = tpStream.keyBy(t -> t.f0);

        SingleOutputStreamOperator<Tuple3<String, String, Integer>> res = keyedStream.map(new CityMoneyFunction());

        res.print();

        env.execute();
    }

    private static class CityMoneyFunction extends RichMapFunction<Tuple3<String, String, Integer>, Tuple3<String, String, Integer>> {

        private MapState<String, Integer> mapState;

        @Override
        public void open(Configuration parameters) throws Exception {
            //定义MapStateDescriptor
            MapStateDescriptor<String, Integer> stateDescriptor = new MapStateDescriptor<>("city-money-state", String.class, Integer.class);
            //初始化或恢复状态
            mapState = getRuntimeContext().getMapState(stateDescriptor);
        }

        @Override
        public Tuple3<String, String, Integer> map(Tuple3<String, String, Integer> input) throws Exception {
            String city = input.f1;
            Integer money = input.f2;
            Integer history = mapState.get(city);//根据小key取小value
            if (history == null) {
                history = 0;
            }
            money += history;
            //更新状态
            mapState.put(city, money);
            //输出数据
            input.f2 = money;
            return input;
        }
    }
}
归约状态(ReducingState)

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

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

java 复制代码
public ReducingStateDescriptor(String name, ReduceFunction<T> reduceFunction, Class<T> typeClass) {...}
聚合状态(AggregatingState)

与归约状态非常类似,聚合状态也是一个值,用来保存添加进来的所有数据的聚合结果。与 ReducingState 不同的是,它的聚合逻辑是由在描述器中传入一个更加一般化的聚合函数

(AggregateFunction)来定义的;这也就是之前讲过的 AggregateFunction,里面通过一个累加器(Accumulator)来表示状态,所以聚合的状态类型可以跟添加进来的数据类型完全不同,使用更加灵活。

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

示例代码:

java 复制代码
public class AggregatingStateExample {
    public static void main(String[] args) throws  Exception{
        //获取执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.createLocalEnvironmentWithWebUI(new Configuration());
        //StreamExecutionEnvironment.getExecutionEnvironment();
        //设置并行度
        env.setParallelism(1);
        //获取数据源
        DataStreamSource<Tuple2<Long, Long>> dataStreamSource =
                env.fromElements(
                        Tuple2.of(1L, 3L),
                        Tuple2.of(1L, 7L),
                        Tuple2.of(2L, 4L),
                        Tuple2.of(1L, 5L),
                        Tuple2.of(2L, 2L),
                        Tuple2.of(2L, 6L));


        // 输出:
        //(1,5.0)
        //(2,4.0)
        dataStreamSource
                .keyBy(0)
                .flatMap(new CountAverageWithAggregateState())
                .print();


        env.execute("TestStatefulApi");
    }

    /**
     *  ValueState<T> :这个状态为每一个 key 保存一个值
     *      value() 获取状态值
     *      update() 更新状态值
     *      clear() 清除状态
     *
     *      IN,输入的数据类型
     *      OUT:数据出的数据类型
     */
    public static class CountAverageWithAggregateState extends RichFlatMapFunction<Tuple2<Long, Long>, Tuple2<Long, String>> {

        private AggregatingState<Long, String> aggregatingState;

        /**
         * 初始化
         */
        @Override
        public void open(Configuration parameters) throws Exception {
            AggregatingStateDescriptor descriptor = new AggregatingStateDescriptor<Long, String, String>("AggregatingDescriptor", new AggregateFunction<Long, String, String>() {
                //变量初始化
                @Override
                public String createAccumulator() {
                    return "Contains";
                }

                //数据处理
                @Override
                public String add(Long value, String accumulator) {
                    return "Contains".equals(accumulator) ? accumulator + value : accumulator + "and" + value;
                }

                //返回值函数
                @Override
                public String getResult(String accumulator) {
                    return accumulator;
                }

                //好像无用.......debug并没有使用到该函数
                @Override
                public String merge(String o, String acc1) {
                    return o + "and1111" + acc1;
                }
            }, String.class);

            aggregatingState = getRuntimeContext().getAggregatingState(descriptor);
        }

        @Override
        public void flatMap(Tuple2<Long, Long> ele, Collector<Tuple2<Long, String>> collector) throws Exception {
            aggregatingState.add(ele.f1);
            collector.collect(Tuple2.of(ele.f0, aggregatingState.get()));
        }
    }
}
状态生存时间(TTL)

在某些场景下 Flink 用户状态一直在无限增长,一些用例需要能够自动清理旧的状态。例如,作业中定义了超长的时间窗口,或者在动态表上应用了无限范围的 GROUP BY 语句。此外,目前开发人员需要自己完成 TTL 的临时实现,例如使用可能不节省存储空间的计时器服务。

对于这些情况,旧版本的 Flink 并不能很好解决,因此 Apache Flink 1.6.0 版本引入了状态 TTL 特性。该特性可以让 Keyed 状态在一定时间内没有被使用下自动过期。如果配置了 TTL 并且状态已过期,那么会尽最大努力来清理过期状态。

使用方法

可以在 Flink 官方文档中看到 State TTL 如下使用方式:

java 复制代码
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);

可以看到,要使用 State TTL 功能,首先要定义一个 StateTtlConfig 对象。StateTtlConfig 对象可以通过构造器模式来创建,典型地用法是传入一个 Time 对象作为 TTL 时间,然后可以设置时间处理语义(TtlTimeCharacteristic)、更新类型(UpdateType)以及状态可见性(StateVisibility)。当创建完 StateTtlConfig 对象,可以在状态描述符中启用 State TTL 功能。

参数说明

Ttl的相关配置参数及其内含的机制,全部封装在StateTtlConfig类中。

过期时间

newBuilder 方法的参数是必需的,表示状态的过期时间,是一个 org.apache.flink.api.common.time.Time 对象。可以简单的认为一旦设置了 TTL,那么如果上次访问的时间戳 + TTL 超过了当前时间,那么表明状态过期了(实际上更复杂一些)。

时间处理语义

TtlTimeCharacteristic 表示 State TTL 功能可以使用的时间处理语义:

java 复制代码
public enum TtlTimeCharacteristic {
    ProcessingTime
}

截止到目前当前版本,只支持 ProcessingTime 时间处理语义。

可以通过如下方法显示设置:

java 复制代码
setTtlTimeCharacteristic(StateTtlConfig.TtlTimeCharacteristic.ProcessingTime)
更新类型

UpdateType 表示状态时间戳(上次访问时间戳)的更新时机:

java 复制代码
public enum UpdateType {
    Disabled,
    OnCreateAndWrite,
    OnReadAndWrite
}
  • 如果设置为 Disabled,则表示禁用 TTL 功能,状态不会过期;

  • 如果设置为 OnCreateAndWrite,那么表示在状态创建或者每次写入时都会更新时间戳;

  • 如果设置为 OnReadAndWrite,那么除了在状态创建和每次写入时更新时间戳外,读取状态也会更新状态的时间戳。

  • 如果不配置默认为 OnCreateAndWrite

可以通过如下方法显示设置:

java 复制代码
// 等价于 updateTtlOnCreateAndWrite()
setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
// 等价于 updateTtlOnReadAndWrite()
setUpdateType(StateTtlConfig.UpdateType.OnReadAndWrite)
状态可见性

StateVisibility 表示状态可见性,在读取状态时是否返回过期值:

java 复制代码
public enum StateVisibility {
    ReturnExpiredIfNotCleanedUp,
    NeverReturnExpired
}
  • 如果设置为 ReturnExpiredIfNotCleanedUp,那么当状态值已经过期,但还未被真正清理掉,就会返回给调用方;

  • 如果设置为 NeverReturnExpired,那么一旦状态值过期了,就永远不会返回给调用方,只会返回空状态。

可以通过如下方法显示设置:

java 复制代码
// 等价于 returnExpiredIfNotCleanedUp()
setStateVisibility(StateTtlConfig.StateVisibility.ReturnExpiredIfNotCleanedUp)
// 等价于 neverReturnExpired()
setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
过期清理策略

从 Flink 1.6.0 版本开始,当生成 Checkpoint 或者 Savepoint 全量快照时会自动删除过期状态。但是,过期状态删除不适用于增量 Checkpoint,必须明确启用全量快照才能删除过期状态。全量快照的大小会减小,但本地状态存储大小并不会减少。只有当用户从快照重新加载其状态到本地时,才会清除用户的本地状态。由于上述这些限制,为了改善用户体验,Flink 1.8.0 引入了两种逐步触发状态清理的策略,分别是针对 Heap StateBackend 的增量清理策略以及针对 RocksDB StateBackend 的压缩清理策略。到目前为止,一共有三种过期清理策略:

  • 全量快照清理策略

  • 增量清理策略

  • RocksDB 压缩清理策略

全量快照清理策略

全量快照清理策略,这种策略可以在生成全量快照(Snapshot/Checkpoint)时清理过期状态,这样可以大大减小快照存储,但需要注意的是本地状态中过期数据并不会被清理。唯有当作业重启并从上一个快照恢复后,本地状态才会实际减小。如果要在 DataStream 中使用该过期请策略,请参考如下所示代码:

java 复制代码
import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.time.Time;

StateTtlConfig ttlConfig = StateTtlConfig
        .newBuilder(Time.seconds(1))
        .cleanupFullSnapshot()
        .build();

这种过期清理策略对开启了增量检查点的 RocksDB 状态后端无效

增量清理策略

针对 Heap StateBackend 的增量清理策略。这种策略下存储后端会为所有状态条目维护一个惰性全局迭代器。每次触发增量清理时,迭代器都会向前迭代删除已遍历的过期数据。如果要在 DataStream 中使用该过期请策略,请参考如下所示代码:

java 复制代码
import org.apache.flink.api.common.state.StateTtlConfig;
StateTtlConfig ttlConfig = StateTtlConfig
        .newBuilder(Time.seconds(1))
        .cleanupIncrementally(5, false)
        .build();

该策略有两个参数:第一个参数表示每次触发清理时需要检查的状态条目数,总是在状态访问时触发。第二个参数定义了在每次处理记录时是否额外触发清理。堆状态后端的默认后台清理每次触发检查 5 个条目,处理记录时不会额外进行过期数据清理。

RocksDB 压缩清理策略

如果使用 RocksDB StateBackend,则会调用 Flink 指定的压缩过滤器进行后台清理。RocksDB 周期性运行异步压缩来合并状态更新并减少存储。Flink 压缩过滤器使用 TTL 检查状态条目的过期时间戳并删除过期状态值。如果要在 DataStream 中使用该过期请策略,请参考如下所示代码:

java 复制代码
import org.apache.flink.api.common.state.StateTtlConfig;

StateTtlConfig ttlConfig = StateTtlConfig
        .newBuilder(Time.seconds(1))
        .cleanupInRocksdbCompactFilter(1000)
        .build();

RocksDB 压缩过滤器在每次处理一定状态条目后,查询当前的时间戳并检查是否过期。频繁地更新时间戳可以提高清理速度,但同样也会降低压缩性能。RocksDB 状态后端的默认每处理 1000 个条目就查询当前时间戳

示例代码

需求:自定义数据源模拟生成车辆驾驶信息,驾驶信息中包含当前车速和限速,如果当前的车速超过了限速,则提示:超速开始 ,如果后续到达的驾驶信息依然超速,则提示:持续超速中 ,反之提示:超速结束

java 复制代码
/** 
 * @desc : flink 有状态计算 状态过期设置(ttl)
 **/
public class Flink_State_3_TTL {
    public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setRuntimeMode(RuntimeExecutionMode.STREAMING);
        env.setParallelism(4);
        final DataStreamSource<Location> locationSource = env.addSource(new LocationSource());
        final WindowedStream<Location, Integer, GlobalWindow> windowedStream = locationSource
                .keyBy(Location::getVehicleId)
                .countWindow(1);
        windowedStream.apply(new SpeedAlarmWindow()).print();
        env.execute("state-ttl");

    }

    public static class SpeedAlarmWindow extends RichWindowFunction<Location, String, Integer, GlobalWindow> {
        MapState<String, Location> locationState;

        @Override
        public void apply(Integer integer, GlobalWindow window, Iterable<Location> locationList, Collector<String> out) throws Exception {
            for (Location location : locationList) {
                final String key = location.getVehicleId().toString();
                final Location preLocation = locationState.get(key);
                if (preLocation == null) {
                    if (location.getGpsSpeed() > location.getLimitSpeed()) {
                        locationState.put(key, location);
                        out.collect(location.toString() + "超速开始");
                        return;
                    }
                } else {
                    if (location.getGpsSpeed() > location.getLimitSpeed()) {
                        locationState.put(key, location);
                        out.collect(location.toString() + "持续超速中" + ">> " +
                                "上一条超速数据为:" + "\n" + preLocation.toString());

                    } else {
                        locationState.remove(key);
                        out.collect(location.toString() + "超速结束");
                    }
                }
            }
        }

        @Override
        public void open(Configuration parameters) throws Exception {
            super.open(parameters);
            StateTtlConfig ttlConfig = StateTtlConfig
                    // 状态有效时间
                    .newBuilder(Time.seconds(10))
                    //设置状态更新类型
                    .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
                    // 已过期但还未被清理掉的状态如何处理
                    .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
                    // 过期对象的清理策略
                    .cleanupFullSnapshot()
                    .build();

            //MapState 状态管理配置
            MapStateDescriptor<String, Location> mapStateDescriptor = new MapStateDescriptor<>("locationState",
                    TypeInformation.of(String.class),
                    TypeInformation.of(Location.class));

            //启用状态存活时间设置
            mapStateDescriptor.enableTimeToLive(ttlConfig);
            this.locationState = getRuntimeContext().getMapState(mapStateDescriptor);
        }
    }

    public static class LocationSource implements SourceFunction<Location> {
        Boolean flag = true;
        @Override
        public void run(SourceContext<Location> ctx) throws Exception {
            Random random = new Random();
            while (flag) {
                int vehicleId = random.nextInt(2) + 1;
                Location location = Location.builder()
                        .vehicleId(vehicleId)
                        .plate("川A000" + vehicleId)
                        .color("绿")
                        .date(Integer.parseInt(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)))
                        .gpsSpeed(RandomUtil.randomInt(88, 100))
                        .limitSpeed(RandomUtil.randomInt(88, 95))
                        .devTime(System.currentTimeMillis())
                        .build();
                ctx.collect(location);
                Thread.sleep(RandomUtil.randomInt(5,15)*1000);

            }
        }

        @Override
        public void cancel() {
            flag = false;
        }
    }

    @NoArgsConstructor
    @AllArgsConstructor
    @Data
    @Builder
    public static class Location {
        //车辆编号
        private Integer vehicleId;
        //车牌
        private String plate;
        //车身颜色
        private String color;
        //日期
        private Integer date;
        //当前车速
        private Integer gpsSpeed;
        //限速
        private Integer limitSpeed;
        private Long devTime;
    }
}

算子状态(Operator State)

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

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

状态类型

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

列表状态(ListState)

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

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

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

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

总结

ListState的快照存储数据,在系统重启后,list数据的重分配模式为: round-robin; 轮询平均分配

联合列表状态(UnionListState)

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

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

总结

unionListState的快照存储数据,在系统重启后,list数据的重分配模式为: 广播模式; 在每个subtask上都拥有一份完整的数据

广播状态(BroadcastState)

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

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

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

代码实现
CheckpointedFunction 接口

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

CheckpointedFunction 接口在源码中定义如下:

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

每次应用保存检查点做快照时,都会调用**.snapshotState()方法,将状态进行外部持久化。而在算子任务进行初始化时,会调用. initializeState()**方法。这又有两种情况:

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

所以, 接口中的**.snapshotState()方法定义了检查点的快照保存逻辑,而. initializeState()**方法不仅定义了初始化逻辑,也定义了恢复逻辑。

代码:

java 复制代码
public class OperatorStateExample {

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

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        env.setRuntimeMode(RuntimeExecutionMode.STREAMING);

        // 开启状态数据的checkpoint机制(快照的周期,快照的模式)
        env.enableCheckpointing(1000, CheckpointingMode.EXACTLY_ONCE);

        DataStreamSource<String> source = env.socketTextStream("node1", 9999);

        // 需要使用map算子来达到一个效果:
        // 没来一条数据(字符串),输出 该条字符串拼接此前到达过的所有字符串
        source.map(new StateMapFunction()).print();

        // 提交一个job
        env.execute();

    }
}

/**
 * 要使用operator state,需要让用户自己的Function类去实现 CheckpointedFunction
 * 然后在其中的 方法initializeState 中,去拿到operator state 存储器
 */
class StateMapFunction implements  MapFunction<String,String> , CheckpointedFunction{

    ListState<String> listState;

    /**
     * 正常的MapFunction的处理逻辑方法
     * @param value The input value.
     * @return
     * @throws Exception
     */
    @Override
    public String map(String value) throws Exception {

        /**
         * 故意埋一个异常,来测试 task级别自动容错效果
         */
        if(value.equals("x") && RandomUtils.nextInt(1,15)% 4 == 0)
            throw new Exception("出错了...");

        // 将本条数据,插入到状态存储器中
        listState.add(value);

        // 然后拼接历史以来的字符串
        Iterable<String> strings = listState.get();
        StringBuilder sb = new StringBuilder();
        for (String string : strings) {
            sb.append(string);
        }

        return sb.toString();
    }

    /**
     * 系统对状态数据做快照(持久化)时会调用的方法,用户利用这个方法,在持久化前,对状态数据做一些操控
     * @param context the context for drawing a snapshot of the operator
     * @throws Exception
     */
    @Override
    public void snapshotState(FunctionSnapshotContext context) throws Exception {
        System.out.println("checkpoint 触发了,checkpointId : " +context.getCheckpointId());
    }

    /**
     * 算子任务在启动之初,会调用下面的方法,来为用户进行状态数据初始化
     * @param context the context for initializing the operator
     * @throws Exception
     */
    @Override
    public void initializeState(FunctionInitializationContext context) throws Exception {

        // 从方法提供的context中拿到一个算子状态存储器
        OperatorStateStore operatorStateStore = context.getOperatorStateStore();

        // 算子状态存储器,只提供List数据结构来为用户存储数据
        ListStateDescriptor<String> stateDescriptor = new ListStateDescriptor<>("strings", String.class); // 定义一个状态存储结构描述器

        // getListState方法,在task失败后,task自动重启时,会帮用户自动加载最近一次的快照状态数据
        // 如果是job重启,则不会自动加载此前的快照状态数据
        listState = operatorStateStore.getListState(stateDescriptor);  // 在状态存储器上调用get方法,得到具体结构的状态管理器


        /**
         * unionListState 和普通 ListState的区别:
         * unionListState的快照存储数据,在系统重启后,list数据的重分配模式为: 广播模式; 在每个subtask上都拥有一份完整的数据
         * ListState的快照存储数据,在系统重启后,list数据的重分配模式为: round-robin; 轮询平均分配
         */
        //ListState<String> unionListState = operatorStateStore.getUnionListState(stateDescriptor);
    }
}

广播状态(Broadcast State)

什么时候会用到这样的广播状态呢?一个最为普遍的应用,就是"动态配置 "或者"动态规则"。在处理流数据时,有时会基于一些配置(configuration)或者规则( rule )。简单的配置当然可以直接读取配置文件,一次加载,永久有效;但数据流是连续不断的,如果这配置随着时间推移还会动态变化,那又该怎么办呢?

解决的办法,还是流处理的"事件驱动"思路------可以将这动态的配置数据看作一条流,将这条流和本身要处理的数据流进行连接(connect ),就可以实时地更新配置进行计算了。

基本用法

通常,首先会:

  • 创建一个Keyed或Non-Keyed的Data Stream

  • 然后再创建一个Broadcasted Stream

  • 最后通过Data Stream来连接(调用connect方法)到Broadcasted Stream上,这样实现将Broadcast State广播到Data Stream下游的每个Task中。

如果Data Stream是Keyed Stream ,则连接到Broadcasted Stream后,添加处理ProcessFunction时需要使用KeyedBroadcastProcessFunction来实现,下面是KeyedBroadcastProcessFunction的API

上面泛型中的各个参数的含义,说明如下:

  • KS:表示Flink程序从最上游的Source Operator开始构建Stream,当调用keyBy时所依赖的Key的类型;

  • IN1:表示非Broadcast的Data Stream中的数据记录的类型;

  • IN2:表示Broadcast Stream中的数据记录的类型;

  • OUT:表示经过KeyedBroadcastProcessFunction的processElement()和processBroadcastElement()方法处理后输出结果数据记录的类型。

如果Data Stream是Non-Keyed Stream ,则连接到Broadcasted Stream后,添加处理ProcessFunction时需要使用BroadcastProcessFunction来实现,下面是BroadcastProcessFunction的API

上面泛型中的各个参数的含义,与前面KeyedBroadcastProcessFunction的泛型类型中的后3个含义相同,只是没有调用keyBy操作对原始Stream进行分区操作,就不需要KS泛型参数。

具体如何使用上面的BroadcastProcessFunction,接下来会在通过实际编程,来以使用KeyedBroadcastProcessFunction为例进行详细说明。

代码执行流程解析
代码实例

案例背景:

  • 流 1:Source1来自socket 输入id数字

  • 流 2:Source2来自自定义Source, 每隔5秒生成对应的广告数据

需要加工流1,把用户的维度信息填充好,利用广播流来实现

java 复制代码
/** 
 * @desc : 广播流及广播状态的使用示例
 *
 * 需求:公司有10个广告位, 其广告的内容(描述和图片)会经常变动(广告到期,更换广告等)
 * 实现:
 * 1)通过socket输入广告id
 * 2)关联出来广告的信息打印出来,如果广告发生改变,能够感知到
 * 以一个自定义source来模拟操作过程
 **/
public class BroadCastExample {
    public static void main(String[] args) throws Exception {
        //TODO 1)初始化flink流式处理的运行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        //TODO 2)设置checkpoint周期性运行,每5秒钟运行一次
        env.enableCheckpointing(5000L);

        //TODO 3)构建数据源(两个数据源)
        //构建事件流
        DataStreamSource<String> socketTextStream = env.socketTextStream("node1", 9999);
        SingleOutputStreamOperator<Integer> adIdDataStream = socketTextStream.map(new MapFunction<String, Integer>() {
            @Override
            public Integer map(String s) throws Exception {
                return Integer.parseInt(s);
            }
        });

        //构建广播流(广告位)
        DataStreamSource<Map<Integer, Tuple2<String, String>>> adSourceStream = env.addSource(new MySourceForBroadcastFunction());
        //将广告位流广播出去
        MapStateDescriptor<Integer, Tuple2<String, String>> mapStateDescriptor =
                new MapStateDescriptor<Integer, Tuple2<String, String>>(
                        "broadcaststate",
                        TypeInformation.of(new TypeHint<Integer>() { }),
                        TypeInformation.of(new TypeHint<Tuple2<String, String>>() { })
                );

        //TODO 4)将广告流作为配置流转换成广播流
        //广告位流变成了广播流
        BroadcastStream<Map<Integer, Tuple2<String, String>>> broadcastStream = adSourceStream.broadcast(mapStateDescriptor);

        //TODO 5)将两个流进行关联操作(connect)
        BroadcastConnectedStream<Integer, Map<Integer, Tuple2<String, String>>> connectedStream = adIdDataStream.connect(broadcastStream);

        //TODO 6)对关联的数据进行拉宽操作
        SingleOutputStreamOperator<Tuple2<String, String>> result = connectedStream.process(new BroadcastProcessFunction<Integer,
                Map<Integer, Tuple2<String, String>>, Tuple2<String, String>>() {

            //定义state的描述器
            MapStateDescriptor<Integer, Tuple2<String, String>> mapStateDescriptor =
                    new MapStateDescriptor<Integer, Tuple2<String, String>>(
                            "broadcaststate",
                            TypeInformation.of(new TypeHint<Integer>() {
                            }),
                            TypeInformation.of(new TypeHint<Tuple2<String, String>>() {
                            })
                    );

            /**
             * 对事件流的每条数据进行处理
             * 只能对广播流的数据进行读取操作
             * @param value
             * @param ctx
             * @param out
             * @throws Exception
             */
            @Override
            public void processElement(Integer value, ReadOnlyContext ctx, Collector<Tuple2<String, String>> out) throws Exception {
                //只读操作,意味着只能读取操作,不能修改数据,根据广告id获取广告信息
                ReadOnlyBroadcastState<Integer, Tuple2<String, String>> broadcastState = ctx.getBroadcastState(mapStateDescriptor);
                //根据广告id获取广告位信息
                Tuple2<String, String> tuple2 = broadcastState.get(value);
                if (tuple2 != null) out.collect(tuple2);
            }

            /**
             * 可以对广播流的数据进行修改
             * @param value
             * @param ctx
             * @param out
             * @throws Exception
             */
            @Override
            public void processBroadcastElement(Map<Integer, Tuple2<String, String>> value,
                                                Context ctx, Collector<Tuple2<String, String>> out) throws Exception {
                //将原来的广播流的数据在状态中删除掉(广播状态意味着可以删除修改数据)
                BroadcastState<Integer, Tuple2<String, String>> broadcastState = ctx.getBroadcastState(mapStateDescriptor);
                //将状态中保存的历史的广播流数据删除掉,避免存在大量垃圾数据
                broadcastState.clear();
                //将最新的广播流数据放入到广播状态中
                broadcastState.putAll(value);
            }
        });

        //TODO 7)打印输出
        adSourceStream.printToErr("广告配置流>>>");
        result.print();

        //TODO 8)运行作业
        env.execute();
    }

    /**
     * 广告位流
     */
    public static class MySourceForBroadcastFunction implements SourceFunction<Map<Integer, Tuple2<String, String>>> {
        private final Random random = new Random();
        private final List<Tuple2<String, String>> ads = Arrays.asList(
                Tuple2.of("baidu", "搜索引擎"),
                Tuple2.of("google", "科技大牛"),
                Tuple2.of("aws", "全球领先的云平台"),
                Tuple2.of("aliyun", "全球领先的云平台"),
                Tuple2.of("腾讯", "氪金使我变强"),
                Tuple2.of("阿里巴巴", "电商龙头"),
                Tuple2.of("字节跳动", "靠算法出名"),
                Tuple2.of("美团", "黄色小公司"),
                Tuple2.of("饿了么", "蓝色小公司"),
                Tuple2.of("瑞幸咖啡", "就是好喝")
        );
        private boolean isRun = true;

        @Override
        public void run(SourceFunction.SourceContext<Map<Integer, Tuple2<String, String>>> ctx) throws Exception {
            while (isRun) {
                Map<Integer, Tuple2<String, String>> map = new HashMap<>();
                int keyCounter = 0;
                for (int i = 0; i < ads.size(); i++) {
                    keyCounter++;
                    map.put(keyCounter, ads.get(random.nextInt(ads.size())));
                }
                ctx.collect(map);

                TimeUnit.SECONDS.sleep(5L);
            }
        }

        @Override
        public void cancel() {
            this.isRun = false;
        }
    }
}

状态持久化和状态后端

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

检查点(Checkpoint)

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

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

默认情况下,检查点是被禁用的, 需要在代码中手动开启。直接调用执行环境的**.enableCheckpointing()**方法就可以开启检查点。

java 复制代码
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); 
env.enableCheckpointing(1000);

这里传入的参数是检查点的间隔时间,单位为毫秒。关于检查点的详细配置,后面会详细讲。

状态后端(State Backends)

检查点的保存离不开 JobManager 和 TaskManager,以及外部存储系统的协调。在应用进行检查点保存时,首先会由 JobManager 向所有 TaskManager 发出触发检查点的命令; TaskManger 收到之后,将当前任务的所有状态进行快照保存,持久化到远程的存储介质中; 完成之后向 JobManager 返回确认信息。这个过程是分布式的,当 JobManger 收到所有TaskManager 的返回信息后,就会确认当前检查点成功保存,如图所示。而这一切工作的协调,就需要一个"专职人员"来完成。

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

状态后端的分类

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

  • 哈希表状态后端(HashMapStateBackend)

    • 状态数据是以java对象形式存储在heap内存中;

    • 内存空间不够时,也会溢出一部分数据到本地磁盘文件;

    • 可以支撑大规模的状态数据;(只不过在状态数据规模超出内存空间时,读写效率就会明显降低)

    对于KeyedState来说:

    HashMapStateBackend在内存中是使用 CopyOnWriteStateMap结构来存储用户的状态数据;

    注意,此数据结构类,名为Map,实非Map,它其实是一个单向链表的数据结构

    对于OperatorState来说:

    可以清楚看出,它底层直接用一个Map集合来存储用户的状态数据:状态名称 --> 状态List

  • 内嵌RocksDB 状态后端(EmbeddedRocksDBStateBackend)

    • 状态数据是交给rocksdb来管理;

    • Rocksdb中的数据是以序列化的kv字节进行存储;

    • Rockdb中的数据,有内存缓存的部分,也有磁盘文件的部分;

    • Rockdb的磁盘文件数据读写速度相对还是比较快的,所以在支持超大规模状态数据时,数据的读写效率不会有太大的降低

注意:上述2中状态后端,在生成checkpoint快照文件时,生成的文件格式是完全一致的;

所以,用户的flink程序在更改状态后端后,重启时依然可以加载和恢复此前的快照文件数据;

今日总结

有状态的流处理是 Flink 的本质,所以状态可以说是 Flink 中最为重要的概念。之前聚合算子、窗口算子中已经提到了状态的概念,而通过本章的学习,对整个 Flink 的状态管理机制和状态编程的方式都有了非常详尽的了解。

本章从状态的概念和分类出发,详细介绍了 Flink 中的按键分区状态(Keyed State)和算子状态(Operator State)的特点和用法,并对广播状态(Broadcast State)做了进一步的展开说明。最后,还介绍了状态的持久化和状态后端,引出了检查点(checkpoint)的概念。检查点是一个非常重要的概念,是 Flink 容错机制的核心,将在下一章继续进行详细的讨论。

相关推荐
Double@加贝1 小时前
StarRocks的执行计划和Profile
大数据·starrocks
云徒川2 小时前
AI对传统IT行业的变革
大数据·人工智能
2401_871290582 小时前
Hadoop 集群的常用命令
大数据·hadoop·分布式
qq_5470261793 小时前
Elasticsearch 评分机制
大数据·elasticsearch·jenkins
果汁华3 小时前
AI产品的基础设施:算法、数据与大语言模型
大数据·人工智能·语言模型
易境通代购商城系统、集运SAAS系统3 小时前
如何利用系统的数据分析能力提高利润额?
大数据
跨境卫士萌萌3 小时前
全球跨境电商进入精耕时代:中国品牌如何重构增长逻辑?
大数据·人工智能
chat2tomorrow3 小时前
数据仓库是什么?数据仓库的前世今生 (数据仓库系列一)
大数据·数据库·数据仓库·低代码·华为·spark·sql2api
yangmf20404 小时前
私有知识库 Coco AI 实战(一):Linux 平台部署
大数据·linux·运维·人工智能·elasticsearch·搜索引擎·全文检索
Elastic 中国社区官方博客4 小时前
Elasticsearch:理解政府中的人工智能 - 应用、使用案例和实施
大数据·人工智能·elasticsearch·机器学习·搜索引擎·ai·全文检索