Spark Structured Streaming筆記

一、流處理基礎

1. 流處理是連續處理新到來的數據以更新計算結果的行爲。在流處理中輸入數據是無邊界的,沒有預定的開始或結束。它是一系列到達流處理系統的事件(例如信用卡交易、點擊網站動作,或從物聯網IoT傳感器讀取的數據),用戶應用程序對此事件流可以執行各種查詢操作(例如跟蹤每種事件類型的發生次數,或將這些事件按照某時間窗口聚合)。應用程序在運行時將輸出多個版本的結果,或者在某外部系統(如HBase等鍵值存儲)中持續保持最新的聚合結果。

而批處理(batch processing)是在固定大小輸入數據集上進行計算的,通常可能是數據倉庫中的大規模數據集,其包含來自應用程序的所有歷史事件(例如過去一個月所有網站訪問記錄或傳感器記錄的數據)。批處理也可以進行查詢計算,與流處理差不多,但只計算一次結果。

雖然流處理和批處理不同,但在實踐中它們經常需要一起使用。例如,流式application通常需要將輸入流數據與批處理job定期產生的數據join起來,流式作業的輸出通常是在批處理作業中要查詢的文件或數據表。此外,應用程序中的任何業務邏輯都需要在流處理和批處理執行之間保持一致:例如如果有一個自定義代碼來計算用戶的計費金額,而流處理和批處理運行出來的結果不同那就出問題了。

爲了滿足這些需求,structured streaming從一開始就設計成可以輕鬆地與其它Spark組件進行交互,包括批處理application。結構化流處理提出了一個叫做連續應用程序(continuous application)的概念,它把包括流處理、批處理和交互式job等全部作用在同一數據集上的處理環節串聯起來,從而構建端到端application,提供最終的處理結果。結構化流處理注重使用端到端的方式構建此類application,而不是僅僅在流數據級別上針對每條記錄進行處理。

下面具體介紹一下流處理的優點。在大多數情況下批處理更容易理解、更容易調試、也更容易編寫應用程序。此外批量處理數據也使得數據處理的吞吐量大大高於許多流處理系統。然而流處理對於以下兩種情況非常必要:

(1)流處理可以降低延遲時間。當用戶需要快速響應時間(分鐘、秒或毫秒級別),就需要一個可以將狀態保存在內存中的流處理系統以獲得更好的性能,例如實時決策和告警系統等。

(2)流處理在更新結果方面也比重複的批處理job更有效,因爲它會自動增量計算。例如,如果要計算過去24小時內的web 流量統計數據,那麼簡單的批處理job實現可能會在每次運行時遍歷所有數據。與此相反,流處理系統可以記住以前計算的狀態,只計算新數據。如果用戶告訴流處理系統每個小時更新一次報告,則每次只需處理1小時的數據(自上次報告以來的新數據),在批處理系統中需要手動實現這種增量計算以獲得相同的性能,從而導致要做大量額外的工作,而這些工作在流處理系統中會自動完成。

2. 然而,流處理也會遇到一些挑戰。舉個例子,假設application接收來自傳感器(例如汽車內部)的輸入消息,該傳感器在不同時間報告其值。然後,用戶希望在該數據流中搜索特定值或特定模式,一個挑戰就是輸入記錄可能會無序地到達應用程序,比如因延遲和重新傳輸,可能會收到以下順序的更新序列,其中time字段顯示實際測量的時間:

{value:1,time:"2017-04-07T00:00:00"}
{value:2,time:"2017-04-07T01:00:00"}
{value:5,time:"2017-04-07T02:00:00"}
{value:10,time:"2017-04-07T01:30:00"}
{value:7,time:"2017-04-07T03:00:00"}

在任何數據處理系統中,都可以構造邏輯來執行例如接收單值“5”的操作。然而,如果你只想根據收到的特定值序列觸發某個動作,比如2 然後10然後5,事情就複雜多了。在批處理的情況下這並不困難,因爲可以簡單地按time字段對所有事件進行排序,以發現10在2和5之間到來。然而對於流處理系統來說這是比較困難的,原因是流處理系統將單獨接收每個事件,並且需要跟蹤事件的某些狀態以記住值爲2和5的事件,並意識到值爲10的事件是在它們之間,這種在流中記住事件狀態的需求帶來了更多的挑戰

總的來說,流處理系統的挑戰有以下幾個方面:

(1)基於application時間戳(也稱爲事件時間) 處理無序數據;

(2)維持大量的狀態;

(3)支持高吞吐量;

(4)即使有機器故障也僅需對事件進行一次處理;

(4)處理負載不均衡和拖延task(straggler)。

(5)快速響應時間;

(6)與其他存儲系統中的數據連接;

(7)確定在新事件到達時如何更新輸出;

(8)事務性地向輸出系統寫入數據;

(9)在運行時更新應用程序的業務邏輯。

3.事件時間(event time)是根據數據源插入記錄中的時間戳來處理數據的概念,而不是流處理application在接收記錄時的時間(稱爲處理時間processing time)。因爲記錄可能會出現無序狀況(例如,如果記錄從不同的網絡路徑返回),並且不同的數據源可能也無法同步(對於標記相同事件時間的記錄,某些記錄可能比其他記錄晚到達),如果application從可能產生延遲的遠程數據源(如手機或物聯網設備)收集數據,則基於事件時間的處理方式就非常必要。如果不基於事件時間,可能在某些數據延遲到達時無法發現某些重要的特徵。相比之下,如果application只處理本地事件(例如在同一個數據中心中生成的數據),則可能不需要複雜的事件時間處理。

在Flink和Spark Streaming的流處理思路中,兩者分別使用連續處理(Continuous Processing)模式和微批處理(Micro-batch Processing)模式。在連續處理系統中,每個節點都不斷地偵聽來自其他節點的消息並將新的更新輸出到其子節點。例如,假設application在多個輸入流上實現了map-reduce運算,實現map的每個節點將從輸入數據源一個一個地讀取記錄,根據函數邏輯執行計算,並將它們發送到相應的reducer。當reducer獲得新記錄時,將更新其狀態,這種情況是發生在每一條記錄上的,如下圖所示:

在輸入數據速率較低時,Flink這樣的連續處理方式提供儘可能低的處理延遲,這是連續處理的優勢,因爲每個節點都會立即響應新消息。但是,連續處理系統通常具有較低的最大吞吐量,因爲它們處理每條記錄的開銷都很大(例如調用操作系統將數據包發送到下游節點)。此外,連續處理系統通常具有固定的計算拓撲,如果不停止整個application,在運行狀態下是不能夠動態改動的(例如動態併發數調整),這也可能會導致負載均衡和資源利用效率(Flink會按整個流程中的最高併發來固定死整個application中的併發數,部分低併發的子流程會浪費資源)的問題

相比之下,Spark Streaming這樣的微批處理系統等待積累少量的輸入數據(比如500 ms範圍),然後使用分佈式task集合並行處理每個批次,類似於在Spark中執行批處理job。微批量處理系統通常可以在每個節點上實現高吞吐量,因爲它們利用與批處理系統相同的優化操作(例如向量化處理Vectorized Processing),並且不會引入頻繁處理單個記錄的額外開銷,如下圖所示:

因此,微批處理只需要較少的節點就可以應對相同生產速率的數據。微批處理系統還可以使用動態負載均衡技術(如Adaptive Execution特性)來應對不斷變化的負載(例如動態增加或減少task數量),提高資源利用率。然而,微批處理的缺點是由於等待累積一個微批量而導致更長的響應延遲。在實際生產中,在處理大規模數據並需要分佈式計算的流處理應用程序往往優先考慮吞吐量,因此Spark最開始就實現了微批量處理,但是在structured streaming中,積極開發了基於相同API來支持連續處理模式。

在這兩種執行模式之間進行選擇時,最主要因素是用戶期望的延遲和總的操作成本(TCO)。根據應用程序的不同,微批處理系統可能將延遲從100毫秒延長到秒。在這種機制下,通常需要較少的節點就可以達到要求的吞吐量,因而降低了運營成本(包括由於節點故障次數較少帶來的維護成本降低)。爲了獲得更低的延遲,那應該考慮一個連續處理系統,或使用一個微批處理系統與快速服務層(fast serving layer)結合以提供低延遲查詢(例如將數據輸出到MySQL或Cassandra,可以用它們在幾毫秒內將數據提供給客戶端)。

4. Spark包括兩種streaming API:早期的DStream API純粹是基於微批處理模式的,但不支持事件時間。新的structured streaming API添加了更高級別的優化、事件時間,並且支持連續處理。但是,DStream API 有幾個限制:首先它完全基於Java/Python對象和函數,而不是DataFrame和Dataset中豐富的結構化表概念,這不利於執行引擎進行優化;其次,DStream API純粹基於processing time(要處理event time操作,應用程序需要自己實現它們);最後,DStream只能以微批處理方式運行,並在其API的某些部分暴露了微批處理的持續時間,這使得其難以支持其他執行模式。

structured streaming是基於Spark結構化API的高級流處理API,它適用於運行結構化處理的所有環境,例如Scala,Java,Python,R和SQL。與DStream一樣它是基於高級操作的聲明式API,但是structured streaming可以自動執行更多類型的優化;與DStream不同,structured streaming對標記event time的數據具有原生支持(所有窗口操作都自動支持它)。並且,Spark 2.3版本已經支持Flink那樣的Continuous Processing

更重要的是,除了簡化流處理之外,structured streaming還旨在輕鬆構建端到端連續application,這些application結合了流處理、批處理和交互式查詢。例如structured streaming不使用DataFrame之外的API,只需編寫一個正常的DataFrame(或SQL)並在數據流上應用它。當數據到達時,structured streaming將以增量方式自動更新此計算的結果,這非常有益於簡化編寫端到端數據處理程序,開發人員不需要維護批處理代碼版本和流處理版本,並且避免這兩個版本代碼失去同步的風險。另外一個例子是,structured streaming可以將數據輸出到SparkSQL(如Parquet表)可用的表格式中,從而便於從另一個Spark application查詢該流的狀態。

二、Structured Streaming基礎

5. Structred Streaming是建立在SparkSQL引擎上的流處理框架,它不是獨立的API,而是使用了Spark中現有的結構化API (包括DataFrame、Dataset和SQL) ,這意味着用戶熟悉的所有操作都支持。用戶可以用批處理的計算方式來表達流處理計算代碼。在指定流處理操作後,Structred Streaming引擎將負責對新到達系統的數據執行增量、連續地查詢。用於計算的邏輯指令將在Catalyst引擎上執行,包括查詢優化、代碼生成等。結構化流處理還包括一些專門用於流處理的特性,例如支持僅執行一次的端到端處理,並且支持基於checkpoint和預寫式日誌(write-ahead log)的錯誤恢復功能。

Structred Streaming背後的主要思想是將數據流視爲連續追加數據的數據表。然後,該job定期檢查新的輸入數據對其進行處理,在需要時更新位於狀態存儲區的某些內部狀態,並更新其結果。API的設計原則是批處理和流處理的查詢代碼是一致的,不需要更改查詢代碼,只需要指定是以批處理還是以流處理方式運行該查詢即可。在內部,Structred Streaming將自動找出增量查詢的方法,即在新數據到達時更新其結果,並以容錯的方式運行,如下圖所示:

簡單來說,結構化流即是“以流處理方式處理的DataFrame”,這使得使用流處理application非常容易,但是Structred Streaming的查詢類型有一定的限制,以及必須考慮到一些特定於流的新概念,例如event time和無序數據。通過與Spark其餘部分集成,Structred Streaming使用戶能夠構建連續應用程序(continuous applications)。連續應用程序是一個端到端的application,它通過組合各種工具來實時對數據作出響應,包括流處理job、批處理job、流數據和離線數據之間的join、以及交互式的ad-hoc查詢(即席查詢)。

由於當前大多數流處理job都是在更大的連續應用程序中部署的,因此Spark開發者試圖使其在一個框架中簡單地指定整個application,並在這些不同部分的數據獲得一致的結果。例如,可以使用Structured Streaming連續地更新一個SparkSQL數據表,或者爲MLlib訓練的機器學習模型提供流處理服務,或者將數據流與在任一個Spark數據源中的離線數據進行join等。

Structred Streaming同樣涉及transformation和action的概念,結構化流處理的transformation操作就是離線批處理中的轉換操作,雖然有一些限制,這些限制通常是在引擎無法增量執行某些類型的查詢上,但一些限制會在新版本的Spark中去除。Structred Streaming中只有一個action操作,即啓動流處理,然後運行並持續輸出結果。除了需要指定數據源(如Kafka)從其讀取數據流,也需要指定接收器(sink)如HBase來設置流處理之後結果集的去處。sink和執行引擎還負責跟蹤數據處理的進度。

6. 爲Structred Streaming作業指定一個sink只解決了一半問題,還需要定義Spark將數據以何種方式寫入sink,例如是否只想追加新信息?當隨着時間的推移收到更多信息時,是否希望更新數據記錄(例如更新給定網頁的點擊次數)?是否希望每次都完全覆蓋結果集(即始終使用總點擊數更新文件)?爲此需要定義一個輸出模式,Spark支持的輸出模式如下:

(1)append(追加,只向輸出接收器中添加新記錄);

(2)update(更新,更新有變化的記錄);

(3)complete(完全,重寫所有輸出)。

值得注意的是,某些查詢和某些sink只支持某些固定的輸出模式,例如假設job只是在流上執行map操作,隨着新記錄的到達輸出數據將無限地增長,因此使用complete模式將所有數據一次性重新寫入一個新的文件,則是沒有意義的,相比之下如果正在針對有限數量的鍵進行聚合操作,則complete模式和update模式是有意義的,但append模式不行,因爲某些鍵的值需要隨着時間的推移不斷更新。

輸出模式定義了數據以何種方式被輸出,trigger則定義了數據何時被輸出,即Structred Streaming應何時檢查處理新輸入數據並更新其結果。默認情況下,Structred Streaming將在處理完最後一組輸入數據後立即檢查是否有新的輸入記錄,從而盡力保證低延遲。但當sink是一組文件時,可能產生許多小輸出文件。因此Spark還支持基於process time的trigger,僅在固定時間間隔內檢查新數據,將來還可能支持其他類型的觸發器。

事件時間(event time表示嵌入到數據中的時間字段。這意味着不是根據它到達系統的時間處理數據,而是根據生成數據的時間對其進行處理,不管由於上傳速度慢、或網絡延遲導致輸入記錄亂序到達依然如此。在Structred Streaming中實現event time處理很簡單,由於系統將輸入數據視作數據表,因此event time只是該表中的一個字段,應用程序可以使用SQL運算符進行分組(grouping)、聚合(aggregation)和窗口化(windowing)。當Spark知道其中一列是event time字段時,Structred Streaming可以採取一些特殊的操作,包括進行查詢優化或確定何時可以安全地忘記某時間窗口的狀態。許多這些操作可以使用watermarks進行。

Watermarks是流處理系統的一項功能,它允許指定在event time內查看數據的延遲程度。例如,某個application需要處理來自移動設備的日誌,由於上傳延遲可能會導致日誌延遲30分鐘。支持event time的系統通常允許設置watermarks來限制記住舊數據的時長。watermarks還可用於控制何時輸出特定event time窗口的結果(例如,一直等待直到它的watermarks過期)。

7. 來看一個使用結構化流的應用示例,這裏將使用異質性人類活動識別數據集(Heterogeneity Human Activity Recognition Dataset),數據是從包括智能手機和智能手錶等設備的各種傳感器採集的讀數,特別是加速度計和陀螺儀,設備以最高頻率進行採樣。這些傳感器採集用戶活動的信息並將其記錄下來,如騎自行車、坐、站、步行等。其中涉及多個不同的智能手機和智能手錶,一共採集了9個用戶的信息。首先讀取數據集的靜態版本到DataFrame:

val static = spark.read.json("/data/activity-data/")
val dataSchema = static.schema
root
|--Arrival_Time:long (nullable = true)
|--Creation_Time:long (nullable = true)
|--Device:string (nullable = true)
|--Index:long (nullable = true)
|--Model:string (nullable = true)
|--User:string (nullable = true)
|--_corrupt_record:string (nullable = true)
|--gt:string (nullable = true)
|--x:double (nullable = true)
|--y:double (nullable = true)
|--z:double (nullable = true)

下面是該DataFrame的一些樣本,其中包括一些時間戳、型號信息、用戶信息和設備信息列,gt字段記錄用戶當時正在進行的活動:

+------------------+--------------------------+----------+------+---------+----+--------+-----+-----
|  Arrival_Time|      Creation_Time|  Device| Index| Model|User|_c…ord|.gt| x
|1424696634224|142469663222623685|nexus4_1|   62|nexus4|   a|   null|stand|-0…
…
|1424696660715|142469665872381726|nexus4_1| 2342| nexus4|   a|   null|stand|-0…
+------------------+--------------------------+----------+------+--------+-----+--------+-----+-----

接下來創建該數據集的流式版本,它會將數據集中的每個輸入文件一個一個地讀出來,就好像是一個流一樣。

流式DataFrame基本與靜態DataFrame相同,需要在Spark application中創建它們,然後對它們執行transformation操作,以使數據成爲正確的格式。基本上所有靜態的結構化API中可用的transformation操作都適用於流式DataFrame。但是,一個小的區別是Structred Streaming不允許在未明確指定它的情況下執行模式推斷(scheme inference),需要通過設置spark.sql.streaming.schemaInference爲true來顯式指定模式推斷。

鑑於此,代碼將從一個具有已知數據模式的文件中讀取模式dataSchema對象,並將dataSchema對象從靜態DataFrame應用到流式DataFrame,在數據可能被更改時,應避免在生產應用中使用模式推斷:

val streaming = spark.readStream.schema(dataSchema)
  .option("maxFilesPerTrigger", 1).json("/data/activity-data")

maxFilesPerTrigger屬性控制Spark將以什麼樣的速度讀取文件夾中的文件。將該值設置的低一些,比如將流限制爲每次觸發讀取一個文件,有助於演示如何以增量方式運行Structred Streaming,但在生產中可能需要設置爲更大的值。就像其他的Spark API 一樣,流式DataFrame的創建和執行是惰性的。這裏將展示一個簡單的轉換,按gt列對數據進行分組和計數,這是用戶在該時間點執行的活動:

val activityCounts = streaming.groupBy("gt").count()

8. 在設置了transformation操作之後,只需要調用action操作來啓動查詢。另外,需要爲查詢結果指定輸出目標或輸出Sink,對於這個示例將編寫一個內存接收器,它將結果保存爲內存中的表。在指定這個sink的過程中,需要指定Spark將如何輸出這些數據,本例中使用complete輸出模式,在每個觸發操作之後重寫覆蓋所有key鍵以及它們的計數:

val activityQuery = activityCounts.writeStream.queryName("activity_counts")
  .format("memory").outputMode("complete")
  .start()

這樣就開始寫出數據流了。這裏設置了唯一的查詢名稱來代表流即“activity_counts”,將格式指定爲內存表,並設置了輸出模式。當運行前面的代碼時,還要包括下面這一行代碼:

activityQuery.awaitTermination()

執行此代碼後,流式計算將在後臺啓動。查詢對象是該活躍流查詢的句柄,這裏需要activityQuery.awaitTermination()來等待查詢終止,以防止查詢程序還在運行而driver已經終止的情況,後面會省略掉這行代碼,但是在生產應用中必須都有,否則流處理程序將無法運行。Spark可以列出這個流和其他Spark Session中活躍的流,通過運行下面代碼,可以看到這些流的列表:

spark.streams.active

Spark爲每個流分配一個UUID,所以可以遍歷流的列表,並選擇其中的一個。在上面例子中,把流賦值給了一個變量activityQuery,所以不需要通過UUID獲取流。現在上面這個流正在運行,可以通過查詢內存中的數據表來查看流聚合的結果,此表叫activity_counts與流的名稱相同。要查看這個輸出表中的當前數據,只需要在一個簡單的循環中執行此操作,每秒打印一次流查詢的結果:

for( i <- 1 to 5 ) {
    spark.sql("SELECT * FROM activity_counts").show()
    Thread.sleep(1000)
}

在該查詢運行時,應該看到每個活動的計數隨時間而變化。例如第一次調用show顯示以下空結果,因爲在流讀取第一個文件的同時查詢了表:

+---+-----+
|  gt|count|
+---+-----+
+---+-----+

在下一個時間返回結果時,裏面就有內容了(不同時間可能結果不同):

+----------+-----+
|      gt|count|
+----------+-----+
|      sit| 8207|
…
|     null| 6966|
|    bike| 7199|
+----------+-----+

通過這個簡單的例子,Structured Streaming的優勢應該很清晰了,可以執行在批處理中使用的相同操作,代碼只需做少量修改就可以直接在數據流上運行。

9. Structured Streaming支持所有的select轉換和篩選轉換,對所有DataFrame函數和對單個列的操作也都支持。這裏使用下面的選擇和篩選操作演示一個簡單的示例,由於Key沒有任何改變,將使用append輸出模式,以便將新結果追加到輸出表中:

import org.apache.spark.sql.functions.expr
val simpleTransform = streaming.withColumn("stairs", expr("gt like '%stairs%'"))
  .where("stairs")
  .where("gt is not null")
  .select("gt", "model", "arrival_time", "creation_time")
  .writeStream
  .queryName("simple_transform")
  .format("memory")
  .outputMode("append")
  .start()

Structured Streaming對聚合操作有很好的支持,可以指定任意聚合,如在結構化API中看到的那樣。例如可以根據電話型號和活動信息進行cube分組後,再計算x、y、z加速平均值等這樣複雜的聚合操作:

val deviceModelStats = streaming.cube("gt", "model").avg()
  .drop("avg(Arrival_time)")
  .drop("avg(Creation_Time)")
  .drop("avg(Index)")
  .writeStream.queryName("device_counts").format("memory").outputMode("complete")
  .start()

查詢該表可以看到下面結果:

SELECT * FROM device_counts

+----------+------+------------------+--------------------+--------------------+
| gt| model| avg(x)| avg(y)| avg(z)|
+----------+------+------------------+--------------------+--------------------+
| sit| null|-3.682775300344...|1.242033094787975...|-4.22021191297611...|
| stand| null|-4.415368069618...|-5.30657295890281...|2.264837548081631...|
...
| walk|nexus4|-0.007342235359...|0.004341030525168...|-6.01620400184307...|
|stairsdown|nexus4|0.0309175199508...|-0.02869185568293...| 0.11661923308518365|
...
+----------+------+------------------+--------------------+--------------------+

除了對數據集中原始列上的這些聚合外,Structured Streaming對錶示event time的列具有特殊支持,包括watermark支持和窗口操作(windowing),並且支持流式DataFrame與靜態DataFrame的join操作。Spark 2.3增加了對join多個流的支持,可以執行多列join,並從靜態數據源中補充流數據:

val historicalAgg = static.groupBy("gt", "model").avg()
val deviceModelStats = streaming.drop("Arrival_Time", "Creation_Time", "Index")
  .cube("gt", "model").avg()
  .join(historicalAgg, Seq("gt", "model"))
  .writeStream.queryName("device_counts").format("memory").outputMode("complete")
  .start()

10. Spark Streaming中最簡單的數據來源是文件源,雖然基本上任何文件源都應該可行,但在實踐中常看到的是Parquet、文本、JSON 和CSV。使用文件數據源或sink與Spark靜態文件源之間的唯一區別是,通過流可以控制在每個trigger讀取的文件數,即maxFilesPerTrigger選項。要注意爲流式job添加到輸入目錄中的任何文件都需要以原子形式顯示,否則Spark將在輸入目錄裏的所有數據源文件完成之前,就開始處理不完整的數據源(如HDFS等可以顯示部分寫入結果的文件系統),最好是在其他目錄中寫文件並在完成後將其移動到輸入目錄中,比如在亞馬遜S3中對象通常只在完全寫完後才能顯示在目錄列表裏。

Kafka是一種分佈式的數據流發佈和訂閱系統。Kafka允許發佈和訂閱與消息隊列類似的記錄流,它們以容錯的方式存儲,可以認爲Kafka類似一個分佈式緩衝區Kafka將記錄流分類存儲在主題(topic中,每條記錄都包含一個鍵、一個值和一個時間戳,topic包含不可改變的記錄序列,每個記錄在序列中的位置稱爲偏移量(offset),讀取數據稱爲訂閱(subscrib)topic,寫數據類似把數據發佈(publish)到topic。Spark可以通過批量方式和流方式從Kafka上讀取DataFrame。

從Kafka讀取數據之前,首先需要選擇下列選項之一:assign,subscribe,或者subscribePattern。assign是一種細粒度的方法,它不僅指定topic,而且還需要指定想讀取的topic partition,這通過一個JSON字符串指定,如{"topicA":[0,1],"topicB":[2,4]}。subscribe和subscribePattern是通過指定topic列表(前者)或指定模式(後者)來訂閱一個或多個topic的方法

其次,需要指定kafka提供的kafka.bootstrap.servers來連接到服務。在指定了這些選項後,還有幾個其他選項可以指定:

(1)startingOffsets指定查詢的起始點,可以是從最早的偏移量earliest; 也可以是最新的偏移量latest; 或者是一個JSON字符串,它指定每個Topic Partition的起始偏移量。JSON 中-2代表最早的偏移量,-1是最新的。例如JSON格式可以寫成是{"topicA":{"0":23,"1":-1},"topicB":{"0":-2}},這僅當新的流式查詢啓動時才適用,並且將始終從查詢中斷的偏移量位置開始恢復,查詢過程中新發現的分區將從最早偏移量開始

(2)endingOffsets是查詢的終止偏移量。

(3)failOnDataLoss。在可能丟失數據(例如刪除topic或offset超出範圍)時是否停止查詢,這可能是一個誤報,如果它不能按預期工作也可以禁用它,默認值爲true。

(4)maxOffsetsPerTrigger爲在給定trigger中讀取的總offset。

還有一些選項可以設置Kafka消費者超時、提取重試次數和間隔時間。要從Kafka讀取數據,需要在Structured Streaming中執行以下操作:

// Subscribe to 1 topic
val ds1 = spark.readStream.format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("subscribe", "topic1")
  .load()
// Subscribe to multiple topics
val ds2 = spark.readStream.format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("subscribe", "topic1,topic2")
  .load()
// Subscribe to a pattern of topics
val ds3 = spark.readStream.format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("subscribePattern", "topic.*")
  .load()

Kafka數據源中的每一行都具有以下模式:

(1)鍵key:二進制binary。

(2)值value:二進制binary。

(3)主題topic:字符串string。

(3)分區partition:整型int。

(4)偏移量offset:長整型long。

(5)時間戳timestamp:長整型long。

Kafka中的每條消息都有可能以某種方式序列化,在結構化API 或UDF中使用原生(native)Spark函數,可以將消息解析爲更結構化的格式。一個常見的模式是使用JSON或Avro讀寫Kafka。

11. 寫入Kafka與從Kafka讀取數據,除了幾個參數不同之外其他幾乎相同。用戶仍然需要指定Kafka的引導服務器(bootstrap server),唯一需要額外提供的是指定topic的列,或者把topic作爲option給出。例如,下面兩種寫法是等效的:

ds1.selectExpr("topic", "CAST(key AS STRING)", "CAST(value AS STRING)")
  .writeStream.format("kafka")
  .option("checkpointLocation", "/to/HDFS-compatible/dir")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .start()
ds1.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")
  .writeStream.format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("checkpointLocation", "/to/HDFS-compatible/dir")
  .option("topic", "topic1")
  .start()

foreach sink類似於Dataset API中的foreachPartitions,此操作在每個分區上並行執行任意操作。要使用foreach sink,必須實現ForeachWriter接口,它在Scala/Java文檔中有所描述,其中包含三個方法:open,process和close。在觸發操作生成輸出行序列的時候,就會調用相關的方法。以下是一些重要的細節:

(1)writer必須是可序列化的(Serializable),它可以是UDF或Dataset映射函數。

(2)每個executor上,這三個方法(open,process,close)將會被調用

(3)writer必須在open()方法中執行其所有初始化,如打開連接或啓動事務。常見的錯誤是,初始化發生在open方法之外(在使用的類中),比如在driver而不是executor上發生。

因爲Foreach sink運行任意用戶代碼,所以使用它時必須考慮的一個關鍵問題是容錯。如果Structured Streaming要求sink寫出一些數據但隨後崩潰,它無法知道原始寫入是否成功。因此,API提供了一些附加參數來保證只處理一次。

首先,ForeachWriter中調用open函數會接收兩個參數,這兩個參數唯一標識了需要執行操作的行集,version參數是一個單調遞增的ID,每次觸發後自動增加,partitionId是任務中輸出分區的IDopen()方法應返回是否處理這組行,如果在外部跟蹤sink的輸出,並看到這組行已經輸出(例如,在存儲系統中看到了最後一個version和partitionId已經輸出),則可以讓open方法返回false來跳過處理這組行,如果要處理則返回true,ForeachWriter將再次打開來寫出每次觸發的數據。

接下來,假設open方法返回true,那麼process方法將處理數據中的每條記錄,這非常簡單就是處理或寫出數據。最後,每當open方法被調用,那不管它返不返回true,close()方法最後都會被調用(除非在這之前節點崩潰)。如果Spark在處理過程中出現錯誤,則close方法將接收到該錯誤,用戶應該在close方法中寫清理任何程序佔用資源的邏輯。同時,ForeachWriter接口可以高效實現自己的sink,包括跟蹤哪些trigger的數據已經被寫入的邏輯,以及在發生錯誤時如何安全恢復的邏輯。可以看下面的ForeachWriter 的例子來理解:

datasetOfString.write.foreach(new ForeachWriter[String] {
  def open(partitionId: Long, version: Long): Boolean = {
    // open a database connection
  }
  def process(record: String) = {
    // write string to connection
  }
  def close(errorOrNull: Throwable): Unit = {
    // close the connection
  }
})

12. Spark還包括幾個用於測試的數據源和sink,可用於流查詢的調試(僅在開發過程中使用而不能應用於生產,因爲它們不爲application提供端到端的容錯能力):

(1)Socket數據源。套接字(socket)數據源允許通過TCP套接字向流發送數據,通過指定要從中讀取數據的主機和端口來啓動它,Spark將打開一個新的TCP連接以從該地址讀取數據。在生產中不要使用Socket數據源,因爲Socket位於driver上,並且不提供端到端的容錯保證。下面是設置從地址端口爲localhost:9999的socket數據源讀取流數據的簡單示例:

val socketDF = spark.readStream.format("socket")
  .option("host", "localhost").option("port", 9999).load()

如果想將數據寫入application,則需要運行偵聽端口9999的服務器,在類Unix系統上可以使用NetCat工具,它可以往端口9999的連接中鍵入文本。在啓動Spark application之前運行下面的命令,然後在裏面寫入文本數據:

nc -lk 9999

Socket源將返回一個文本字符串的數據表,輸入數據中每一行對應數據表中的一行。

(2)控制檯接收器(Console sink)。它允許將一些流式查詢寫入控制檯,對調試很有用但它不支持容錯。寫到控制檯很簡單,將流查詢的一些行打印到控制檯即可,它支持append和complete輸出模式:

activityCounts.format("console").write()

(3)內存接收器(Memory sink)。設置內存接收器是測試流處理系統的一個簡單方法。它與控制檯接收器類似,它將數據收集到driver,然後使數據整理爲可用於交互式查詢的內存表。這個sink不是容錯的,所以不應該在生產中使用它,但是在開發過程中用於測試自然是很合適的。它支持append和complete輸出模式:

activityCounts.writeStream.format("memory").queryName("my_device_table")

如果要將數據輸出到表中,以便在生產中進行交互式SQL查詢,建議在分佈式文件系統上使用Parquet文件sink(例如S3),這樣就可以在任何Spark application中查詢數據。

13. 上面知道了數據輸出到哪裏,下面討論結果數據集輸出時的形式,這就是輸出模式(output mode),它與靜態DataFrame的保存模式是相同的概念。結構化流支持三種輸出模式:

(1)append模式。它是默認的模式,當新行添加到結果數據表時,它們將根據指定的trigger輸出到sink。假設sink提供容錯能力,此模式確保每行輸出一次(並且僅一次)。當使用帶有event time和watermarks的append模式時,只有最終結果纔會輸出到sink。

(2)complete模式將結果表的整個狀態輸出到sink。當使用有狀態數據時這就很有用,因爲每行都可能會隨時間而變化,也可能因爲正在寫入的sink不支持行級更新。可以把它想象爲在上一批量運行後流的狀態。

(3)update模式類似於complete模式,但只把與上一個批量輸出中不同的行纔會被寫出到sink,當然sink必須支持行級更新以支持此模式。如果查詢不包含聚合操作,則它和append模式是等價的。

Structured Streaming在有些情況是限制某種模式的,因爲要保證應用到查詢的模式必須有意義。例如如果查詢只是執行map操作,則Structured Streaming不允許complete模式,因爲這將要求它記住自job開始以來的所有輸入記錄並重寫整個輸出表,這會變得非常耗時。如果選擇的模式不可用,則在啓動流時Spark流將引發異常。

爲了控制數據何時輸出到sink,需要設置一個觸發器(Trigger。默認情況下當上一個trigger完成處理時,Structured Streaming將立即啓動數據。可以使用trigger來確保不輸出過多的更新數據以避免對sink造成太大的負載,也可以使用trigger來嘗試控制文件大小。目前有一種基於processing time的週期性trigger類型,以及一個手動控制的trigger類型來觸發一次數據,將來可能會增加更多的trigger。

(1)對於處理時間觸發器(processing time trigger),只需用字符串指定processing time週期(也可以使用Scala中的Duration或者Java中的TimeUnit):

import org.apache.spark.sql.streaming.Trigger

activityCounts.writeStream.trigger(Trigger.ProcessingTime("100 seconds"))
  .format("console").outputMode("complete").start()

Processing Time trigger將等待給定持續時間的倍數間隔才能輸出數據,例如觸發時間爲一分鐘,trigger將在12:00、12:01、12:02 等處觸發。如果由於先前的處理尚未完成而錯過觸發時間,則Spark將等待到下一個觸發點(如下一分鐘),而不是在上一次處理完成後立即運行。

(2)也可以設置一次性觸發器(once tirgger來運行流處理job,這可能看起來很奇怪但實際上非常有用。在開發過程中,可以一次只測試一個trigger的數據。在生產過程中,可以使用once trigger以較低的速率手動運行job(例如只是偶爾將新數據輸出到摘要表中)。由於Structured Streaming仍然完全跟蹤處理所有輸入文件和計算狀態,這比編寫自定義邏輯來跟蹤批處理作業更容易,並且比運行Flink那樣的continuous job節省了大量資源:

import org.apache.spark.sql.streaming.Trigger

activityCounts.writeStream.trigger(Trigger.Once())
  .format("console").outputMode("complete").start()

14. 關於Structured Streaming,不僅支持使用DataFrame API來處理流數據,還能以類型安全的方式使用Dataset執行相同的計算。可以將流式DataFrame轉換爲Dataset,這與靜態數據的處理方式相同。與靜態Dataset一樣,Dataset的元素需要是Scala case class或Java bean類。另外在流DataFrame和Dataset上的操作與在靜態數據上的操作方式相同,在流上運行時也會變成流式執行計劃。下面是一個示例,使用美國航班數據集:

case class Flight(DEST_COUNTRY_NAME: String, ORIGIN_COUNTRY_NAME: String,
  count: BigInt)
val dataSchema = spark.read
  .parquet("/data/flight-data/parquet/2010-summary.parquet/")
  .schema
val flightsDF = spark.readStream.schema(dataSchema)
  .parquet("/data/flight-data/parquet/2010-summary.parquet/")
val flights = flightsDF.as[Flight]
def originIsDestination(flight_row: Flight): Boolean = {
  return flight_row.ORIGIN_COUNTRY_NAME == flight_row.DEST_COUNTRY_NAME
}

flights.filter(flight_row => originIsDestination(flight_row))
  .groupByKey(x => x.DEST_COUNTRY_NAME).count()
  .writeStream.queryName("device_counts").format("memory").outputMode("complete")
  .start()

使用Structured Streaming是編寫流式application的有效方法,批處理job程序的代碼幾乎不需要怎麼改動,就可以轉換爲流處理job程序。

三、Event time和有狀態處理

15. event time是一個很重要的概念,而Spark Streaming的老版API即DStream不支持event time的處理。在流處理系統中,每個事件實際上有兩個相關的時間:數據本身發生的時間(event time),以及數據被處理或到達流處理系統的時間(processing time)。流處理系統面臨的挑戰是事件數據由於網絡等原因可能會延遲或亂序,所以必須能夠處理亂序或延遲到達的數據。

只有需要長時間使用或更新中間結果信息(狀態)時,才需要進行有狀態處理(在微批處理模型和麪向記錄模型中都是如此)。當使用event time或在鍵上執行聚合操作時,有狀態處理就可能會發生,無論是否涉及event time。大多數情況下當執行有狀態操作時,Spark會自動處理所有複雜的事情,例如在實現分組操作時,Structured Streaming會維護並更新信息,用戶只需指定處理邏輯。在執行有狀態操作時,Spark會將中間結果信息存儲在狀態存儲(state store)中,Spark當前的狀態存儲實現是一個內存狀態存儲,它通過將中間狀態存儲到checkpoint目錄來實現容錯

有狀態處理功能足以解決許多流處理問題,但是有時關於應該存儲什麼狀態、如何更新狀態、以及何時移除狀態(顯式指定或通過一個超時限制)等這些問題,需要進行細粒度控制,這叫做任意(或定製)有狀態處理,來用一些例子來說明:

(1)記錄有關電子商務網站上用戶會話的信息。例如可能希望跟蹤用戶在本次session過程中訪問的頁面,以便在下一次session中實時地提供建議。每個用戶session的啓動時間和停止時間可以是任意的。

(2)公司希望報告web應用程序中的錯誤,但僅在用戶session期間發生5個錯誤事件時纔會報告錯誤,可以使用基於計數的窗口來執行此操作,只有發生某種類型的事件5次時才輸出結果。

(3)希望刪除流數據裏的重複記錄,爲此需要在找到重複數據之前一直跟蹤看到的每條記錄。

下面的例子使用與之前相同的數據集,在使用event time時,它只是數據集中的一列,因此只需用列的方式去訪問即可:

spark.conf.set("spark.sql.shuffle.partitions", 5)
val static = spark.read.json("/data/activity-data")
val streaming = spark
  .readStream
  .schema(static.schema)
  .option("maxFilesPerTrigger", 10)
  .json("/data/activity-data")
streaming.printSchema()

root
|--Arrival_Time:long (nullable = true)
|--Creation_Time:long (nullable = true)
|--Device:string (nullable = true)
|--Index:long (nullable = true)
|--Model:string (nullable = true)
|--User:string (nullable = true)
|--gt:string (nullable = true)
|--x:double (nullable = true)
|--y:double (nullable = true)
|--z:double (nullable = true)

在此數據集中有兩個基於時間的列,Creation_Time列是事件的創建時間,而Arrival_Time是事件從上游某處到達服務器的時間,因此將前者作爲event time。

16. event time分析的第一步是將時間戳列轉換爲合適的SparkSQL時間戳類型。上面數據集中的時間列單位是納秒(表示爲long長整型),因此要做一些操作將其轉換成爲適當的格式:

val withEventTime = streaming.selectExpr(
  "*",
  "cast(cast(Creation_Time as double)/1000000000 as timestamp) as event_time)")

接下來最簡單的操作就是計算給定時間窗口中某事件的發生次數,即滾動窗口(tumbling window)。下圖描述了基於輸入數據和鍵執行簡單求和的過程:

這裏正在對某時間窗口內中的鍵值執行聚合。每次trigger運行時都會更新結果表(怎麼更新取決於輸出模式),這將根據自上次trigger以來收到的數據進行操作。本例在10分鐘的時間窗口中進行,並且這些時間窗口不會發生任何重疊(一個事件只可能落入一個時間窗口),並且結果也將實時更新,如果系統上游添加了新的事件,則Structured Streaming將相應地更新這些計數。這裏使用了complete輸出模式,因此無論是否處理完了整個數據集,Spark都會輸出整個結果表:

import org.apache.spark.sql.functions.{window, col}
withEventTime.groupBy(window(col("event_time"), "10 minutes")).count()
  .writeStream
  .queryName("events_per_window")
  .format("memory")
  .outputMode("complete")
  .start()

這裏把數據寫到了內存sink中以便於調試,可以在運行流處理之後使用SQL查詢它:

spark.sql("SELECT * FROM events_per_window").printSchema()

該查詢的結果如下,結果取決於在運行查詢時已經處理了的數據量:

+---------------------------------------------+-----+
|window |count|
+---------------------------------------------+-----+
|[2015-02-23 10:40:00.0,2015-02-23 10:50:00.0]|11035|
|[2015-02-24 11:50:00.0,2015-02-24 12:00:00.0]|18854|
...
|[2015-02-23 13:40:00.0,2015-02-23 13:50:00.0]|20870|
|[2015-02-23 11:20:00.0,2015-02-23 11:30:00.0]|9392 |
+---------------------------------------------+-----+

作爲參考,下面是從查詢中得到的模式schema:

root
|--window:struct (nullable = false)
| |--start:timestamp (nullable = true)
| |--end:timestamp (nullable = true)
|--count:long (nullable = false)

注意時間窗口實際上是一個結構體(一個複雜類型),使用此方法可以查詢該結構體以獲得特定時間窗口的開始時間和結束時間。重要的是可以在多個列上執行聚合操作,包括event time列,甚至可以使用cube之類的複雜分組方法來執行聚合。下面代碼涉及到執行多鍵聚合,但這確實適用於任何窗口式聚合(或有狀態計算):

import org.apache.spark.sql.functions.{window, col}
withEventTime.groupBy(window(col("event_time"), "10 minutes"), "User").count()
  .writeStream
  .queryName("events_per_window")
  .format("memory")
  .outputMode("complete")
  .start()

17. 前面例子是在給定窗口中簡單計數(滾動窗口),但有時多個時間窗口是可以重疊的,下圖的例子可以說明滑動窗口(Slide Window):

上圖中正在運行一個以1小時爲增量的滑動窗口,但希望每10分鐘獲得一次狀態,這意味着將隨着時間的推移更新這些值,並考慮之前1小時的數據。在這個例子中每隔5分鐘設置一個窗口,該窗口包括當前時刻往前10分鐘內的所有狀態,因此每個事件都將落到兩個不同的時間窗口:

import org.apache.spark.sql.functions.{window, col}
withEventTime.groupBy(window(col("event_time"), "10 minutes", "5 minutes"))
  .count()
  .writeStream
  .queryName("events_per_window")
  .format("memory")
  .outputMode("complete")
  .start()

然後按如下方法查詢內存表:

SELECT * FROM events_per_window

此查詢返回以下結果,要注意這裏每隔5分鐘就啓動一個時間窗口,而不是10分鐘,這樣就會產生兩個重疊的時間窗口,這與之前的例子不一樣:

+---------------------------------------------+-----+
|window |count|
+---------------------------------------------+-----+
|[2015-02-23 14:15:00.0,2015-02-23 14:25:00.0]|40375|
|[2015-02-24 11:50:00.0,2015-02-24 12:00:00.0]|56549|
...
|[2015-02-24 11:45:00.0,2015-02-24 11:55:00.0]|51898|
|[2015-02-23 10:40:00.0,2015-02-23 10:50:00.0]|33200|
+---------------------------------------------+-----+

18. 前面的例子雖然很好但有一個缺陷,就是未指定系統可以接受延遲多久的遲到數據,這意味着Spark需要永久存儲這些中間數據,如果指定了一個過期時間,數據遲到超過一定時間閾值將不會處理它,這適用於所有基於event time的有狀態處理,這個閾值稱爲水位(watermark這樣就可以免於由於長時間不清理過期數據而造成系統存儲壓力過大

具體來說,watermark是給定事件或事件集之後的一個時間長度,在該時間長度之後不希望再看到來自該時間長度之前的任何數據。這種數據到達延遲可能是由於網絡延遲、設備斷開連接等。在老DStream API中,過去沒有一種處理延遲數據的可靠方法,如果某個事件是在某個時間窗口發生的,但在對該時間窗口的批處理開始時該事件還未到達處理系統,則它將顯示在其他的批處理批次中。Structured Streaming可以處理這種問題,在基於event time的有狀態處理中,一個時間窗口的狀態或數據是與processing time解耦的

如果知道一般情況下通常會在幾分鐘內收到上游生產的數據,但偶爾也遇到過在事件發生後5個小時才收到數據的情況(可能是用戶失去了手機連接),可以按照以下方式指定watermark:

import org.apache.spark.sql.functions.{window, col}
withEventTime
  .withWatermark("event_time", "5 hours")
  .groupBy(window(col("event_time"), "10 minutes", "5 minutes"))
  .count()
  .writeStream
  .queryName("events_per_window")
  .format("memory")
  .outputMode("complete")
  .start()

這樣查詢語句幾乎沒有任何變化,只是增加了一個watermark配置。現在Structured Streaming會等待10分鐘窗口中最晚時間戳之後的5小時,然後才確定該窗口的結果。這裏可以查詢數據表並查看所有中間結果,因爲使用的是complete輸出模式,它們將隨着時間的推移而持續更新。而在append輸出模式下,結果直到窗口關閉時纔會輸出,如下所示:

SELECT * FROM events_per_window

+---------------------------------------------+-----+
|window |count|
+---------------------------------------------+-----+
|[2015-02-23 14:15:00.0,2015-02-23 14:25:00.0]|9505 |
|[2015-02-24 11:50:00.0,2015-02-24 12:00:00.0]|13159|
...
|[2015-02-24 11:45:00.0,2015-02-24 11:55:00.0]|12021|
|[2015-02-23 10:40:00.0,2015-02-23 10:50:00.0]|7685 |

如果沒有指定最多接受遲到多晚的數據,那麼Spark會永久將這些數據保留在內存中,指定watermark可使其從內存中釋放,從而使流處理能夠長時間持續運行

19. 在一次一記錄(record-at-a-time)系統中,更困難的操作之一是刪除數據流中的重複項,必須一次對一批記錄進行操作纔可能查找重複項,處理系統需要很高的協調開銷。重複項刪除是許多應用程序需要支持的一個重要能力,尤其是當消息可能從上游系統多次傳遞時。IoT應用程序就是這樣一個例子,上游產品在非穩定的網絡環境中生產消息,並且相同的消息可能會被多次發送,下游應用程序和聚合操作需要保證每個消息只出現一次。

Structured Streaming可以輕鬆地讓消息系統支持“at least once”的語義,並根據記錄任意鍵的方法來丟棄重複消息,將其轉換爲“exact once”的語義。爲了消除重複數據,Spark將維護許多用戶指定的鍵,並忽略重複項。與其他有狀態處理應用程序一樣,需要指定一個watermark以確保維護的狀態在流處理過程中不會無限增長。在下面的例子中,注意是如何將event time列指定爲需要去重的列,一個重要假設是重複事件具有相同的時間戳和標識符,具有兩個不同時間戳的行是兩個不同的記錄:

import org.apache.spark.sql.functions.expr

withEventTime
  .withWatermark("event_time", "5 seconds")
  .dropDuplicates("User", "event_time")
  .groupBy("User")
  .count()
  .writeStream
  .queryName("deduplicated")
  .format("memory")
  .outputMode("complete")
  .start()

結果如下所示,隨着讀取數據流中更多的數據,這個結果將隨着時間的推移而不斷更新:

+----+-----+
|User|count|
+----+-----+
| a| 8085|
| b| 9123|
| c| 7715|
| g| 9167|
| h| 7733|
| e| 9891|
| f| 9206|
| d| 8124|
| i| 9255|
+----+-----+

20. 上面演示了Spark如何根據用戶配置來維護信息和更新窗口,但是當有更復雜的窗口概念時情況會有所不同,而這是任意有狀態處理(Arbitrary Stateful Processing)的所長之處。執行有狀態處理時,可能需要執行以下操作:

(1)根據給定鍵的計數創建窗口。

(2)如果在特定時間範圍內發生多個特定事件,則發出警報。

(3)在不確定時間內維護用戶session,保存這些session以便稍後進行一些分析。

如果執行這類處理時,將需要執行以下兩件事情:

(1)映射數據中的組,對每組數據進行操作,併爲每個組生成至多一行。這個用例的相關API是mapGroups WithState。

(2)映射數據中的組,對每組數據進行操作,併爲每個組生成一行或多行。這個用例的相關API是flatMapGroups WithState。

當對每組數據進行操作時,可以獨立於其他數據組去任意更新該組,也就是說也可以定義不符合tumbling window或slide window的任意窗口類型。在執行這種處理方式時獲得的一個重要好處是可以控制配置狀態超時,使用time window和watermark非常簡單:當一個window開始前watermark已經超時,只需暫停該window。這不適用於任意有狀態處理,因爲是基於用戶定義的概念來管理狀態,所以需要適當地設置狀態超時。

超時時間是指在標記某個中間狀態爲超時(timeout)之前應該等待多長時間,超時時間是作用在每組上的一個全局參數。超時可以基於處理時間(GroupStateTimeout.ProcessingTimeTimeout)也可以基於事件時間(GroupStateTimeout.EventTimeTimeout)。要先檢查超時時間的設置,可以通過檢查state.hasTimedOut標誌或檢查值迭代器是否爲空來獲取此信息。

基於processing time的超時,可以通過調用GroupState.setTimeoutDuration設置超時時間。如果超時時間設置爲D毫秒,可以保證:

(1)在時鐘時間增加D ms之前,超時不會發生。

(2)當查詢中存在某個trigger在D ms之後,則最終會發生超時。因此超時最終發生的時間沒有嚴格的上限限制,例如查詢的觸發間隔會影響實際發生超時的時間,如果數據流中沒有任何數據(相對於某個組),就沒有觸發的機會,直到有數據時纔會發生超時函數調用

由於processing time超時是基於時鐘時間的,所以系統時鐘的不同會對超時有影響,這意味着時區不同和時鐘偏差是需要給予考慮的。基於event time的超時,用戶還必須指定watermark,設置後比watermark延遲更久的舊數據會被過濾掉。可以通過使用GroupState.setTimeoutTimestamp(...)的API設置超時時間戳,從而設置watermark應參考的時間戳,當watermark超出設定的時間戳時將發生超時。當然,可以通過指定較長的watermark來控制超時延遲,或者在處理流時動態更新超時時間。因爲可以用代碼來實現,所以可以針對特定組來更改超時時間,此超時提供的保證是,在watermark超過設置的超時時間之前,保證不會發生這種情況。

與processing time超時類似,當超時實際發生時延遲沒有嚴格的上限,在event time已經超時的情況下,只有當流中有數據時纔會提高watermark。值得強調的是,雖然超時很重要,但它們可能並不總是按預期的那樣運行。例如Spark2.2中Structured Streaming不支持異步job執行,這意味着不會在週期(epoch)結束和下一次開始之間輸出數據或超時數據,因爲它在那個時候不處理任何數據。此外,如果處理的一批數據中沒有記錄(這是批處理的批次,而不是組)則沒有更新,並且不可能有event time超時,這可能會在未來版本中改變。

21. 在使用這種任意有狀態處理時,需要注意並非所有輸出模式都被支持。隨着Spark的不斷改進,這肯定會發生變化,但在Spark2.2中mapGroupsWithState僅支持update輸出模式,而flatMapGroupsWithState支持append和update輸出模式。append模式意味着只有在超時(超過watermark)之後,數據纔會顯示在結果集中。但這不會自動發生,用戶需要負責輸出正確的一行或者多行。

mapGroupsWithState()類似UDAF,它將更新數據集作爲輸入,然後將其解析爲對應一個值集合的鍵,需要給出如下幾個定義:

(1)三個類定義:輸入定義、狀態定義、以及可選的輸出定義。

(2)基於鍵、事件迭代器和先前狀態的一個更新狀態函數。

(3)超時時間參數。

通過這些對象和定義,可以通過創建、隨時間更新以及刪除它來控制任意狀態。從一個基於定量的狀態更新key的例子開始,來看如何處理傳感器數據,要查找給定用戶在數據集中執行某項活動的第一個和最後一個時間戳,這意味着groupBy操作的key是user和activity組合。當使用mapGroupsWithState時,輸出始終是每個鍵(或組)只對應一行如果希望每個組都有多個輸出,則應該使用flatMapGroupsWithState。接下來建立上面說的輸入、狀態和輸出定義:

case class InputRow(user:String, timestamp:java.sql.Timestamp, activity:String)
case class UserState(user:String,
  var activity:String,
  var start:java.sql.Timestamp,
  var end:java.sql.Timestamp)

還有設置函數,定義如何根據給定行更新狀態:

def updateUserStateWithEvent(state:UserState, input:InputRow):UserState = {
  if (Option(input.timestamp).isEmpty) {
    return state
  }
  if (state.activity == input.activity) {

    if (input.timestamp.after(state.end)) {
      state.end = input.timestamp
    }
    if (input.timestamp.before(state.start)) {
      state.start = input.timestamp
    }
  } else {
    if (input.timestamp.after(state.end)) {
      state.start = input.timestamp
      state.end = input.timestamp
      state.activity = input.activity
    }
  }

  state
}

現在需要通過函數來定義根據每一批次行來更新狀態:

import org.apache.spark.sql.streaming.{GroupStateTimeout, OutputMode, GroupState}
def updateAcrossEvents(user:String,
  inputs: Iterator[InputRow],
  oldState: GroupState[UserState]):UserState = {
  var state:UserState = if (oldState.exists) oldState.get else UserState(user,
        "",
        new java.sql.Timestamp(6284160000000L),
        new java.sql.Timestamp(6284160L)
    )
  // we simply specify an old date that we can compare against and
  // immediately update based on the values in our data

  for (input <- inputs) {
    state = updateUserStateWithEvent(state, input)
    oldState.update(state)
  }
  state
}

當定義好了這些後,就可以通過傳遞相關信息來執行查詢。在指定mapGroupsWithState時,需要指定是否需要超時給定組的狀態,它告訴系統如果在一段時間後沒有收到更新的狀態應該做什麼。在這個例子中,希望無限期地維護狀態,因此指定Spark不會超時,使用update輸出模式以便獲得用戶活動的更新:

import org.apache.spark.sql.streaming.GroupStateTimeout
withEventTime
  .selectExpr("User as user",
    "cast(Creation_Time/1000000000 as timestamp) as timestamp", "gt as activity")
  .as[InputRow]
  .groupByKey(_.user)
  .mapGroupsWithState(GroupStateTimeout.NoTimeout)(updateAcrossEvents)
  .writeStream
  .queryName("events_per_window")
  .format("memory")
  .outputMode("update")
  .start()

以下是查詢結果:

+----+--------+--------------------+--------------------+
|user|activity| start| end|
+----+--------+--------------------+--------------------+
| a| bike|2015-02-23 13:30:...|2015-02-23 14:06:...|
| a| bike|2015-02-23 13:30:...|2015-02-23 14:06:...|
...
| d| bike|2015-02-24 13:07:...|2015-02-24 13:42:...|
+----+--------+--------------------+--------------------+

22. 典型的窗口操作是把落入開始時間和結束時間之內的所有事件都來進行計數或求和。但是有時候不是基於時間創建窗口,而是基於大量事件創建它們,而不考慮狀態和event time,並在該數據窗口上執行一些聚合。例如可能想要爲接收到的每500個事件計算一個值,而不管它們何時收到。

下一個示例分析用戶活動數據集,並定期輸出每個設備的平均讀數,根據事件計數創建一個窗口,並在每次爲該設備累積500個事件時輸出該累積結果,爲此程序定義了兩個case類:包括輸入行格式(它只是一個設備和一個時間戳),以及狀態和輸出行(其中包含收集記錄的當前計數、設備ID、窗口中事件數組)。下面是各種自描述的case類定義:

case class InputRow(device: String, timestamp: java.sql.Timestamp, x: Double)
case class DeviceState(device: String, var values: Array[Double],
  var count: Int)
case class OutputRow(device: String, previousAverage: Double)

現在可以定義一個函數,根據輸入的一行來更新狀態,也可以用許多其他方式編寫這個例子,這個例子幫助瞭解如何基於給定的行來更新狀態:

def updateWithEvent(state:DeviceState, input:InputRow):DeviceState = {
  state.count += 1
  // maintain an array of the x-axis values
  state.values = state.values ++ Array(input.x)
  state
}

現在來定義一個基於多輸入行的更新函數。在下面的示例中可以看到,有一個特定的鍵、包含一系列輸入的迭代器、還有舊的狀態,然後隨着時間推移在接收到新事件後持續更新這個舊狀態。它根據每個設備上發生事件的計數數量,針對每個設備返回更新的輸出行。這種情況非常簡單,即在一定事件數量之後,更新狀態並重置它,然後創建一個輸出行,可以在輸出表中看到此行:

import org.apache.spark.sql.streaming.{GroupStateTimeout, OutputMode,
  GroupState}

def updateAcrossEvents(device:String, inputs: Iterator[InputRow],
  oldState: GroupState[DeviceState]):Iterator[OutputRow] = {
  inputs.toSeq.sortBy(_.timestamp.getTime).toIterator.flatMap { input =>
    val state = if (oldState.exists) oldState.get
      else DeviceState(device, Array(), 0)

    val newState = updateWithEvent(state, input)
    if (newState.count >= 500) {
      // 某個窗口完成之後用空的DeviceState代替該狀態,並輸出舊狀態中過去500項的平均值
      oldState.update(DeviceState(device, Array(), 0))
      Iterator(OutputRow(device,
        newState.values.sum / newState.values.length.toDouble))
    }
    else {
      更新此處的DeviceState並不輸出任何結果記錄
      oldState.update(newState)
      Iterator()
    }
  }
}

現在可以運行流處理任務了,會注意到需要顯式說明輸出模式即append模式,還需要設置一個GroupStateTimeout,這個超時時間指定了窗口輸出的等待時間(即使它沒有達到所需的計數)。在這種情況下,如果設置一個無限的超時時間,則意味着如果一個設備一直沒有累積到要求的500計數閾值,該狀態將一直保持爲“不完整”並且不會輸出到結果表,在updateAcrossEvents函數中指定這兩個參數之後,就可以啓動流處理了:

import org.apache.spark.sql.streaming.GroupStateTimeout

withEventTime
  .selectExpr("Device as device",
    "cast(Creation_Time/1000000000 as timestamp) as timestamp", "x")
  .as[InputRow]
  .groupByKey(_.device)
  .flatMapGroupsWithState(OutputMode.Append,
    GroupStateTimeout.NoTimeout)(updateAcrossEvents)
  .writeStream
  .queryName("count_based_device")
  .format("memory")
  .outputMode("append")
  .start()

啓動流後,就可以執行實時查詢。結果如下:

SELECT * FROM count_based_device

+--------+--------------------+
| device| previousAverage|
+--------+--------------------+
|nexus4_1| 4.660034012E-4|
|nexus4_1|0.001436279298199...|
...
|nexus4_1|1.049804683999999...|
|nexus4_1|-0.01837188737960...|
+--------+--------------------+

可以看到每個窗口的統計值都在變化,新的統計結果加入到了結果集中。

23. 第二個有狀態處理的示例使用flatMapGroupsWithState,這與mapGroupsWithState非常相似,不同之處在於一個鍵不是隻對應最多一個輸出,而是一個鍵可以對應多個輸出。這可提供更多的靈活性,並且與mapGroupsWithState具有相同的基本結構,需要定義下面幾項:

(1)三個類定義:輸入定義、狀態定義、以及可選的輸出定義。

(2)一個函數,輸入參數爲一個鍵、一個多事件迭代器、和先前狀態。

(3)超時事件參數(如前面超時時間部分所述)。

通過這些對象和定義,可以通過創建狀態、(隨着時間推移)更新狀態、以及刪除狀態來控制任意狀態。接下來看一個會話化(Sessionization)的例子。會話是未指定的時間窗口,其中可能發生一系列事件。通常需要將這些不同的事件記錄在數組中,以便將其與將來的其他會話進行比較。在一次會話中,用戶可能會設計各種不同的邏輯來維護和更新狀態,以及定義窗口何時結束(如計數)或通過簡單的超時來結束。以前面的例子爲基礎,並把它更嚴格地定義爲一個會話。

有時候可能有一個明確的會話ID,可以在函數中使用它。這是很容易實現的,因爲只是執行簡單的聚合,甚至可能不需要自定義的有狀態邏輯。在下面這個例子中,將根據用戶ID和一些時間信息即時創建會話,並且如果在五秒內沒有看到該用戶的新事件則會話將終止,注意此代碼使用超時的方式與其他示例中的不同,可以遵循創建類的相同流程,定義單個事件更新函數,然後定義多事件更新函數:

case class InputRow(uid:String, timestamp:java.sql.Timestamp, x:Double,
  activity:String)
case class UserSession(val uid:String, var timestamp:java.sql.Timestamp,
  var activities: Array[String], var values: Array[Double])
case class UserSessionOutput(val uid:String, var activities: Array[String],
  var xAvg:Double)

def updateWithEvent(state:UserSession, input:InputRow):UserSession = {
  // handle malformed dates
  if (Option(input.timestamp).isEmpty) {
    return state
  }

  state.timestamp = input.timestamp
  state.values = state.values ++ Array(input.x)
  if (!state.activities.contains(input.activity)) {
    state.activities = state.activities ++ Array(input.activity)
  }
  state
}

import org.apache.spark.sql.streaming.{GroupStateTimeout, OutputMode,
  GroupState}

def updateAcrossEvents(uid:String,
  inputs: Iterator[InputRow],
  oldState: GroupState[UserSession]):Iterator[UserSessionOutput] = {

  inputs.toSeq.sortBy(_.timestamp.getTime).toIterator.flatMap { input =>
    val state = if (oldState.exists) oldState.get else UserSession(
    uid,
    new java.sql.Timestamp(6284160000000L),
    Array(),
    Array())
    val newState = updateWithEvent(state, input)

    if (oldState.hasTimedOut) {
      val state = oldState.get
      oldState.remove()
      Iterator(UserSessionOutput(uid,
      state.activities,
      newState.values.sum / newState.values.length.toDouble))
    } else if (state.values.length > 1000) {
      val state = oldState.get
      oldState.remove()
      Iterator(UserSessionOutput(uid,
      state.activities,
      newState.values.sum / newState.values.length.toDouble))
    } else {
      oldState.update(newState)
      oldState.setTimeoutTimestamp(newState.timestamp.getTime(), "5 seconds")
      Iterator()
    }

  }
}

可以看到只希望看到最多延遲5秒的事件,將忽略太晚到達的事件。在這個有狀態操作中將使用EventTimeTimeout來基於event time設置超時:

import org.apache.spark.sql.streaming.GroupStateTimeout

withEventTime.where("x is not null")
  .selectExpr("user as uid",
    "cast(Creation_Time/1000000000 as timestamp) as timestamp",
    "x", "gt as activity")
  .as[InputRow]
  .withWatermark("timestamp", "5 seconds")
  .groupByKey(_.uid)
  .flatMapGroupsWithState(OutputMode.Append,
    GroupStateTimeout.EventTimeTimeout)(updateAcrossEvents)
  .writeStream
  .queryName("count_based_device")
  .format("memory")
  .start()

查詢此表將顯示此時間段內每個用戶的輸出行:

SELECT * FROM count_based_device

+---+--------------------+--------------------+
|uid| activities| xAvg|
+---+--------------------+--------------------+
| a| [stand,null,sit]|-9.10908533566433...|
| a| [sit,null,walk]|-0.00654280428601...|
...
| c|[null,stairsdown...|-0.03286657789999995|
+---+--------------------+--------------------+

正如所預料的那樣,其中包含許多活動的會話比具有較少活動的會話具有更高的x軸平均值。

四、生產中的Structured Streaming

24. 流處理application中最需要重視的問題是故障恢復。故障是不可避免的,可能會丟失集羣中的一臺機器、在沒有適當遷移的情況下數據模式schema發生意外更改、或者可能需要重新啓動集羣或應用程序。在上面任何一種情況下,Structured Streaming允許僅通過重新啓動application來恢復它,爲此必須將application配置爲使用checkpoint和預寫日誌,這兩者都由引擎自動處理。

具體來說,用戶必須配置查詢以寫入可靠文件系統上的checkpoint位置(例如HDFS、S3或其他兼容文件系統)。然後,Structured Streaming將週期性地保存所有相關進度信息(例如一個trigger操作處理的offset範圍),以及當前中間狀態值,將它們保存到checkpoint路徑下。在失敗情況下只需重新啓動application,確保指向相同的checkpoint位置,它將自動恢復其狀態並在其中斷的位置重新開始處理數據,用戶不必手動管理此狀態,Structured Streaming可以自動完成這些過程。

要使用checkpoint,請在啓動application之前通過writeStream上的checkpointLocation選項指定checkpoint位置。如下所示:

val static = spark.read.json("/data/activity-data")
val streaming = spark
  .readStream
  .schema(static.schema)
  .option("maxFilesPerTrigger", 10)
  .json("/data/activity-data")
  .groupBy("gt")
  .count()
val query = streaming
  .writeStream
  .outputMode("complete")
  .option("checkpointLocation", "/some/location/")
  .queryName("test_stream")
  .format("memory")
  .start()

如果丟失了checkpoint目錄或它內部的信息,則application將無法從故障中恢復,用戶將不得不從頭開始重新啓動流處理。

25. checkpoint對於生產中運行application來說是最重要的事情,這是因爲checkpoint將存儲所有的信息,包括流到目前爲止已處理的所有信息,以及它可能存儲的中間狀態等。但是checkpoint有一個小問題,當更新流處理application時,不得不對舊的checkpoint數據進行推理。在更新Spark時,必須確保該更新不是一箇中斷性的更改。下面針對兩種更新類型進行詳細介紹:更新application代碼、運行新的Spark版本:

(1)Structured Streaming允許在application兩次重啓之間對代碼進行某些更改,最重要的是隻要具有相同的類型簽名,就可以更改UDF,此功能對於bug修復非常有用。例如假設application開始接收新類型的數據,並且當前邏輯中的某個數據解析函數崩潰,使用Structured Streaming可以使用該函數的新版本重新編譯application,並在之前流處理的崩潰位置開始流處理。

儘管像添加新列或更改UDF這樣的小調整不算是破壞性的改變,並且不需要新的checkpoint目錄,但有某些更大的更改需要新的檢查點目錄,例如如果修改代碼以添加用於聚合操作的新鍵,或甚至更改查詢本身,則Spark無法爲新查詢從舊的checkpoint目錄構建所需的狀態。在這些情況下Structured Streaming將會拋出一個異常,表示無法從checkpoint目錄開始,必須從頭開始用一個新的(空)目錄作爲檢查點位置

(2)對於基於補丁(patch)的版本更新,Structured Streaming應用程序應該能夠從舊checkpoint目錄重新啓動(例如從Spark 2.2.0遷移到2.2.1到2.2.2)。checkpoint格式被設計爲向前兼容,因此它可能被破壞的原因是由於關鍵bug修復。如果某個Spark發行版無法從舊版本checkpoint恢復,則會在其發行說明中明確說明。

一般而言,集羣的大小應該能夠輕鬆處理超出數據速率突發的情況,用戶應該在應用程序和集羣中監視下面所述的關鍵指標。一般來說,如果輸入速率比處理率高得多,那麼就需要優化代碼與參數、擴展集羣資源等。根據資源管理器和部署情況,可能需將Executor動態添加到application中,這些更改可能會導致一些處理延遲(因爲當executor被刪除時,數據會被重新計算或分區)。

雖然有時需要對集羣或application進行底層結構的更改,但有時候的更改可能只需要用新的配置來重新啓動application或流。例如在流處理正在運行時,不支持更改spark.sql.shuffle.partitions配置(即使更改但實際上也不會生效並改變shuffle的併發度),這需要重新啓動流處理而不一定是整個application,更重量級的更改(如更改application配置)可能需要重新啓動application。

26. 流處理application中的metric和監視與離線批處理Spark application的度量(Metrics)與監視(Monitoring)工具相同,但是Structured Streaming增加了更多選項,可以用兩個關鍵API來查詢流查詢的狀態,並查看其最近的執行進度。查詢狀態(query status)是最基本的監視API,它回答“流正在執行什麼處理?”這樣的問題,此信息可以在startStream返回的查詢對象的status字段中報告。例如有一個簡單的計數流,它提供由下列查詢定義的IOT設備的計數(這裏我們使用第三節中的相同查詢,省略了初始化代碼):

query.status

要獲取給定查詢的結果狀態,只需運行query.status命令即可返回流的當前狀態,它返回數據流在那個點當前正在發生事情的細節。以下是查詢此狀態時將返回的結果:

{
    "message" :"Getting offsets from ...",
    "isDataAvailable" :true,
    "isTriggerActive" :true
}

上面的JSON段描述了從結構化流數據源獲取偏移量(因此“message”字段描述爲獲取偏移量)。有各種各樣的消息來描述流的狀態。在這裏顯示了在Spark shell中調用status命令的方式,但是對於獨立application,可以通過實現監視服務器來顯示其狀態,例如一個監聽某端口的小型HTTP服務器,該服務器在監聽端口收到請求時返回query.status,或者可以使用後面描述的StreamingQueryListener API來監聽更多事件,它提供更豐富的功能接口。

雖然查詢當前狀態很有用,但查看流執行進度的能力同樣重要。進度API(progress API)可以回答諸如“處理元組的速度怎樣?”或“元組從數據源到達的速度有多快?”等問題。通過運行query.recentProgress將獲得更多基於時間的信息,如處理速率和批處理持續時間。流查詢的進度信息還包括有關數據流的輸入源和輸出sink的信息:

query.recentProgress

以下是運行代碼之後的版本的結果,版本的結果類似:

Array({
    "id" :"d9b5eac5-2b27-4655-8dd3-4be626b1b59b",
    "runId" :"f8da8bc7-5d0a-4554-880d-d21fe43b983d",
    "name" :"test_stream",
    "timestamp" :"2017-08-06T21:11:21.141Z",
    "numInputRows" :780119,
    "processedRowsPerSecond" :19779.89350912779,
    "durationMs" :{
        "addBatch" :38179,
        "getBatch" :235,
        "getOffset" :518,
        "queryPlanning" :138,
        "triggerExecution" :39440,
        "walCommit" :312
    },
    "stateOperators" :[ {
        "numRowsTotal" :7,
        "numRowsUpdated" :7
    } ],
    "sources" :[ {
        "description" :"FileStreamSource[/some/stream/source/]",
        "startOffset" :null,
        "endOffset" :{
            "logOffset" :0
        },
    "numInputRows" :780119,
    "processedRowsPerSecond" :19779.89350912779
} ],
    "sink" :{
        "description" :"MemorySink"
    }
})

正如從上面所顯示輸出中看到的,這包括有關流狀態的許多詳細信息。需要注意的是,這是一個某個時間點的快照(根據詢問進度的時間)。爲了持續獲得有關流處理的狀態,需要重複執行此API查詢以獲取更新的狀態。查詢結果輸出中的大部分字段是容易理解的,但是來詳細介紹一下更爲重要的字段。

27. 在監控metrics信息中,輸入速率(input rate)是指數據從輸入源流向Structured Streaming系統的速度,處理速率(process rate)是application處理分析數據的速度。在理想的情況下輸入速率和處理速率應變化一致。另一種情況是輸入速率遠大於處理速率,當這種情況發生時流處理就延遲落後了,需要擴展集羣以處理更大的負載。

一些流式傳輸系統使用微批處理以任何合理的吞吐量運行(有些可選擇高延遲以換取更高的吞吐量)。Structured Streaming實現了兩者,隨着時間的推移Structured Streaming會以不同的批量大小來處理事件,因此可能會看到微批處理每個批次處理時間的持續變化。當執行選項爲連續處理引擎(continuous processing engine)時,此度量值就沒有太大意義。

一般來說,最好的做法是將每個微批次的持續時間以及輸入和處理速率的變化用可視化的方法顯示出來,相對於持續的文本報告,可視化方法可以更好幫助用戶理解數據流的狀態。在Spark UI上,每個流處理application將顯示爲一系列短job,每個都有一個trigger,可以使用相同的UI來查看application中的指標、查詢計劃、task持續時間和日誌。DStream API與Structured Streaming的一個區別是Structured Streaming不使用流標籤(Streaming Tab)。

瞭解和查看結構化流查詢的metrics是重要的第一步,這涉及到持續監視儀表板和各種指標,以發現潛在的問題。同時還需要強大的自動警報功能,以便在job失敗時通知用戶,或者在處理速率跟不上輸入數據速率的情況下通知,整個過程是自動的不需要人工參與。將現有告警工具與Spark集成有幾種方法,通常是基於之前說的近期進度(recent progress)API,例如可以直接將metrics提供給監控系統如開源的CodaHale Metrics庫或Prometheus,或者可以簡單地記錄它們並使用Splunk等日誌聚合系統。除了對查詢過程進行監視和告警之外,還可以監視集羣運行狀態和整個application並提供告警服務。

可以使用queryProgress API將監視事件輸出到所選的監視平臺上(例如日誌聚合系統或Prometheus儀表板)。除了這些方法之外,還有一種更低級但更強大的方式來監視application的執行過程,這就是使用StreamingQueryListener。StreamingQueryListener類允許從流查詢中接收異步更新,以便自動將此信息輸出到其他系統,並實現強大的監視和警報機制。

首先要開發自己的對象來擴展StreamingQueryListener,然後將其附加到正在運行的SparkSession。一旦使用sparkSession.streams.addListener()附加了自定義偵聽器,自定義類將在查詢開始或停止時收到通知,或者在active狀態查詢上有進度變化時收到通知。以下是Structured Streaming文檔的一個監聽器示例:

val spark: SparkSession = ...

spark.streams.addListener(new StreamingQueryListener() {
    override def onQueryStarted(queryStarted: QueryStartedEvent): Unit = {
        println("Query started: " + queryStarted.id)
    }
    override def onQueryTerminated(
      queryTerminated: QueryTerminatedEvent): Unit = {
        println("Query terminated: " + queryTerminated.id)
    }
    override def onQueryProgress(queryProgress: QueryProgressEvent): Unit = {
        println("Query made progress: " + queryProgress.progress)
    }
})

流偵聽器使用戶可以用自定義代碼處理每次進度更新或狀態更改,並將其傳遞給外部系統。例如以下StreamingQueryListener的實現代碼是用於將所有查詢進度信息轉發給Kafka,一旦從Kafka讀取數據獲取實際metric指標,必須解析此JSON字符串:

class KafkaMetrics(servers: String) extends StreamingQueryListener {
  val kafkaProperties = new Properties()
  kafkaProperties.put(
    "bootstrap.servers",
    servers)
  kafkaProperties.put(
    "key.serializer",
    "kafkashaded.org.apache.kafka.common.serialization.StringSerializer")
  kafkaProperties.put(
    "value.serializer",
    "kafkashaded.org.apache.kafka.common.serialization.StringSerializer")

  val producer = new KafkaProducer[String, String](kafkaProperties)

  import org.apache.spark.sql.streaming.StreamingQueryListener
  import org.apache.kafka.clients.producer.KafkaProducer

  override def onQueryProgress(event:
    StreamingQueryListener.QueryProgressEvent): Unit = {
    producer.send(new ProducerRecord("streaming-metrics",
      event.progress.json))
  }
  override def onQueryStarted(event:
    StreamingQueryListener.QueryStartedEvent): Unit = {}
  override def onQueryTerminated(event:
    StreamingQueryListener.QueryTerminatedEvent): Unit = {}
}

通過使用StreamingQueryListener接口,甚至可以運行Structured Streaming application來監視同一個(或另一個)集羣上的Structured Streaming application,也可以用這種方式管理多個流。

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