Flink的窗口算子 WindowOperator的實現原理

窗口算子WindowOperator是窗口機制的底層實現,它幾乎會牽扯到所有窗口相關的知識點,因此相對複雜。本文將以由面及點的方式來分析WindowOperator的實現。首先,我們來看一下對於最常見的時間窗口(包含處理時間和事件時間)其執行示意圖:

上圖中,左側從左往右爲事件流的方向。方框代表事件,事件流中夾雜着的豎直虛線代表水印,Flink通過水印分配器(TimestampsAndPeriodicWatermarksOperator和TimestampsAndPunctuatedWatermarksOperator這兩個算子)向事件流中注入水印。元素在streaming dataflow引擎中流動到WindowOperator時,會被分爲兩撥,分別是普通事件和水印。

如果是普通的事件,則會調用processElement方法(上圖虛線框中的三個圓圈中的一個)進行處理,在processElement方法中,首先會利用窗口分配器爲當前接收到的元素分配窗口,接着會調用觸發器的onElement方法進行逐元素觸發。對於時間相關的觸發器,通常會註冊事件時間或者處理時間定時器,這些定時器會被存儲在WindowOperator的處理時間定時器隊列和水印定時器隊列中(見圖中虛線框中上下兩個圓柱體),如果觸發的結果是FIRE,則對窗口進行計算。

如果是水印(事件時間場景),則方法processWatermark將會被調用,它將會處理水印定時器隊列中的定時器。如果時間戳滿足條件,則利用觸發器的onEventTime方法進行處理。

而對於處理時間的場景,WindowOperator將自身實現爲一個基於處理時間的觸發器,以觸發trigger方法來消費處理時間定時器隊列中的定時器滿足條件則會調用窗口觸發器的onProcessingTime,根據觸發結果判斷是否對窗口進行計算。

以上是WindowOperator的常規流程最簡單的表述,事實上其邏輯要複雜得多。我們首先分解掉幾個內部核心對象,上圖中我們看到有兩個隊列:分別是水印定時器隊列和處理時間定時器隊列。這裏的定時器是什麼?它有什麼作用呢?接下來我們就來看看它的定義——WindowOperator的內部類Timer。Timer是所有時間窗口執行的基礎,它其實是一個上下文對象,封裝了三個屬性:

timestamp:觸發器觸發的時間戳; key:當前元素所歸屬的分組的鍵; window:當前元素所屬窗口;

在我們講解窗口觸發器時,我們曾提及過觸發器上下文對象,它作爲process系列方法參數。在WindowOperator內部我們終於看到了對該上下文對象接口的實現——Context,它主要提供了三種類型的方法:

提供狀態存儲與訪問; 定時器的註冊與刪除; 窗口觸發器process系列方法的包裝;

在註冊定時器時,會新建定時器對象並將其加入到定時器隊列中。等到時間相關的處理方法(processWatermark和trigger)被觸發調用,則會從定時器隊列中消費定時器對象並調用窗口觸發器,然後根據觸發結果來判斷是否觸動窗口的計算。我們選擇事件時間的處理方法processWatermark進行分析(處理時間的處理方法trigger跟其類似):

?

1

2

3

4

5

6

7

8

9

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

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

public void  processWatermark(Watermark mark) throws Exception {

    //定義一個標識,表示是否仍有定時器滿足觸發條件  

    boolean fire;  

    do {

        //從水印定時器隊列中查找隊首的一個定時器,注意此處並不是出隊(注意跟remove方法的區別)     

        Timer<k, w=""> timer = watermarkTimersQueue.peek();     

        //如果定時器存在,且其時間戳戳不大於水印的時間戳

        //(注意理解條件是:不大於,水印用於表示小於該時間戳的元素都已到達,所以所有不大於水印的觸發時間戳都該被觸發)

        if (timer != null && timer.timestamp <= mark.getTimestamp()) {

            //置標識爲真,表示找到滿足觸發條件的定時器        

            fire = true;        

            //將該元素從隊首出隊

            watermarkTimers.remove(timer);        

            watermarkTimersQueue.remove();

            //構建新的上下文        

            context.key = timer.key;        

            context.window = timer.window;        

            setKeyContext(timer.key);        

            //窗口所使用的狀態存儲類型爲可追加的狀態存儲

            AppendingState<in, acc=""> windowState;        

            MergingWindowSet<w> mergingWindows = null;        

            //如果分配器是合併分配器(比如會話窗口)

            if (windowAssigner instanceof MergingWindowAssigner) {

                //獲得合併窗口幫助類MergingWindowSet的實例           

                mergingWindows = getMergingWindowSet();           

                //獲得當前窗口對應的狀態窗口(狀態窗口對應着狀態後端存儲的命名空間)

                W stateWindow = mergingWindows.getStateWindow(context.window);           

                //如果沒有對應的狀態窗口,則跳過本次循環

                if (stateWindow == null) {                             

                    continue;           

                }

                //獲得當前窗口對應的狀態表示           

                windowState = getPartitionedState(stateWindow,

                    windowSerializer, windowStateDescriptor);        

            else {

                //如果不是合併分配器,則直接獲取窗口對應的狀態表示           

                windowState = getPartitionedState(context.window,

                    windowSerializer, windowStateDescriptor);        

            }

            //從窗口狀態表示中獲得窗口中所有的元素        

            ACC contents = windowState.get();        

            if (contents == null) {           

                // if we have no state, there is nothing to do           

                continue;        

            }

            //通過上下文對象調用窗口觸發器的事件時間處理方法並獲得觸發結果對象

            TriggerResult triggerResult = context.onEventTime(timer.timestamp);        

            //如果觸發的結果是FIRE(觸動窗口計算),則調用fire方法進行窗口計算

            if (triggerResult.isFire()) {           

                fire(context.window, contents);        

            }

            //而如果觸動的結果是清理窗口,或者事件時間等於窗口的清理時間(通常爲窗口的maxTimestamp屬性)        

            if (triggerResult.isPurge() ||

                (windowAssigner.isEventTime()

                    && isCleanupTime(context.window, timer.timestamp))) {

                //清理窗口及元素           

                cleanup(context.window, windowState, mergingWindows);        

            }     

        else {

            //隊列中沒有符合條件的定時器,置標識爲否,終止循環        

            fire = false;     

        }  

    while (fire);  

    //向下遊發射水印

    output.emitWatermark(mark);  

    //將當前算子的水印屬性用新水印的時間戳覆蓋

    this.currentWatermark = mark.getTimestamp();

}</w></in,></k,>

以上方法雖然冗長但流程還算清晰,其中的fire方法用於對窗口進行計算,它會調用內部窗口函數(即InternalWindowFunction,它包裝了WindowFunction)的apply方法。

而isCleanupTime和cleanup這對方法主要涉及到窗口的清理。如果當前窗口是時間窗口,且窗口的時間到達了清理時間,則會進行清理窗口清理。那麼清理時間如何判斷呢?Flink是通過窗口的最大時間戳屬性結合允許延遲的時間聯合計算的:

?

1

2

3

4

5

6

7

8

private long  cleanupTime(W window) {

    //清理時間被預置爲窗口的最大時間戳加上允許的延遲事件  

    long cleanupTime = window.maxTimestamp() + allowedLateness;

    //如果窗口爲非時間窗口(其maxTimestamp屬性值爲Long.MAX_VALUE),則其加上允許延遲的時間,

    //會造成Long溢出,從而會變成負數,導致cleanupTime < window.maxTimestamp 條件成立,

    //則直接將清理時間設置爲Long.MAX_VALUE  

    return cleanupTime >= window.maxTimestamp() ? cleanupTime : Long.MAX_VALUE;

}

求出清理時間後會與定時器註冊的時間進行對比,如果兩者相等則布爾條件爲真,否則爲假:

?

1

2

3

4

protected final  boolean  isCleanupTime(W window, long time) {  

    long cleanupTime = cleanupTime(window);  

    return  cleanupTime == time;

}

下面我們來看一下清理方法主要做了哪些事情:

?

1

2

3

4

5

6

7

8

9

10

11

12

private void  cleanup(W window,              

    AppendingState<in, acc=""> windowState,              

    MergingWindowSet<w> mergingWindows) throws Exception {

    //清空窗口對應的狀態後端的狀態  

    windowState.clear();

    //如果支持窗口合併,則清空窗口合併集合中對應當前窗口的記錄  

    if (mergingWindows != null) {  

        mergingWindows.retireWindow(window);  

    }

    //清空上下文對象狀態  

    context.clear();

}</w></in,>

關於窗口清理,其實三大處理方法(processElement\/processWatermark\/trigger)都會進行判斷,如果滿足條件則清理。而真正註冊清理定時器的邏輯在processElement中,它會調用registerCleanupTimer方法:

?

1

2

3

4

5

6

7

8

9

10

protected void  registerCleanupTimer(W window) {

    //這裏註冊的時間即爲計算過了的清理時間  

    long cleanupTime = cleanupTime(window);

    //根據不同的時間分類調用不同的註冊方法  

    if (windowAssigner.isEventTime()) {     

        context.registerEventTimeTimer(cleanupTime);  

    else {     

        context.registerProcessingTimeTimer(cleanupTime);  

    }

}

從上面的代碼段可知:清理定時器跟普通定時器是一樣的。

如果沒有延遲,對於事件時間和處理時間而言,也許它們的窗口清理不一定是由清理定時器觸發。因爲在事件時間觸發器和處理時間觸發器中,它們註冊的定時器對應的時間點就是窗口的最大時間戳。由於這些定時器在隊列中一般排在清理定時器之前,所以這些定時器會優先於清理定時器得到執行(優先觸發窗口的清理)。而這裏的registerCleanupTimer方法,是一般化的清理機制,針對所有類型的窗口都適用,並確保窗口一定會得到清理。而對於剛剛提到的這種情況,重複的“清理”定時器並不會產生負作用。

WindowOperator還有一個繼承者:EvictingWindowOperator,該算子在常規的窗口算子上支持了元素驅逐器(見上圖中大虛線框內部的小虛線長方形)。EvictingWindowOperator特別的地方主要在於其fire的實現——在進行窗口計算之前會預先對符合驅逐條件的元素進行剔除,具體實現見如下代碼:

?

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

private void  fire(W window, Iterable<streamrecord<in>> contents) throws Exception {  

    timestampedCollector.setAbsoluteTimestamp(window.maxTimestamp());  

    //計算要驅逐的元素個數  

    int toEvict = evictor.evict((Iterable) contents, Iterables.size(contents), context.window);  

    FluentIterable<in> projectedContents = FluentIterable     

        .from(contents)     

        .skip(toEvict)     

        .transform(new Function<streamrecord<in>, IN>() {        

            @Override        

            public IN apply(StreamRecord<in> input) {           

                return input.getValue();        

            }     

        });  

    userFunction.apply(context.key, context.window, projectedContents, timestampedCollector);

}</in></streamrecord<in></in></streamrecord<in>

在最終調用窗口計算的apply方法之前,會先計算要驅逐的元素個數,然後跳過這些元素並且跳過的都是從首個元素開始的連續個元素(這一點在之前我們分析窗口元素驅逐器是也曾提及過)。

這裏採用了Guava類庫的FluentIterable幫助類,它擴展了Iterable接口並提供了非常豐富的擴展API。

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