流處理系統正確性基石:ExactlyOnce的設計和實現

所謂的流式處理其實就是對Stream的讀取-處理-寫入(ETL)操作,應用從Stream中讀取數據,再對數據進行相應的處理分析,最後將結果寫入另一個Stream中。其中僅一次語義保證了哪怕系統發生故障,每一個ETL操作也僅會被執行一次,不會產生數據的丟失或者重複。這樣的可靠性保證對於一些交易、金融類的應用來說至關重要,這就需要Pravega作爲流存儲與流計算引擎共同努力來完成。

通常來說,對於單獨的消息系統而言,語義分爲如下三種:

至多一次(At most once):不管Writer在等待ACK時是否發生超時或者得到錯誤異常,Writer都不會重新發送Event,因此會有數據丟失的風險。在具體的實現過程中,這一種語義無需做任何額外的控制,實現起來最爲簡單,因此也通常有着最優的性能。在某些特定的場景中,我們只希望追求極致的性能而不關心數據的丟失,可能會選用此方案。

至少一次(At least once):如果Writer在等待ACK時發生超時或者得到錯誤異常,Writer將會重新發送消息,這樣能保證每個Event至少被處理一次,保證了數據不會丟失,從而提高了系統的可靠性,但同時會帶來數據重複的問題,例如,當Writer往Stream中成功寫入一個Event,但是當系統嘗試給Writer返回ACK的時候出現網絡異常,Writer因沒有收到ACK而判斷爲寫入Event失敗,因此Writer還是會重新發送此Event,導致數據重複。

僅一次(Exactly once):在系統發生異常時,Writer可以嘗試多次重新發送Event,同時能保證最終每個Event只被寫入一次。一些對數據準確性要求非常高的系統需要保證exactly-once語義,譬如支付系統,當用戶在移動端付款時,很有可能會因爲網絡原因導致延時較長甚至超時,用戶可能會手動進行刷新操作,如果沒有exactly-once的語義支持,很有可能會發生兩次扣費,我們絕對不希望此類錯誤發生。

僅一次語義是實現流處理系統正確性(correctness)的基石,因此也是流存儲Pravega自從設計之初就規劃好的設計目標。但是,exactly-once的實現也面臨着諸多挑戰,例如Kafka也直到0.11版本引入了KIP-98之後才完成了僅一次的支持。這種更強的語義不僅使編寫應用程序更容易,而且使Pravega有了更爲廣泛的應用空間。這一篇文章我們將介紹Pravega實現這一特性的設計細節,以及和Flink社區合作開發的端到端(end-to-end)的exactly-once的實現。

Pravega自身的exactly-once語義

從之前對三種語義的描述可以看出,要滿足exactly-once的語義,需要對於可能發生的故障具有足夠的恢復機制,來保證最終結果的每一條Event僅被寫入一次。Pravega通過實現了讀寫端的以下兩個性質,增強了Pravega的讀寫語義,完成了這一目標:

  1. 數據的可恢復性

  2. Writer的冪等性

其中數據的可恢復性保證了在Pravega讀取數據過程中,發生故障之後數據進行正確重放的可能性,需要在讀客戶端優化。而冪等性保證了單一一條數據在數據寫入的過程不會出現重複,需要在寫客戶端優化。

數據的可恢復性

發生故障時,數據的恢復需要有以下三個性質的保障,纔能有數據恢復的前提。

持久性 Durability

持久性是指一旦寫操作確認成功,即使系統在發生故障的情況下數據都不會丟失,保證了數據的可恢復性。Pravega利用了Bookkeeper以及分層的存儲實現了流式存儲系統的持久性,幫助用戶解決了數據可能會丟失的問題。用戶只需要直接使用Pravega,而不需要考慮額外的數據備份工作。

Pravega的持久化詳細內容請參閱本系列之前的文章:

https://www.infoq.cn/article/VXA51t57pKphQ3d*7ZST

一致性 Consistency

一致性是指,不管系統是否發生異常,所有的Reader讀到的相同鍵值下的數據都是一致的、有序的。對Pravega來說,不管是tail read還是catch up read都滿足一致性。

有序性 Ordering

有序性是指Reader讀取Event的順序要和Event被寫入的順序保持一致。對Pravega來說,數據與應用定義的路由鍵(routing key)一併寫入Stream,Pravega根據路由鍵的哈希值將寫入操作分配至不同的Segment。Pravega保證在路由鍵內部的有序性,即針對在同一個路由鍵的數據寫入是保證有序的。Pravega的有序性同樣保證了讀客戶端在數據讀取發生異常時,仍然能夠進行有序的恢復重放。

有了以上三個特性,Pravega得以進行有效的數據重放,保證了at-least-once語義。

Pravega是如何防止數據重複的

Pravega實現了at-least-once語義,但是爲了要更進一步得滿足exactly-once語義,我們還需要避免數據重複。所有的寫入操作需要保證重複數據的消除,也稱爲冪等性(idempotency)。由於分佈式環境下,數據交互複雜導致故障的發生位置衆多,實現需要考慮諸多細節,這也是exactly-once實現的難點。這需要從讀寫兩方面進行控制。

Pravega中的Reader每成功消費Segment中的一條數據後都會將數據的位置信息以SegmentID+offset的形式寫入State Synchronizer(可參閱之前的文章)中進行持久化。這樣的SegmentID+offset的對應關係構成了Pravega的StreamCut(可參考http://pravega.io/docs/latest/streamcuts/),代表了當前Stream讀取位置的狀態信息。對於一個ReaderGroup中的所有讀客戶端而言,ReaderGroup實現了Checkpoint機制,以Map<Stream, StreamCut>的形式保存了Reader讀取的所有Stream的一致性的狀態信息。確定了一致的恢復位置,我們就保證了故障發生時,Segment中的數據也僅被讀取一次。

Pravega的Writer內部自帶一個ID,在Writer與Pravega服務發生重新連接時可以通過ID定位到最近一次成功寫入Stream中的Event,Writer會以block的形式將Event批量追加寫入到Stream中,當block成功寫入後,Writer會發送一個block end指令,指令中包含number of events writtenlast event number。當Writer與Segment Store之間發生斷開重連時,Segment Store通過Writer ID並通過握手將last event number傳遞給該Writer,這樣Writer就能知道應該從哪裏開始發送Event。這也是分佈式系統中最常用的保證冪等性的方法之一。

然而Pravega中Writer的ID是無法保障永遠不變的,一旦Writer發生異常崩潰了,新起的Writer將會生產一個新的Writer ID,所以在考慮Writer發生故障的情況下,我們就需要將Writer ID與Segment Store解耦。

因此,Pravega使用事務來保證數據寫入時的exactly-once。Event將會被批量地寫入事務中,這些Event要麼同時被提交,要麼同時被丟棄。

Pravega的事務(Transaction)

接下來讓我們一起來看一下Pravega中的事務。Pravega提供了Transaction API,支持事務性地數據寫入,代碼示例如下。

先回顧一下將普通Event寫入Pravega Stream的操作:

// 創建一個client factory, 一個 writer 然後 寫入event
try(ClientFactory clientFactory =
      ClientFactory.withScope(scope, controllerURI) {
    EventStreamWriter<String> writer = clientFactory
         .createEventWriter(streamName,
                            new JavaSerializer<String>(),
                            EventWriterConfig.builder().build());
    writer.writeNext("Key 1", "Hello");
    writer.writeNext("Key 2", "World!");
}

接下來是將Event事務寫入Pravega Stream的操作

Transaction<String> txn = writer.beginTxn();
txn.writeEvent("Key 1", "Hello");
txn.writeEvent("Key 2", "World!");
txn.commit();

API很簡單,只需調用beginTxn()開啓一個Transaction,使用commit()方法進行事務提交即可。下面我們以一個例子進行說明其內部實現。

image

如圖,Stream有3個active的Segments,當在該Stream上創建Transaction時,該Transaction會從Pravega的元數據庫Zookeeper中讀取相應Stream的所有的*active*的Segment集合(保證一致性),同樣會分配3個對應的Transaction Segment(也稱爲Shadow Segment)。

當事件被寫入到事務中時,它被路由並分配給與Stream相同的編號的Segment(即分配給Stream中的Segment 3的事件將被分配給Transaction中的Segment 3),並與Stream類似,事件本身將附加到事務的Segment的尾端。在事務處理的過程中,Transaction Segment並不對讀客戶端可見,保證了隔離性。

提交事務後,所有Transaction的Segment將自動附加到Stream中對應的Stream Segment,由於Pravega的持久化特性,數據也就被持久化了。如果事務終止,則其所有的Transaction Segment以及其中的數據都將從Pravega中刪除,保證了事務操作的原子性。至此,分佈式事務的ACID四大特性均得到滿足。

熟悉Kafka的讀者可能會發現,Pravega的Transaction和Kafka的Transaction的實現方式不同,Pravega通過副本的方式創建Transaction Segment,直到Transaction Segment提交合併入真正的Segment中後,Reader才能開始消費Transaction寫入的數據,然而Kafka直接是將Transaction的Event寫入Topic的Partition中,並且允許用戶額外配置事務隔離級別滿足不同需求。因此對比Kafka,Pravega存在以下優勢:

  1. Kafka中被終止(aborted)的Transaction會殘留在topic partition中,這就導致了磁盤空間和IO帶寬資源的浪費,而Pravega的Transaction使用了一個臨時的Segment,當Transaction abort之後,臨時的Segment就將被回收。

  2. Kafka中Transaction的Event是直接寫入partition中的,當Kafka的隔離級別爲read-commit時,Reader在嘗試讀取一個open Transaction中的數據時會發生阻塞等待,直到Transaction完成(committed或者aborted),而Pravega中,未提交的Transaction以一個臨時的Segment表示,在提交成功前,Reader端是無法感知到Transaction Segment的存在的,因此Reader不會被阻塞。

Pravega Stream的彈性伸縮機制會對事務產生影響,在Writer端負載偏高時,Segment會相應做split操作,原先的Segment會被seal,同時生產兩個新的Segment,細心的讀者會發現這裏會存在問題,倘若Writer正在往Transaction的Transaction Segment中寫數據,如果此時Segment發生了split,就會發生與Transaction Segment不一致的情況,當我們合併Transaction Segment的時候就會發現找不到相對應的Segment。Pravega進一步實現了Rolling Transaction的機制來將Stream的伸縮和Transaction解耦,讓他們同時工作互不影響。有了Rolling Transaction的支持,用戶既能享受Pravega Stream的彈性伸縮機制也同時能保證exactly-once的支持。

Pravega的Transaction還包括以下API:

API 描述
getTnxId() 獲取Transaction UUID
flush() 等待write操作成功
ping() 跟新Transaction的等待時間
checkStatus() 查詢Transaction的狀態 Open, Committing, Committed, Aborting, Aborted
commit() 提交
abort() 中斷Transaction並且丟棄所有Events

Pravega與Flink的端到端exactly-once語義

設想文章最初提到的ETL場景,一個讀客戶端應用首先從Pravega中讀取到數據A,對數據進行處理F(A),此時如果在執行F(A)時讀客戶端應用發生故障重啓,因爲A已經被成功消費過了,State Synchronizer中的元信息已經更新,恢復之後將導致A數據的丟失。這一例子說明了ETL系統端到端的exactly-once僅靠流存儲本身無法保證,需要配合處理端進行端到端的同步才能實現。

對於一般的端到端的exactly-once實現,ETL的三個組件要分別達到如下的要求。

  1. 輸入端要支持固定位置的重放

  2. 流處理系統的容錯處理保證任務只產生exactly-once的輸出

  3. 輸出端要有事務性的支持

爲了解決這個問題,Pravega團隊與Apache Flink展開了深入的合作,實現了Pravega Flink Connector連接器與Flink通信,使得用戶能夠在Flink中調用API,對Pravega進行數據的讀寫,更多詳細內容可以期待Pravega系列之後的一篇文章。在這篇文章中,我們將重點介紹:如何配合Pravega提供的Checkpoint和事務機制以及Flink提供的Checkpoint,實現Pravega->Flink->Pravega的exactly-once。

熟悉Flink的讀者應該知道,Flink在1.4.0版本之前通過Flink的Chandy-Lamport算法實現的Checkpoint機制,做到了Flink應用內部的exactly-once語義。然而,使用Checkpoint也意味着當時的exactly-once的應用是有條件的,只有每條消息對於Flink應用狀態的影響有且只有一次才能滿足exactly-once,例如數據流過Flink直接寫入到數據庫中的無狀態應用是無法保證exactly-once的。Flink在1.4.0版本引入了TwoPhaseCommitSinkFunction,通過兩階段提交(2PC)協議對接事務性寫客戶端解決了寫入的冪等性問題,從而支持了Kafka 0.11+以及Pravega作爲輸入輸出端的端到端的exactly-once語義。

整體結構

從整體設計來看,Flink和Pravega實現端到端exactly-once有以下四個步驟:

  1. 當Pravega作爲Source時,Flink的每個Checkpoint Event會觸發Pravega ReaderGroup的Checkpoint

  2. 當Pravega作爲Sink時,每兩個Flink Checkpoint之間,會創建一個Pravega Transaction

  3. 當Checkpoint完成時,提交Transaction到Pravega Stream

  4. Flink異常恢復後,嘗試重新提交pending狀態的Transaction或是恢復到最新Checkpoint

Pravega的PravegaFlinkWriter並沒有直接繼承Flink提供的TwoPhaseCommitSinkFunction,而是繼承了其父類RichSinkFunction,從而用統一的入口支持了exactly-once和其它的語義。PravegaWriterMode中提供了三種寫入模式:BEST_EFFORT, ATLEAST_ONCE以及EXACTLY_ONCE。其中的EXACTLY_ONCE模式即使用本文描述的事務性寫入。代碼如下:

protected AbstractInternalWriter createInternalWriter() {
  Preconditions.checkState(this.clientFactory != null, "clientFactory not initialized");
  if (this.writerMode == PravegaWriterMode.EXACTLY_ONCE) {
    return new TransactionalWriter(this.clientFactory);
  } else {
    ExecutorService executorService = createExecutorService();
    return new NonTransactionalWriter(this.clientFactory, executorService);
  }
}

EXACTLY_ONCE模式的實現依然遵循Flink Checkpoint的兩階段提交協議,在Flink本地算子快照時提交本地事務(進行pre-commit一階段提交),通過Flink JobManager協調完成投票,當一切正常時,通知各算子完成Checkpoint,並最終提交事務。若存在失敗則Checkpoint也將失敗,視具體情況進行相應的處理,確保數據僅處理一次。

值得一提的是,投票處理的過程是異步進行的,不會影響正常的數據讀寫線程,對整體處理性能的影響較小。

故障恢復

針對其中的4,由於異常同樣可能在流處理進行的各階段發生,接下來將具體介紹以下三種情況出現異常時的處理方法。

Flink寫入Event時發生異常

image

上圖中,Checkpoint-1與Checkpoint-2均成功完成,但在Checkpoint-2之後的Event往Transaction-3中寫的時候發生了異常。此時,Flink將從Checkpoint-2恢復,這時候由於沒有Checkpoint-3因此Transaction-3也不會被提交到Pravega Stream中,因此Flink程序就可以從Checkpoint-2恢復重新在新的Transaction內寫Event,不會發生數據重複,保證了從Flink到Pravega的exactly-once

Flink本地快照時發生異常

image

上圖中,Checkpoint-1成功,當在Checkpoint-2的時候由於有些算子快照時失敗。此時,程序從Checkpoint-1恢復,沒有成功提交的Transaction-2和Transaction-3被丟棄,保證了exactly-once。

Flink Checkpoint成功,但是Transaction提交失敗

image

上圖中,Checkpoint-2成功,但在Transaction-2往Pravege Stream中提交的時候由於網絡原因提交失敗,這種情況下無需讓Flink恢復到Checkpoint-1,而是隻需重新提交Transaction-2即可,如下圖:

image

實例展示

接下來我們通過一個實際的例子,展示並驗證Flink寫入Pravega的exactly-once。

  1. 首先我們保證Pravega的運行。Pravega可以選擇部署standalone版本,具體步驟可以參考之前的文章。Flink可以按照官網推薦步驟進行部署,然後提交任務Jar包運行。也可以不進行部署,Flink會自動創建本地的ExecutionEnvironment運行,本例選擇後者。

  2. 下載並構建pravega-sample,

$ git clone https://github.com/pravega/pravega-samples
$ cd pravega-samples
$ ./gradlew installDist
$ cd flink-connector-examples/build/install/pravega-flink-examples

該目錄的bin/下有兩個程序,exactlyOnceWriterexactlyOnceChecker

exactlyOnceWriter是一個Flink應用,會生成一組整數的Events(默認爲1~50)寫入Pravega,並且會在26位置處製造一個人爲的模擬異常,同時會以100毫秒爲週期進行Checkpoint操作,如果出現異常應用就從最近Checkpoint恢復。

exactlyOnceChecker應用則是一個簡單的Pravega Reader應用,檢測寫入Pravega中的數據否重複。

  1. 我們首先在一個命令行窗口中啓動exactlyOnceChecker
$ bin/exactlyOnceChecker --scope examples --stream mystream --controller tcp://localhost:9090
  1. 然後在另一個窗口中運行exactlyOnceWriter開始往Pravega中寫數據,我們先不開啓EXACTLY_ONCE模式,將--exactlyonce 參數設置爲false,此時默認爲ATLEAST_ONCE模式。
$ bin/exactlyOnceWriter --controller tcp://localhost:9090 --scope examples --stream mystream --exactlyonce false

觀察exactlyOnceWriter的輸出,會產生類似如下內容:

...
Start checkpointing at position 6
Complete checkpointing at position 6
Artificial failure at position 26
...
Restore from checkpoint at position 6
Start checkpointing at position 50
Complete checkpointing at position 50
...

我們發現第一次Checkpoint在6的位置成功,同時數據繼續往Pravega Stream中寫入,直到26的位置,我們模擬了一個Flink Transaction的異常,導致了應用從最近的Checkpoint點恢復,程序又開始從6位置繼續往Pravega中寫數據直到結束。由於沒有開啓Pravega的EXACTLY_ONCE模式,7~26的數據就會重複寫入Pravega。

再觀察exactlyOnceChecker的輸出,的確監測到了數據重複

============== Checker starts ===============
Duplicate event: 8
Duplicate event: 7
...
Duplicate event: 23
Duplicate event: 24
Found duplicates
============== Checker ends  ===============
  1. 現在以EXACTLY_ONCE的模式重新運行exactlyOnceWriter
$ bin/exactlyOnceWriter --controller tcp://localhost:9090 --scope examples --stream mystream --exactlyonce true

exactlyOnceChecker的輸出如下:

============== Checker starts ===============
No duplicate found. EXACTLY_ONCE!
============== Checker ends  ===============

EXACTLY_ONCE模式下,Event 1~6 是一個Transaction,當Flink標記Checkpoint成功後Event 1~6纔會被提交到Pravega Stream中,而在26處Flink Transaction發生了異常,這時Transaction中的Event 7-26並沒有成功提交到Pravega Stream中,而是被丟棄了,因此在exactlyOnceWriter從6的Checkpoint處恢復後,重新從7開始寫入數據,從而保證了端到端的exactly-once。

Pravega 系列文章計劃

Pravega 根據 Apache 2.0 許可證開源,0.5 版本將於近日發佈。我們歡迎對流式存儲感興趣的大咖們加入 Pravega 社區,與 Pravega 共同成長。本篇文章爲 Pravega 系列第七篇,系列文章標題如下(標題根據需求可能會有更新):

  1. 實時流處理 (Streaming) 統一批處理 (Batch) 的最後一塊拼圖:Pravega

  2. 開源 Pravega 架構解析:如何通過分層解決流存儲的三大挑戰?

  3. Pravega 應用實戰:爲什麼雲原生特性對流存儲至關重要

  4. “ToB” 產品必備特性: Pravega 的動態彈性伸縮

  5. 取代 ZooKeeper!高併發下的分佈式一致性開源組件 StateSynchronizer

  6. 分佈式一致性解決方案 - 狀態同步器 (StateSynchronizer) API 示例

  7. Pravega 的僅一次語義及事務支持

  8. 與 Apache Flink 集成使用

作者簡介

滕昱,就職於 DellEMC 非結構化數據存儲部門 (Unstructured Data Storage) 團隊並擔任軟件開發總監。2007 年加入 DellEMC 以後一直專注於分佈式存儲領域。參加並領導了中國研發團隊參與兩代 DellEMC 對象存儲產品的研發工作並取得商業上成功。從 2017 年開始,兼任 Streaming 存儲和實時計算系統的設計開發與領導工作。

周煜敏,復旦大學計算機專業研究生,從本科起就參與 DellEMC 分佈式對象存儲的實習工作。現參與 Flink 相關領域研發工作。

趙凱皓,現就職於 DellEMC,從事流存儲和雲原生相關的設計與開發工作。

參考資料

  1. http://pravega.io

  2. http://blog.pravega.io

  3. https://github.com/pravega/pravega

  4. https://github.com/pravega/flink-connectors

  5. https://github.com/pravega/pravega-samples

image

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