Flink 維表關聯多種方案對比

上篇博客提到 Flink SQL 如何 Join 兩個數據流,有讀者反饋說如果不打算用 SQL 或者想自己實現底層操作,那麼如何基於 DataStream API 來關聯維表呢?實際上由於 Flink DataStream API 的靈活性,實現這個需求的方式是非常多樣的,但是大部分用戶很難在設計架構時就考慮得很全面,可能會走不少彎路。

針對於此,筆者根據工作經驗以及社區資源整理了用 DataStream 實現 Join 維表的常見方式,並給每種的方式優劣和適用場景給出一點可作爲參考的個人觀點。

衡量指標

總體來講,關聯維表有三個基礎的方式:實時數據庫查找關聯(Per-Record Reference Data Lookup)、預加載維表關聯(Pre-Loading of Reference Data)和維表變更日誌關聯(Reference Data Change Stream),而根據實現上的優化可以衍生出多種關聯方式,且這些優化還可以靈活組合產生不同效果(不過爲了簡單性這裏不討論同時應用多種優化的實現方式)。對於不同的關聯方式,我們可以從以下 7 個關鍵指標來衡量(每個指標的得分將以 1-5 五檔來表示):

  1. 實現簡單性: 設計是否足夠簡單,易於迭代和維護。

  2. 吞吐量: 性能是否足夠好。

  3. 維表數據的實時性: 維度表的更新是否可以立刻對作業可見。

  4. 數據庫的負載: 是否對外部數據庫造成較大的負載(負載越低分越高)。

  5. 內存資源佔用: 是否需要大量內存來緩存維表數據(內存佔用越少分越高)。

  6. 可拓展性: 在更大規模的數據下會不會出現瓶頸。

  7. 結果確定性: 在數據延遲或者數據重放情況下,是否可以得到一致的結果。

和大多數架構設計一樣,這三類關聯方式不存在絕對的好壞,更多的是針對業務場景在各指標上的權衡取捨,因此這裏的得分也僅僅是針對通用場景來說。

實時數據庫查找關聯

實時數據庫查找關聯是在 DataStream API 用戶函數中直接訪問數據庫來進行關聯的方式。這種方式通常開發量最小,但一般會給數據庫帶來很大的壓力,而且因爲關聯是基於 Processing Time 的,如果數據有延遲或者重放,會得到和原來不一致的數據。

同步數據庫查找關聯

同步實時數據庫查找關聯是最爲簡單的關聯方式,只需要在一個 Map 或者 FlatMap 函數中訪問數據庫,處理好關聯邏輯後,將結果數據輸出。

圖1.同步數據庫查找關聯架構

這種方式的主要優點在於實現簡單、不需要額外內存且維表的更新延遲很低,然而缺點也很明顯: 

  1. 因爲每條數據都需要請求一次數據庫,給數據庫造成的壓力很大;

  2. 訪問數據庫是同步調用,導致 subtak 線程會被阻塞,影響吞吐量;

  3. 關聯是基於 Processing Time 的,結果並不具有確定性;

  4. 瓶頸在數據庫端,但實時計算的流量通常遠大於普通數據庫的設計流量,因此可拓展性比較低。

圖2.同步數據庫查找關聯關鍵指標

從應用場景來說,同步數據庫查找關聯可以用於流量比較低的作業,但通常不是最好的選擇。

異步數據庫查找關聯

異步數據庫查找關聯是通過 AsyncIO[2]來訪問外部數據庫的方式。利用數據庫提供的異步客戶端,AsyncIO 可以併發地處理多個請求,很大程度上減少了對 subtask 線程的阻塞。

因爲數據庫請求響應時長是不確定的,可能導致後輸入的數據反而先完成計算,所以 AsyncIO 提供有序和無序兩種輸出模式,前者會按請求返回順序輸出數據,後者則會緩存提前完成計算的數據,並按輸入順序逐個輸出結果。

圖3.異步數據庫查找關聯架構

比起同步數據庫查找關聯,異步數據庫查找關聯稍微複雜一點,但是大部分的邏輯都由 Flink AsyncIO API 封裝,因此總體來看還是比較簡單。然而,有序輸出模式下的 AsyncIO 會需要緩存數據,且這些數據會被寫入 checkpoint,因此在內容資源方面的得分會低一點。另一方面,同步數據庫查找關聯的吞吐量問題得到解決,但仍不可避免地有數據庫負載高和結果不確定兩個問題。

圖4.異步數據庫查找關聯關鍵指標

從應用場景來說,異步數據庫查找關聯比較適合流量低的實時計算。

帶緩存的數據庫查找關聯

爲了解決上述兩種關聯方式對數據庫造成太大壓力的問題,可以引入一層緩存來減少直接對數據庫的請求。緩存並一般不需要通過 checkpoint 機制持久化,因此簡單地用一個 WeakHashMap 或者 Guava Cache 就可以實現。

圖5.帶緩存的數據庫查找關聯架構

雖然在冷啓動的時候仍會給數據庫造成一定壓力,但後續取決於緩存命中率,數據庫的壓力將得到一定程度的緩解。然而使用緩存帶來的問題是維表的更新並不能及時反應到關聯操作上,當然這也和緩存剔除的策略有關,需要根據維度表更新頻率和業務對過時維表數據的容忍程度來設計。

圖6.帶緩存的數據庫查找關聯關鍵指標

總而言之,帶緩存的數據庫查找關聯適合於流量比較低,且對維表數據實時性要求不太高或維表更新比較少的業務場景。

預加載維表關聯

相比起實時數據庫查找在運行期間爲每條數據訪問一次數據庫,預加載維表關聯是在作業啓動時就將維表讀到內存中,而在後續運行期間,每條數據都會和內存中的維表進行關聯,而不會直接觸發對數據的訪問。與帶緩存的實時數據庫查找關聯相比,區別是後者如果不命中緩存還可以 fallback 到數據庫訪問,而前者如果不名中則會關聯不到數據。

啓動預加載維表

啓動預加載維表是最爲簡單的一種方式,即在作業初始化的時候,比如用戶函數的 open() 方法,直接從數據庫將維表拷貝到內存中。維表並不需要用 State 來保存,因爲無論是手動重啓或者是 Flink 的錯誤重試機制導致的重啓,open() 方法都會被執行,從而得到最新的維表數據。

圖7.啓動預加載維表架構

啓動預加載維表對數據庫的壓力只持續很短時間,但因爲是拷貝整個維表所以壓力是很大的,而換來的優勢是在運行期間不需要再訪問數據庫,可以提高效率,有點類似離線計算。相對地,問題在於運行期間維表數據不能更新,且對 TaskManager 內存的要求比較高。

圖8.啓動預加載維表關鍵指標

啓動預加載維表適合於維表比較小、變更實時性要求不高的場景,比如根據 ip 庫解析國家地區,如果 ip 庫有新版本,重啓作業即可。

啓動預加載分區維表

對於維表比較大的情況,可以啓動預加載維表基礎之上增加分區功能。簡單來說就是將數據流按字段進行分區,然後每個 Subtask 只需要加在對應分區範圍的維表數據。值得注意的是,這裏的分區方式並不是用 keyby 這種通用的 hash 分區,而是需要根據業務數據定製化分區策略,然後調用 DataStream#partitionCustom。比如按照 userId 等區間劃分,0-999 劃分到 subtask 1,1000-1999 劃分到 subtask 2,以此類推。而在 open() 方法中,我們再根據 subtask 的 id 和總並行度來計算應該加載的維表數據範圍。

圖9.啓動預加載分區維表架構

通過這種分區方式,維表的大小上限理論上可以線性拓展,解決了維表大小受限於單個 TaskManager 內存的問題(現在是取決於所有 TaskManager 的內存總量),但同時給帶來設計和維護分區策略的複雜性。

圖10.啓動預加載分區維表關鍵指標

總而言之,啓動預加載分區維表適合維表比較大而變更實時性要求不高的場景,比如用戶點擊數據關聯用戶所在地。

啓動預加載維表並定時刷新

除了維表大小的限制,啓動預加載維表的另一個主要問題在於維度數據的更新,我們可以通過引入定時刷新機制的辦法來緩解這個問題。定時刷新可以通過 Flink ProcessFucntion 提供的 Timer 或者直接在 open() 初始化一個線程(池)來做這件事。不過 Timer 要求 KeyedStream,而上述的 DataStream#partitionCustom 並不會返回一個 KeyedStream,因此兩者並不兼容。而如果使用額外線程定時刷新的辦法則不受這個限制。

圖11.啓動預加載維表並定時刷新架構

比起基礎的啓動預加載維表 ,這種方式在於引入比較小複雜性的情況下大大緩解了的維度表更新問題,但也給維表數據庫帶來更多壓力,因爲每次 reload 的時候都是一次請求高峯。

圖12.啓動預加載維表並定時刷新關鍵指標

啓動預加載維表和定時刷新的組合適合維表變更實時性要求不是特別高的場景。取決於定時刷新的頻率和數據庫的性能,這種方式可以滿足大部分關聯維表的業務。

啓動預加載維表 + 實時數據庫查找

啓動預加載維表還可以和實時數據庫查找混合使用,即將預加載的維表作爲緩存給實時關聯時使用,若未名中則 fallback 到數據庫查找。

圖13.啓動預加載維表結合實時數據庫查找架構

這種方式實際是帶緩存的數據庫查找關聯的衍生,不同之處在於相比冷啓動時未命中緩存導致的多次實時數據庫訪問,該方式直接批量拉取整個維表效率更高,但也有可能拉取到不會訪問到的多餘數據。下面雷達圖中顯示的是用異步數據庫查找,如果是同步數據庫查找吞吐量上會低一些。

圖14.啓動預加載維表結合實時數據庫查找關鍵指標

這種方式和帶緩存的實時數據庫查找關聯基本相同,適合流量比較低,且對維表數據實時性要求不太高或維表更新比較少的業務場景。

維表變更日誌關聯

不同於上述兩者將維表作爲靜態表關聯的方式,維表變更日誌關聯將維表以 changelog 數據流的方式表示,從而將維表關聯轉變爲兩個數據流的 join。這裏的 changelog 數據流類似於 MySQL 的 binlog,通常需要維表數據庫端以 push 的方式將日誌寫到 Kafka 等消息隊列中。Changelog 數據流稱爲 build 數據流,另外待關聯的主要數據流成爲 probe 數據流。

維表變更日誌關聯的好處在於可以獲取某個 key 數據變化的時間,從而使得我們能在關聯中使用 Event Time(當然也可以使用 Processing Time)。

Processing Time 維表變更日誌關聯

如果基於 Processing Time 做關聯,我們可以利用 keyby 將兩個數據流中關聯字段值相同的數據劃分到 KeyedCoProcessFunction 的同一個分區,然後用 ValueState 或者 MapState 將維表數據保存下來。在普通數據流的一條記錄進到函數時,到 State 中查找有無符合條件的 join 對象,若有則關聯輸出結果,若無則根據 join 的類型決定是直接丟棄還是與空值關聯。這裏要注意的是,State 的大小要儘量控制好。首先是隻保存每個 key 最新的維度數據值,其次是要給 State 設置好 TTL,讓 Flink 可以自動清理。

圖15.Processing Time 維表變更日誌關聯架構

基於 Processing Time 的維表變更日誌關聯優點是不需要直接請求數據庫,不會對數據庫造成壓力;缺點是比較複雜,相當於使用 changelog 在 Flink 應用端重新構建一個維表,會佔用一定的 CPU 和比較多的內存和磁盤資源。值得注意的是,我們可以利用 Flink 提供的 RocksDB StateBackend,將大部分的維表數據存在磁盤而不是內存中,所以並不會佔用很高的內存。不過基於 Processing Time 的這種關聯對兩個數據流的延遲要求比較高,否則如果其中一個數據流出現 lag 時,關聯得到的結果可能並不是我們想要的,比如可能會關聯到未來時間點的維表數據。

圖16.Processing Time 維表變更日誌關聯關鍵指標

基於 Processing Time 的維表變更日誌關聯比較適用於不便直接訪問數據的場景(比如維表數據庫是業務線上數據庫,出於安全和負載的原因不能直接訪問),或者對維表的變更實時性要求比較高的場景(但因爲數據準確性的關係,一般用下文的 Event Time 關聯會更好)。

Event Time 維表變更日誌關聯

基於 Event Time 的維表關聯實際上和基於 Processing Time 的十分相似,不同之處在於我們將維表 changelog 的多個時間版本都記錄下來,然後每當一條記錄進來,我們會找到對應時間版本的維表數據來和它關聯,而不是總用最新版本,因此延遲數據的關聯準確性大大提高。不過因爲目前 State 並沒有提供 Event Time 的 TTL,因此我們需要自己設計和實現 State 的清理策略,比如直接設置一個 Event Time Timer(但要注意 Timer 不能太多導致性能問題),再比如對於單個 key 只保存最近的 10 個版本,當有更新版本的維表數據到達時,要清理掉最老版本的數據。

圖17.Event Time 維表變更日誌關聯架構

基於 Event Time 的維表變更日誌關聯相對基於 Processing Time 的方式來說是一個改進,雖然多個維表版本導致空間資源要求更大,但確保準確性對於大多數場景來說都是十分重要的。相比 Processing Time 對兩個數據的延遲都有要求,Event Time 要求 build 數據流的延遲低,否則可能一條數據到達時關聯不到對應維表數據或者關聯了一個過時版本的維表數據,

圖18.Event Time 維表變更日誌關聯關鍵指標

基於 Event Time 的維表變更日誌關聯比較適合於維表變更比較多且對變更實時性要求較高的場景 同時也適合於不便直接訪問數據庫的場景。

Temporal Table Join

Temporal Table Join 是 Flink SQL/Table API 的原生支持,它對兩個數據流的輸入都進行了緩存,因此比起上述的基於 Event Time 的維表變更日誌關聯,它可以容忍任意數據流的延遲,數據準確性更好。Temporal Table Join 在 SQL/Table API 使用時是十分簡單的,但如果想在 DataStream API 中使用,則需要自己實現對應的邏輯。

總體思路是使用一個 CoProcessFunction,將 build 數據流以時間版本爲 key 保存在 MapState 中(與基於 Event Time 的維表變更日誌關聯相同),再將 probe 數據流和輸出結果也用 State 緩存起來(同樣以 Event Time 爲 key),一直等到 Watermark 提升到它們對應的 Event Time,才把結果輸出和將兩個數據流的輸入清理掉。

這個 Watermark 觸發很自然地是用 Event Time Timer 來實現,但要注意不要爲每條數據都設置一遍 Timer,因爲一旦 Watermark 提升會觸發很多個 Timer 導致性能急劇下降。比較好的實踐是爲每個 key 只註冊一個 Timer。實現上可以記錄當前未處理的最早一個 Event Time,並用來註冊 Timer。當前 Watermark。每當 Watermark 觸發 Timer 時,我們檢查處理掉未處理的最早 Event Time 到當前 Event Time 的所有數據,並將未處理的最早 Event Time 更新爲當前時間。

圖19.Temporal Table Join 架構

Temporal Table Join 的好處在於對於兩邊數據流的延遲的容忍度較大,但作爲代價會引入一定的輸出結果的延遲,這也是基於 Watermark 機制的計算的常見問題,或者說,妥協。另外因爲吞吐量較大的 probe 數據流也需要緩存,Flink 應用對空間資源的需求會大很多。最好,要注意的是如果維表變更太慢,導致 Watermark 提升太慢,會導致 probe 數據流被大量緩存,所以最好要確保 build 數據流儘量實時,同時給 Source 設置一個比較短的 idle timeout。

圖20.Temporal Table Join 關鍵指標

Temporal Table Join 這種方式最爲複雜,但數據準確性最好,適合一些對數據準確性要求高且可以容忍一定延遲(一般分鐘級別)的關鍵業務。

衡量指標

用 Flink DataStream API 實現關聯維表的方式十分豐富,可以直接訪問數據庫查找(實時數據庫查找關聯),可以啓動時就將全量維表讀到內存(預加載維表關聯),也可以通過維表的 changelog 在 Flink 應用端實時構建一個新的維表(維表變更日誌關聯)。我們可以從實現簡單性、吞吐量、維表數據的實時性、數據庫的負載、內存資源佔用、可拓展性和結果確定性這 7 個維度來衡量一個具體實現方式,並根據業務需求來選擇最合適的實現。

參考

[1] WEBINAR: 99 Ways to Enrich Streaming Data with Apache Flink

http://www.whitewood.me/2020/01/16/Flink-DataStream-關聯維表實戰/#more

[2] Asynchronous I/O for External Data Access

http://www.whitewood.me/2020/01/16/Flink-DataStream-關聯維表實戰/#more

作者介紹:

林小鉑,網易遊戲高級開發工程師,負責遊戲數據中心實時平臺的開發及運維工作,目前專注於 Apache Flink 的開發及應用。探究問題本來就是一種樂趣。

點擊「閱讀原文」可查看作者原版博客~


END

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

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

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