Flink 清理過期 Checkpoint 目錄的正確姿勢

本博客是筆者在生產環境使用 Flink 遇到的 Checkpoint 相關故障後,整理輸出,價值較高的 實戰採坑記,本文會帶你更深入的瞭解 Flink 實現增量 Checkpoint 的細節。

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

  • Flink Checkpoint 目錄的清除策略

  • 生產環境應該選擇哪種清除策略

  • 生產環境必須定期腳本清理 Checkpoint 和 Savepoint 目錄

  • RocksDB 增量 Checkpoint 實現原理

  • 如何合理地刪除 Checkpoint 目錄?

  • 通過解析 Flink Checkpoint 的元數據信息來合理清理 Checkpoint 信息

1. 故障背景

本次故障涉及到的知識面比較多,將從以下多個角度來詳細描述。

1.1 Flink Checkpoint 目錄的清除策略

如下圖所示,紅圈處的一行配置 env.getCheckpointConfig().enableExternalizedCheckpoints() 表示當 Flink 任務取消時,是否保留外部保存的 CheckPoint 信息。

參數有兩種枚舉,分別是:ExternalizedCheckpointCleanup.DELETE_ON_CANCELLATION 和 ExternalizedCheckpointCleanup.DELETE_ON_CANCELLATION。這兩種枚舉分別代表什麼含義呢?看一下源碼中的解釋:

  • DELETE_ON_CANCELLATION:僅當作業失敗時,作業的 Checkpoint 纔會被保留用於任務恢復。當作業取消時,Checkpoint 狀態信息會被刪除,因此取消任務後,不能從 Checkpoint 位置進行恢復任務。

  • RETAIN_ON_CANCELLATION:當作業手動取消時,將會保留作業的 Checkpoint 狀態信息。注意,這種情況下,需要手動清除該作業保留的 Checkpoint 狀態信息,否則這些狀態信息將永遠保留在外部的持久化存儲中

無論是選擇上述哪種方式,後面都提示了一句:如果 Flink 任務失敗了,Checkpoint 的狀態信息將被保留。

1.2 生產環境中,該參數應該配置爲 DELETE_ON_CANCELLATION 還是 RETAIN_ON_CANCELLATION ?

在 Flink 任務運行過程中,爲了保障故障容錯,會定期進行 Checkpoint 將狀態信息保存到外部存儲介質中,當 Flink 任務由於各種原因出現故障時,Flink 任務會自動從 Checkpoint 處恢復,這就是 Checkpoint 的作用。Flink 中還有一個 Savepoint 的概念,Savepoint 與 Checkpoint 類似,同樣需要把狀態信息存儲到外部介質,當作業失敗時,可以從外部存儲中恢復。Savepoint 與 Checkpoint 的區別如下表所示:

CheckpointSavepoint
由 Flink 的 JobManager 定時自動觸發並管理由用戶手動觸發並管理
主要用於任務發生故障時,爲任務提供給自動恢復機制主要用戶升級 Flink 版本、修改任務的邏輯代碼、調整算子的並行度,且必須手動恢復
當使用 RocksDBStateBackend 時,支持增量方式對狀態信息進行快照僅支持全量快照
Flink 任務停止後,Checkpoint 的狀態快照信息默認被清除一旦觸發 Savepoint,狀態信息就被持久化到外部存儲,除非用戶手動刪除
Checkpoint 設計目標:輕量級且儘可能快地恢復任務Savepoint 的生成和恢復成本會更高一些,Savepoint 更多地關注代碼的可移植性和兼容任務的更改操作

相比 Savepoint 而言,Checkpoint 更加輕量級,但有些場景 Checkpoint 並不能完全滿足我們的需求。所以在使用過程中,如果我們的需求能使用 Checkpoint 來解決優先使用 Checkpoint。當 Flink 任務中的一些依賴組件需要升級重啓時,例如 hdfs、Kafka、yarn 升級或者 Flink 任務的 Sink 端對應的 MySQL、Redis 由於某些原因需要重啓時,Flink 任務在這段時間也需要重啓。但是由於 Flink 任務的代碼並沒有修改,所以 Flink 任務啓動時可以從 Checkpoint 處恢復任務。

如果 Checkpoint 目錄的清除策略配置爲 DELETE_ON_CANCELLATION,那麼在取消任務時 Checkpoint 的狀態信息會被清理掉,我們就無法通過 Checkpoint 來恢復任務了。爲了從 Checkpoint 中輕量級地進行任務恢復,我們需要將該參數配置爲 RETAIN_ON_CANCELLATION。

注:對於狀態超過 100G 的 Flink 任務 ,筆者在生產環境驗證過:每次從 Savepoint 恢復任務時需要耗時 10分鐘以上,而 Checkpoint 可以在 2分鐘以內恢復完成。充分說明了 Checkpoint 相比 SavePoint 而言,確實是輕量級的,所以 Checkpoint 能滿足的業務場景強烈建議使用 Checkpoint 恢復任務,而不是使用 SavePoint。

1.3 必須定期腳本清理 Checkpoint 和 Savepoint 目錄

假設任務運行對應的 jobId 爲 A,job A 停止且重啓後對應的 jobId 爲 B,此時 A 對應的 Checkpoint 可能已經沒用了,可以將其清理掉從而節省 hdfs 的存儲空間。同學們可能想,不清理可以嗎?當然可以,但是如果一直不清理就會佔用 hdfs 存儲空間。下圖是筆者生產環境中兩個狀態比較大的任務,第一個任務的狀態信息在 hdfs 三副本總共佔用了 1 T,第二個任務的狀態信息在 hdfs 三副本中總共佔用了 700G。如果 Flink 任務經常重啓,那麼這些大狀態的任務將會在 Checkpoint 目錄下對應很多個 job 目錄,將會把大狀態任務存儲很多份。假如 1 T 的任務保存了 5 份,那就是佔用 5 T 的磁盤空間,但其實只需要保存一份全量數據就夠了。因此必須有定期清理 Checkpoint 目錄的策略。

在 1.1 Flink Checkpoint 目錄的清除策略 部分,源碼中專門提示:如果選擇 RETAIN_ON_CANCELLATION 策略,需要手動清除該作業保留的 Checkpoint 狀態信息,否則這些狀態信息將永遠保留在外部的持久化存儲中。那如果選擇了 DELETE_ON_CANCELLATION 策略,就可以不定期清理 Checkpoint 目錄嗎?錯,也需要定期清理,源碼中有寫:無論配合何種策略,如果 Flink 任務失敗了,Checkpoint 的狀態信息將被保留。

簡言之,Flink 任務重啓或者失敗後,Checkpoint 信息會保存在 hdfs,如果不手動定期清理,那麼一年以後 Checkpoint 信息還會保存在 hdfs 上。請問一年後這個 Checkpoint 信息還有用嗎?肯定沒用了,完全是浪費存儲資源。

Savepoint 也是同樣的道理,可以把那些舊的不再會使用的狀態信息定期清理掉。

1.4 哪些狀態信息應該被清理掉?

必須要有清理策略,關鍵問題來了:如果來判斷哪些狀態信息不會再使用了?我們會認爲:不會再使用的狀態信息就是那些應該被清理掉的狀態信息。

清理的判斷依據

如下圖所示,Flink 中配置了 Checkpoint 目錄爲:/user/flink/checkpoints,子目錄名爲 jobId 名。hdfs 的 api 可以拿到 jobId 目錄最後修改的時間。對於一個正在運行的 Flink 任務,每次 Checkpoint 時 jobId 的 Checkpoint 目錄最後修改時間會更新爲當前時間。可以認爲如果 jobId 對應的最後修改時間是 10 天之前,意味着這個 job 已經十天沒有運行了。

對於 10 天沒有運行的 job,我們會認爲它已經重啓了對應到其他新的 jobId,或者當前任務已經下線了。無論哪種情況,我們都認爲 10 天沒有運行的 job,它的 Checkpoint 目錄也沒有存在的意義了,應該被清理掉。

因此我們線上的清理策略就來了,定時每天調度一次,將那些最後修改時間是 10 天之前的 jobId 對應的 Checkpoint 目錄清理掉。

故障出現了

就這麼運行了 4 個月(專門看了清理策略上線時間),沒出現過任何問題。突然在 12月3號晚上從 Checkpoint 位置重啓某幾個大狀態任務時,突然起不來了,現象是這樣的。

任務從 /user/flink/checkpoints/0165a1467c65a95a010f9c0134c9e576/chk-17052 目錄恢復時,報出以下錯誤:
Caused by: java.io.FileNotFoundException: File does not exist: /user/flink/checkpoints/37c37bd26da17b2e6ac433866d51201d/shared/bdc46a09-fc2a-4290-af5c-02bd39e9a1ca

請注意一個重點,上述兩個 hdfs 的目錄不一樣。簡單描述故障現象是這樣的:任務從 job A 的 Checkpoint 目錄恢復,恢復後的 jobId 爲 B,job B 並沒有正常恢復,報錯信息爲在 hdfs 目錄找不到 job C 的 Checkpoint 信息。解決問題的過程中是完全蒙的,手動去 hdfs 確認,發現 job A 的 Checkpoint 目錄存在的,非常納悶:我的要從 job A 的 Checkpoint 處恢復任務,Flink 爲什麼要找 job C 的 Checkpoint 目錄?甚至還懷疑是不是 hdfs 出問題了。摸爬滾打了一個小時,沒找到根本原因,爲了不發生事故,最後沒有將任務從 Checkpoint 處恢復,選擇了直接重啓的方式。

2. 分析原因

問題沒解決了,晚上躺牀上接着想可能造成該現象的原因,沒錯真的是晚上躺牀上想了好久纔想到的根本原因。

簡單描述就是:這次故障是因爲 Flink 基於 RocksDB 的增量 Checkpoint 導致。不能怪 RocksDB 的增量 Checkpoint 導致我們故障,只能怪我們的清理策略沒有實現閉環操作。閉環操作指清理 Checkpoint 信息時,應該有一個主動檢測機制確保當前刪除的 Checkpoint 信息 100% 已經沒用了才能刪除。而不應該憑藉一個經驗說 10 天過去了,應該沒用了,所以去刪除。

2.1 RocksDB 增量 Checkpoint 實現原理

RocksDB 是一個基於 LSM 實現的 KV 數據庫。LSM 全稱 Log Structured Merge Trees,LSM 樹本質是將大量的磁盤隨機寫操作轉換成磁盤的批量寫操作來極大地提升磁盤數據寫入效率。一般 LSM Tree 實現上都會有一個基於內存的 MemTable 介質,所有的增刪改操作都是寫入到 MemTable 中,當 MemTable 足夠大以後,將 MemTable 中的數據 flush 到磁盤中生成不可變且內部有序的 ssTable(Sorted String Table)文件,全量數據保存在磁盤的多個 ssTable 文件中。HBase 也是基於 LSM Tree 實現的,HBase 磁盤上的 HFile 就相當於這裏的 ssTable 文件,每次生成的 HFile 都是不可變的而且內部有序的文件。基於 ssTable 不可變的特性,才實現了增量 Checkpoint,具體流程如下所示:

第一次 Checkpoint 時生成的狀態快照信息包含了兩個 sstable 文件:sstable1 和 sstable2 及 Checkpoint1 的元數據文件 MANIFEST-chk1,所以第一次 Checkpoint 時需要將 sstable1、sstable2 和 MANIFEST-chk1 上傳到外部持久化存儲中。第二次 Checkpoint 時生成的快照信息爲 sstable1、sstable2、sstable3 及元數據文件 MANIFEST-chk2,由於 sstable 文件的不可變特性,所以狀態快照信息的 sstable1、sstable2 這兩個文件並沒有發生變化,sstable1、sstable2 這兩個文件不需要重複上傳到外部持久化存儲中,因此第二次 Checkpoint 時,只需要將 sstable3 和 MANIFEST-chk2 文件上傳到外部持久化存儲中即可。這裏只將新增的文件上傳到外部持久化存儲,也就是所謂的增量 Checkpoint。

基於 LSM Tree 實現的數據庫爲了提高查詢效率,都需要定期對磁盤上多個 sstable 文件進行合併操作,合併時會將刪除的、過期的以及舊版本的數據進行清理,從而降低 sstable 文件的總大小。圖中可以看到第三次 Checkpoint 時生成的快照信息爲sstable3、sstable4、sstable5 及元數據文件 MANIFEST-chk3, 其中新增了 sstable4 文件且 sstable1 和 sstable2 文件合併成 sstable5 文件,因此第三次 Checkpoint 時只需要向外部持久化存儲上傳 sstable4、sstable5 及元數據文件 MANIFEST-chk3。

基於 RocksDB 的增量 Checkpoint 從本質上來講每次 Checkpoint 時只將本次 Checkpoint 新增的快照信息上傳到外部的持久化存儲中,依靠的是 LSM Tree 中 sstable 文件不可變的特性。對 LSM Tree 感興趣的同學可以深入研究 RocksDB 或 HBase 相關原理及實現。

2.2 爲什麼會出現 1.4 部分的故障呢?

如上圖所示,job X 運行過程中,僅僅做了一次 Checkpoint,也就是圖中的 Checkpoint 1。所以在 hdfs 上 job A 的 Checkpoint 目錄中包含了 sstable1 和 sstable2。Checkpoint 1 之後由於某些原因任務掛了,所以任務從 Checkpoint 1 處恢復任務,恢復後的任務爲 job Y。job Y 恢復後運行一段時間後進行第二次 Checkpoint,也就是圖中的 Checkpoint 2,Checkpoint 2 包含了 MANIFEST-chk2、sstable1、sstable2 和 sstable3,由於使用的基於 RocksDB 的增量 Checkpoint,因此 Checkpoint 2 只需要將 MANIFEST-chk2 和 sstable3 上傳到 hdfs 即可,此時 job Y 的 checkpoint 目錄僅僅包含 sstable3,而 sstable1、sstable2 依然保存在 job X 的 Checkpoint 目錄中。接着 job Y 繼續運行,第三次 Checkpoint 時,新增了 sstable4 文件且 sstable1 和 sstable2 文件合併成 sstable5 文件,因此第三次 Checkpoint 時只需要向 hdfs 上傳 sstable4、sstable5 及元數據文件 MANIFEST-chk3。

由於 job Y 運行一切良好,我就認爲 job X 的 Checkpoint 信息已經沒用了,爲了節省 hdfs 存儲空間,手動把 job X 的 Checkpoint 信息清理了。假設第三次 Checkpoint 後,任務突然需要重啓,請問任務可以從 Checkpoint 3 恢復嗎?當然可以,如圖所示,Checkpoint 3 總共依賴 MANIFEST-chk3、sstable5、sstable3 和 sstable4,這四個文件都保存在了 job Y 的 Checkpoint 目錄中,所以 job Y 可以正常恢復。那請問任務可以從 Checkpoint 2 恢復嗎?NoNoNo!!!恢復不了,Checkpoint 2 包含了 MANIFEST-chk2、sstable1、sstable2 和 sstable3。其中 sstable1 和 sstable2 包含在 job X 的 Checkpoint 目錄中,不好意思,我剛纔把 job X 的 Checkpoint 全清理了。

所以大家明白爲什麼任務不能正常恢復了嗎?雖然 job Y 任務已經啓動了,但是 job Y 可能還會依賴 job X 的一些 Checkpoint 信息。我舉的例子中,job Y 的 Checkpoint 3 已經不依賴 job X 了,但是在真實的生產環境中,job Y 進行了 100 次 Checkpoint 後可能還會依賴 job X 的 Checkpoint 信息。

圖中所示的 merge 策略,完全依賴於 RocksDB 本身的合併策略,Flink 並不能主動控制。這裏的 merge 操作就是 HBase 的 compact 操作,compact 操作就由外部的寫入操作來自動觸發的。如果遇到冷數據,經常不訪問也不寫入,那麼合併操作可能會非常不頻繁,所以並不能說 10 天過去了,RocksDB 中舊的 sstable 文件就定不需要了。

2.3 如何合理地刪除 Checkpoint 目錄?

由於合併操作完全依賴 RocksDB,所以時間策略肯定不靠譜。Checkpoint 的次數也不靠譜,不能說進行了 1000次 Checkpoint 了,第一次 Checkpoint 的 sstable 文件肯定已經被廢棄了,還真有可能在使用,雖然機率很小。所以我們現在需要一個主動檢測的策略,需要主動去發現,到底哪些 sstable 文件在使用呢?

筆者研究了 Flink Checkpoint 目錄中的文件分佈,發現 Checkpoint 過程中所有 sstable 文件都保存在當前 job Checkpoint 目錄下的 shared 目錄裏。chk-689 表示第 689 次 Checkpoint,chk-689 目錄下保存着本次 Checkpoint 的元數據信息及 OperatorState 的狀態信息。

在 chk-689 目錄中有一個 _metadata 命名的文件,這裏存儲着本次 Checkpoint 的元數據信息,元數據信息中存儲了本次 Checkpoint 依賴哪些 sstable 文件並且 sstable 文件存儲在 hdfs 的位置信息。所以只需要解析最近一次 Checkpoint 目錄的元數據文件就可以知道當前的 job 依賴哪些 sstable 文件。原理分析清楚了,必須給大家分享解析元數據的源碼。

//  讀取元數據文件
File f=new File("module-flink/src/main/resources/_metadata");
//第二步,建立管道,FileInputStream文件輸入流類用於讀文件
FileInputStream fis=new FileInputStream(f);
BufferedInputStream bis = new BufferedInputStream(fis);
DataInputStream dis = new DataInputStream(bis);

// 通過 Flink 的 Checkpoints 類解析元數據文件
Savepoint savepoint = Checkpoints.loadCheckpointMetadata(dis,
        MetadataSerializer.class.getClassLoader());
// 打印當前的 CheckpointId
System.out.println(savepoint.getCheckpointId());

// 遍歷 OperatorState,這裏的每個 OperatorState 對應一個 Flink 任務的 Operator 算子
// 不要與 OperatorState  和 KeyedState 混淆,不是一個層級的概念
for(OperatorState operatorState :savepoint.getOperatorStates()) {
    System.out.println(operatorState);
    // 當前算子的狀態大小爲 0 ,表示算子不帶狀態,直接退出
    if(operatorState.getStateSize() == 0){
        continue;
    }

    // 遍歷當前算子的所有 subtask
    for(OperatorSubtaskState operatorSubtaskState: operatorState.getStates()) {
        // 解析 operatorSubtaskState 的 ManagedKeyedState
        parseManagedKeyedState(operatorSubtaskState);
        // 解析 operatorSubtaskState 的 ManagedOperatorState
        parseManagedOperatorState(operatorSubtaskState);

    }
}


/**
 * 解析 operatorSubtaskState 的 ManagedKeyedState
 * @param operatorSubtaskState operatorSubtaskState
 */
private static void parseManagedKeyedState(OperatorSubtaskState operatorSubtaskState) {
    // 遍歷當前 subtask 的 KeyedState
    for(KeyedStateHandle keyedStateHandle:operatorSubtaskState.getManagedKeyedState()) {
        // 本案例針對 Flink RocksDB 的增量 Checkpoint 引發的問題,
        // 因此僅處理 IncrementalRemoteKeyedStateHandle
        if(keyedStateHandle instanceof IncrementalRemoteKeyedStateHandle) {
            // 獲取 RocksDB 的 sharedState
            Map<StateHandleID, StreamStateHandle> sharedState =
               ((IncrementalRemoteKeyedStateHandle) keyedStateHandle).getSharedState();
            // 遍歷所有的 sst 文件,key 爲 sst 文件名,value 爲對應的 hdfs 文件 Handle
            for(Map.Entry<StateHandleID,StreamStateHandle> entry:sharedState.entrySet()){
                // 打印 sst 文件名
                System.out.println("sstable 文件名:" + entry.getKey());
                if(entry.getValue() instanceof FileStateHandle) {
                    Path filePath = ((FileStateHandle) entry.getValue()).getFilePath();
                    // 打印 sst 文件對應的 hdfs 文件位置
                    System.out.println("sstable文件對應的hdfs位置:" + filePath.getPath());
                }
            }
        }
    }
}


/**
 * 解析 operatorSubtaskState 的 ManagedOperatorState
 * 注:OperatorState 不支持 Flink 的 增量 Checkpoint,因此本案例可以不解析
 * @param operatorSubtaskState operatorSubtaskState
 */
private static void parseManagedOperatorState(OperatorSubtaskState operatorSubState) {
    // 遍歷當前 subtask 的 OperatorState
    for(OperatorState operatorStateHandle:operatorSubState.getManagedOperatorState()) {
        StreamStateHandle delegateState = operatorStateHandle.getDelegateStateHandle();
        if(delegateState instanceof FileStateHandle) {
            Path filePath = ((FileStateHandle) delegateStateHandle).getFilePath();
            System.out.println(filePath.getPath());
        }
    }
}

上述代碼解析的是 jobId 爲 1fe80538d21e27d116185cd97e89294e 任務的 Checkpoint 元數據信息,我們可以看到有很多的 sstable 文件,並且看到 sstable 文件既包含 jobId 爲 1fe80538d21e27d116185cd97e89294e 的 Checkpoint 文件,也包含 jobId 爲 37c37bd26da17b2e6ac433866d51201d 的 Checkpoint 文件,也就是說依賴了之前 job 的 Checkpoint 數據。所以 jobId 爲 37c37bd26da17b2e6ac433866d51201d 的任務雖然結束了,但是由於後續任務還依賴它的 Checkpoint 信息,所以不能刪除它的 Checkpoint 信息。

於是我們的清理 Checkpoint 的新策略來了,完全閉環的操作。刪除元數據時加了檢測,因此能保證清理的 Checkpoint 目錄都是不可能再使用的目錄。大功告成。

3. 總結

本博客來自於對一次 Flink 故障引發的思考,再次加深了對 Flink Checkpoint 相關的理解和認識。

本博客詳細代碼請參考 https://github.com/1996fanrui/fanrui-learning/blob/master/module-flink/src/main/java/com/dream/flink/checkpoint/metadata/MetadataSerializer.java

代碼中解析的 Checkpoint 元數據請參考 https://github.com/1996fanrui/fanrui-learning/blob/master/module-flink/src/main/resources/_metadata

博客涉及到的知識相對比較零散,如果想系統學習 Flink,歡迎掃碼訂閱知識星球。

END

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

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

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