Flink Kafka Connector與Exactly Once剖析

TalkingData發表於2019-09-18

Flink Kafa Connector是Flink內建的Kafka聯結器,它包含了從Kafka Topic讀入資料的Flink Kafka Consumer以及向Kafka Topic寫出資料的Flink Kafka Producer,除此之外Flink Kafa Connector基於Flink Checkpoint機制提供了完善的容錯能力。本文從Flink Kafka Connector的基本使用到Kafka在Flink中端到端的容錯原理展開討論。

一、Flink Kafka的使用

在Flink中使用Kafka Connector時需要依賴Kafka的版本,Flink針對不同的Kafka版本提供了對應的Connector實現。

1.版本依賴

既然Flink對不同版本的Kafka有不同實現,在使用時需要注意區分,根據使用環境引入正確的依賴關係。

1<dependency>
2  <groupId>org.apache.flink</groupId>
3  <artifactId>${flink_kafka_connector_version}</artifactId>
4  <version>${flink_version}</version>
5</dependency>

在上面的依賴配置中${flink_version}指使用Flink的版本,${flink_connector_kafka_version}指依賴的Kafka connector版本對應的artifactId。下表描述了截止目前為止Kafka服務版本與Flink Connector之間的對應關係。
Flink官網內容Apache Kafka Connector(https://ci.apache.org/project...)中也有詳細的說明。

clipboard.png

從Flink 1.7版本開始為Kafka 1.0.0及以上版本提供了全新的的Kafka Connector支援,如果使用的Kafka版本在1.0.0及以上可以忽略因Kafka版本差異帶來的依賴變化。

2.基本使用

明確了使用的Kafka版本後就可以編寫一個基於Flink Kafka讀/寫的應用程式「本文討論內容全部基於Flink 1.7版本和Kafka 1.1.0版本」。根據上面描述的對應關係在工程中新增Kafka Connector依賴。

1<dependency>
2  <groupId>org.apache.flink</groupId>
3  <artifactId>flink-connector-kafka_2.11</artifactId>
4  <version>1.7.0</version>
5</dependency>

下面的程式碼片段是從Kafka Topic「flink_kafka_poc_input」中消費資料,再寫入Kafka Topic「flink_kafka_poc_output」的簡單示例。示例中除了讀/寫Kafka Topic外,沒有做其他的邏輯處理。

1public static void main(String[] args) {
 2  StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
 3
 4  /** 初始化Consumer配置 */
 5  Properties consumerConfig = new Properties();
 6  consumerConfig.setProperty("bootstrap.servers", "127.0.0.1:9091");
 7  consumerConfig.setProperty("group.id", "flink_poc_k110_consumer");
 8
 9  /** 初始化Kafka Consumer */
10  FlinkKafkaConsumer<String> flinkKafkaConsumer = 
11    new FlinkKafkaConsumer<String>(
12      "flink_kafka_poc_input", 
13      new SimpleStringSchema(), 
14      consumerConfig
15    );
16  /** 將Kafka Consumer加入到流處理 */
17  DataStream<String> stream = env.addSource(flinkKafkaConsumer);
18
19  /** 初始化Producer配置 */
20  Properties producerConfig = new Properties();
21  producerConfig.setProperty("bootstrap.servers", "127.0.0.1:9091");
22
23  /** 初始化Kafka Producer */
24  FlinkKafkaProducer<String> myProducer = 
25    new FlinkKafkaProducer<String>(
26      "flink_kafka_poc_output", 
27      new MapSerialization(), 
28      producerConfig
29    );
30  /** 將Kafka Producer加入到流處理 */
31  stream.addSink(myProducer);
32
33  /** 執行 */
34  env.execute();
35}
36
37class MapSerialization implements SerializationSchema<String> {
38  public byte[] serialize(String element) {
39    return element.getBytes();
40  }
41}

Flink API使用起來確實非常簡單,呼叫addSource方法和addSink方法就可以將初始化好的FlinkKafkaConsumerFlinkKafkaProducer加入到流處理中。execute執行後,KafkaConsumer和KafkaProducer就可以開始正常工作了。

二、Flink Kafka的容錯

眾所周知,Flink支援Exactly-once semantics。什麼意思呢?翻譯過來就是「恰好一次語義」。流處理系統中,資料來源源不斷的流入到系統、被處理、最後輸出結果。我們都不希望系統因人為或外部因素產生任何意想不到的結果。對於Exactly-once語義達到的目的是指即使系統被人為停止、因故障shutdown、無故關機等任何因素停止執行狀態時,對於系統中的每條資料不會被重複處理也不會少處理。

1.Flink Exactly-once
Flink宣稱支援Exactly-once其針對的是Flink應用內部的資料流處理。但Flink應用內部要想處理資料首先要有資料流入到Flink應用,其次Flink應用對資料處理完畢後也理應對資料做後續的輸出。在Flink中資料的流入稱為Source,資料的後續輸出稱為Sink,對於Source和Sink完全依靠外部系統支撐(比如Kafka)。

Flink自身是無法保證外部系統的Exactly-once語義。但這樣一來其實並不能稱為完整的Exactly-once,或者說Flink並不能保證端到端Exactly-once。而對於資料精準性要求極高的系統必須要保證端到端的Exactly-once,所謂端到端是指Flink應用從Source一端開始到Sink一端結束,資料必經的起始和結束兩個端點。

那麼如何實現端到端的Exactly-once呢?Flink應用所依賴的外部系統需要提供Exactly-once支撐,並結合Flink提供的Checkpoint機制和Two Phase Commit才能實現Flink端到端的Exactly-once。對於Source和Sink的容錯保障,Flink官方給出了具體說明:

Fault Tolerance Guarantees of Data Sources and Sinks(https://ci.apache.org/project...

2.Flink Checkpoint
在討論基於Kafka端到端的Exactly-once之前先簡單瞭解一下Flink Checkpoint,詳細內容在《Flink Checkpoint原理》中有做討論。Flink Checkpoint是Flink用來實現應用一致性快照的核心機制,當Flink因故障或其他原因重啟後可以通過最後一次成功的Checkpoint將應用恢復到當時的狀態。如果在應用中啟用了Checkpoint,會由JobManager按指定時間間隔觸發Checkpoint,Flink應用內所有帶狀態的Operator會處理每一輪Checkpoint生命週期內的幾個狀態。

  • initializeState:由CheckpointedFunction介面定義。Task啟動時獲取應用中所有實現了CheckpointedFunction的Operator,並觸發執行initializeState方法。在方法的實現中一般都是從狀態後端將快照狀態恢復。
  • snapshotState:由CheckpointedFunction介面定義。JobManager會定期發起Checkpoint,Task接收到Checkpoint後獲取應用中所有實現了CheckpointedFunction的Operator並觸發執行對應的snapshotState方法。JobManager每發起一輪Checkpoint都會攜帶一個自增的checkpointId,這個checkpointId代表了快照的輪次。
1public interface CheckpointedFunction {
2  void snapshotState(FunctionSnapshotContext context) throws Exception;
3  void initializeState(FunctionInitializationContext context) throws Exception;
4}
  • notifyCheckpointComplete:由CheckpointListener介面定義。當基於同一個輪次(checkpointId相同)的Checkpoint快照全部處理成功後獲取應用中所有實現了CheckpointListener的Operator並觸發執行notifyCheckpointComplete方法。觸發notifyCheckpointComplete方法時攜帶的checkpointId引數用來告訴Operator哪一輪Checkpoint已經完成。
1public interface CheckpointListener {
2  void notifyCheckpointComplete(long checkpointId) throws Exception;
3}

3.Flink Kafka端到端Exactly-once
Kafka是非常收歡迎的分散式訊息系統,在Flink中它可以作為Source,同時也可以作為Sink。Kafka 0.11.0及以上版本提供了對事務的支援,這讓Flink應用搭載Kafka實現端到端的exactly-once成為了可能。下面我們就來深入瞭解提供了事務支援的Kafka是如何與Flink結合實現端到端exactly-once的。

本文忽略了Barrier機制,所以示例和圖中都以單執行緒為例。Barrier在《Flink Checkpoint原理》有較多討論。

Flink Kafka Consumer

Kafka自身提供了可重複消費訊息的能力,Flink結合Kafka的這個特性以及自身Checkpoint機制,得以實現Flink Kafka Consumer的容錯。
Flink Kafka Consumer是Flink應用從Kafka獲取資料流訊息的一個實現。除了資料流獲取、資料傳送下游運算元這些基本功能外它還提供了完善的容錯機制。這些特性依賴了其內部的一些元件以及內建的資料結構協同處理完成。這裡,我們先簡單瞭解這些元件和內建資料結構的職責,再結合Flink 執行時故障恢復時 兩個不同的處理時機來看一看它們之間是如何協同工作的。

  • KafkaTopic後設資料:從Kafka消費資料的前提是需要知道消費哪個topic,這個topic有多少個partition。元件AbstractPartitionDiscoverer負責獲得指定topic的後設資料資訊,並將獲取到的topic後設資料資訊封裝成KafkaTopicPartition集合。
  • KafkaTopicPartition:KafkaTopicPartition結構用於記錄topic與partition的對應關係,內部定義了String topicint partition兩個主要屬性。假設topic A有2個分割槽,通過元件AbstractPartitionDiscoverer處理後將得到由兩個KafkaTopicPartition物件組成的集合:KafkaTopicPartition(topic:A,partition:0)KafkaTopicPartition(topic:A, partition:1)
  • Kafka資料消費:作為Flink Source,Flink Kafka Consumer最主要的職責就是能從Kafka中獲取資料,交給下游處理。在Kafka Consumer中AbstractFetcher元件負責完成這部分功能。除此之外Fetcher還負責offset的提交、KafkaTopicPartitionState結構的資料維護。
  • KafkaTopicPartitionState:KafkaTopicPartitionState是一個非常核心的資料結構,基於內部的4個基本屬性,Flink Kafka Consumer維護了topic、partition、已消費offset、待提交offset的關聯關係。Flink Kafka Consumer的容錯機制依賴了這些資料。除了這4個基本屬性外KafkaTopicPartitionState還有兩個子類,一個是支援PunctuatedWatermark的實現,另一個是支援PeriodicWatermark的實現,這兩個子類在原有基礎上擴充套件了對水印的支援,我們這裡不做過多討論。

clipboard.png

  • 狀態持久化:Flink Kafka Consumer的容錯性依靠的是狀態持久化,也可以稱為狀態快照。對於Flink Kafka Consumer來說,這個狀態持久化具體是對topic、partition、已消費offset的對應關係做持久化。
    在實現中,使用ListState<Tuple2<KafkaTopicPartition,Long>>定義了狀態儲存結構,在這裡Long表示的是offset型別,所以實際上就是使用KafkaTopicPartition和offset組成了一個對兒,再新增到狀態後端集合。
  • 狀態恢復:當狀態成功持久化後,一旦應用出現故障,就可以用最近持久化成功的快照恢復應用狀態。在實現中,狀態恢復時會將快照恢復到一個TreeMap結構中,其中key是KafkaTopicPartition,value是對應已消費的offset。恢復成功後,應用恢復到故障前Flink Kafka Consumer消費的offset,並繼續執行任務,就好像什麼都沒發生一樣。

執行時
我們假設Flink應用正常執行,Flink Kafka Consumer消費topic為Topic-ATopic-A只有一個partition。在執行期間,主要做了這麼幾件事

  • Kafka資料消費:KafkaFetcher不斷的從Kafka消費資料,消費的資料會傳送到下游運算元並在內部記錄已消費過的offset。下圖描述的是Flink Kafka Consumer從消費Kafka訊息到將訊息傳送到下游運算元的一個處理過程。

clipboard.png

接下來我們再結合訊息真正開始處理後,KafkaTopicPartitionState結構中的資料變化。

clipboard.png

可以看到,隨著應用的執行,KafkaTopicPartitionState中的offset屬性值發生了變化,它記錄了已經傳送到下游運算元訊息在Kafka中的offset。在這裡由於訊息P0-C已經傳送到下游運算元,所以KafkaTopicPartitionState.offset變更為2。

  • 狀態快照處理:如果Flink應用開啟了Checkpoint,JobManager會定期觸發Checkpoint。FlinkKafkaConsumer實現了CheckpointedFunction,所以它具備快照狀態(snapshotState)的能力。在實現中,snapshotState具體幹了這麼兩件事

下圖描述當一輪Checkpoint開始時FlinkKafkaConsumer的處理過程。在例子中,FlinkKafkaConsumer已經將offset=3的P0-D訊息傳送到下游,當checkpoint觸發時將topic=Topic-A;partition=0;offset=3作為最後的狀態持久化到外部儲存。

  • 將當前快照輪次(CheckpointId)與topic、partition、offset寫入到一個待提交offset的Map集合,其中key是CheckpointId。
  • FlinkKafkaConsumer當前執行狀態持久化,即將topic、partition、offset持久化。一旦出現故障,就可以根據最新持久化的快照進行恢復。

下圖描述當一輪Checkpoint開始時FlinkKafkaConsumer的處理過程。在例子中,FlinkKafkaConsumer已經將offset=3的P0-D訊息傳送到下游,當checkpoint觸發時將topic=Topic-A;partition=0;offset=3作為最後的狀態持久化到外部儲存。

clipboard.png

  • 快照結束處理:當所有運算元基於同一輪次快照處理結束後,會呼叫CheckpointListener.notifyCheckpointComplete(checkpointId)通知運算元Checkpoint完成,引數checkpointId指明瞭本次通知是基於哪一輪Checkpoint。在FlinkKafkaConsumer的實現中,接到Checkpoint完成通知後會變更KafkaTopicPartitionState.commitedOffset屬性值。最後再將變更後的commitedOffset提交到Kafka
    brokers或Zookeeper。

在這個例子中,commitedOffset變更為4,因為在快照階段,將topic=Topic-A;partition=0;offset=3的狀態做了快照,在真正提交offset時是將快照的offset + 1作為結果提交的。「原始碼KafkaFetcher.java 207行doCommitInternalOffsetsToKafka方法」

clipboard.png

故障恢復

Flink應用崩潰後,開始進入恢復模式。假設Flink Kafka Consumer最後一次成功的快照狀態是topic=Topic-A;partition=0;offset=3,在恢復期間按照下面的先後順序執行處理。

  • 狀態初始化
    狀態初始化階段嘗試從狀態後端載入出可以用來恢復的狀態。它由CheckpointedFunction.initializeState介面定義。在FlinkKafkaConsumer的實現中,從狀態後端獲得快照並寫入到內部儲存結構TreeMap,其中key是由KafkaTopicPartition表示的topic與partition,value為offset。下圖描述的是故障恢復的第一個階段,從狀態後端獲得快照,並恢復到內部儲存。

clipboard.png

  • function初始化

    function初始化階段除了初始化OffsetCommitMode和partitionDiscoverer外,還會初始化一個Map結構,該結構用來儲存應用待消費資訊。如果應用需要從快照恢復狀態,則從待恢復狀態中初始化這個Map結構。下圖是該階段從快照恢復的處理過程。

clipboard.png

function初始化階段相容了正常啟動和狀態恢復時offset的初始化。對於正常啟動過程,StartupMode的設定決定待消費資訊中的結果。該模式共有5種,預設為StartupMode.GROUP_OFFSETS

clipboard.png

  • 開始執行
    在該階段中,將KafkaFetcher初始化、初始化內部消費狀態、啟動消費執行緒等等,其目的是為了將FlinkKafkaConsumer執行起來,下圖描述了這個階段的處理流程

clipboard.png

這裡對圖中兩個步驟做個描述

  • 步驟3,使用狀態後端的快照結果topic=Topic-A;partition=0;offset=3初始化Flink Kafka
    Consumer內部維護的Kafka處理狀態。因為是恢復流程,所以這個內部維護的處理狀態也應該隨著快照恢復。
  • 步驟4,在真正消費Kafka資料前(指呼叫KafkaConsumer.poll方法),使用Kafka提供的seek方法將offset重置到指定位置,而這個offset具體演算法就是狀態後端offset 1。在例子中,消費Kafka資料前將offset重置為4,所以狀態恢復後KafkaConsumer是從offset=4位置開始消費。「原始碼KafkaConsumerThread.java
    428行」

總結

上述的3個步驟是恢復期間主要的處理流程,一旦恢復邏輯執行成功,後續處理流程與正常執行期間一致。最後對FlinkKafkaConsumer用一句話做個總結。

「將offset提交權交給FlinkKafkaConsumer,其內部維護Kafka消費及提交的狀態。基於Kafka可重複消費能力並配合Checkpoint機制和狀態後端儲存能力,就能實現FlinkKafkaConsumer容錯性,即Source端的Exactly-once語義」。

Flink Kafka Producer

Flink Kafka Producer是Flink應用向Kafka寫出資料的一個實現。在Kafka 0.11.0及以上版本中提供了事務支援,這讓Flink搭載Kafka的事務特性可以輕鬆實現Sink端的Exactly-once語義。關於Kafka事務特性在《Kafka冪等與事務》中做了詳細討論。

在Flink Kafka Producer中,有一個非常重要的元件FlinkKafkaInternalProducer,這個元件代理了Kafka客戶端org.apache.kafka.clients.producer.KafkaProducer,它為Flink Kafka Producer操作Kafka提供了強有力的支撐。在這個元件內部,除了代理方法外,還提供了一些關鍵操作。個人認為,Flink Kafka Sink能夠實現Exactly-once語義除了需要Kafka支援事務特性外,同時也離不開FlinkKafkaInternalProducer元件提供的支援,尤其是下面這些關鍵操作:

事務重置FlinkKafkaInternalProducer元件中最關鍵的處理當屬事務重置,事務重置由resumeTransaction方法實現「原始碼FlinkKafkaInternalProducer.java 144行」。由於Kafka客戶端未暴露針對事務操作的API,所以在這個方法內部,大量的使用了反射。方法中使用反射獲得KafkaProducer依賴的transactionManager物件,並將狀態後端快照的屬性值恢復到transactionManager物件中,這樣以達到讓Flink Kafka Producer應用恢復到重啟前的狀態。
下面我們結合Flink 執行時故障恢復 兩個不同的處理時機來了解Flink Kafka Producer內部如何工作。

執行時

我們假設Flink應用正常執行,Flink Kafka Producer正常接收上游資料並寫到Topic-B的Topic中,Topic-B只有一個partition。在執行期間,主要做以下幾件事:

  • 資料傳送到Kafka

    上游運算元不斷的將資料Sink到FlinkKafkaProducerFlinkKafkaProducer接到資料後封裝ProducerRecord物件並呼叫Kafka客戶端KafkaProducer.send方法將ProducerRecord物件寫入緩衝「原始碼FlinkKafkaProducer.java 616行」。下圖是該階段的描述:

clipboard.png

  • 狀態快照處理 Flink 1.7及以上版本使用FlinkKafkaProducer作為Kafka
    Sink,它繼承抽象類TwoPhaseCommitSinkFunction,根據名字就能知道,這個抽象類主要實現兩階段提交。為了整合Flink Checkpoint機制,抽象類實現了CheckpointedFunctionCheckpointListener,因此它具備快照狀態(snapshotState)能力。狀態快照處理具體做了下面三件事:

①呼叫KafkaProducer客戶端flush方法,將緩衝區內全部記錄傳送到Kafka,但不提交。這些記錄寫入到Topic-B,此時這些資料的事務隔離級別為UNCOMMITTED,也就是說如果有個服務消費Topic-B,並且設定的isolation.level=read_committed,那麼此時這個消費端還無法poll到flush的資料,因為這些資料尚未commit。什麼時候commit呢?在快照結束處理階段進行commit,後面會提到。

②將快照輪次與當前事務記錄到一個Map表示的待提交事務集合中,key是當前快照輪次的CheckpointId,value是由TransactionHolder表示的事務物件。TransactionHolder物件內部記錄了transactionalId、producerId、epoch以及Kafka客戶端kafkaProducer的引用。

③持久化當前事務處理狀態,也就是將當前處理的事務詳情存入狀態後端,供應用恢復時使用。

下圖是狀態快照處理階段處理過程

clipboard.png

  • 快照結束處理
    TwoPhaseCommitSinkFunction實現了CheckpointListener,應用中所有運算元的快照處理成功後會收到基於某輪Checkpoint完成的通知。當FlinkKafkaProducer收到通知後,主要任務就是提交上一階段產生的事務,而具體要提交哪些事務是從上一階段生成的待提交事務集合中獲取的。

clipboard.png

圖中第4步執行成功後,flush到Kafka的資料從UNCOMMITTED變更為COMMITTED,這意味著此時消費端可以poll到這批資料了。

2PC(兩階段提交)理論的兩個階段分別對應了FlinkKafkaProducer的狀態快照處理階段和快照結束處理階段,前者是通過Kafka的事務初始化、事務開啟、flush等操作預提交事務,後者是通過Kafka的commit操作真正執行事務提交。

故障恢復

Flink應用崩潰後,FlinkKafkaProducer開始進入恢復模式。下圖為應用崩潰前的狀態描述:

clipboard.png

在恢復期間主要的處理在狀態初始化階段。當Flink任務重啟時會觸發狀態初始化,此時應用與Kafka已經斷開了連線。但在執行期間可能存在資料flush尚未提交的情況。

如果想重新提交這些資料需要從狀態後端恢復當時KafkaProducer持有的事務物件,具體一點就是恢復當時事務的transactionalId、producerId、epoch。這個時候就用到了FlinkKafkaInternalProducer元件中的事務重置,在狀態初始化時從狀態後端獲得這些事務資訊,並重置到當前KafkaProducer中,再執行commit操作。這樣就可以恢復任務重啟前的狀態,Topic-B的消費端依然可以poll到應用恢復後提交的資料。

需要注意的是:如果這個重置並提交的動作失敗了,可能會造成資料丟失。下圖描述的是狀態初始化階段的處理流程:

clipboard.png

總結

FlinkKafkaProducer故障恢復期間,狀態初始化是比較重要的處理階段。這個階段在Kafka事務特性的強有力支撐下,實現了事務狀態的恢復,並且使得狀態儲存佔用空間最小。依賴Flink提供的TwoPhaseCommitSinkFunction實現類,我們自己也可以對Sink做更多的擴充套件。

本文作者:TalkingData 史天舒

相關文章