再忙也需要看的Flink狀態管理

Flink狀態管理詳解

上幾篇我們講完了Flink窗口的相關內容,那麼問題來了,我們如果在一個開窗的流處理上做求和操作,突然處理涼了,怎麼辦呢?那我們需要去重頭計算嗎?所以接下來也就需要去介紹我們的Flink狀態管理了。在介紹前先放一句話在這裏,Flink的狀態管理首先需要區分兩個概念,state與checkpoint,state一般指一個具體的task/operator的狀態。而checkpoint則表示了一個Flink Job,在一個特定時刻的一份全局狀態快照,即包含了所有task/operator的狀態,不理解,不重要看完這篇後再來看這個就理解了。

一、 簡介

流式計算其實分爲無狀態和有狀態兩種情況。無狀態的計算觀察每個獨立事件,並根據最後一個事件輸出結果。例如,流處理應用程序從傳感器接收溫度讀數,並在溫度超過90度時發出警告。有狀態的計算則會基於多個事件輸出結果。那麼究竟哪些是有狀態的呢?如下:

  • 所有類型的窗口。例如,計算過去一小時的平均溫度,就是有狀態的計算。
  • 所有用於複雜事件處理的狀態機。例如,若在一分鐘內收到兩個相差20度以上的溫度讀數,則發出警告,這是有狀態的計算。
  • 流與流之間的所有關聯操作,以及流與靜態表或動態表之間的關聯操作,都是有狀態的計算

下圖展示了無狀態流處理和有狀態流處理的主要區別。無狀態流處理分別接收每條數據記錄(圖中的黑條),然後根據最新輸入的數據生成輸出數據(白條)。有狀態流處理會維護狀態(根據每條輸入記錄進行更新),並基於最新輸入的記錄和當前的狀態值生成輸出記錄(灰條)
在這裏插入圖片描述
上圖中輸入數據由黑條表示。無狀態流處理每次只轉換一條輸入記錄,並且僅根據最新的輸入記錄輸出結果(白條)。有狀態流處理維護所有已處理記錄的狀態值,並根據每條新輸入的記錄更新狀態,因此輸出記錄(灰條)反映的是綜合考慮多個事件之後的結果。儘管無狀態的計算很重要,但是流處理對有狀態的計算更感興趣。事實上,正確地實現有狀態的計算比實現無狀態的計算難得多。舊的流處理系統並不支持有狀態的計算,而新一代的流處理系統則將狀態及其正確性視爲重中之重。

二、 有狀態的算子和應用

Flink內置的很多算子,數據源source,數據存儲sink都是有狀態的,流中的數據都是buffer records,會保存一定的元素或者元數據。例如: ProcessWindowFunction會緩存輸入流的數據,ProcessFunction會保存設置的定時器信息等等。
在Flink中,狀態始終與特定算子相關聯。總的來說,有兩種類型的狀態:

  • 算子狀態(operator state)
  • 鍵控狀態(keyed state)

2.1 算子狀態(operator state)

算子狀態的作用範圍限定爲算子任務。這意味着由同一並行任務所處理的所有數據都可以訪問到相同的狀態,狀態對於同一任務而言是共享的。算子狀態不能由相同或不同算子的另一個任務訪問。如下圖
在這裏插入圖片描述
Flink爲算子狀態提供三種基本數據結構:

  • 列表狀態(List state):將狀態表示爲一組數據的列表。
  • 聯合列表狀態(Union list state):也將狀態表示爲數據的列表。它與常規列表狀態的區別在於,在發生故障時,或者從保存點(savepoint)啓動應用程序時如何恢復。
  • 廣播狀態(Broadcast state):如果一個算子有多項任務,而它的每項任務狀態又都相同,那麼這種特殊情況最適合應用廣播狀態。

2.2 鍵控狀態(keyed state)

鍵控狀態是根據輸入數據流中定義的鍵(key)來維護和訪問的。Flink爲每個鍵值維護一個狀態實例,並將具有相同鍵的所有數據,都分區到同一個算子任務中,這個任務會維護和處理這個key對應的狀態。當任務處理一條數據時,它會自動將狀態的訪問範圍限定爲當前數據的key。因此,具有相同key的所有數據都會訪問相同的狀態。Keyed State很類似於一個分佈式的key-value map數據結構,只能用於KeyedStream(keyBy算子處理之後)。如下圖
在這裏插入圖片描述

Flink的Keyed State支持以下數據類型:

  • ValueState[T]保存單個的值,值的類型爲T。
    • get操作: ValueState.value()
    • set操作: ValueState.update(value: T)
  • ListState[T]保存一個列表,列表裏的元素的數據類型爲T。基本操作如下:
    • ListState.add(value: T)
    • ListState.addAll(values: java.util.List[T])
    • ListState.get()返回Iterable[T]
    • ListState.update(values: java.util.List[T])
  • MapState[K, V]保存Key-Value對。
    • MapState.get(key: K)
    • MapState.put(key: K, value: V)
    • MapState.contains(key: K)
    • MapState.remove(key: K)
  • ReducingState[T]
  • AggregatingState[I, O]
    State.clear()是清空操作。
val sensorData: DataStream[SensorReading] = ...
val keyedData: KeyedStream[SensorReading, String] = sensorData.keyBy(_.id)

val alerts: DataStream[(String, Double, Double)] = keyedData
.flatMap(new TemperatureAlertFunction(1.7))

class TemperatureAlertFunction(val threshold: Double) extends RichFlatMapFunction[SensorReading, (String, Double, Double)] {
private var lastTempState: ValueState[Double] = _

override def open(parameters: Configuration): Unit = {
val lastTempDescriptor = new ValueStateDescriptor[Double]("lastTemp", classOf[Double])
lastTempState = getRuntimeContext.getState[Double](lastTempDescriptor)
}

override def flatMap(reading: SensorReading,
out: Collector[(String, Double, Double)]): Unit = {
val lastTemp = lastTempState.value()
val tempDiff = (reading.temperature - lastTemp).abs
if (tempDiff > threshold) {
out.collect((reading.id, reading.temperature, tempDiff))
}
this.lastTempState.update(reading.temperature)
}
}

通過RuntimeContext註冊StateDescriptor。StateDescriptor以狀態state的名字和存儲的數據類型爲參數。在open()方法中創建state變量

接下來我們使用了FlatMap with keyed ValueState的快捷方式flatMapWithState實現以上需求。

val alerts: DataStream[(String, Double, Double)] = keyedSensorData
  .flatMapWithState[(String, Double, Double), Double] {
    case (in: SensorReading, None) =>
      (List.empty, Some(in.temperature))
    case (r: SensorReading, lastTemp: Some[Double]) =>
      val tempDiff = (r.temperature - lastTemp.get).abs
      if (tempDiff > 1.7) {
        (List((r.id, r.temperature, tempDiff)), Some(r.temperature))
      } else {
        (List.empty, Some(r.temperature))
      }
  }

2.3 狀態一致性

當在分佈式系統中引入狀態時,自然也引入了一致性問題。一致性實際上是"正確性級別"的另一種說法,也就是說在成功處理故障並恢復之後得到的結果,與沒有發生任何故障時得到的結果相比,前者到底有多正確?舉例來說,假設要對最近一小時登錄的用戶計數。在系統經歷故障之後,計數結果是多少?如果有偏差,是有漏掉的計數還是重複計數?

2.3.1 一致性級別

在流處理中,一致性可以分爲3個級別:

  • at-most-once: 這其實是沒有正確性保障的委婉說法——故障發生之後,計數結果可能丟失。
  • at-least-once: 這表示計數結果可能大於正確值,但絕不會小於正確值。也就是說,計數程序在發生故障後可能多算,但是絕不會少算。
  • exactly-once: 這指的是系統保證在發生故障後得到的計數結果與正確值一致。

曾經,at-least-once非常流行。第一代流處理器(如Storm和Samza)剛問世時只保證at-least-once,原因有二。

  • 保證exactly-once的系統實現起來更復雜。這在基礎架構層(決定什麼代表正確,以及exactly-once的範圍是什麼)和實現層都很有挑戰性。
  • 流處理系統的早期用戶願意接受框架的侷限性,並在應用層想辦法彌補(例如使應用程序具有冪等性,或者用批量計算層再做一遍計算)。

最先保證exactly-once的系統(Storm Trident和Spark Streaming)在性能和表現力這兩個方面付出了很大的代價爲了保證exactly-once,這些系統無法單獨地對每條記錄運用應用邏輯,而是同時處理多條(一批)記錄,保證對每一批的處理要麼全部成功,要麼全部失敗。這就導致在得到結果前,必須等待一批記錄處理結束。因此,用戶經常不得不使用兩個流處理框架(一個用來保證exactly-once,另一個用來對每個元素做低延遲處理),結果使基礎設施更加複雜。曾經,用戶不得不在保證exactly-once與獲得低延遲和效率之間權衡利弊。Flink避免了這種權衡。
Flink的一個重大價值在於,它既保證了exactly-once,也具有低延遲和高吞吐的處理能力
從根本上說,Flink通過使自身滿足所有需求來避免權衡,它是業界的一次意義重大的技術飛躍。儘管這在外行看來很神奇,但是一旦瞭解,就會恍然大悟。
注:Exactly_once是爲有狀態的計算準備的!換句話說,沒有狀態的算子操作(operator),Flink無法也無需保證其只被處理Exactly_once!爲什麼無需呢?因爲即使失敗的情況下,無狀態的operator(map、filter等)只需要數據重新計算一遍即可。

2.3.2 端到端(end-to-end)狀態一致性

目前我們看到的一致性保證都是由流處理器實現的,也就是說都是在 Flink 流處理器內部保證的;而在真實應用中,流處理應用除了流處理器以外還包含了數據源(例如 Kafka)和輸出到持久化系統。
端到端的一致性保證,意味着結果的正確性貫穿了整個流處理應用的始終;每一個組件都保證了它自己的一致性,整個端到端的一致性級別取決於所有組件中一致性最弱的組件。具體可以劃分如下:

  • 內部保證 —— 依賴checkpoint
  • source 端 —— 需要外部源可重設數據的讀取位置
  • sink 端 —— 需要保證從故障恢復時,數據不會重複寫入外部系統
    而對於sink端,又有兩種具體的實現方式:冪等(Idempotent)寫入事務性(Transactional)寫入
    • 冪等寫入
      所謂冪等操作,是說一個操作,可以重複執行很多次,但只導致一次結果更改,也就是說,後面再重複執行就不起作用了
    • 事務寫入
      需要構建事務來寫入外部系統,構建的事務對應着 checkpoint,等到 checkpoint 真正完成的時候,才把所有對應的結果寫入 sink 系統中

對於事務性寫入,具體又有兩種實現方式:預寫日誌(WAL)兩階段提交(2PC)DataStream API 提供了GenericWriteAheadSink模板類和TwoPhaseCommitSinkFunction 接口,可以方便地實現這兩種方式的事務性寫入。

不同 Source 和 Sink 的一致性保證可以用下表說明:
在這裏插入圖片描述

2.4 檢查點(checkpoint)

Flink具體如何保證exactly-once呢? 它使用一種被稱爲"檢查點"(checkpoint)的特性,在出現故障時將系統重置回正確狀態。下面通過簡單的類比來解釋檢查點的作用。
假設你和兩位朋友正在數項鍊上有多少顆珠子,如下圖所示。你捏住珠子,邊數邊撥,每撥過一顆珠子就給總數加一。你的朋友也這樣數他們手中的珠子。當你分神忘記數到哪裏時,怎麼辦呢? 如果項鍊上有很多珠子,你顯然不想從頭再數一遍,尤其是當三人的速度不一樣卻又試圖合作的時候,更是如此(比如想記錄前一分鐘三人一共數了多少顆珠子,回想一下一分鐘滾動窗口)。
在這裏插入圖片描述
於是,你想了一個更好的辦法: 在項鍊上每隔一段就鬆鬆地繫上一根有色皮筋,將珠子分隔開; 當珠子被撥動的時候,皮筋也可以被撥動; 然後,你安排一個助手,讓他在你和朋友撥到皮筋時記錄總數。用這種方法,當有人數錯時,就不必從頭開始數。相反,你向其他人發出錯誤警示,然後你們都從上一根皮筋處開始重數,助手則會告訴每個人重數時的起始數值,例如在粉色皮筋處的數值是多少。
Flink檢查點的作用就類似於皮筋標記。數珠子這個類比的關鍵點是: 對於指定的皮筋而言,珠子的相對位置是確定的; 這讓皮筋成爲重新計數的參考點。總狀態(珠子的總數)在每顆珠子被撥動之後更新一次,助手則會保存與每根皮筋對應的檢查點狀態,如當遇到粉色皮筋時一共數了多少珠子,當遇到橙色皮筋時又是多少。當問題出現時,這種方法使得重新計數變得簡單。

2.4.1 Flink的檢查點算法

Flink檢查點的核心作用是確保狀態正確,即使遇到程序中斷,也要正確。記住這一基本點之後,我們用一個例子來看檢查點是如何運行的。Flink爲用戶提供了用來定義狀態的工具。例如,以下這個Scala程序按照輸入記錄的第一個字段(一個字符串)進行分組並維護第二個字段的計數狀態。

val stream: DataStream[(String, Int)] = ... 
val counts: DataStream[(String, Int)] = stream
.keyBy(record => record._1)
.mapWithState(  (in: (String, Int), state: Option[Int])  => 
state match { 
case Some(c) => ( (in._1, c + in._2), Some(c + in._2) ) 
case None => ( (in._1, in._2), Some(in._2) )
})

該程序有兩個算子: keyBy算子用來將記錄按照第一個元素(一個字符串)進行分組,根據該key將數據進行重新分區,然後將記錄再發送給下一個算子: 有狀態的map算子(mapWithState)map算子在接收到每個元素後,將輸入記錄的第二個字段的數據加到現有總數中,再將更新過的元素髮射出去。下圖表示程序的初始狀態: 輸入流中的6條記錄被檢查點分割線(checkpoint barrier)隔開,所有的map算子狀態均爲0(計數還未開始)。所有key爲a的記錄將被頂層的map算子處理,所有key爲b的記錄將被中間層的map算子處理,所有key爲c的記錄則將被底層的map算子處理。
在這裏插入圖片描述
上圖是程序的初始狀態。注意,a、b、c三組的初始計數狀態都是0,即三個圓柱上的值。ckpt表示檢查點分割線(checkpoint barriers)。每條記錄在處理順序上嚴格地遵守在檢查點之前或之後的規定,例如[“b”,2]在檢查點之前被處理,[“a”,2]則在檢查點之後被處理。
當該程序處理輸入流中的6條記錄時,涉及的操作遍佈3個並行實例(節點、CPU內核等)。那麼,檢查點該如何保證exactly-once呢?
檢查點分割線和普通數據記錄類似。它們由算子處理,但並不參與計算,而是會觸發與檢查點相關的行爲。當讀取輸入流的數據源(在本例中與keyBy算子內聯)遇到檢查點屏障時,它將其在輸入流中的位置保存到持久化存儲中。如果輸入流來自消息傳輸系統(Kafka),這個位置就是偏移量。Flink的存儲機制是插件化的,持久化存儲可以是分佈式文件系統,如HDFS。下圖展示了這個過程。
在這裏插入圖片描述

當Flink數據源(在本例中與keyBy算子內聯)遇到檢查點分界線(barrier)時,它會將其在輸入流中的位置保存到持久化存儲中。這讓 Flink可以根據該位置重啓。
檢查點像普通數據記錄一樣在算子之間流動。當map算子處理完前3條數據並收到檢查點分界線時,它們會將狀態以異步的方式寫入持久化存儲,如下圖所示。
在這裏插入圖片描述
位於檢查點之前的所有記錄([“b”,2]、[“b”,3]和[“c”,1])被map算子處理之後的情況。此時,持久化存儲已經備份了檢查點分界線在輸入流中的位置(備份操作發生在barrier被輸入算子處理的時候)。map算子接着開始處理檢查點分界線,並觸發將狀態異步備份到穩定存儲中這個動作。
當map算子的狀態備份和檢查點分界線的位置備份被確認之後,該檢查點操作就可以被標記爲完成,如下圖所示。我們在無須停止或者阻斷計算的條件下,在一個邏輯時間點(對應檢查點屏障在輸入流中的位置)爲計算狀態拍了快照。通過確保備份的狀態和位置指向同一個邏輯時間點,後文將解釋如何基於備份恢復計算,從而保證exactly-once。值得注意的是,當沒有出現故障時,Flink檢查點的開銷極小,檢查點操作的速度由持久化存儲的可用帶寬決定。回顧數珠子的例子: 除了因爲數錯而需要用到皮筋之外,皮筋會被很快地撥過。
在這裏插入圖片描述
檢查點操作完成,狀態和位置均已備份到穩定存儲中。輸入流中的所有數據記錄都已處理完成。值得注意的是,備份的狀態值與實際的狀態值是不同的。備份反映的是檢查點的狀態。
如果檢查點操作失敗,Flink可以丟棄該檢查點並繼續正常執行,因爲之後的某一個檢查點可能會成功。雖然恢復時間可能更長,但是對於狀態的保證依舊很有力。只有在一系列連續的檢查點操作失敗之後,Flink纔會拋出錯誤,因爲這通常預示着發生了嚴重且持久的錯誤。
現在來看看下圖所示的情況: 檢查點操作已經完成,但故障緊隨其後。
在這裏插入圖片描述
在這種情況下,Flink會重新拓撲(可能會獲取新的執行資源),將輸入流倒回到上一個檢查點,然後恢復狀態值並從該處開始繼續計算。在本例中,[“a”,2]、[“a”,2]和[“c”,2]這幾條記錄將被重播。
下圖展示了這一重新處理過程。從上一個檢查點開始重新計算,可以保證在剩下的記錄被處理之後,得到的map算子的狀態值與沒有發生故障時的狀態值一致。
在這裏插入圖片描述
Flink將輸入流倒回到上一個檢查點屏障的位置,同時恢復map算子的狀態值。然後,Flink從此處開始重新處理。這樣做保證了在記錄被處理之後,map算子的狀態值與沒有發生故障時的一致。
Flink檢查點算法的正式名稱是異步分界線快照(asynchronous barrier snapshotting)。該算法大致基於Chandy-Lamport分佈式快照算法。
檢查點是Flink最有價值的創新之一,因爲它使Flink可以保證exactly-once,並且不需要犧牲性能。

2.4.2 Flink+Kafka如何實現端到端的exactly-once語義

我們知道,端到端的狀態一致性的實現,需要每一個組件都實現,對於Flink + Kafka的數據管道系統(Kafka進、Kafka出)而言,各組件怎樣保證exactly-once語義呢?

  • 內部—— 利用checkpoint機制,把狀態存盤,發生故障的時候可以恢復,保證內部的狀態一致性
  • source —— kafka consumer作爲source,可以將偏移量保存下來,如果後續任務出現了故障,恢復的時候可以由連接器重置偏移量,重新消費數據,保證一致性
  • sink —— kafka producer作爲sink,採用兩階段提交 sink,需要實現一個TwoPhaseCommitSinkFunction內部的checkpoint機制我們已經有了瞭解,那source和sink具體又是怎樣運行的呢?接下來我們逐步做一個分析。

我們知道Flink由JobManager協調各個TaskManager進行checkpoint存儲,checkpoint保存在 StateBackend中,默認StateBackend是內存級的,也可以改爲文件級的進行持久化保存
在這裏插入圖片描述
checkpoint 啓動時,JobManager 會將檢查點分界線(barrier)注入數據流;barrier會在算子間傳遞下去
在這裏插入圖片描述
每個算子會對當前的狀態做個快照,保存到狀態後端。對於source任務而言,就會把當前的offset作爲狀態保存起來。下次從checkpoint恢復時,source任務可以重新提交偏移量,從上次保存的位置開始重新消費數據。
在這裏插入圖片描述
每個內部的 transform 任務遇到 barrier 時,都會把狀態存到 checkpoint 裏
sink 任務首先把數據寫入外部 kafka,這些數據都屬於預提交的事務(還不能被消費);當遇到 barrier 時,把狀態保存到狀態後端,並開啓新的預提交事務。
在這裏插入圖片描述
當所有算子任務的快照完成,也就是這次的 checkpoint 完成時,JobManager 會向所有任務發通知,確認這次 checkpoint 完成。當sink 任務收到確認通知,就會正式提交之前的事務,kafka 中未確認的數據就改爲“已確認”,數據就真正可以被消費了。
在這裏插入圖片描述
所以我們看到,執行過程實際上是一個兩段式提交,每個算子執行完成,會進行“預提交”,直到執行完sink操作,會發起“確認提交”,如果執行失敗,預提交會放棄掉。
具體的兩階段提交步驟總結如下:

  • 第一條數據來了之後,開啓一個 kafka 的事務(transaction),正常寫入 kafka 分區日誌但標記爲未提交,這就是“預提交” jobmanager 觸發 checkpoint 操作,barrier 從 source 開始向下傳遞,遇到 barrier 的算子將狀態存入狀態後端,並通知 jobmanager
  • sink 連接器收到 barrier,保存當前狀態,存入 checkpoint,通知 jobmanager,並開啓下一階段的事務,用於提交下個檢查點的數據
  • jobmanager 收到所有任務的通知,發出確認信息,表示 checkpoint 完成
  • sink 任務收到 jobmanager 的確認信息,正式提交這段時間的數據
  • 外部kafka關閉事務,提交的數據可以正常消費了。

所以我們也可以看到,如果宕機需要通過StateBackend進行恢復,只能恢復所有確認提交的操作。

2.5 選擇一個狀態後端(state backend)

MemoryStateBackend
內存級的狀態後端,會將鍵控狀態作爲內存中的對象進行管理,將它們存儲在TaskManager的JVM堆上;而將checkpoint存儲在JobManager的內存中
FsStateBackend
將checkpoint存到遠程的持久化文件系統(FileSystem)上。而對於本地狀態,跟MemoryStateBackend一樣,也會存在TaskManager的JVM堆上。
RocksDBStateBackend
將所有狀態序列化後,存入本地的RocksDB中存儲。
注意:RocksDB的支持並不直接包含在flink中,需要引入依賴:

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-statebackend-rocksdb_2.11</artifactId>
    <version>1.7.2</version>
</dependency>

設置狀態後端爲FsStateBackend:

val env = StreamExecutionEnvironment.getExecutionEnvironment
val checkpointPath: String = ???
val backend = new RocksDBStateBackend(checkpointPath)

env.setStateBackend(backend)
env.setStateBackend(new FsStateBackend("file:///tmp/checkpoints"))
env.enableCheckpointing(1000)
// 配置重啓策略
env.setRestartStrategy(RestartStrategies.fixedDelayRestart(60, Time.of(10, TimeUnit.SECONDS)))
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章