在流式系統中如何引入Watermark支持:以Pravega和Flink爲例

在流式計算的世界中,時間問題一直是困擾着業界的難點與痛點:如何能夠更加精確地進行基於事件時間窗口的計算?Watermark的概念應運而生。Watermark試圖將更加精確的時間參考引入流式計算,並取得了越來越多的流式平臺的支持。Pravega也不例外,在最近的版本更新中(v0.6),Pravega已經加入了Watermark的完整支持。由於Pravega原生支持Segment級別的企業級動態縮放特性,在此基礎上要實現Watermark並非易事。本文將按照“發現問題-解決問題”的線索,循序漸進地討論Watermark機制在Pravega中的設計和實現,並對比Flink的實現。本文最早出自Pravega的官方博客。

1 動機

流式處理(Stream Processing)從廣義上指的是從無界數據源注入數據並在注入的過程中進行數據處理的能力。這些數據可以是用戶生成的數據,例如社交網絡或其它在線應用;也可以是機器生成的數據,例如來自物聯網和邊緣應用的服務器遙測數據或傳感器樣本。

典型的流式數據處理應用通常按照數據產生的順序依次處理數據。在實際應用中,由於以下原因,嚴格按照全序處理數據通常是無法實現的:

  1. 數據源本身就不是一個單一的元素,它可能由多個用戶,服務器或者網關組成;
  2. 應用的內在設計也可能導致不同數據項目被亂序注入和處理。

因此,在Pravega和其它類似系統中,順序都指的是數據注入的順序,並且由“鍵”確定。“鍵”這一概念連結了數據流中的各個元素。

按生成順序處理數據是流式處理最有意思的一面,因爲這使得應用程序可以在不同事件中建立起一種臨時的相關關係,儘管這種關係比較鬆散。例如,某個應用程序能夠涉及這樣的提問:在過去的一小時中有多少不同的用戶登錄了,或者在過去 的十分鐘內有多少傳感器報告了異常讀數。爲了實現並回答這些查詢,應用程序必須能夠爲每一個報告週期生成相應的結果(第一個例子的報告週期是一小時,而第二個例子的報告週期是十分鐘)。這些報告週期通常被稱作時間窗口(Time Window)。

在數據生成時就進行數據處理使得應用程序可以在數據生成的同時就輸出結果。對於有界數據集(不會新增數據),可以通過使用map-reduce對所有窗口並行地進行窗口聚合。而這對無界數據集(流)卻並不可行,因爲數據一直在動態持續增長。因此,對於持續生成的數據源,可以選擇用map-reduce的方式週期性地處理數據集快照或增量(這將引入更長的處理時間),也可以用流的方式在數據注入的同時就進行處理。相對於週期性地處理,後者可以提供更低的端到端時延。

爲了進行諸如窗口聚合之類的計算,首先必須擁有某種時間參考,並且使得每個數據元素(例如:消息,事件,記錄等)都與一個時間值相關聯。如果沒有一個時間參考,應用程序就無法確定一個數據元素究竟屬於哪個時間窗口。典型的用於討論時間參考的時域包括事件時間(Event Time)和處理時間(Processing Time)。事件時間指的是數據源賦予事件的時間,通常用的是事件生成時的掛鐘時間。處理時間用的是事件被進行數據處理時的時間作參考。某個事件所關聯的時間要麼是在應用程序從Pravega讀取數據的時候被確定,要麼是在事件被處理的時候確定。此外,我們還考慮注入時間(Ingestion Time),即進行注入的應用程序收到事件的事件。例如,在某個利用Pravega進行流式數據存儲的應用程序中,注入時間就是事件被寫入Pravega Stream的時間。圖 1展示了上述三個時域。

圖 1 Pravega中的時間

由於這三種時域各自將事件生命週期中的不同時間點與一個事件相關聯,它們必然存在差異。當數據源在事件生成的同時就立刻進行發送時,事件時間和注入時間之間的差異通常較小。但是由於網絡連接原因,也可能出現一些具有顯著偏差的離羣點。注入時間和處理時間之間的差異取決於注入過程和處理過程的實際發生時間。事實上,對於Pravega來說,這一差值可能會相當大,因爲Pravega是一個存儲系統,數據在被注入之後可能在任意時間之後才被應用程序處理。在Pravega中,我們將這種在任意長時間之前就已經注入的老數據稱爲歷史數據(Historical Data)。

能夠使用我們上述討論過的時域之一將一個時間值關聯到一個事件上還遠遠不夠。應用程序的確可以從一個時間戳推斷出某個事件屬於哪個時間窗口,但它如何能知道它已經收到了某個時間窗口內的所有事件並且可以關閉當前窗口了呢?在處理時間是連續遞增的假設下,關閉一個基於處理時間的窗口非常簡單,但對於事件時間和注入時間就完全不同了。對於事件和處理時間,進行數據處理的應用程序需要知道它何時(即便只是估計)能夠關閉一個給定的窗口並報告計算結果。當然,應用程序也可以選擇永遠不關閉時間窗口並且持續重複處理窗口內數據。但是,在某個時間點,應用程序總是需要調用和使用最終的計算結果,然後向前推進,這已經等價於關閉當前時間窗口了。

爲了讓應用程序能夠對時間窗口結束進行斷言,我們需要知道事件關聯時間的下界,而這些下界就被稱作Watermark。Watermark w保證所有時間戳小於w的事件都已經被讀取或者處理了(究竟是讀取還是處理的語義則要依賴上下文確定)。然而,遲到的事件(Late Event)總是有機率發生。如何處理和最小化遲到事件則依賴具體的應用程序實現。圖 2展示了Watermark的概念。

圖 2 時間和Watermark

爲了計算基於時間窗口的聚合,我們需要能夠將事件映射到窗口並且知曉何時能夠關閉窗口(計算當前窗口內的聚合)。即便我們假設只有單一的事件序列,順序賦值的方法也是行不通的,因爲事件可以亂序出現。圖中,事件7和事件8就出現了這種亂序。因此,對於每一個事件我們都需要一個時間參考,以便確定將它分配到哪個時間窗口。我們還需要知道何時能夠關閉一個時間窗口,而Watermark正是這樣一種抽象:通過提供時間下界允許窗口正確關閉。現實中,要提供嚴格的Watermark保證是極其困難的。分佈式系統的異步本質使得爲遲到事件提供強保證變得非常複雜。另外,從進度所關注的角度看,提前關閉時間窗口並允許一小部分遲到事件往往是一種較好的選擇。這種選擇通常是依賴具體應用程序的。

在這篇文章中,我們將會討論Pravega新增的對事件時間和注入時間的支持。我們必須克服的關鍵難點之一就是如何在流式數據的Segment集合會因自動縮放機制而動態變化的情況下提供Watermark的支持。我們對Pravega的Reader Group加入了內部支持,以便簡化與流式處理器的關聯,例如Apache Flink。我們用Apache Flink作爲基於Watermark的流式處理器的一個典型例子,討論Flink對Watermark的支持以及與Pravega的Flink連接器(Connector)的集成問題。我們還會對如何與任意應用程序集成進行總結,並根據我們對該特性現有的經驗給出建議。

2 示例:Apache Flink

Apache Flink是一個爲流式和批式數據而設計的開源平臺,而我們編寫了一個連接器允許應用程序可以使用Flink處理Pravega的流式數據。Flink由一個允許應用程序編寫作業(Job)的編程模型和一個執行Flink程序的分佈式運行時環境構成。在運行時,Flink環境把一個程序映射成一個數據流,而這個數據流由一個或多個源(Source),一系列變換算子(Operator)以及一個或多個匯(Sink)組成。在本文關於Watermark的討論中,源是最有意思的元素,因爲正是由它利用Pravega的時間信息產生Watermark。

Watermark是Apache Flink中的核心概念。它允許一系列基於時間的計算,例如不同時域下的時間窗口:事件時間,注入時間和處理時間。在Flink中,它們被稱作時間特徵(Time Characteristics)。事件時間和注入時間在Flink中有着不同的定義。在Flink中,注入時間代表事件進入Flink數據流時的時間,而不是指事件被注入數據管道(例如寫入Pravega)的時間。事件時間代表應用程序賦予的時間值,它涵蓋了由應用程序確定的任意形式的時間和Watermark,包括在源端進行的基於Pravega傳播的時間信息的賦值。因此,事件時間是Flink的時間特徵,包含了Pravega所提供的事件時間和注入時間。圖 3展示了這些不同的時間特徵以及與Pravega的區別。

圖 3 Flink和Pravega中的時間

爲了確定在一個作業究竟使用何種時間特徵,Flink需要程序在執行環境中設置:

final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);

當在Flink中使用事件時間時,必須對事件進行時間戳賦值,並且系統需要Watermark作爲事件時間的進度指標量。這有兩種實現方法:通過源直接進行或者通過時間戳賦值器(同時負責產生Watermark)。時間戳賦值器是作業規格(Job Specification)的一部分,它必須在第一個使用時間的操作發生之前被指明(通常在源之後)。時間戳賦值器將覆寫源直接生成的時間戳和Watermark。

與Pravega最相關的選項就是讓源賦值時間戳並且產生Watermark。當使用這一選項時,我們可以使用本文所描述的方法,在Pravega連接器中加入時間戳賦值和Watermark生成的支持。支持事件時間的Flink源需要調用如下方法:

  • SourceContext#collectWithTimestamp(T element, long timestamp): 從源中產生一個事件,並賦值時間戳。
  • SourceContext#emitWatermark(Watermark mark): 產生Watermark。

在接下來兩個小節,我們將給出我們的設計,在Pravega中支持Watermark。在我們討論完設計與實現之後,我們將回頭展示更多與連接器集成的細節。

3 難點

假設現在我們有一個簡單的應用程序和一組產生事件的傳感器,一個Pravega流,以及一個Flink作業。就目前的討論而言,究竟這個作業在進行怎樣的操作並不重要,但我們假設它正基於Pravega的流進行某種形式的時間窗口聚合,並且它需要知道時間窗口的邊界。

如果傳感器本身能夠進行時間戳賦值,這樣寫入Pravega的事件都附加着時間戳信息,那麼Flink的作業源就可以提取這些時間戳並具有某種時間進度的概念。儘管這是一個合法的方法,但這麼做有兩個嚴重的缺點:

  1. 對於一個給定的時間戳,我們並不知道是否還有一個具有相同時間戳的事件,因此我們無法推進Watermark。
  2. 如果Flink源沒有收到事件,它就不知道究竟是事件時間仍在向前推進而僅僅只是沒有新事件產生,還是系統正在經歷異步過程(例如事件被任意延遲)。

通常說來,遲到事件不可能完全避免,因爲有太多情況可能導致遲到事件,例如連接或者節點不可用。但是,源和應用程序一般都有事件時間的相關信息(例如自身的時鐘),並且在理想情況下我們應當傳播這些信息以便Flink源可以更加精確地推進事件時間。

現在我們看一下如何用Pravega實現這些。假設我們週期性地向Pravega流的字節序列裏寫入標記來表徵時間進度。這些標記指明所有事件時間早於這個標記的所有事件都已經寫入了。這麼做會帶來三個問題:

  1. 具有多個Writer的Pravega流需要協調標記的寫入,保證它們反映出所有Writer的狀態。
  2. Pravega流通常都不是一個簡單的字節序列,它一般由多個並行的Segment構成。
  3. Segment的內部實現是一組字節序列,因此將標記這種控制數據與應用程序數據混合存儲在一起並不是一個好方法。

爲了解決問題1,我們需要某種機制來參考所有已知的Writer,而問題2要求標記能夠反映跨Segment的位置。對於問題3,我們需要在外部維護標記。圖 4展示了一個往Pravega注入事件並進行處理的應用程序的時間流。

圖 4 Pravega中的時間流

參考所有Writer並不是一件簡單的事,因爲Writer可以在線也可以離線。我們選擇的任何機制都必須考慮Writer集合的這種動態性。在外部保存標記的同時還要能夠將它們映射到跨Segment的位置,我們需要某種數據結構來維護這種Segment到偏移量的映射關係,並且我們需要在流數據之外維護這些標記,例如在一個單獨的Segment中。

現在,還有一個問題需要解決:空閒Reader。Reader Group協調對組內Reader的Segment分配。假設一個給定的Reader沒有被分配到Segment。這種場景是可能存在的,例如,當組內Reader的數量大於Segment數量的時候。在這種情況下,一個沒有被分配到Segment的Reader如何能夠知道事件時間在向前推進?爲了讓空閒Reader在沒有被分配到Segment的時候也能夠產生Watermark,我們通過Reader Group的狀態同步器(State Synchronizer)來協調事件時間的推進。這種協調使得所有Reader可以不依賴Segment的分配而推進事件時間。

到目前爲止,我們一直在討論時間卻始終沒有說明時間參考究竟從何而來。這是有意而爲之:我們不想限制應用程序使用特定的時間參考,或者限定這種時間參考何時開始存在。這種時間參考可以是掛鐘時間,非常接近數據生成時的當前時間,也可以是從文件讀取事件時的任意過去時間點。我們不想試圖規定或強制任何對時間賦值的方法,尤其是對於事件時間,我們希望應用程序可以根據自身的設計使用任何有意義的方法設置這個值。

在接下來的幾個小節中,我們會詳述我們的設計和實現。許多我們已經討論過的抽象概念都會在餘下的章節中具現化。

4 Pravega對Watermark的支持

Pravega的Watermark機制由三個主要部分組成,如提案文檔所述:獲取時間,時間戳聚合以及時間窗口的獲取。

4.1 獲取時間

首先是EventStreamWriter上的一個API,用於記錄時間。這允許一個進行數據寫入的應用程序向Pravega表明當前正在寫入的數據所對應的時間。

EventStreamWriter<EventType> writer = clientFactory.createEventWriter(stream, serializer, EventWriterConfig.builder().build()); 
//... write events ... 
writer.noteTime(currentTime);

這裏,“noteTime ”API可以被週期性地調用,指明所有已經寫入的事件都發生在某個時間之前。

這個API的結構使得那些不關心Watermark的應用程序不必額外做任何事情。此外,它還允許應用程序定義自己的時間概念。

類似地,對於事務性Writer,在事務的commit()方法上有一個可選參數,允許應用程序指明當前事務所寫入事件的時間。

Transaction<EventType> txn = writer.beginTxn();
//... write events to transaction. 
txn.commit(txnTimestamp);

noteTime()方法和commit()方法都接受一個時間戳參數,而並非直接查詢系統時鐘。這允許用事件時間的方式定義時間。

如果正在進行事件寫入的進程並不是事件的真正生產者,例如事件來自Web前端,移動App,或者嵌入式系統,那麼事件的發生與寫入之間一定存在時間差。這同樣適用於事件本身就是從某個上游源頭導出的場景。例如,從某個流讀取數據,用某種方式進行數據處理(比如聚合),然後再將其寫入另一個流,這是非常常見的應用。

如果你的應用程序不需要定義時間,那麼可以直接使用注入時間:有一個名爲automaticallyNoteTime配置參數可以提供這一行爲。你可以這樣配置:

EventStreamWriter<EventType> writer = clientFactory.createEventWriter(stream, serializer, EventWriterConfig.builder().automaticallyNoteTime(true).build());

當這一選項開啓時,就無需再調用noteTime()方法了。

一旦獲取了時間,流上的所有Writer都必須形成一個統一的視圖。爲了進行這種聚合,客戶端在內部會將時間值與Writer的當前位置進行組合,並將信息發送給控制器(Controller)。

4.2 從多個Writer進行時間戳聚合

控制器從所有的Writer接收這些時間戳與位置信息。控制器這樣做信息聚合:它從一個流上的所有Writer收集時間戳並輸出一個Stream Cut,這個Stream Cut大於等於所有Writer當前位置的最大值,同時還輸出所有Writer報告時間的最小值作爲時間戳。如下:

圖 5 聚合Writer的時間戳

通過這樣的方式聚合時間戳,當一個Reader的當前位置超過一個給定Stream Cut的時候就一定能保證已經讀取了所有對應的事件。

當然,Writer可以在線也可以離線。很自然的,如果一個Writer關閉並且不再上線,我們不希望一直持有它的遞增時間信息。爲了排除這種情況,流上有一個名爲timestampAggregationTimeout的配置參數。這一配置項指明當超過多長時間沒有收到一個Writer的信息後,就把它排除在時間窗口計算之外。

爲了讓Reader可以讀取這些聚合後的信息,控制器將聚合後的時間和Stream Cut信息寫入一個特殊的Segment。這個Segment在Pravega內部被稱爲Mark Segment。Reader可以從這個特殊的Segment讀取相應的信息來確定它們在流中的位置。

4.3 Reader獲取時間窗口

最終,所有的Reader協調它們各自的位置信息,得到一個Stream Cut形式的組合位置信息。這有一點難理解,因爲爲了知道Reader相對於Mark Segment中所記錄的Stream Cut的位置,Reader必須首先生成一個聚合後的Stream Cut。這需要Reader Group中所有Reader共同協作。我們是這樣實現的:讓每一個Reader都把它們的位置信息記錄在一個狀態同步器中。

一旦獲取了一個位置信息,接下來就需要對它進行比較。事實上,比較的結果並不是一個單一的數值。例如,考慮如下Stream Cut上的一個Reader Group:

圖 6 一個時間上下界分別爲T5和T2的Stream Cut

在這個例子中,Reader Group部分超越某個時間值,但又部分落後於它。如果你從Watermark的設計初衷考慮,這一切就都說得通了。數據在多個主機上被並行處理,我們想要確定這樣一個時間點:在該時間點之前的所有事件都已經被處理了。

正因爲如此,Reader收到的是一個TimeWindow數據結構而不是一個簡單的時間數值。這正是Reader的分佈區間。在上述例子中,時間上下界分別爲T5和T2。這一時間窗口可以通過調用如下方法獲取:

TimeWindow window = reader.getTimeWindow();

在這一過程中始終保持的不變量是,一個Reader得到的時間下界意味着所有早於這個時間點的事件都一定已經被讀取了。還有一些極端的例子需要注意。

  • Reader Group可能位於當前流第一次記錄的時間戳之前,在這種情況下,時間戳的下界無法準確定義。唯一可以確定的是,Reader Group處於第一個時間戳之前。
  • Reader Group可能位於控制器所記錄的最後一個標記之後。例如,如果一個Reader Group正處於流的尾端並且消費速度緊跟注入數據,那麼它很有可能在控制器聚合時間之前就完成了事件處理。此時,當應用程序調用getTimeWindow()方法時,返回的TimeWindow結構中,upperTimeBound成員可能爲空值。類似地,lowerTimeBound成員也可能滯後於Reader的實際位置,因爲它必須等待時間信息進行聚合操作。
  • TimeWindow結構是基於Reader當前已經讀取的位置而不是應用程序處理的位置(因爲Pravega根本無從知曉這一信息)。所以,如果應用程序由於Reader死亡而調用了readerOffline()方法指明需要重新處理事件,那麼TimeWindow可能倒退以便反映出某些事件需要被重新處理,因爲在Reader死亡的過程中,這些正在被處理的事件已經丟失了。

4.4 與處理邏輯的聯繫

在EventStreamReader接口上,getTimeWindow()方法返回一個TimeWindow對象。TimeWindow對象提供了時間的上界和下界。

這是一個基於拉取(Pull)而不是推送(Push)的模型,也就是說我們可以假設往流中注入一些“僞事件”。這一模型有如下優點:它無需強制爲每一個流都處理時間,它允許時間在一個沒有任何事件的流上向前推進,但最重要的是它爲TimeWindow的計算頻率提供了靈活性。

TimeWindow反映了流上的當前位置,因此,如果需要的話,可以在每次調用完readNextEvent()方法之後都調用它,或者也可以週期性地調用它以便將事件按窗口分組。

4.5 Flink連接器的示例

在Pravega的Flink連接器中就有這樣一個例子,實現了TimestampsAndPeriodicWatermarksOperator接口:

@Override 
public void onProcessingTime(long timestamp) throws Exception { 
    // register next timer 
    Watermark newWatermark = userFunction.getCurrentWatermark(); 
    if (newWatermark != null && newWatermark.getTimestamp() > currentWatermark) { 
        currentWatermark = newWatermark.getTimestamp(); 
         // emit watermark 
         output.emitWatermark(newWatermark); 
    } 
    long now = getProcessingTimeService().getCurrentProcessingTime(); 
    getProcessingTimeService().registerTimer(now + watermarkInterval, this); 
}

此處,連接器獲取時間窗口,如果條件滿足,則推進Flink的Watermark,生成新的Watermark並調度任務在一個可配的時間間隔後重新運行。

由於這段邏輯是在連接器上實現的,所有使用Pravega的Flink應用程序都可以通過使用標準Flink的API享受到基於事件時間或注入時間的Watermark的好處。

5 總結

處理流的尾部數據和歷史數據是Pravega的兩大組成特性。Pravega存儲流式數據並使用統一的API允許應用程序在數據可用時立即處理或者在將來任意時間處理。爲了結果的一致性,流式數據需要有一個時間參考以便使得結果與流式數據何時被處理無關,並且這也綁定了時間允許進行時間窗口計算,這是非常關鍵的一點。正是流式數據的這種對時間信息的需求使得我們對Pravega加入了Watermark的支持。

我們對Watermark的支持由以下幾部分組成:將時間戳關聯到Pravega的寫入數據上,根據時間戳生成表徵位置信息的Stream Cut,以及通過Reader對外暴露時間信息以便允許應用程序生成Watermark。某個Reader得到的時間信息是一個根據各Reader的位置生成的跨Reader時間範圍。這個時間範圍給出了所有Reader已經讀取數據的下界以及在Reader Group上的分佈跨度。

本文的方法是一個通用方法,並且支持任意應用程序生成單調遞增的時間戳。我們選擇Apache Flink作爲首個集成對象,因爲它對窗口聚合和Watermark具有高級支持。我們在Pravega的Flink連接器上加入了Flink支持,使得使用Pravega的Flink作業可以從Watermark中獲益。我們期待未來的Pravega連接器可以提供類似的支持,並且獨立的應用程序可以自己實現這種支持,因爲本文已經展示了使用該API所需加入的邏輯是非常簡單直白的。

References

[1] T. Kaitchuck, “Pravega Watermarking Support,” [Online]. Available: http://blog.pravega.io/2019/11/08/pravega-watermarking-support/. [2] J. Manyika, R. Dobbs, M. Chui, J. Bughin, P. Bisson and J. Woetzel, “The Internet of Things: Mapping the value beyond the hype,” McKinsey Global Institute, McKinsey & Company, 2015. [3] T. Akidau, R. Bradshaw, C. Chambers, S. Chernyak, R. J. Fernández-Moctezuma, R. Lax, S. McVeety, D. Mills, F. Perry, E. Schmidt and S. Whittle, “The dataflow model: a practical approach to balancing correctness, latency, and cost in massive-scale, unbounded, out-of-order data processing,” in Proceedings of the VLDB Endowment, Kohala Coast, Hawaii, 2015. [4] “Apache Flink,” [Online]. Available: https://flink.apache.org. [5] “Pravega Connector for Flink,” [Online]. Available: https://github.com/pravega/flink-connectors . [6] “Flink Event Time,” [Online]. Available: https://ci.apache.org/projects/flink/flink-docs-stable/dev/event_time.html. [7] “Generating Timestamps / Watermarks,” [Online]. Available: https://ci.apache.org/projects/flink/flink-docs-release-1.9/dev/event_timestamps_watermarks.html [8] F. Junqueira, “Streams In and out of Pravega,” [Online]. Available: http://blog.pravega.io/2018/02/12/streams-in-and-out-of-pravega/ . [9] “PDP-33 Watermarking,” [Online]. Available: https://github.com/pravega/pravega/wiki/PDP-33:-Watermarking.

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