Flink Kafka 端到端 Exactly-Once 分析

摘要:本文基於 Flink 1.9.0 和 Kafka 2.3 版本,對 Flink kafka 端到端 Exactly-Once 進行分析及 notifyCheckpointComplete 順序,主要內容分爲以下兩部分:

1.Flink-kafka 兩階段提交源碼分析

  • TwoPhaseCommitSinkFunction 分析

2.Flink 中 notifyCheckpointComplete 方法調用順序

  • 定義

  • 樣例

  • operator 調用 notifyCheckpointComplete

  • 對 Exactly-Once 語義的影響

Tips:Flink 中文社區徵稿啦,感興趣的同學可點擊「閱讀原文」瞭解詳情~

Flink-kafka 兩階段提交源碼分析

FlinkKafkaProducer 實現了 TwoPhaseCommitSinkFunction,也就是兩階段提交。關於兩階段提交的原理,可以參見《An Overview of End-to-End Exactly-Once Processing in Apache Flink》,本文不再贅述兩階段提交的原理,但是會分析 FlinkKafkaProducer 源碼中是如何實現兩階段提交的,並保證了在結合 Kafka 的時候做到端到端的 Exactly Once 語義的。

https://flink.apache.org/features/2018/03/01/end-to-end-exactly-once-apache-flink.html

TwoPhaseCommitSinkFunction 分析

public abstract class TwoPhaseCommitSinkFunction<IN, TXN, CONTEXT>      extends RichSinkFunction<IN>      implements CheckpointedFunction, CheckpointListener


TwoPhaseCommitSinkFunction 實現了 CheckpointedFunction 和 CheckpointListener 接口,首先就是在 initializeState 方法中開啓事務,對於 Flink sink 的兩階段提交,第一階段就是執行 CheckpointedFunction#snapshotState 當所有 task 的 checkpoint 都完成之後,每個 task 會執行 CheckpointedFunction#notifyCheckpointComplete 也就是所謂的第二階段。

FlinkKafkaProducer 第一階段分析

@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
   // this is like the pre-commit of a 2-phase-commit transaction
   // we are ready to commit and remember the transaction


   checkState(currentTransactionHolder != null, "bug: no transaction object when performing state snapshot");


   long checkpointId = context.getCheckpointId();
   LOG.debug("{} - checkpoint {} triggered, flushing transaction '{}'", name(), context.getCheckpointId(), currentTransactionHolder);


   preCommit(currentTransactionHolder.handle);
   pendingCommitTransactions.put(checkpointId, currentTransactionHolder);
   LOG.debug("{} - stored pending transactions {}", name(), pendingCommitTransactions);


   currentTransactionHolder = beginTransactionInternal();
   LOG.debug("{} - started new transaction '{}'", name(), currentTransactionHolder);


   state.clear();
   state.add(new State<>(
      this.currentTransactionHolder,
      new ArrayList<>(pendingCommitTransactions.values()),
      userContext));
}

這部分代碼的核心在於:

  1. 先執行 preCommit 方法,EXACTLY_ONCE 模式下會調 flush,立即將數據發送到指定的 topic,這時如果消費這個 topic,需要指定 isolation.level 爲 read_committed 表示消費端應用不可以看到未提交的事物內的消息。

@Overrideprotected void preCommit(FlinkKafkaProducer.KafkaTransactionState transaction) throws FlinkKafkaException {   switch (semantic) {      case EXACTLY_ONCE:      case AT_LEAST_ONCE:         flush(transaction);         break;      case NONE:         break;      default:         throw new UnsupportedOperationException("Not implemented semantic");   }   checkErroneous();}


注意第一次調用的 send 和 flush 的事務都是在 initializeState 方法中開啓事務。

transaction.producer.send(record, callback);
transaction.producer.flush();


  1. pendingCommitTransactions 保存了每個 checkpoint 對應的事務,併爲下一次 checkpoint 創建新的 producer 事務,即 currentTransactionHolder = beginTransactionInternal();下一次的 send 和 flush 都會在這個事務中。也就是說第一階段每一個 checkpoint 都有自己的事務,並保存在 pendingCommitTransactions 中。

 FlinkKafkaProducer 第二階段分析

當所有 checkpoint 都完成後,會進入第二階段的提交。

@Overridepublic final void notifyCheckpointComplete(long checkpointId) throws Exception {   // the following scenarios are possible here   //   //  (1) there is exactly one transaction from the latest checkpoint that   //      was triggered and completed. That should be the common case.   //      Simply commit that transaction in that case.   //   //  (2) there are multiple pending transactions because one previous   //      checkpoint was skipped. That is a rare case, but can happen   //      for example when:   //   //        - the master cannot persist the metadata of the last   //          checkpoint (temporary outage in the storage system) but   //          could persist a successive checkpoint (the one notified here)   //   //        - other tasks could not persist their status during   //          the previous checkpoint, but did not trigger a failure because they   //          could hold onto their state and could successfully persist it in   //          a successive checkpoint (the one notified here)   //   //      In both cases, the prior checkpoint never reach a committed state, but   //      this checkpoint is always expected to subsume the prior one and cover all   //      changes since the last successful one. As a consequence, we need to commit   //      all pending transactions.   //   //  (3) Multiple transactions are pending, but the checkpoint complete notification   //      relates not to the latest. That is possible, because notification messages   //      can be delayed (in an extreme case till arrive after a succeeding checkpoint   //      was triggered) and because there can be concurrent overlapping checkpoints   //      (a new one is started before the previous fully finished).   //   // ==> There should never be a case where we have no pending transaction here   //
   Iterator<Map.Entry<Long, TransactionHolder<TXN>>> pendingTransactionIterator = pendingCommitTransactions.entrySet().iterator();   checkState(pendingTransactionIterator.hasNext(), "checkpoint completed, but no transaction pending");   Throwable firstError = null;
   while (pendingTransactionIterator.hasNext()) {      Map.Entry<Long, TransactionHolder<TXN>> entry = pendingTransactionIterator.next();      Long pendingTransactionCheckpointId = entry.getKey();      TransactionHolder<TXN> pendingTransaction = entry.getValue();      if (pendingTransactionCheckpointId > checkpointId) {         continue;      }
      LOG.info("{} - checkpoint {} complete, committing transaction {} from checkpoint {}",         name(), checkpointId, pendingTransaction, pendingTransactionCheckpointId);
      logWarningIfTimeoutAlmostReached(pendingTransaction);      try {         commit(pendingTransaction.handle);      } catch (Throwable t) {         if (firstError == null) {            firstError = t;         }      }
      LOG.debug("{} - committed checkpoint transaction {}", name(), pendingTransaction);
      pendingTransactionIterator.remove();   }
   if (firstError != null) {      throw new FlinkRuntimeException("Committing one of transactions failed, logging first encountered failure",         firstError);   }}


這一階段會將 pendingCommitTransactions 中的事務全部提交。

@Overrideprotected void commit(FlinkKafkaProducer.KafkaTransactionState transaction) {   if (transaction.isTransactional()) {      try {         transaction.producer.commitTransaction();      } finally {         recycleTransactionalProducer(transaction.producer);      }   }}


這時消費端就能看到 read_committed 的數據了,至此整個 producer 的流程全部結束。

 Exactly-Once 分析

當輸入源和輸出都是 kafka 的時候,Flink 之所以能做到端到端的 Exactly-Once 語義,主要是因爲第一階段 FlinkKafkaConsumer 會將消費的 offset 信息通過checkpoint 保存,所有 checkpoint 都成功之後,第二階段 FlinkKafkaProducer 纔會提交事務,結束 producer 的流程。這個過程中很大程度依賴了 kafka producer 事務的機制。

Flink 中 notifyCheckpointComplete 方法調用順序

定義

notifyCheckpointComplete 方法在 CheckpointListener 接口中定義。

/** * This interface must be implemented by functions/operations that want to receive * a commit notification once a checkpoint has been completely acknowledged by all * participants. */@PublicEvolvingpublic interface CheckpointListener {
   /**    * This method is called as a notification once a distributed checkpoint has been completed.    *     * Note that any exception during this method will not cause the checkpoint to    * fail any more.    *     * @param checkpointId The ID of the checkpoint that has been completed.    * @throws Exception    */   void notifyCheckpointComplete(long checkpointId) throws Exception;}


簡單說這個方法的含義就是在 checkpoint 做完之後,JobMaster 會通知 task 執行這個方法,例如在 FlinkKafkaProducer 中 notifyCheckpointComplete 中做了事務的提交。

樣例


下面的程序會被分爲兩個 task,task1 是 Source: Example Source 和 task2 是 Map -> Sink: Example Sink。

DataStream<KafkaEvent> input = env.addSource(
      new FlinkKafkaConsumer<>("foo", new KafkaEventSchema(), properties)
         .assignTimestampsAndWatermarks(new CustomWatermarkExtractor())).name("Example Source")
   .keyBy("word")
   .map(new MapFunction<KafkaEvent, KafkaEvent>() {
      @Override
      public KafkaEvent map(KafkaEvent value) throws Exception {
         value.setFrequency(value.getFrequency() + 1);
         return value;
      }
   });
input.addSink(
  new FlinkKafkaProducer<>(
    "bar",
    new KafkaSerializationSchemaImpl(),
    properties,
    FlinkKafkaProducer.Semantic.EXACTLY_ONCE)).name("Example Sink");


■ operator 調用 notifyCheckpointComplete

根據上面的例子,task1 中只有一個 source 的 operator,但是 task2 中有兩個operator,分別是 map 和 sink。

在 StreamTask 中,調用 task 的 notifyCheckpointComplete 方法。

@Override
public void notifyCheckpointComplete(long checkpointId) throws Exception {
   boolean success = false;
   synchronized (lock) {
      if (isRunning) {
         LOG.debug("Notification of complete checkpoint for task {}", getName());


         for (StreamOperator<?> operator : operatorChain.getAllOperators()) {
            if (operator != null) {
               operator.notifyCheckpointComplete(checkpointId);
            }
         }


         success = true;
      }
      else {
         LOG.debug("Ignoring notification of complete checkpoint for not-running task {}", getName());
      }
   }
   if (success) {
      syncSavepointLatch.acknowledgeCheckpointAndTrigger(checkpointId, this::finishTask);
   }
}

其中關鍵的部分就是:

for (StreamOperator<?> operator : operatorChain.getAllOperators()) {   if (operator != null) {      operator.notifyCheckpointComplete(checkpointId);   }}


operator 的調用順序取決於 allOperators 變量,可以看到源碼中的註釋,operator 是以逆序存放的。

/** * Stores all operators on this chain in reverse order. */private final StreamOperator<?>[] allOperators;

也就是說上面客戶端的代碼,雖然先調用了 map 後調用的 sink,但是實際執行的時候,確實先調用 sink 的 notifyCheckpointComplete 方法,後調用 map 的。

對 Exactly-Once 語義的影響

上面的例子,是先執行 source 的 notifyCheckpointComplete 方法,再執行 sink 的 notifyCheckpointComplete 方法。但是如果把 .keyBy("word") 去掉,那麼只會有一個 task,所有 operator 逆序執行,也就是先調用 sink 的 notifyCheckpointComplete 方法再調用 source 的。

爲了方便理解整個流程,下文只考察併發度爲1的情況,不考慮部分 subtask 成功部分不成功的情況。

Tips:以下討論的都是基於 kafka source 和 sink

■ 先 sink 後 source

sink 成功之後 source 執行之前

sink 成功之前

checkpoint 恢復

exactly-once
__consumer_offsets 恢復重複消費

sink 成功之後 source 執行之前,表示 sink 的 notifyCheckpointComplete 方法執行成功了,但是在執行 source 的 notifyCheckpointComplete 方法之前任務失敗。

sink 成功之前,表示 sink 的 notifyCheckpointComplete 方法執行失敗,提交事務失敗。

  • 測試用例

測試代碼主體架構如下:

DataStream<KafkaEvent> input = env.addSource(      new FlinkKafkaConsumer<>("foo", new KafkaEventSchema(), properties)         .assignTimestampsAndWatermarks(new CustomWatermarkExtractor())).name("Example Source")   .map(new MapFunction<KafkaEvent, KafkaEvent>() {      @Override      public KafkaEvent map(KafkaEvent value) throws Exception {         value.setFrequency(value.getFrequency() + 1);         return value;      }   });input.addSink(   new FlinkKafkaProducer<>(      "bar",      new KafkaSerializationSchemaImpl(),         properties,      FlinkKafkaProducer.Semantic.EXACTLY_ONCE)).name("Example Sink");

測試環境採用的是 Flink 1.9.0 Standalone Cluster 模式,一個 JobManager,一個TaskManager,默認只保存一個 checkpoint。

模擬異常的方法,通過 kill -9 殺掉 JobManager 和 TaskManager 進程。

  1. 在 FlinkKafkaProducer#commit 方法第一行設置斷點,當程序走到這個斷點的時候 kill -9 殺掉 JobManager 和 TaskManager 進程,模擬 sink 的notifyCheckpointComplete 方法執行失敗的場景;

  2. 監控1,通過 bin/kafka-console-consumer.sh --topic bar --bootstrap-server 10.1.236.66:9092 監控 producer 是否 flush 數據;監控2,通過 bin/kafka-console-consumer.sh --topic bar --bootstrap-server 10.1.236.66:9092 --isolation-level read_committed 監控 producer 的事務是否成功提交;監控3,通過 bin/kafka-console-consumer.sh --topic __consumer_offsets --bootstrap-server 10.1.236.66:9092 --formatter "kafka.coordinator.group.GroupMetadataManager\$OffsetsMessageFormatter" --consumer.config /tmp/Consumer.properties 監控 consumer 的offset 是否提交到 kafka;

  3. 發送數據一條數據 a,5,1572845161023,當走到斷點的時候,說明 consumer 的 checkpoint 已經生成,但是還沒有將 offset 提交到 kafka,也就是checkpoint 認爲 offset 已經成功發送,但是 kafka 認爲並沒有發送,監控1有數據,監控2和監控3都沒有數據。kill -9 殺掉 JobManager 和 TaskManager進程;

  4. 重新啓動,並提交作業,不指定 checkpoint 路徑。監控1,2,3,都有數據,所以這種情況,監控2,只收到了一次數據,也就是 exactly-once。這時候監控3收到的數據爲:partition0 的 offset=37,partition1 的 offset=43,partition2 的 offset=39;

  5. 同樣1-3步驟,發送數據一條數據 b,6,1572845161023,第4步,啓動作業的時候通過-s指定要恢復的 checkpoint 路徑,啓動後監控1,2都沒有數據,但是監控3的數據爲:partition0 的 offset=37,partition1 的 offset=43,partition2 的 offset=40,再查看 task 的日誌 FlinkKafkaConsumerBase - Consumer subtask 0 restored state: {KafkaTopicPartition{topic='foo', partition=0}=36, KafkaTopicPartition{topic='foo', partition=1}=42, KafkaTopicPartition{topic='foo', partition=2}=39}.,說明 checkpoint 認爲上一次 partition2 的 offset=39 已經成功消費,所以恢復之後向 kafka 發送的offset 爲 40。這樣就導致了 partition2 的 offset=39 這條數據丟失。

同樣的方法可以測試 sink 成功之後 source 執行之前的場景,只是這時候需要將斷點設置在 TwoPhaseCommitSinkFunction#notifyCheckpointComplete 方法的最後一行,這樣就會發現故障之前,監控1,2都是有數據的,監控3沒有數據。不指定 checkpoint 路徑恢復,監控1,2都會收到數據,這樣就導致了重複消費。如果指定 checkpoint 路徑消費,那麼監控1,2就不會收到數據,保證了 exactly-once。

  • 原因分析

產生上面情況的原因主要就是因爲 checkpoint 存儲的 offset 和 kafka 中的 offset 不一致導致的。

■ 先 source 後 sink

需要說明的一點這個場景的兩個 task 實際是並行的,並沒有絕對的先後關係,只是會有這種前後關係的可能。

source 成功之後 sink 執行之前source 成功之前
checkpoint 恢復丟數據
__consumer_offsets 恢復丟數據

source 成功之後 sink 執行之前,表示 source 的 notifyCheckpointComplete 方法執行成功了,但是在執行 sink 的 notifyCheckpointComplete 方法之前任務失敗。

source 成功之前,表示 source 的 notifyCheckpointComplete 方法執行失敗,提交事務失敗。


  • 測試用例

模擬 source 成功之後 sink 執行之前:

  1. 需要在上面的用例中加入 keyby 算子,確保生成兩個 task,監控3收到數據的時候說明 consumer 的 notifyCheckpointComplete 方法已經執行完。在FlinkKafkaProducer#commit 方法第一行設置斷點,當程序走到這個斷點並且監控3收到數據的時候,kill -9 殺掉 JobManager 和 TaskManager 進程,模擬 sink 執行 notifyCheckpointComplete 方法失敗的場景;

  2. 這時候重啓作業,checkpoint 和 kafka 中 offset 已經是一致的了,無論是從checkpoint 還是 kafka,都是一樣的。所以 source 認爲已經成功消費了,不會再讀上次的 offset,都會導致數據丟失。

source 成功之前:

對於在 source 之前程序就掛掉,相當於所有的 operator 都沒有執行notifyCheckpointComplete 方法,但是 source 的 checkpoint 已經做過了,只是沒有將 offset 發送到 kafka,這樣只有從 __consumer_offsets 恢復才能保證不丟數據。

 

小結:本節通過一種極端的測試場景希望讓讀者可以更深入的理解 Flink 中的  Exactly-Once 語義。在程序掛了以後需要排查是什麼原因和什麼階段導致的,才能通過合適的方式恢復作業。在實際的生產環境中,會有重試或者更多的方式保證高可用,也建議保留多個 checkpoint,以便業務上可以恢復正確的數據。

作者介紹:

吳鵬,亞信科技資深工程師,Apache Flink Contributor。先後就職於中興,IBM,華爲。目前在亞信科技負責實時流處理引擎產品的研發。

▼ 更多技術文章 

Hive 終於等來了 Flink

Flink 生態:一個案例快速上手 PyFlink

Flink 如何支持特徵工程、在線學習、在線預測等 AI 場景?

Flink Batch SQL 1.10 實踐

Flink SQL 如何實現數據流的 Join?

Demo:基於 Flink SQL 構建流式應用

Flink DataStream 關聯維表實戰

Flink 1.10 Native Kubernetes 原理與實踐

從開發到生產上線,如何確定集羣大小?

在 Flink 算子中使用多線程如何保證不丟數據?

一行配置作業性能提升53%!Flink SQL 性能之旅

性能提升約 7 倍!Apache Flink 與 Apache Hive 的集成

Flink 1.10 和 Hive 3.0 性能對比(附 Demo 演示 PPT)

Flink on Zeppelin (3) - Streaming 篇

Flink on Zeppelin (2) - Batch 篇

Flink on Zeppelin (1) - 入門篇


關注 Flink 中文社區,獲取更多技術乾貨

你也「在看」嗎?????

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