所謂的流式處理其實就是對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的讀寫語義,完成了這一目標:
-
數據的可恢復性
-
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 written
和last 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()
方法進行事務提交即可。下面我們以一個例子進行說明其內部實現。
如圖,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存在以下優勢:
-
Kafka中被終止(aborted)的Transaction會殘留在topic partition中,這就導致了磁盤空間和IO帶寬資源的浪費,而Pravega的Transaction使用了一個臨時的Segment,當Transaction abort之後,臨時的Segment就將被回收。
-
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的三個組件要分別達到如下的要求。
-
輸入端要支持固定位置的重放
-
流處理系統的容錯處理保證任務只產生exactly-once的輸出
-
輸出端要有事務性的支持
爲了解決這個問題,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有以下四個步驟:
-
當Pravega作爲Source時,Flink的每個Checkpoint Event會觸發Pravega ReaderGroup的Checkpoint
-
當Pravega作爲Sink時,每兩個Flink Checkpoint之間,會創建一個Pravega Transaction
-
當Checkpoint完成時,提交Transaction到Pravega Stream
-
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時發生異常
上圖中,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本地快照時發生異常
上圖中,Checkpoint-1成功,當在Checkpoint-2的時候由於有些算子快照時失敗。此時,程序從Checkpoint-1恢復,沒有成功提交的Transaction-2和Transaction-3被丟棄,保證了exactly-once。
Flink Checkpoint成功,但是Transaction提交失敗
上圖中,Checkpoint-2成功,但在Transaction-2往Pravege Stream中提交的時候由於網絡原因提交失敗,這種情況下無需讓Flink恢復到Checkpoint-1,而是隻需重新提交Transaction-2即可,如下圖:
實例展示
接下來我們通過一個實際的例子,展示並驗證Flink寫入Pravega的exactly-once。
-
首先我們保證Pravega的運行。Pravega可以選擇部署standalone版本,具體步驟可以參考之前的文章。Flink可以按照官網推薦步驟進行部署,然後提交任務Jar包運行。也可以不進行部署,Flink會自動創建本地的ExecutionEnvironment運行,本例選擇後者。
-
下載並構建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/
下有兩個程序,exactlyOnceWriter
和exactlyOnceChecker
exactlyOnceWriter
是一個Flink應用,會生成一組整數的Events(默認爲1~50)寫入Pravega,並且會在26位置處製造一個人爲的模擬異常,同時會以100毫秒爲週期進行Checkpoint操作,如果出現異常應用就從最近Checkpoint恢復。
exactlyOnceChecker
應用則是一個簡單的Pravega Reader應用,檢測寫入Pravega中的數據否重複。
- 我們首先在一個命令行窗口中啓動
exactlyOnceChecker
$ bin/exactlyOnceChecker --scope examples --stream mystream --controller tcp://localhost:9090
- 然後在另一個窗口中運行
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 ===============
- 現在以
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 系列第七篇,系列文章標題如下(標題根據需求可能會有更新):
-
Pravega 的僅一次語義及事務支持
-
與 Apache Flink 集成使用
作者簡介
滕昱,就職於 DellEMC 非結構化數據存儲部門 (Unstructured Data Storage) 團隊並擔任軟件開發總監。2007 年加入 DellEMC 以後一直專注於分佈式存儲領域。參加並領導了中國研發團隊參與兩代 DellEMC 對象存儲產品的研發工作並取得商業上成功。從 2017 年開始,兼任 Streaming 存儲和實時計算系統的設計開發與領導工作。
周煜敏,復旦大學計算機專業研究生,從本科起就參與 DellEMC 分佈式對象存儲的實習工作。現參與 Flink 相關領域研發工作。
趙凱皓,現就職於 DellEMC,從事流存儲和雲原生相關的設計與開發工作。
參考資料