Flink 使用 connect 實現雙流匹配

閱讀本文需要提前瞭解 connect 和 ProcessFunction 相關的知識。如果不瞭解的同學可以先通過官網或其他資料熟悉一下。

一、案例分析

在生產環境中,我們經常會遇到雙流匹配的案例,例如:

  • 一個訂單包含了訂單主體信息和商品的信息。

  • 外賣行業,一個訂單包含了訂單付款信息和派送信息。

  • 互聯網廣告行業,一次點擊包含了用戶的點擊行爲日誌和計費日誌。

  • 等其他相關的案例

上述這些案例都需要涉及到雙流匹配的操作,也就是所謂的雙流 join。下面用一個案例來詳解如何用 connect 實現雙流 join。

本文案例

一個訂單分成了大訂單和小訂單,大小訂單對應的數據流來自 Kafka 不同的 Topic,需要在兩個數據流中按照訂單 Id 進行匹配,這裏認爲相同訂單 id 的兩個流的延遲最大爲 60s。大訂單和小訂單匹配成功後向下游發送,若 60s 還未匹配成功,意味着當前只有一個流來臨,則認爲訂單異常,需要將數據進行側流輸出。

思路描述

提取兩個流的時間戳,因爲要通過訂單 Id 進行匹配,所以這裏按照訂單 Id 進行 keyBy,然後兩個流 connect,大訂單和小訂單的處理邏輯一樣,兩個流通過 ValueState 進行關聯。假如大訂單流對應的數據先來了,需要將大訂單的相關信息保存到大訂單的 ValueState 狀態中,註冊一個 60s 之後的定時器。

  • 如果 60s 內來了小訂單流對應的數據來了,則將兩個數據拼接發送到下游。

  • 如果 60s 內小訂單流對應的數據還沒來,就會觸發 onTimer,然後進行側流輸出。

如果小訂單流對應的數據先到,也是同樣的處理邏輯,先將小訂單的信息保存到小訂單的 ValueState 中,註冊 60s 之後的定時器。

二、實現

用代碼來講述如何實現,首先要配置 Checkpoint 等參數,這裏就不詳細闡述。

1. 定義訂單類

這裏大小訂單都使用同一個類演示:

@Data
public class Order {
    /** 訂單發生的時間 */
    long time;

    /** 訂單 id */
    String orderId;

    /** 用戶id */
    String userId;

    /** 商品id */
    int goodsId;

    /** 價格 */
    int price;

    /** 城市 */
    int cityId;
}

2. 從 Kafka 的 topic 讀取大小訂單數據

讀取大訂單數據,從 json 解析成 Order 類。從 Order 中提取 EventTime、並分配 WaterMark。按照訂單 id 進行 keyBy 得到 bigOrderStream。

// 讀取大訂單數據,讀取的是 json 類型的字符串
FlinkKafkaConsumerBase<String> consumerBigOrder =
        new FlinkKafkaConsumer011<>("big_order_topic_name",
                new SimpleStringSchema(),
                KafkaConfigUtil.buildConsumerProps(KAFKA_CONSUMER_GROUP_ID))
                .setStartFromGroupOffsets();

KeyedStream<Order, String> bigOrderStream = env.addSource(consumerBigOrder)
        // 有狀態算子一定要配置 uid
        .uid(KAFKA_TOPIC)
        // 過濾掉 null 數據
        .filter(Objects::nonNull)
        // 將 json 解析爲 Order 類
        .map(str -> JSON.parseObject(str, Order.class))
        // 提取 EventTime,分配 WaterMark
        .assignTimestampsAndWatermarks(
                new BoundedOutOfOrdernessTimestampExtractor<Order>
                        (Time.seconds(60)) {
                    @Override
                    public long extractTimestamp(Order order) {
                        return order.getTime();
                    }
                })
        // 按照 訂單id 進行 keyBy
        .keyBy(Order::getOrderId);

小訂單的處理邏輯與上述流程完全類似,只不過讀取的 topic 不是同一個。

// 小訂單處理邏輯與大訂單完全一樣
FlinkKafkaConsumerBase<String> consumerSmallOrder =
        new FlinkKafkaConsumer011<>("small_order_topic_name",
                new SimpleStringSchema(),
                KafkaConfigUtil.buildConsumerProps(KAFKA_CONSUMER_GROUP_ID))
                .setStartFromGroupOffsets();

KeyedStream<Order, String> smallOrderStream = env.addSource(consumerSmallOrder)
        .uid(KAFKA_TOPIC)
        .filter(Objects::nonNull)
        .map(str -> JSON.parseObject(str, Order.class))
        .assignTimestampsAndWatermarks(
                new BoundedOutOfOrdernessTimestampExtractor<Order>
                        (Time.seconds(10)) {
                    @Override
                    public long extractTimestamp(Order order) {
                        return order.getTime();
                    }
                })
        .keyBy(Order::getOrderId);

3. connect 連接大小訂單流,使用 process 進行匹配

再次描述一下處理流程:

兩個流通過 ValueState 進行關聯,假如大訂單流對應的數據先來了,需要將大訂單的相關信息保存到大訂單的 ValueState 狀態中,註冊一個 60s 之後的定時器。

  • 如果 60s 內來了小訂單流對應的數據來了,則將兩個數據拼接發送到下游。

  • 如果 60s 內小訂單流對應的數據還沒來,就會觸發 onTimer,然後進行側流輸出。

需要提前定義好側流輸出需要用到的 OutTag:

private static OutputTag<Order> bigOrderTag = new OutputTag<>("bigOrder");
private static OutputTag<Order> smallOrderTag = new OutputTag<>("smallOrder")

代碼實現如下:

// 使用 connect 連接大小訂單的流,然後使用 CoProcessFunction 進行數據匹配
SingleOutputStreamOperator<Tuple2<Order, Order>> resStream = bigOrderStream
        .connect(smallOrderStream)
        .process(new CoProcessFunction<Order, Order, Tuple2<Order, Order>>() {
            // 大訂單數據先來了,將大訂單數據保存在 bigState 中。
            ValueState<Order> bigState;
            // 小訂單數據先來了,將小訂單數據保存在 smallState 中。
            ValueState<Order> smallState;

            // 大訂單的處理邏輯
            @Override
            public void processElement1(Order bigOrder, Context ctx,
                                        Collector<Tuple2<Order, Order>> out)
                    throws Exception {
                // 獲取當前 小訂單的狀態值
                Order smallOrder = smallState.value();
                // smallOrder 不爲空表示小訂單先來了,直接將大小訂單拼接發送到下游
                if (smallOrder != null) {
                    out.collect(Tuple2.of(smallOrder, bigOrder));
                    // 清空小訂單對應的 State 信息
                    smallState.clear();
                } else {
                    // 小訂單還沒來,將大訂單放到狀態中,並註冊 1 分鐘之後觸發的 timerState
                    bigState.update(bigOrder);
                    // 1 分鐘後觸發定時器,當前的 eventTime + 60s
                    long time = bigOrder.getTime() + 60000;
                    ctx.timerService().registerEventTimeTimer(time);
                }
            }

            @Override
            public void processElement2(Order smallOrder, Context ctx,
                                        Collector<Tuple2<Order, Order>> out)
                    throws Exception {
                // 這裏先省略代碼,小訂單的處理邏輯與大訂單的處理邏輯完全類似
            }

            @Override
            public void onTimer(long timestamp, OnTimerContext ctx,
                                Collector<Tuple2<Order, Order>> out)
                    throws Exception {
                // 定時器觸發了,即 1 分鐘內沒有接收到兩個流。
                // 大訂單不爲空,則將大訂單信息側流輸出
                if (bigState.value() != null) {
                    ctx.output(bigOrderTag, bigState.value());
                }
                // 小訂單不爲空,則將小訂單信息側流輸出
                if (smallState.value() != null) {
                    ctx.output(smallOrderTag, smallState.value());
                }
                bigState.clear();
                smallState.clear();
            }

            @Override
            public void open(Configuration parameters) throws Exception {
                super.open(parameters);
                // 初始化狀態信息
                bigState = getRuntimeContext().getState(
                        new ValueStateDescriptor<>("bigState", Order.class));
                smallState = getRuntimeContext().getState(
                        new ValueStateDescriptor<>("smallState", Order.class));
            }
        });
小優化

假如 60s 以內,兩個流的數據都到了,也就是執行了 out.collect(Tuple2.of(smallOrder, bigOrder)); 還有必要觸發定時器嗎?

定時器的目的是爲了保證當 60s 時間到了,仍然有一個流還未到達。那麼當兩個流都到達時,沒有必要再去觸發定時器。所以當兩個流都到達時,可以刪除註冊的定時器。(定時器的維護和觸發也是需要成本的,所以及時清理這些垃圾是一個比較好的習慣)

改造後的代碼如下,申請了一個 ValueState 類型的 timerState 用於維護註冊的定時器時間,如果兩個流都到達時,觸發 delete 操作,同時要注意調用 timerState.clear() 去清理 timerState 的狀態信息。

// 大訂單的處理邏輯
@Override
public void processElement1(Order bigOrder, Context ctx,
                            Collector<Tuple2<Order, Order>> out)
        throws Exception {
    // 獲取當前 小訂單的狀態值
    Order smallOrder = smallState.value();
    // smallOrder 不爲空表示小訂單先來了,直接將大小訂單拼接發送到下游
    if (smallOrder != null) {
        out.collect(Tuple2.of(smallOrder, bigOrder));
        // 清空小訂單對應的 State 信息
        smallState.clear();
        // 這裏可以將 Timer 清除。因爲兩個流都到了,沒必要再觸發 onTimer 了
        ctx.timerService().deleteEventTimeTimer(timerState.value());
        timerState.clear();
    } else {
        // 小訂單還沒來,將大訂單放到狀態中,並註冊 1 分鐘之後觸發的 timerState
        bigState.update(bigOrder);
        // 1 分鐘後觸發定時器,並將定時器的觸發時間保存在 timerState 中
        long time = bigOrder.getTime() + 60000;
        timerState.update(time);
        ctx.timerService().registerEventTimeTimer(time);
    }
}

4. 結果輸出

這裏直接將正常的輸出結果還有策略輸出都通過 print 進行輸出,生產環境肯定是需要通過 Sink 輸出到外部系統的。側流輸出的數據屬於異常數據,需要保存到外部系統,進行特殊處理。

// 正常匹配到數據的 輸出。生產環境肯定是需要通過 Sink 輸出到外部系統
resStream.print();

// 只有大訂單時,沒有匹配到 小訂單,屬於異常數據,需要保存到外部系統,進行特殊處理
resStream.getSideOutput(bigOrderTag).print();
// 只有小訂單時,沒有匹配到 大訂單,屬於異常數據,需要保存到外部系統,進行特殊處理
resStream.getSideOutput(smallOrderTag).print();

env.execute(JOB_NAME);

三、 總結

Flink 使用 connect 實現雙流 join 在 Flink 的 Streaming Api 中相對經常使用的 map、flatMap 等算子來講已經屬於比較複雜的場景了。文中開始處介紹的那些場景都可以通過本文的案例經過簡單改造即可實現。而且 connect 實現雙流 join 屬於 Flink 面試的高頻考點,希望讀者通過本文有所收穫。

END

關注我
公衆號(zhisheng)裏回覆 面經、ES、Flink、 Spring、Java、Kafka、監控 等關鍵字可以查看更多關鍵字對應的文章。

你點的每個贊,我都認真當成了喜歡

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章