Flink 使用 broadcast 實現維表或配置的實時更新

問題導讀

1.本文介紹了幾種維表方案?
2.各個方案有什麼優缺點?
3. broadcast如何實現實時更新維表案例?



通過本文你能 get 到以下知識:
 

  • Flink 常見的一些維表關聯的案例
  • 常見的維表方案及每種方案適用場景,優缺點
  • 案例:broadcast 實現維表或配置的實時更新




一、案例分析

維表服務在 Flink 中是一個經常遇到的業務場景,例如:
 

  • 客戶端上報的用戶行爲日誌只包含了城市 Id,可是下游處理數據需要城市名字
  • 商品的交易日誌中只有商品 Id,下游分析數據需要用到商品所屬的類目
  • 物聯網溫度報警的場景中,處理的是設備上報的一條條溫度信息,之前的報警規則是:只要溫度大於 20 度需要報警,現在需要改成大於 18 度則報警。這裏的報警閾值需要動態調整,因此不建議將代碼寫死



對於上述的場景,實際上都可以通過維表服務的方式來解決。


二、 維表方案

Flink 中常見的維表方案有以下幾種:

1. 預加載維表
在算子的 open 方法中讀取 MySQL 或其他存儲介質,獲取全量維表信息。將維表信息全量保存在內存中。處理數據流時,與內存中的維度進行進行匹配。

例如維度信息保存的是商品 Id 與商品類目的映射關係,那麼可以從商品的交易日誌中讀取出相應的商品 Id,然後去維度表中找出對應的商品類目,將交易日誌與商品類目組合起來一塊發送給下游。

如果新上架了一些商品 Id 或者某些商品的類目變了,我們無法更新內存裏的維度信息。但我們可以在 open 方法中開啓一個一分鐘一次的定時調度器,每分鐘將維度信息讀取一次到內存中,從而實現了維度信息的變更。

該方案實現簡單,但是有兩個很直觀的缺陷:
 

  • 維度信息延遲變更:MySQL 中的維度信息隨便可能在變,但是隻有每分鐘纔會同步一次
  • 維度信息全量加載到內存中:所以不適合維度信息較大的場景。


根據缺陷得出:該方案適用於維表數據量較小,且維表變更頻率較低的場景。

當然在 open 方法中我們不只是可以從 MySQL 中去讀取,可以自定義各種數據源、各種 DB,甚至可以讀取文件,也可以讀取 Flink 的 Distributed Cache。


2. 熱存儲關聯
當維度數據較大時,不能全量加載到內存中,可以實時去查詢外部存儲,例如 MySQL、HBase 等。這樣就解決了維度信息不能全量放到內存中的問題,但是對於吞吐量較高的場景,可能與 MySQL 交互就變成了 Flink 任務的瓶頸。每來一條數據都需要進行一次同步 IO,於是優化點就來了:

 

  • 同步 IO 優化爲異步 IO
  • 對於頻繁查找的熱數據,可以緩存在內存中,不用每次去查詢 MySQL。強烈建議使用 guava 的 Cache 來做緩存。




該方案的優劣勢:支持大維度數據量,由於增加了 Cache,可能會導致維度數據更新不及時。


優雅的使用 Cache
Cache 可以認爲是功能很豐富的 Map,一般需要設置過期時間,假設 Cache 中設置的 1 s 過期,當緩存中數據存在時,直接查緩存,不查 MySQL,但是在這 1s 內外部 MySQL 中的維度信息可能已經被實時修改了。所以,一定要根據業務場景給 Cache 設定合理的過期時間。對於準確性要求較高的場景過期時間可能要設置在 200ms 以內。

guava 的 Cache 支持兩種過期策略,一種是按照訪問時間過期,一種是按照寫入時間過期。

按照訪問時間過期指的是:每次訪問都會延長一下過期時間,假如設置的 expireAfterAccess(300, TimeUnit.MILLISECONDS)  ,即 300ms 不訪問則 Cache 中的數據就會過期。每次訪問,它的過期時間就會延長至 300ms 以後。如果每 200ms 訪問一次,那麼這條數據將永遠不會過期了。所以一定要注意避坑,如果發現 Cache 中數據一直是舊數據,不會變成最新的數據,可以看看是不是這個原因。

按照寫入時間過期指的是:每次寫入或者修改都會延遲一下過期時間,可以設置 expireAfterWrite(300, TimeUnit.MILLISECONDS)  表示 300ms 不寫入或者不修改這個 key 對應的 value,那麼這一對 kv 數據就會被刪除。就算在 300ms 訪問了 1 萬次 Cache,300ms 過期這條數據也會被清理,這樣才能保證數據被更新。

對於維度數據不會發生變化的業務場景,按照訪問時間過期是最佳的選擇。

定義一個 Cache 的代碼如下所示:
 

1
2
3
4
5
6
7
Cache<Long, Long> cache = CacheBuilder.newBuilder()
        .maximumSize(1000)
        // 表示按照訪問時間過期
        .expireAfterAccess(300, TimeUnit.MILLISECONDS)
        // 表示按照寫入時間過期
        .expireAfterWrite(300, TimeUnit.MILLISECONDS)
        .build();



guava Cache 的功能很豐富,大家可以深入研究其功能及其實現原理。想學習其實現原理的同學,筆者建議先學習 Java 中 LinkedHashMap 的原理。

前面兩種方案都存在一個問題:當維度數據發生變化時,更新的數據不能及時更新到 Flink 的內存中,導致線上業務關聯到的維表數據是舊數據。那有沒有能及時把維度信息通知給 Flink 應用的機制呢?繼續往下看,今天的重點來啦。


3. 廣播維表
利用 broadcast State 將維度數據流廣播到下游所有 task 中。這個 broadcast 的流可以與我們的事件流進行 connect,然後在後續的 process 算子中進行關聯操作即可。

當維度信息修改後,我們不只是要把維度信息更新到 MySQL 中,還需要將維度信息更新到 MQ 中。Flink 的 broadcast 流實時消費 MQ 中數據,就可以實時讀取到維表的更新,然後配置就會在 Flink 任務生效,通過這種方法及時的修改了維度信息。broadcast 可以動態實時更新配置,然後影響另一個數據流的處理邏輯。

注:廣播變量存在於每個節點的內存中,所以數據集不能太大,因爲廣播出去的數據,會一直在內存中存在。

理論可能理解了,通過案例來深入使用一波。


三、 broadcast 實時更新維表案例

實時處理訂單信息,但是訂單信息中沒有商品的名稱,只有商品的 id,需要將訂單信息與對應的商品名稱進行拼接,一起發送到下游。怎麼實現呢?

兩個 topic:

  • order_topic_name topic 中存放的訂單的交易信息
  • goods_dim_topic_name 中存放商品 id 與 商品名稱的映射關係


訂單類信息如下所示:
 

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
@Data
publicclass Order {
    /** 訂單發生的時間 */
    long time;
 
    /** 訂單 id */
    String orderId;
 
    /** 用戶id */
    String userId;
 
    /** 商品id */
    int goodsId;
 
    /** 價格 */
    int price;
 
    /** 城市 */
    int cityId;
}



商品信息如下所示:

1
2
3
4
5
6
7
8
@Data
publicclass Goods {
    /** 商品id */
    int goodsId;
 
    /** 價格 */
    String goodsName;
}




讀取訂單交易信息,並從 json 解析爲 Order 的過程:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
// 讀取訂單數據,讀取的是 json 類型的字符串
FlinkKafkaConsumerBase<String> consumerBigOrder =
        new FlinkKafkaConsumer011<>("order_topic_name",
                new SimpleStringSchema(),
                KafkaConfigUtil.buildConsumerProps(KAFKA_CONSUMER_GROUP_ID))
                .setStartFromGroupOffsets();
 
// 讀取訂單數據,從 json 解析成 Order 類,
SingleOutputStreamOperator<Order> orderStream = env.addSource(consumerBigOrder)
        // 有狀態算子一定要配置 uid
        .uid("order_topic_name")
        // 過濾掉 null 數據
        .filter(Objects::nonNull)
        // 將 json 解析爲 Order 類
        .map(str -> JSON.parseObject(str, Order.class));



讀取商品 ID 和 名稱的映射信息,從 json 解析成 Goods 類:

01
02
03
04
05
06
07
08
09
10
11
12
// 讀取商品 id 與 商品名稱的映射關係維表信息
FlinkKafkaConsumerBase<String> consumerSmallOrder =
        new FlinkKafkaConsumer011<>("goods_dim_topic_name",
                new SimpleStringSchema(),
                KafkaConfigUtil.buildConsumerProps(KAFKA_CONSUMER_GROUP_ID))
                .setStartFromGroupOffsets();
 
// 讀取商品 ID 和 名稱的映射信息,從 json 解析成 Goods 類
SingleOutputStreamOperator<Goods> goodsDimStream = env.addSource(consumerSmallOrder)
        .uid("goods_dim_topic_name")
        .filter(Objects::nonNull)
        .map(str -> JSON.parseObject(str, Goods.class));




定義存儲 維度信息的 MapState,將訂單流與商品映射信息的廣播流進行 connect,進行在 process 中進行關聯。process 中,廣告流的處理邏輯是:將映射關係加入到狀態中。事件流的處理邏輯是:從狀態中獲取當前商品 Id 對應的商品名稱,拼接在一塊發送到下游。最後打印輸出。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 存儲 維度信息的 MapState
final MapStateDescriptor<Integer, String> GOODS_STATE = new MapStateDescriptor<>(
        "GOODS_STATE",
        BasicTypeInfo.INT_TYPE_INFO,
        BasicTypeInfo.STRING_TYPE_INFO);
 
SingleOutputStreamOperator<Tuple2<Order, String>> resStream = orderStream
        // 訂單流與 維度信息的廣播流進行 connect
        .connect(goodsDimStream.broadcast(GOODS_STATE))
        .process(new BroadcastProcessFunction<Order, Goods, Tuple2<Order, String>>() {
 
            // 處理 訂單信息,將訂單信息與對應的商品名稱進行拼接,一起發送到下游。
            @Override
            public void processElement(Order order,
                                       ReadOnlyContext ctx,
                                       Collector<Tuple2<Order, String>> out)
                    throws Exception {
                ReadOnlyBroadcastState<Integer, String> broadcastState =
                        ctx.getBroadcastState(GOODS_STATE);
                // 從狀態中獲取 商品名稱,拼接後發送到下游
                String goodsName = broadcastState.get(order.getGoodsId());
                out.collect(Tuple2.of(order, goodsName));
            }
 
            // 更新商品的維表信息到狀態中
            @Override
            public void processBroadcastElement(Goods goods,
                                                Context ctx,
                                                Collector<Tuple2<Order, String>> out)
                    throws Exception {
                BroadcastState<Integer, String> broadcastState =
                        ctx.getBroadcastState(GOODS_STATE);
                // 商品上架,應該添加到狀態中,用於關聯商品信息
                broadcastState.put(goods.getGoodsId(), goods.getGoodsName());
            }
        });
 
// 結果進行打印,生產環境應該是輸出到外部存儲
resStream.print();



通過上述代碼,已經完成了我們的需求,生產環境中將結果輸出到外部存儲即可。

小優化點:(小但是非常有必要的優化)
商品下架,就不會再有該商品的交易信息,此時應該將商品從狀態中移除,防止狀態無限制的增大。怎麼設計呢?

首先對 Goods 類進行重新定義,增加了 isRemove 字段,要來標識當前商品是上架還是下架,如果下架應該從 State 中去移除:
 

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@Data
publicclass Goods {
 
    /** 商品id */
    int goodsId;
 
    /** 價格 */
    String goodsName;
 
    /**
     * 當前商品是否被下架,如果下架應該從 State 中去移除
     * true 表示下架
     * false 表示上架
     */
    boolean isRemove;
}


BroadcastProcessFunction 的 processBroadcastElement 也應該改動,判斷如果是上架,應該添加到狀態中,用於關聯商品信息。如果商品下架,應該要從狀態中移除,否則狀態將無限增大。

 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
// 更新商品的維表信息到狀態中
@Override
public void processBroadcastElement(Goods goods,
                                    Context ctx,
                                    Collector<Tuple2<Order, String>> out)
        throws Exception {
    BroadcastState<Integer, String> broadcastState =
            ctx.getBroadcastState(GOODS_STATE);
    if (goods.isRemove()) {
        // 商品下架了,應該要從狀態中移除,否則狀態將無限增大
        broadcastState.remove(goods.getGoodsId());
    } else {
        // 商品上架,應該添加到狀態中,用於關聯商品信息
        broadcastState.put(goods.getGoodsId(), goods.getGoodsName());
    }
}




當維度信息較大,每臺機器上都存儲全量維度信息導致內存壓力過大時,可以考慮進行 keyBy,這樣每臺節點只會存儲當前 key 對應的維度信息,但是使用 keyBy 會導致所有數據都會進行 shuffle。當然上述代碼需要將維度數據廣播到所有實例,也是一種 shuffle,但是維度變更一般只是少量數據,成本較低,可以接受。大家在開發 Flink 任務時應該根據實際的業務場景選擇最合適的方案。

四、 總結

開篇介紹了一些需要使用維表的場景,然後講述了常見的維表方案及每種方案適用場景,優缺點。最後着重通過一個案例給大家詳細介紹瞭如何使用 broadcast 實現維表或配置的實時更新,並給出了一些優化點。維表關聯屬於面試常見考點,且 broadcast 實現維表關聯非常受面試官的歡迎,希望本文對大家有所幫助。

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