本文介紹了以 Pulsar 做流數據平臺,使用 Spark 進行批流一體數據處理的編程實踐。
(閱讀本文需要約 15 分鐘)
批流現狀
在大規模并行數據分析領域,AMPLab 的『One stack to rule them all』提出用 Apache Spark 作為統一的引擎支持批處理、流處理、交互查詢和機器學習等常見的數據處理場景。 2017 年 7 月,Spark 2.2.0 版本正式推出的 Spark structured streaming 將 Spark SQL 作為流處理、批處理底層統一的執行引擎,提供對無界表(無邊界的源源不斷到達的流數據)和有界表(靜態歷史數據)的優化查詢,而向用戶提供 Dataset/DataFrame API 對批流數據聯合處理,進一步模糊了批流數據處理的邊界。
另一方面,Apache Flink 在 2016 年左右進入大眾視野,憑借其當時更優的流處理引擎,原生的 Watermark 支持『Exaclty Once』的數據一致性保證,和批流一體計算等各種場景的支持,成為 Spark 的有力挑戰者。無論是使用 Spark 還是 Flink,用戶真正關心的是如何更好地使用數據,更快地挖掘數據中的價值,流數據和靜態數據不再是分離的個體,而是一份數據的兩種不同表征方式。
然而在實踐中,構建一個批流一體的數據平臺并不只是計算引擎層的任務。因為在傳統解決方案中,近實時的流、事件數據通常采用消息隊列(例如 RabbitMQ)、實時數據管道(例如 Apache Kafka)存儲,而批處理所需要的靜態數據通常使用文件系統、對象存儲進行保存。這就意味著,一方面,在數據分析過程中,為了保證結果的正確性和實時性,需要對分別存儲在兩類系統中數據進行聯合查詢;另一方面,在運維過程中,需要定期將流數據轉存到文件/對象存儲中,通過維持流形式的數據總量在閾值之下來保證消息隊列、數據管道的性能(因為這類系統的以分區為主的架構設計緊耦合了消息服務和消息存儲,而且多數都太過依賴文件系統,隨著數據量的增加,系統性能會急劇下降),但人為的數據搬遷不但會提升系統的運維成本,而且搬遷過程中的數據清洗、讀取、加載也是對集群資源的巨大消耗。
與此同時,從 Mesos 和 YARN 的流行、Docker 的興起到現在的 Kubernetes 被廣泛采用,整個基礎架構正在全面地向容器化方向發展,傳統緊耦合消息服務和消息計算的架構并不能很好地適應容器化的架構。以 Kafka 為例,其以分區為中心的架構緊耦合了消息服務和消息存儲。Kafka 的分區與一臺或者一組物理機強綁定,這帶來的問題是在機器失效或集群擴容中,需要進行昂貴且漫長的分區數據重新均衡的過程;其以分區為粒度的存儲設計也不能很好利用已有的云存儲資源;此外,過于簡單的設計導致其為了進行容器化需要解決多租戶管理、IO 隔離等方面很多架構上的缺陷。
Pulsar 簡介
Apache Pulsar 是一個多租戶、高性能的企業級消息發布訂閱系統,最初由 Yahoo 研發, 2018 年 9 月從 Apache 孵化器畢業,成為 Apache 基金會的頂級開源項目。Pulsar 基于發布訂閱模式(pub-sub)構建,生產者(producer)發布消息(message)到主題(topic),消費者可以訂閱主題,處理收到的消息,并在消息處理完成后發送確認(Ack)。Pulsar 提供了四種訂閱類型,它們可以共存在同一個主題上,以訂閱名進行區分:
- 獨享(exclusive)訂閱——一個訂閱名下同時只能有一個消費者。
- 共享(shared)訂閱——可以由多個消費者訂閱,每個消費者接收其中一部分消息。
- 失效備援(failover)訂閱——允許多個消費者連接到同一個主題,但只有一個消費者能夠接收消息。只有在當前消費者發生失效時,其他消費者才開始接收消息。
- 鍵劃分(key-shared)訂閱(測試版功能)——多個消費者連接到同一主題,相同 Key 總會發送給同一個消費者。
Pulsar 從設計之初就支持多租戶(multi-tenancy)的概念,租戶(tenant)可以橫跨多個集群(clusters),每個租戶都有其認證和鑒權方式,租戶也是存儲配額、消息生存時間(TTL)和隔離策略的管理單元。Pulsar 多租戶的特性可以在 topic URL 上得到充分體現,其結構是persistent://tenant/namespace/topic
。命名空間(namespace)是 Pulsar 中最基本的管理單元,我們可以設置權限、調整復制選項、管理跨集群的數據復制、控制消息的過期時間或執行其他關鍵任務。
Pulsar 獨特架構
Pulsar 和其他消息系統的最根本區別在于其采用計算和存儲分離的分層架構。Pulsar 集群由兩層組成:無狀態服務層,它由一組接受和傳遞消息的 broker 組成;分布式存儲層,它由一組名為 bookies 的 Apache BookKeeper 存儲節點組成,具備高可用、強一致、低延時的特點。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-nKUt0340-1570764602625)(media/pulsar-spark/pulsar-partition-log-segment.png)]
和 Kafka 一樣,Pulsar 也是基于主題分區(Topic partition)的邏輯概念進行主題數據的存儲。不同的是,Kafka 的物理存儲也是以分區為單位,每個 partition 必須作為一個整體(一個目錄)被存儲在一個 broker 上,而 Pulsar 的每個主題分區本質上都是存儲在 BookKeeper 上的分布式日志,每個日志又被分成分段(Segment)。每個 Segment 作為 BookKeeper 上的一個 Ledger,均勻分布并存儲在多個 bookie 中。存儲分層的架構和以 Segment 為中心的分片存儲是 Pulsar 的兩個關鍵設計理念。以此為基礎為 Pulsar 提供了很多重要的優勢:無限制的主題分區、存儲即時擴展,無需數據遷移 、無縫 broker 故障恢復、無縫集群擴展、無縫的存儲(Bookie)故障恢復和獨立的可擴展性。
消息系統解耦了生產者與消費者,但實際的消息本質上仍是有結構的,因此生產者和消費者之間需要一種協調機制,達到生產、消費過程中對消息結構的共識,以達到類型安全的目的。Pulsar 有內置的 Schema 注冊方式在消息系統端提供傳輸消息類型約定的方式,客戶端可以通過上傳 Schema 來約定主題級別的消息類型信息,而由 Pulsar 負責消息的類型檢查和有類型消息的自動序列化、反序列化,從而降低多應用間的消息解析代碼反復開發、維護的成本。當然,Schema 定義與類型安全是一種可選的機制,并不會給非類型化消息的發布、消費產生任何性能開銷。
在 Spark 中實現對 Pulsar 數據的讀寫——Spark Pulsar Connector
自 Spark 2.2 版本 Structured Streaming 正式發布,Spark 只保留了 SparkSession
作為主程序入口,你只需編寫 DataSet/DataFrame API 程序,以聲明形式對數據的操作,而將具體的查詢優化與批流處理執行的細節交由 Spark SQL 引擎進行處理。對于一個數據處理作業,需要定義 DataFrame 的產生、變換和寫出三個部分,而將 Pulsar 作為流數據平臺與 Spark 進行集成正是要解決如何從 Pulsar 中讀取數據(Source)和如何向 Pulsar 寫出運算結果(Sink)兩個問題。
為了實現以 Pulsar 為源讀取批流數據與支持批流數據向 Pulsar 的寫入,我們構建了 Spark Pulsar Connector。
對 Structured Streaming 的支持
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-qMYfG5Hn-1570764602651)(media/pulsar-spark/ssc.png)]
上圖展示了 Structured Streaming(以下簡稱 SS )的主要組件:
- 輸入和輸出——為了提供細粒度的容錯,SS 要求輸入數據源(Source)是可重放(replayable)的;為了提供端到端的 Exactly-Once 的語義,需要輸出(Sink)支持冪等寫出(一條消息被多次寫入與一次寫入效果一致,可由 DBMS、KV 系統通過鍵約束的方式支持)。
- API——用戶通過編寫 Spark SQL 的 batch API(SQL 或 DataFrame)指定對一個或多個流、表的查詢,并定義一個輸出表保存所有的輸出結果,而引擎內部決定如何將結果增量地寫到 Sink 中。為了支持流處理,SS 在原有的 Spark SQL API 上添加了一些接口:
- 觸發器(Trigger)——控制引擎觸發流處理執行、在Sink中更新結果的頻率。
- 水印機制(Watermark policy)——用戶通過指定字段做 event time,來決定對晚到數據的處理。
- 有狀態算子(stateful operator)——用戶可以根據 Key 跟蹤和更新算子內部的可變狀態,完成復雜的業務需求(例如,基于會話的窗口)。
- 執行層——當收到一個查詢時,SS 決定它的增量執行方式,進行優化、并開始執行。SS 有兩種可選的執行模型:
- Microbatch model(微批處理模式)——默認的執行方式,與 Spark Streaming 的 DStream 類似,將流切成 micro batch,對每個 batch 分別處理。這種模式支持動態負載均衡、故障恢復等機制,適合將吞吐率作為主要性能指標的應用。
- Continuous mode(持續模式)——在集群上啟動長時間運行的算子,適合處理較為簡單、延遲敏感類應用。
- Log 和 State Store —— SS 利用兩種持久化存儲來提供容錯保障:一個 Write-ahead-Log(WAL),記錄被成功消費且持久化寫出的每個數據源中的位置;一個大規模的 state store, 存儲長期運行的聚集算子內部的狀態快照。當故障發生時,SS 會根據快照的位置,通過重放之后的消息完成流處理狀態的恢復。
具體到源碼層面,Source 接口定義了可重放數據源需要提供的功能。
trait Source {
def schema: StructType
def getOffset: Option[Offset]
def getBatch(start: Option[Offset], end: Offset): DataFrame
def commit(end: Offset): Unit
def stop(): Unit
}
trait Sink {
def addBatch(batchId: Long, data: DataFrame): Unit
}
以 microbatch 執行模式為例:
- 在每個 microbatch 的最開始,SS 會向 source 詢問當前的最新進度(
getOffset
),并將其持久化到 WAL 中。 - 隨后,source 根據 SS 提供的
start
end
偏移量,提供區間范圍的數據(getBatch
)。 - SS 觸發計算邏輯的優化和編譯,把計算結果寫出給 sink(addBatch),這時才觸發實際的取數據操作以及計算過程。
- 在數據完整寫出到 sink 后,SS 通知 source 可以廢棄數據(
commit
),并將成功執行的batchId
寫入內部維護的 commitLog 中。
具體到 Pulsar 的 connector 實現中:
- 在所有批次開始執行前,SS 會調用 schema 方法返回消息的結構信息,在 schema 方法內部,我們從 Pulsar 的 Schema Registry 提取出所有主題的 Schema,并進行一致性檢查。
- 隨后,我們為每個主題分區創建一個消費者,按照 (start, end] 返回主題分區中的數據。
- 當收到 SS 的 commit 通知時,通過
topics
中的resetCursor
向 Pulsar 標志消息消費的完成。Sink 中構建的生產者則將 addBatch 中獲取的實際數據以消息形式追加寫入相應的主題中。
對批處理作業的支持
在某個時間點執行的批作業,可以看作是對 Pulsar 平臺中的流數據在一個時間點的快照進行的數據分析。Spark 對歷史數據的查詢是以 Relation 為單位,Spark Pulsar Connector 提供 createRelation
方法的實現根據用戶指定的多個主題分區構建表,并返回包含 Schema 信息的 DataSet。在查詢計劃階段,Connector 的功能分成兩步:首先,根據用戶提供的一個或多個主題,在 Pulsar Schema Registry 中查找主題 Schema,并檢查多個主題 Schema 的一致性;其次,將用戶指定的所有主題分區進行任務劃分(Partition),得到的分片即是 Spark source task 的執行粒度。
Pulsar 提供了兩層的接口對其中的數據進行訪問,基于主題分區的 Consumer/Reader 接口,以傳統消息接收為語義的順序數據讀??;Segment 級的讀接口,提供對 Segment 數據的直接讀取。因此,相應地從 Pulsar 讀數據執行批作業可以分成兩種粒度(即讀取數據的并行度)進行:以主題分區為粒度(每個主題分區作為一個分片);以 Segment 為粒度(將一個主題分區的多個 Segment 組織成一個分片,因此一個主題分區會有多個對應的分片)。你可以按照批作業的并行度需求和可分配計算資源選擇合適的消息讀取的并行粒度。另一方面,將批作業的執行存儲到 Pulsar 也很直觀,你只需指定寫入的主題和消息路由規則(RoundRobin 或者按 Key 劃分),在 Sink task 中創建的每個生產者會將待寫出的消息送至對應的主題分區。
如何使用 Spark Pulsar Connector
- 根據一個或多個主題創建流處理 Source。
val df = spark
.readStream
.format("pulsar")
.option("service.url", "pulsar://localhost:6650")
.option("admin.url", "http://localhost:8080")
.option("topicsPattern", "topic.*") // Subscribe to a pattern
// .option("topics", "topic1,topic2") // Subscribe to multiple topics
// .option("topic", "topic1"). //subscribe to a single topic
.option("startingOffsets", startingOffsets)
.load()
df.selectExpr("CAST(__key AS STRING)", "CAST(value AS STRING)")
.as[(String, String)]
- 構建批處理 Source。
val df = spark
.read
.format("pulsar")
.option("service.url", "pulsar://localhost:6650")
.option("admin.url", "http://localhost:8080")
.option("topicsPattern", "topic.*")
.option("startingOffsets", "earliest")
.option("endingOffsets", "latest")
.load()
df.selectExpr("CAST(__key AS STRING)", "CAST(value AS STRING)")
.as[(String, String)]
- 使用數據中本身的 topic 字段向多個主題進行持續 Sink。
val ds = df
.selectExpr("topic", "CAST(__key AS STRING)", "CAST(value AS STRING)")
.writeStream
.format("pulsar")
.option("service.url", "pulsar://localhost:6650")
.start()
- 將批處理結果寫回 Pulsar。
df.selectExpr("CAST(__key AS STRING)", "CAST(value AS STRING)")
.write
.format("pulsar")
.option("service.url", "pulsar://localhost:6650")
.option("topic", "topic1")
.save()
注意
由于 Spark Pulsar Connector 支持結構化消息的消費和寫入,為了避免消息負載中字段和消息元數據(event time、publish time、key 和 messageId)的潛在命名沖突,消息元數據字段在 Spark schema 中以雙下劃線做為前綴(例如,__eventTime)。
參考資料