Flink 原理與實現:Session Window

原文鏈接: http://wuchong.me/blog/2016/06/06/flink-internals-session-window/


上一篇文章:Window機制中,我們介紹了窗口的概念和底層實現,以及 Flink 一些內建的窗口,包括滑動窗口、翻滾窗口。本文將深入講解一種較爲特殊的窗口:會話窗口(session window)。建議您在閱讀完上一篇文章的基礎上再閱讀本文。

當我們需要分析用戶的一段交互的行爲事件時,通常的想法是將用戶的事件流按照“session”來分組。session 是指一段持續活躍的期間,由活躍間隙分隔開。通俗一點說,消息之間的間隔小於超時閾值(sessionGap)的,則被分配到同一個窗口,間隔大於閾值的,則被分配到不同的窗口。目前開源領域大部分的流計算引擎都有窗口的概念,但是沒有對 session window 的支持,要實現 session window,需要用戶自己去做完大部分事情。而當 Flink 1.1.0 版本正式發佈時,Flink 將會是開源流計算領域第一個內建支持 session window 的引擎。

在 Flink 1.1.0 之前,Flink 也可以通過自定義的window assigner和trigger來實現一個基本能用的session window。release-1.0版本中提供了一個實現 session window 的 example:SessionWindowing。這個session window範例的實現原理是,基於GlobleWindow這個window assigner,將所有元素都分配到同一個窗口中,然後指定一個自定義的trigger來觸發執行窗口。這個trigger的觸發機制是,對於每個到達的元素都會根據其時間戳(timestamp)註冊一個會話超時的定時器(timestamp+sessionTimeout),並移除上一次註冊的定時器。最新一個元素到達後,如果超過 sessionTimeout 的時間還沒有新元素到達,那麼trigger就會觸發,當前窗口就會是一個session window。處理完窗口後,窗口中的數據會清空,用來緩存下一個session window的數據。

但是這種session window的實現是非常弱的,無法應用到實際生產環境中的。因爲它無法處理亂序 event time 的消息。 而在即將到來的 Flink 1.1.0 版本中,Flink 提供了對 session window 的直接支持,用戶可以通過SessionWindows.withGap()來輕鬆地定義 session widnow,而且能夠處理亂序消息。Flink 對 session window 的支持主要借鑑自 Google 的 DataFlow 。

Session Window in Flink

假設有這麼個場景,用戶點開手機淘寶後會進行一系列的操作(點擊、瀏覽、搜索、購買、切換tab等),這些操作以及對應發生的時間都會發送到服務器上進行用戶行爲分析。那麼用戶的操作行爲流的樣例可能會長下面這樣:

通過上圖,我們可以很直觀地觀察到,用戶的行爲是一段一段的,每一段內的行爲都是連續緊湊的,段內行爲的關聯度要遠大於段之間行爲的關聯度。我們把每一段用戶行爲稱之爲“session”,段之間的空檔我們稱之爲“session gap”。所以,理所當然地,我們應該按照 session window 對用戶的行爲流進行切分,並計算每個session的結果。如下圖所示:

爲了定義上述的窗口切分規則,我們可以使用 Flink 提供的 SessionWindows 這個 widnow assigner API。如果你用過 SlidingEventTimeWindowsTumlingProcessingTimeWindows等,你會對這個很熟悉。

DataStream input = …
DataStream result = input
 .keyBy(<key selector>)
 .window(SessionWindows.withGap(Time.seconds(<seconds>))
 .apply(<window function>) // or reduce() or fold()

這樣,Flink 就會基於元素的時間戳,自動地將元素放到不同的session window中。如果兩個元素的時間戳間隔小於 session gap,則會在同一個session中。如果兩個元素之間的間隔大於session gap,且沒有元素能夠填補上這個gap,那麼它們會被放到不同的session中。

底層實現

爲了實現 session window,我們需要擴展 Flink 中的窗口機制,使得能夠支持窗口合併。要理解其原因,我們需要先了解窗口的現狀。在上一篇文章中,我們談到了 Flink 中 WindowAssigner 負責將元素分配到哪個/哪些窗口中去,Trigger 決定了一個窗口何時能夠被計算或清除。當元素被分配到窗口之後,這些窗口是固定的不會改變的,而且窗口之間不會相互作用。

對於session window來說,我們需要窗口變得更靈活。基本的思想是這樣的:SessionWindows assigner 會爲每個進入的元素分配一個窗口,該窗口以元素的時間戳作爲起始點,時間戳加會話超時時間爲結束點,也就是該窗口爲[timestamp, timestamp+sessionGap)。比如我們現在到了兩個元素,它們被分配到兩個獨立的窗口中,兩個窗口目前不相交,如圖:

當第三個元素進入時,分配到的窗口與現有的兩個窗口發生了疊加,情況變成了這樣:

由於我們支持了窗口的合併,WindowAssigner可以合併這些窗口。它會遍歷現有的窗口,並告訴系統哪些窗口需要合併成新的窗口。Flink 會將這些窗口進行合併,合併的主要內容有兩部分:

  1. 需要合併的窗口的底層狀態的合併(也就是窗口中緩存的數據,或者對於聚合窗口來說是一個聚合值)

  2. 需要合併的窗口的Trigger的合併(比如對於EventTime來說,會刪除舊窗口註冊的定時器,並註冊新窗口的定時器)

總之,結果是三個元素現在在同一個窗口中了:

需要注意的是,對於每一個新進入的元素,都會分配一個屬於該元素的窗口,都會檢查併合並現有的窗口。在觸發窗口計算之前,每一次都會檢查該窗口是否可以和其他窗口合併,直到trigger觸發後,會將該窗口從窗口列表中移除。對於 event time 來說,窗口的觸發是要等到大於窗口結束時間的 watermark 到達,當watermark沒有到,窗口會一直緩存着。所以基於這種機制,可以做到對亂序消息的支持。

這裏有一個優化點可以做,因爲每一個新進入的元素都會創建屬於該元素的窗口,然後合併。如果新元素連續不斷地進來,並且新元素的窗口一直都是可以和之前的窗口重疊合並的,那麼其實這裏多了很多不必要的創建窗口、合併窗口的操作,我們可以直接將新元素放到那個已存在的窗口,然後擴展該窗口的大小,看起來就像和新元素的窗口合併了一樣。

源碼分析

FLINK-3174 這個JIRA中有對 Flink 如何支持 session window 的詳細說明,以及代碼更新。建議可以結合該 PR 的代碼來理解本文討論的實現原理。

爲了擴展 Flink 中的窗口機制,使得能夠支持窗口合併,首先 window assigner 要能合併現有的窗口,Flink 增加了一個新的抽象類 MergingWindowAssigner 繼承自 WindowAssigner,這裏面主要多了一個 mergeWindows 的方法,用來決定哪些窗口是可以合併的。

public abstract class MergingWindowAssigner<T, W extends Window> extends WindowAssigner<T, W> {
 private static final long serialVersionUID = 1L;

 /**
  * 決定哪些窗口需要被合併。對於每組需要合併的窗口, 都會調用 callback.merge(toBeMerged, mergeResult)
  *
  * @param windows 現存的窗口集合 The window candidates.
  * @param callback 需要被合併的窗口會回調 callback.merge 方法
  */
 public abstract void mergeWindows(Collection<W> windows, MergeCallback<W> callback);

 public interface MergeCallback<W> {

   /**
    * 用來聲明合併窗口的具體動作(合併窗口底層狀態、合併窗口trigger等)。
    *
    * @param toBeMerged  需要被合併的窗口列表
    * @param mergeResult 合併後的窗口
    */
   void merge(Collection<W> toBeMerged, W mergeResult);
 }
}

所有已經存在的 assigner 都繼承自 WindowAssigner,只有新加入的 session window assigner 繼承自 MergingWindowAssigner,如:ProcessingTimeSessionWindowsEventTimeSessionWindows

另外,Trigger 也需要能支持對合並窗口後的響應,所以 Trigger 添加了一個新的接口 onMerge(W window, OnMergeContext ctx),用來響應發生窗口合併之後對trigger的相關動作,比如根據合併後的窗口註冊新的 event time 定時器。

OK,接下來我們看下最核心的代碼,也就是對於每個進入的元素的處理,代碼位於WindowOperator.processElement方法中,如下所示:

public void processElement(StreamRecord<IN> element) throws Exception {
 Collection<W> elementWindows = windowAssigner.assignWindows(element.getValue(), element.getTimestamp());
 final K key = (K) getStateBackend().getCurrentKey();
 if (windowAssigner instanceof MergingWindowAssigner) {
   // 對於session window 的特殊處理,我們只關注該條件塊內的代碼
   MergingWindowSet<W> mergingWindows = getMergingWindowSet();

   for (W window: elementWindows) {
     final Tuple1<TriggerResult> mergeTriggerResult = new Tuple1<>(TriggerResult.CONTINUE);
     
     // 加入新窗口, 如果沒有合併發生,那麼actualWindow就是新加入的窗口
     // 如果有合併發生, 那麼返回的actualWindow即爲合併後的窗口,
     // 並且會調用 MergeFunction.merge 方法, 這裏方法中的內容主要是更新trigger, 合併舊窗口中的狀態到新窗口中
     W actualWindow = mergingWindows.addWindow(window, new MergingWindowSet.MergeFunction<W>() {
       @Override
       public void merge(W mergeResult,
           Collection<W> mergedWindows, W stateWindowResult,
           Collection<W> mergedStateWindows) throws Exception {
         context.key = key;
         context.window = mergeResult;

         // 這裏面會根據新窗口的結束時間註冊新的定時器
         mergeTriggerResult.f0 = context.onMerge(mergedWindows);

         // 刪除舊窗口註冊的定時器
         for (W m: mergedWindows) {
           context.window = m;
           context.clear();
         }

         // 合併舊窗口(mergedStateWindows)中的狀態到新窗口(stateWindowResult)中
         getStateBackend().mergePartitionedStates(stateWindowResult,
             mergedStateWindows,
             windowSerializer,
             (StateDescriptor<? extends MergingState<?,?>, ?>) windowStateDescriptor);
       }
     });

     // 取 actualWindow 對應的用來存狀態的窗口
     W stateWindow = mergingWindows.getStateWindow(actualWindow);
     // 從狀態後端拿出對應的狀態
     AppendingState<IN, ACC> windowState = getPartitionedState(stateWindow, windowSerializer, windowStateDescriptor);
     // 將新進入的元素數據加入到新窗口(或者說合並後的窗口)中對應的狀態中
     windowState.add(element.getValue());

     context.key = key;
     context.window = actualWindow;

     // 檢查是否需要fire or purge
     TriggerResult triggerResult = context.onElement(element);

     TriggerResult combinedTriggerResult = TriggerResult.merge(triggerResult, mergeTriggerResult.f0);

     // 根據trigger結果決定怎麼處理窗口中的數據
     processTriggerResult(combinedTriggerResult, actualWindow);
   }

 } else {
   // 對於普通window assigner的處理, 這裏我們不關注
   for (W window: elementWindows) {

     AppendingState<IN, ACC> windowState = getPartitionedState(window, windowSerializer,
         windowStateDescriptor);

     windowState.add(element.getValue());

     context.key = key;
     context.window = window;
     TriggerResult triggerResult = context.onElement(element);

     processTriggerResult(triggerResult, window);
   }
 }
}

其實這段代碼寫的並不是很clean,並且不是很好理解。在第六行中有用到MergingWindowSet,這個類很重要所以我們先介紹它。這是一個用來跟蹤窗口合併的類。比如我們有A、B、C三個窗口需要合併,合併後的窗口爲D窗口。這三個窗口在底層都有對應的狀態集合,爲了避免代價高昂的狀態替換(創建新狀態是很昂貴的),我們保持其中一個窗口作爲原始的狀態窗口,其他幾個窗口的數據合併到該狀態窗口中去,比如隨機選擇A作爲狀態窗口,那麼B和C窗口中的數據需要合併到A窗口中去。這樣就沒有新狀態產生了,但是我們需要額外維護窗口與狀態窗口之間的映射關係(D->A),這就是MergingWindowSet負責的工作。這個映射關係需要在失敗重啓後能夠恢復,所以MergingWindowSet內部也是對該映射關係做了容錯。狀態合併的工作示意圖如下所示:

然後我們來解釋下processElement的代碼,首先根據window assigner爲新進入的元素分配窗口集合。接着進入第一個條件塊,取出當前的MergingWindowSet。對於每個分配到的窗口,我們就會將其加入到MergingWindowSet中(addWindow方法),由MergingWindowSet維護窗口與狀態窗口之間的關係,並在需要窗口合併的時候,合併狀態和trigger。然後根據映射關係,取出結果窗口對應的狀態窗口,根據狀態窗口取出對應的狀態。將新進入的元素數據加入到該狀態中。最後,根據trigger結果來對窗口數據進行處理,對於session window來說,這裏都是不進行任何處理的。真正對窗口處理是由定時器超時後對完成的窗口調用processTriggerResult

總結

本文在上一篇文章:Window機制的基礎上,深入講解了 Flink 是如何支持 session window 的,核心的原理是窗口的合併。Flink 對於 session window 的支持很大程度上受到了 Google DataFlow 的啓發,所以也建議閱讀下 DataFlow 的論文。

參考資料


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