FLIP-147:支援包含結束任務的 Checkpoint 操作與作業結束流程修正

ApacheFlink發表於2022-07-15
作者|高贇(雲騫)

點選進入 Flink 中文學習網

第一部分

簡介

Flink 可以同時支援有限資料集和無限資料集的分散式處理。在最近幾個版本中,Flink 逐步實現了流批一體的 DataStream API 與 Table / SQL API。大部分使用者都同時有流處理與批處理的需求,流批一體的開發介面可以幫助這些使用者減小開發、運維與保證兩類作業處理結果一致性等方面的複雜度, 例如阿里巴巴雙十一的場景 [1]

img

圖1. 流執行模式與批執行模式的對比。以Count運算元為例,在流模式下,到達的資料是無序的,運算元將會讀寫與該元素對應的狀態並進行增量計算。而在批模式下,運算元將首先對資料排序,相同Key的資料將被統一處理。

在流批一體的介面之下,Flink 提供了兩種不同的執行模式,即流執行模式與批執行模式。流執行模式下 Flink 基於中間狀態增量的處理到達的資料,它可以同時支援有限資料與無限資料的處理。批執行模式則是基於按拓撲序依次執行作業中的所有任務,並通過預先對資料進行排序來避免對狀態的隨機訪問,因此它只能用於有限資料的處理,但是一般情況下可以取得更好的效能。雖然許多場景下使用者直接採用批處理模式來處理有限資料集,但是也存在許多場景使用者仍然依賴流處理模式來處理有限資料集。例如,使用者可能想要使用 SQL 的 Retraction 功能或者使用者可能依賴於流模式下資料近似按時間有序的性質(例如 Kappa+ 架構 [2])。此外,許多使用者需要執行同時包括無限資料流與有限維表的作業,這類作業也必須採用流執行模式。

在流執行模式下,Checkpointing [3] 是保證 Exactly-once 語義的核心機制。通過定期的儲存作業的狀態,當發生錯誤時 Flink 可以從最新的儲存點恢復並繼續執行。但是,在之前的版本中,Flink 不支援當部分任務執行結束之後進行 Checkpoint 操作。對於同時包括無限和有限資料輸入的作業,這個問題將導致當有限資料輸入處理完成後作業無法繼續進行 Checkpoint 操作,從而導致當發生錯誤時需要從很久之前重新計算。

此外,無法對於部分任務結束後的作業進行 Checkpoint 操作也會影響使用兩階段提交 Sink 來保證端到端一致性 [4] 的作業。為了保證端到端一致性,兩階段提交的 Sink 通常首先將資料寫入臨時檔案或者啟用外部系統的事務,然後在 Checkpoint 成功完成後提交 Checkpoint 之前寫入的資料,從而避免在發生錯誤後重放這部分資料導致資料重複。但是,如果作業中包含有限資料來源,在這部分源節點任務結束後作業將無法繼續提交資料。特別是對於所有資料來源均為有限資料來源的情況,作業總是無法提交最後一次 Checkpoint 到作業執行結束中間的這部分資料。在之前的實現中,Flink 在作業結束時直接忽略這部分資料,這對於使用者帶來了極大的困擾,在郵件列表中也有不少使用者在詢問這個問題。

因此,為了完善流執行模式對有限資料流的支援,Flink 需要:

  1. 支援任務結束後繼續進行 Checkpoint 操作。
  2. 修正作業結束的流程,保證所有資料都可以被正常提交。

下文我們將首先簡要描述針對這兩個目標所進行的改動。在第二部分,我們也將分享更詳細的實現。

支援包含結束任務的 Checkpoint

總體來說,支援包含結束任務的 Checkpoint 操作的核心思路是給已經執行完成的運算元打標,從而在重啟後可以跳過這部分運算元的執行。如圖 2 所示,在 Flink 中,Checkpoint 是由所有運算元的狀態來組成的。如果一個運算元的所有併發都已經執行完成,那們我們就可以將該運算元標記為『執行完成』,並在重啟後跳過。對於其它運算元,運算元的狀態是由所有當前還在執行的併發的狀態組成,當重啟後,運算元狀態將在所有併發中重新劃分。

img

圖2. 擴充套件的Checkpoint格式

為了能夠在有任務執行完成的情況下完成上述 Checkpoint 操作,我們修改了 Checkpoint 操作的流程。之前在進行 Checkpoint 時,JobManager 中的 Checkpoint 控制器將首先通知所有的源節點儲存當前狀態,然後源節點將通過 Barrier 事件通知後續的運算元。由於現在源節點可能已經執行完成,Checkpoint 控制器需要改為通知那些本身尚未執行結束、但是所有的前驅任務都已經執行完成的任務。最後,如果一個運算元所有任務要麼在開始 Checkpoint 的時候已經變為『完成』狀態、要麼在儲存當前狀態時已經處理完成所有資料,該運算元就會被標記為『執行完成』。

除了在 Checkpoint 時確實有任務執行完成的情況下我們限制作業升級時對作業拓撲結構的修改,上述修改對使用者是透明的。具體來說,我們不允許使用者在一個被標記為『執行完成』的運算元前增加新的運算元,因為這將導致一個『執行完成』的運算元有一個尚未『執行完成』的前驅,而這違反了 Flink 中運算元按拓撲序結束的語義。

修正作業結束的流程

基於上述對包含結束任務的作業進行 Checkpoint 的能力,我們現在可以解決兩階段提交的運算元在流模式下無法提交最後一部分資料的問題。總的來說,Flink 作業結束有兩種可能的方式:

  1. 所有資料來源都是有限的,這種情況下作業會在處理完所有輸入資料並提交所有輸出到外部系統後結束。
  2. 使用者顯式執行 stop-with-savepoint [--drain] 操作。這種情況下作業會建立一個 Savepoint 後結束。如果指定了 --drain,作業將永久結束,這種情況下需要完成所有外部系統中臨時資料的提交。另一方面,如果沒有指定該引數,那麼作業預期後續會基於該 Savepoint 重啟,這種情況下則不需要一定完成所有臨時資料的提交,只要保持 Savepoint 中記錄的狀態與外部系統中臨時資料的狀態一致即可。

我們首先看一下所有資料來源有限的情況。為了能夠實現端到端的一致性,使用兩階段提交的運算元只在 Checkpoint 完成之後才會提交該 Checkpoint 之前的資料。但是,在之前的實現中,對於最後一個週期性的 Checkpoint 到作業執行結束期間所產生的資料,作業是沒有一個合適的機會來進行提交的,從而導致資料丟失。需要注意的是我們在作業結束的時候直接提交這部分資料也是不可取的:如果在某個任務完成提交之後,由於其它任務發生錯誤導致發生了重啟,那麼從最後一次 Checkpoint 開始的資料就會被重放,從而導致資料重複。

使用者通過 stop-with-savepoint [--drain] 來停止作業的情況同樣存在問題。在之前的實現中,Flink 將首先阻塞所有的任務,然後建立一個 Savepoint。在 Savepoint 成功之後,所有的資料來源任務將主動停止執行,從而使整個作業執行結束。儘管看起來我們可以通過這次 Savepoint 來提交所有資料,但是在現在的實現中,還有一些邏輯實際上是在作業停止執行的過程中執行的,如果這些邏輯產生了新的資料,這些資料最終會丟失。例如,在之前的實現中 endInput() 方法就是在作業停止過程中執行的,一些運算元可能在該方法中傳送資料,例如用於非同步操作的 AsyncWaitOperator。

最後,儘管不指定 drain 引數時,執行 stop-with-savepoint 不需要提交所有的資料,但是我們還是希望這種情況下作業結束的流程可以與前兩種情況統一,從而保證程式碼的可維護性。

為了解決現有實現中存在的問題,我們需要修改作業結束的流程來保證在需要的時候所有的資料都可以保證提交。如圖 3 所示,一個直接的想法是我們可以在任務生命週期增加一步,讓任務在結束之前等待下一個 Checkpoint 完成。但是,如下文所述,這種方式仍然不能解決所有問題。

img

圖3. 兩種保證任務結束前完成資料提交的方法對比。第一種方法直接在任務的生命週期中插入一步,即等待下一個Checkpoint結束,但這種方式下不同任務無法等待同一個Checkpoint / Savepoint。第二種方式解耦了『完成執行邏輯』與『任務結束』,從而允許所有任務首先完成資料處理,然後它們有機會等待同一個Checkpoint / Savepoint。

對於所有資料來源都是有限的情況,這種直接的方式可以解決資料無法提交的問題,但是它可能導致比較嚴重的效能問題。如圖 4 所示,如果有多個級連的任務,每個任務都包含兩階段提交的 Sink,那麼每個任務都需要在結束之前等待下一次 Checkpoint 完成,這樣整個作業需要等待 3 個 Checkpoint 才能結束,這將對作業的執行時間有較大的影響。

img

圖4. 一個有多級任務並且每個任務都包含兩階段提交運算元的例子

對於 stop-with-savepoint [--drain] 的情況,這種直接的想法就不能實施了,因為這種情況下由於不同的任務必須等待不同的 Checkpoint / Savepoint,最終作業無法得到一個完整的 Savepoint。

因此,我們無法採用這種直接的想法。我們採用的方式是將『作業完成所有執行邏輯』與『作業結束』解耦:我們首先讓所有任務完成所有的執行邏輯,包括 呼叫 “endInput()” 這些生命週期方法在內,然後所有的任務就可以並行的等待下一個 Checkpoint / Savepoint 了。此外,對於 stop-with-savepoint [--drain] 的情況,我們也類似的反轉當前實現:所有任首先完成所有的執行邏輯,然後它們就可以等待下一個 Savepoint 完成後結束。可以看出,通完這這種方式,我們可以以同樣的流程來統一所有作業結束的情況。

基於這一思想,如圖 3 的右半部分所示,為了解耦『作業完成所有執行邏輯』與『作業結束』,我們引入了一個新的 EndOfData 事件。對於每一個任務,在完所有的執行邏輯後,它將首先向所有下游傳送一個 EndOfData 事件,這樣下游也可以明確推斷出自己完成了所有的執行邏輯。然後所有的任務就可以並行的等待下一次 Checkpoint 或者指定的 Savepoint 完成後,此時這些任務可以向外部系統提交所有資料後結束。

最後,在修改過程中,我們還重新整理和重新命名了『close()』和『dispose()』 兩個運算元生命週期演算法。這兩個方法的語義是有所區別的,因為 close() 實際上只在作業正常結束的情況下呼叫,而 dispose() 在正常結束和異常退出的情況下都會呼叫。但是,使用者很難從這兩個名子上看出這個語義。因此,我們將這兩上方法重新命名為了『finish()』和『close()』:

  1. finish() 標記著所有的運算元已經執行完成,並且不會再產生新的資料。因此,只有當作業正常結束並且已經完全執行完成(即所有資料來源執行結束或者使用者使用了stop-with-savepoint --drain)的情況下才會呼叫。
  2. close() 在所有情況下都會呼叫,用於釋放任務佔用的資源。

第二部分

在上述第一部分中,我們已經簡要介紹了為支援包含結束任務的 Checkpoint 以及優化作業結束流程所做的工作。在這一部分中我們將更介紹更多實現細節,包括包含結束任務時 Checkpoint 的具體流程與作業結束的具體流程。

包含結束任務的 Checkpoint 實現

如第一部分所述,支援包含結束任務的 Checkpoint 操作的核心思想是對已經完全執行完成的運算元打標,並在重啟後跳過這些運算元的執行。為了實現這一思想,我們需要修改現在 Checkpoint 的流程來建立這些標記並且在恢復時使用這些標記。本節將介紹這一流程的細節實現。

在之前的實現中,只有當所有任務都在執行狀態時才可以進行 Checkpoint 操作。如圖 5 所示,在這些情況下 Checkpoint 協調器將首先通知所有的資料來源任務,資料來源任務在完成狀態儲存後再繼續通知後續任務。類似的,在部分任務執行結束的情況下,我們需要首先找到當前仍在執行的部分中的新的『源任務』,也就是那些正在執行但是所有前驅任務都已經執行完成的任務,然後通過通知這些任務來啟動 Checkpoint 擔任。Checkpoint 協調器在 JobManaer 中任務記錄的最新狀態原子的計算當前的『源任務』列表。

通知這些源任務的過程可能存在 狀態競爭:當 Checkpoint 協調器選中一個任務進行通知的過程中,這個任務可能恰好執行完成並彙報結束狀態,從而導致通知失敗。在這種情況下,我們選擇終止這次 Checkpoint。

img

圖5. 部分任務結束後Checkpoint的Trigger方式

為了在 Checkpoint 中記錄運算元的結束狀態,我們需要擴充套件 Checkpoint 的格式。一個 Checkpoint 是由所有有狀態運算元的狀態組成的,而每個運算元的狀態則是由它所有併發例項的狀態組成。這裡需要指出的是,任務(Task)這一概念並不在 Checkpoint 中反映。任務更多的是一個物理執行的視窗,用於驅動它所包含的所有運算元併發例項的執行。 但是在一個作業的多次執行中,由於使用者可能修改作業拓撲結構 ,從而使任務的劃分發生變化,因此任務在兩次執行中可能不是一一對應的。因此,執行結束的標記需要附加在 Checkpoint 中的運算元狀態上。

如第一部分圖 2 所示,在進行 Checkpoint 時根據運算元當前的執行狀態可以將運算元分為三類:

  1. 完全執行結束:如果一個運算元的所有併發例項都執行完成,該運算元可以被認為完全執行結束,在重啟後可以跳過該運算元的執行。我們就需要對這些運算元打標。
  2. 部分執行結束:如果一個運算元的部分例項執行完成,那麼它在作業重啟後需要繼續執行剩餘的邏輯。整體上我們可以認為這種情況下運算元的狀態是由所有仍在執行的併發例項的狀態組成的,這些狀態可以代表尚未執行完成的邏輯。
  3. 沒有完成的例項:這種情況下運算元狀態與現有實現相同。

後續作業從 Checkpoint 中重啟時,我們可以跳過完全執行結束的運算元,並且繼續執行其它兩種型別的運算元。

但是,對於部分執行結束的運算元,實際情況會更加複雜。在重啟時,部分執行結束運算元的剩餘狀態將會被重新分發到所有例項中,這一流程與運算元併發修改的情況類似。對於所有型別的狀態,Keyed State [5] 與普通的 Oeprator State [6] 的狀態可以正常分發,但是 Broacast State [7]Union Operator State [8] 存在問題:

  1. Broadcast State 在重啟後總是將第一個併發例項的狀態廣播給所有的新的併發例項。但是,如果第一個併發例項已經執行結束,那麼它的狀態將為空,這將導致所有併發例項的狀態變為空,運算元將從頭執行,這是不符合預期的。
  2. Union Operator State 在重啟後會將所有運算元的狀態聚合後分發給所有的新的併發例項。基於這一行為,許多運算元可能會選擇其中一個併發例項來儲存所有併發例項共享的狀態。類似的,如果所選中的併發例項已經執行結束,那麼這部分狀態就丟失了。

這實際修改併發的場景中,這兩個問題是不會發生的,因為這種情況下並不存在已執行完成的子任務。為了解決上述問題,對於 Broadcast State,我們選擇任意一個執行狀態的子任務做為廣播狀態的來源;對於 Union Operator State,我們需要保證能夠收集所有子任務的狀態,因此目前如果我們觀察到一個使用了 Union Operator State 的運算元部分執行結束的話,我們取消這次 Checkpoint,後續等到該運算元所有子任務執行完成,Checkpoint 將可以繼續。

原則上,使用者可以在兩次執行期間對拓撲進行修改。但是,考慮到任務結束的情況,對拓撲修改有一定的限制:使用者不能在一個完全結束的運算元之前增加新的運算元。Flink 將在作業重啟時進行檢測並在有此類修改時報錯。

修正後的作業結束流程

如第一部分所述,基於在部分任務結束後繼續做 Checkpoint 的能力,我們可以對現有作業結束流程進行修正,從而保證兩階段提交的運算元總是可以正常提交資料。本節將詳細描述修改前後的結束流程。

原作業結束流程

如前文所述,作業結束包括兩種情況:所有資料來源結束或使用者執行 stop-with-savepoint --drain。我們首先來看一下之前的作業結束流程。

所有資料來源結束

如果所有的資料來源都是有限的,那麼作業將在所有資料處理完成後結束,並且所有資料都需要提交。在這種情況下,資料來源任務將首先傳送一個 MAX_WATERMARK (Long.MAX_VALUE) 然後開始結束任務。在結束過程中,任務將依次對所有運算元呼叫 endOfInput()、close()、和dispose(),然後向下遊傳送 EndOfPartitionEvent 事件。後續任務在收所有輸入邊中都讀到 EndOfPartitionEvent 事件後,也會開始執行結束流程,這一過程不斷重複直到所有任務都結束。

  1. Source operators emit MAX_WATERMARK
  1. On received MAX_WATERMARK for non-source operators

    a. Trigger all the event-time timers

    b. Emit MAX_WATERMARK

  1. Source tasks finished

    a. endInput(inputId) for all the operators

    b. close() for all the operators

    c. dispose() for all the operators

    d. Emit EndOfPartitionEvent

    e. Task cleanup

  1. On received EndOfPartitionEvent for non-source tasks

    a. endInput(int inputId) for all the operators

    b. close() for all the operators

    c. dispose() for all the operators

    d. Emit EndOfPartitionEvent

    e. Task cleanup

使用者執行 stop-with-savepoint --drain

使用者可以對有限或無限資料流作業執行 stop-with-savepoint [--drain] 操作來結束作業。在這種情況下,作業將首先觸發一個同步 Savepoint 操作,並且阻塞所有任務直到該 Savepoint 完成。如果 Savepoint 成功完成,那麼所有的資料來源任務將主動執行結束流程,後續流程與所有資料來源有限的情況類似。

  1. Trigger a savepoint
  1. Sources received savepoint trigger RPC

    a. If with –-drain

    ​ i. source operators emit MAX_WATERMARK

    b. Source emits savepoint barrier

  1. On received MAX_WATERMARK for non-source operators

    a. Trigger all the event times

    b. Emit MAX_WATERMARK

  1. On received savepoint barrier for non-source operators

    a. The task blocks till the savepoint succeed

  1. Finish the source tasks actively

    a. If with –-drain

    ​ ii. endInput(inputId) for all the operators

    b. close() for all the operators

    c. dispose() for all the operators

    d. Emit EndOfPartitionEvent

    e. Task cleanup

  1. On received EndOfPartitionEvent for non-source tasks

    a. If with –-drain

    ​ i. endInput(int inputId) for all the operators

    b. close() for all the operators

    c. dispose() for all the operators

    d. Emit EndOfPartitionEvent e. Task cleanup

該命令有一個可選的 --drain 的引數,如果未指定該引數,後續作業可以從 Savepoint 恢復執行,否則使用者預期作業永久結束。因此,只有使用者指定該引數的情況下,作業才會傳送 MAX_WATERMARK 並且對所有運算元呼叫 endInput()。

修正後作業結束流程

如第一部分所述,在修正後的結束流程中,我們通過增加一個新的 EndOfData 事件解耦了『任務完成執行邏輯』與『任務結束』。每個任務將首先在完成全部執行邏輯後向下游傳送一個 EndOfData 事件,這樣下游任務也可以先完成所有執行邏輯,然後所有任務就可以並行的等待下一個 Checkpoint 或 指定的 Savepoint 來完成提交所有資料。

本節將詳細描述修正後的執行流程。由於我們將 close() / dispose() 方法重新命名了 finish() / close(),我們將在後續描述中堅持使用這一術語。

修正後的執行流程如下:

  1. Source tasks finished due to no more records or stop-with-savepoint.

    a. if no more records or stop-with-savepoint –-drain

    ​ i. source operators emit MAX_WATERMARK

    ​ ii. endInput(inputId) for all the operators

    ​ iii. finish() for all the operators

    ​ iv. emit EndOfData[isDrain = true] event

    b. else if stop-with-savepoint

    ​ i. emit EndOfData[isDrain = false] event

    c. Wait for the next checkpoint / the savepoint after operator finished complete

    d. close() for all the operators

    e. Emit EndOfPartitionEvent

    f. Task cleanup

  1. On received MAX_WATERMARK for non-source operators

    a. Trigger all the event times

    b. Emit MAX_WATERMARK

  1. On received EndOfData for non-source tasks

    a. If isDrain

    ​ i. endInput(int inputId) for all the operators

    ​ ii. finish() for all the operators

    b. Emit EndOfData[isDrain = the flag value of the received event]

  1. On received EndOfPartitionEvent for non-source tasks

    a. Wait for the next checkpoint / the savepoint after operator finished complete

    b. close() for all the operators

    c. Emit EndOfPartitionEvent

    d. Task cleanup

img

圖6. 一個使用修正後的結束流程的作業的例子

一個例子如圖 6 所示。我們首先來看一下所有資料來源都是有限的情況。

如果任務 C 首先在處理完所有資料後結束,它將首先傳送 MAX_WATERMARK,然後對所有運算元執行對應結束的生命週期方法併傳送 EndOfData 事件。在這之後,它首先等待下一個 Checkpoint 完成,然後傳送 EndOfPartitionEvent 事件。

任務 D 在收到 EndOfData 事件後將首先對運算元執行結束對應對應結束的生命週期方法。由於任何在運算元執行結束後開始的 Checkpoint 都可以提交剩餘的資料,而任務 C 提交資料所依賴的 Checkpoint 的 Barrier 事件在 EndOfData 事件後到達,因此任務 D 實際上可以與任務 C 使用同樣的 Checkpoint 完成資料提交。

任務 E 略有不同,因為它有兩個輸入,而任務 A 可能繼續執行一段時間。因此,任務 E 必須等到它從兩個輸入都讀取到 EndOfData 事件後才可以開始結束運算元執行,並且它需要依賴一個不同的 Checkpoint 來完成資料提交。

另一方面,當使用 stop-with-savepoint [--drain] 來結束作業時,整個流程與資料來源有限的情況相同,只是所有任務不是等待任意的下一個 Checkpoint,而是等待一個指定的 Savepoint 來完成資料提交。此外,這種情況下由於任務 C 與任務 A 一定同時結束,因此我們可以保證任務E也可以在結束前等到這個特定的 Savepoint。

結論

通過支援在部分任務結束後的 Checkpoint 作業並且修正作業結束的流程,我們可以支援同時使用有限資料來源與無限資料來源的作業,並且可以保證所有資料來源都是有限的情況下最後一部分資料可以正常提交。這部分修改保證了資料一致性與結束完整性,並且支援了包含有限資料來源的作業的錯誤恢復。這一機制主要在 1.14 實現,並且在 1.15 中預設開啟。如果遇到任何問題,歡迎在 dev 或 user / user-zh 郵件列表中發起討論或提出問題。

[1] https://www.ververica.com/blo...

[2] https://www.youtube.com/watch...

[3] https://nightlies.apache.org/...

[4] https://flink.apache.org/feat...

[5] https://nightlies.apache.org/...

[6] https://nightlies.apache.org/...

[7] https://nightlies.apache.org/...

[8] https://nightlies.apache.org/...


點選進入 Flink 中文學習網

更多 Flink 相關技術問題,可掃碼加入社群釘釘交流群
第一時間獲取最新技術文章和社群動態,請關注公眾號~

img

活動推薦

阿里雲基於 Apache Flink 構建的企業級產品-實時計算Flink版現開啟活動:
99 元試用 實時計算Flink版(包年包月、10CU)即有機會獲得 Flink 獨家定製衛衣;另包 3 個月及以上還有 85 折優惠!
瞭解活動詳情:https://www.aliyun.com/produc...

image.png

相關文章