Github 上的開源項目 Waterdrop,此項目Star + Fork的有將近1200人,是一個基於Spark和Flink構建的生產環境的海量數據計算產品。Waterdrop的特性包括
- 簡單易用,靈活配置,無需開發;
- 同時支持流式和離線處理;
- 模塊化和插件化,易於擴展;
- 支持利用SQL做數據處理和聚合;
- 支持選擇Spark或Flink作爲底層引擎層。
作爲 Spark 或者 Flink 的開發者,你是否也曾經想過要打造這樣一款通用的計算引擎,是是否曾經有這樣的疑問,Waterdrop爲什麼能實現這麼多實用又吸引人的特性呢?
哈哈哈,其實都是有”套路“的,今天我們特別邀請到了 Waterdrop 項目的核心開發者Gary,爲我們掰開了揉碎了講講,這個“套路”是什麼?感興趣的同學,你可以去這個地址https://github.com/InterestingLab/waterdrop,學習和研究一下Waterdrop的源代碼,想進一步交流的同學,請搜索微信號(garyelephant)加Gary的微信,他是個喜歡交流技術的程序員。
Apache Spark,爲開發者提供了一套分佈式計算API,我們只要調用這些API,就能夠完成海量數據和分佈式的業務計算。當你開發了多個Spark程序以後,會發現大部分數據處理的流程相似度很高,每個環節的計算邏輯也有很多相似之處。那麼我們可以通過什麼辦法來實現一個通用引擎,進而減少這種重複性呢?
使用Spark API開發業務需求時,由於業務的重複性,做了很多重複的Spark代碼開發。Spark開發者完全可以使用Spark的API打造出一款通用的計算引擎,來應對80%的業務需求。同時,通過實現插件體系,來應對20%的特殊需求。而這樣實現的計算引擎,可以大大提高大數據開發的效率。
簡單的通用數據處理流程
你可能比較感興趣的有這樣幾個問題,一個是通用的數據處理流程是什麼樣的。另一個是Spark 如何做數據輸入、輸出和計算。第三個問題是:如何在Spark上實現通用的數據處理工作引擎。
接下來,先來講一下第一個問題的解決方案,看看通用的數據處理流程到底是什麼樣的。
最簡單的流程應該很容易想到(如圖),就是有一個數據輸入(Source),一個數據處理(Transform)還有一個是數據輸出(Sink)。
這裏舉一個例子,以具體的場景切入,假設有一個電商網站,每天有幾千萬的用戶訪問,用戶在這個網站上的行爲包括:查看商品詳情、加購物車、下單、評論和收藏。各個用戶行爲日誌已經收集上報到了分佈式消息隊列Kafka中,現在需要用Spark來完成分析和處理,並輸出到Elasticsearch中。接下來,我們一起來看下這個數據處理流程(如圖):
如果輸出到 Elasticsearch 的同時,還想輸出到 MySQL,我們稱它爲“分裂”,再來看下這張圖(如圖):
如果再增加一個transform(如圖4),就是Transform-1 先處理數據,之後輸出,再由Transform-2來處理,相當於一個管道化(Pipeline)數據處理流程。
當然還有更復雜的數據處理流程,比如同時處理多個數據輸入,就是常說的“流關聯”。這個流程實現起來就比較複雜了,而且應用場景也不是特別多,今天的課程就不詳細展開了,如果你感興趣,可以自行查閱Spark中“流關聯”的相關技術(以下):
今天的分享中,主要介紹的是前兩種較爲通用的數據處理流程的實現。接下來,再來講講第二個問題:Spark 如何做數據輸入、輸出、數據處理?
這裏需要注意一下,我們介紹的是電商數據處理場景下,使用Spark Streaming的常見用法,要構建的是這樣的數據處理流程。
一個是Kafka Source:數據源是 Kafka,數據類型是字符串,其中的各個字段以tab(\t)分割。另外,來看下Split Transform:數據進入Spark後,經過一次字符串分割後,把非結構化數據轉換成了結構化數據。再一個就是Elasticsearch Sink:數據計算完成後輸出到Elasticsearch。
首先,來看看Kafka Source的實現方式(如下):
/// kafka consumer配置
val kafkaParams = Map[String, Object](
"bootstrap.servers" -> "localhost:9092,anotherhost:9092",
"key.deserializer" -> classOf[StringDeserializer],
"value.deserializer" -> classOf[StringDeserializer],
"group.id" -> "use_a_separate_group_id_for_each_stream",
"auto.offset.reset" -> "latest",
"enable.auto.commit" -> (false: java.lang.Boolean)
)
// 待消費的topic
val topics = Array("topicA", "topicB")
// 創建DStream
val dstream = KafkaUtils.createDirectStream[String, String](
streamingContext,
PreferConsistent,
Subscribe[String, String](topics, kafkaParams)
)
// 生成 DStream[String],其中每條數據的內容就是從Kafka消費到的數據。
val resultDstream = dstream.map(record => record.value)
有了Kafka Source以後,接下來我們只要處理代碼中生成的result Dstream就可以了,如果你有開發過Spark Streaming的話,就會知道,DStream提供了一個foreachRDD()方法,允許我們處理每個streaming批次的數據。在foreachRDD方法中,我們可以把默認用來表示分佈式數據集的RDD,轉換爲Dataset[Row] 。Row表示的是Dataset中 m的每一行數據,是數據處理的基本單位。
Dataset是Spark中常用的分佈式數據集,不僅可以用在Spark SQL中,也可用在Spark Streaming中。在Dataset上面,開發者可以執行預定義好的UDF和SQL,也可以執行自己實現的函數,非常方便。所以,我們把Dataset作爲整個數據處理流程中的核心數據結構。
這段代碼演示的是如何從DStream 生成Dataset,我們一起來看下:
resultDstream.foreachRDD(rdd => {
val rowsRDD = rdd.map(element => {
element match {
case (topic, message) => {
RowFactory.create(topic, message)
}
}
})
val schema = StructType(
Array(StructField("topic", DataTypes.StringType), StructField("raw_message", DataTypes.StringType)))
val inputDf = sparkSession.createDataFrame(rowsRDD, schema)
// 生成dataset後,在後面完成其他計算,並輸出到Elasticsearch
})
其次,Split Transform 的實現方式是這樣的(如下)。我們先定義一個字符串 split()
函數:
/**
* Split string by delimiter, if size of splited parts is less than fillLength,
* empty string is filled; if greater than fillLength, parts will be truncated.
* */
private def split(str: String, delimiter: String, fillLength: Int): Seq[String] = {
val parts = str.split(delimiter).map(_.trim)
val filled = (fillLength compare parts.size) match {
case 0 => parts
case 1 => parts ++ Array.fill[String](fillLength - parts.size)("")
case -1 => parts.slice(0, fillLength)
}
filled.toSeq
}
然後,在分佈式數據集(Dataset)上,執行字符串分割:
// 定義字段名稱列表
val fieldNames = List("timestamp", "uid", "product_id", "user_agent")
// 定義UDF
val splitUdf = udf((s: String) => { split(s, "\t", fieldNames.size()) })
// 定義臨時字段名
val tmpField = "_tmp_";
// 在數據集上執行UDF,把split後的字段都放到臨時字段
tmpDf = inputDf.withColumn(tmpField, splitUdf(col(srcField)))
// 把split後的字段都放到Top Level
for (i <- 0 until fieldNames.size()) {
tmpDf = tmpDf.withColumn(fieldNames.get(i), col(tmpField)(i))
}
// 刪掉臨時字段
var resultDf = tmpDf.drop(tmpField)
最後,Elasticsearch Sink的實現方式是這樣的(如下):
// Elasticsearch輸出配置
val esCfg : Map[String, String] = Map()
esCfg += ("es.index.auto.create" -> true)
esCfg += ("es.batch.size.entries" -> 100000)
esCfg += ("es.nodes" -> "localhost:9200")
// 指定索引名稱
val indexName = "myindex"
val indexType = "logs"
// 數據輸出到Elasticsearch
resultDf.saveToEs(indexName + "/" + indexType, esCfg)
完成了前面這些代碼,只要將打包好的spark程序Jar包,通過spark-submit腳本,提交到Spark集羣上就可開始運行,完成指定的業務邏輯計算。目前講到的是3個具體的 Source
、Transform
、Sink
案例,實際上你可以參考這些代碼,開發出更多的數據處理邏輯。
這裏我們開始回答第三個問題:如何在Spark上實現通用的數據處理工作流?
前面我們講過了一個數據處理流程的具體案例,接下來面臨的問題是,這個案例和其他的案例有哪些相似之處,哪些地方可以做一下抽象分層,來實現一套通用的計算引擎呢?
使用 Spark 構建通用引擎
我來提供一種方案,供你參考。概括來講,用Spark實現一個通用的計算引擎的步驟是這樣的,我們一起來看下:
- 第一部分:搭建一套插件API體系,定義完整的
BaseSouce
BaseTransform
以及BaseSink
API (SPI) - 第二部分:基於插件API體系,開發出對應的流程控制代碼。
- 第三部分:集成插件API實現常用的
Source
,Transform
和Sink
插件
第一步:構建一套插件API體系
我們來看一下Waterdrop相關的 API 定義。
再來看下第二部分,基於插件API體系,開發出對應的流程控制代碼。第三部分是使用插件API實現常見的 Source
,Transform
,Sink
插件。
接下來,我們來逐個拆解。先來看看第一部分,定義插件接口。這裏我們需要先定義一個最基礎的Plugin插件接口。這裏演示的代碼都是用Scala寫的,可能有些同學不熟悉Scala,在這裏簡單地把trait理解爲Java裏面的interface就可以。
import com.typesafe.config.Config
...
trait Plugin extends Serializable with Logging {
/**
* Set Config.
* */
def setConfig(config: Config): Unit
/**
* Get Config.
* */
def getConfig(): Config
/**
* Return true and empty string if config is valid,
return false and error message if config is invalid.
*/
def checkConfig(): (Boolean, String)
/**
* Get Plugin Name.
*/
def name: String = this.getClass.getName
/**
* Prepare before running, do things like set
config default value, add broadcast variable,
accumulator.
*/
def prepare(spark: SparkSession): Unit = {}
}
代碼中,setConfig()
, getConfig()
, checkConfig()
這3個方法,分別用來設置、獲取、檢查傳入的插件配置;name
是插件名稱的定義;preprare()
方法的作用是在插件開始處理數據之前,需要做的一些預處理邏輯可以在 prepare()
中實現。接下來,再定義所有 Source
的接口:
abstract class BaseSource[T] extends Plugin {
/**
* Things to do after filter and before output
* */
def beforeOutput: Unit = {}
/**
* Things to do after output, such as update offset
* */
def afterOutput: Unit = {}
/**
* This must be implemented to convert RDD[T] to
Dataset[Row] for later processing
* */
def rdd2dataset(spark: SparkSession, rdd: RDD[T]):
Dataset[Row]
/**
* start should be invoked in when data is ready.
* */
def start(spark: SparkSession, ssc: StreamingContext,
handler: Dataset[Row] => Unit): Unit = {
getDStream(ssc).foreachRDD(rdd => {
val dataset = rdd2dataset(spark, rdd)
handler(dataset)
})
}
/**
* Create spark dstream from data source, you can
specify type parameter.
* */
def getDStream(ssc: StreamingContext): DStream[T]
}
BaseSource的定義中,我們用到了泛型符號T,來指定通過 Source
獲取到的 DStream 的數據類型。rdd2dataset()
, getDStream()
, start()
,這三個方法在 Source
插件的運行流程中完成從數據源獲取數據,生成 RDD 並將 RDD 轉換爲Dataset,讓流程後面的插件可以直接處理 Dataset,這跟我們之前的預期一樣。
接下來定義所有Transform的接口:
abstract class BaseTransform extends Plugin {
def process(spark: SparkSession, df: Dataset[Row]): Dataset[Row]
}
這個接口看起來就要簡單一點,只有一個 process()
方法,輸入是上一個插件處理後輸出的Dataset,輸出是當前這個process()方法處理後生成的Dataset。最後再定義所有Sink的接口:
abstract class BaseSink extends Plugin {
def process(df: Dataset[Row])
}
這個也很簡單,只有一個 process()
方法,輸入是Dataset[Row],沒有輸出,因爲在此處,插件的開發者實現自己的插件時,就需要把數據輸出到外部存儲系統了。
第二部:流程控制邏輯實現
開發出對應的流程控制邏輯,概括來說就是這幾個步驟的流程控制(如圖):
我們假設有一個描述數據處理流程的配置文件,內容是這樣的:
# application.conf
source {
kafka {
topic = ...
consumer_group_id = ...
broker_list = ...
}
}
transform {
split {
fields = ["f1", "f2", "f3"]
source_field = "message"
}
}
sink {
elasticsearch {
hosts = ...
index = ...
bulk_size = ...
}
}
那麼對於這個(以上)配置文件,通用計算引擎的流程控制邏輯是怎樣的呢,我們一起來看下。分爲這樣的幾個步驟:
- 第一步就是加載配置文件
- 第二部是根據第一步加載的配置,確認要加載哪些插件,然後去加載。
- 第三步就是根據第一步加載的配置,設置好各個插件的初始配置。
- 第四步,根據第一步加載的配置,把各個插件的使用順序串聯起來,組成 Pipeline Graph。
Pipeline Graph,用來表示計算引擎中各個插件處理數據的先後順序。例如,對於剛剛講到的配置文件,數據會從kafkaSource
插件中讀取到,然後進入引擎內部,經過splitTransform
的處理後,最終通過elasticsearchSink
輸出到 Elasticsearch。 - 最後第五步的具體操作,則是需要啓動 Pipeline。其實它的底層代碼,就是對 Spark Streaming 中 StreamingContext 的
start()
方法的包裝。
由此,我們構建出了一個插件化體系,它有三個核心要素。其中,一個是插件API;再一個就是插件的具體實現;第三個核心要素就是流程控制邏輯。
講到這裏,你可能會問,在這個計算引擎中,這麼精妙的插件化體系是如何設計出來的呢。其實,這是一個很著名軟件設計方法,叫“控制反轉”,或者叫“依賴注入”。“控制反轉”可以用一句話來概括,也就是:上層不應該依賴底層,兩者應該依賴抽象。我給它又加了一句,是這樣的:明確區分什麼是業務邏輯,什麼是流程控制。
例如,對於我們設計的這個通用的計算引擎來說,上層指的是流程控制邏輯,底層指的是各個插件的具體實現,兩者不會直接互相依賴,而是都依賴插件的API。如果我們想要設計出一個擴展性比較好的插件化體系,就必須很好地區分代碼中哪裏是業務邏輯,哪裏是流程控制,這裏的業務邏輯指的是插件的具體實現。
第三步: 常用插件的實現
接下來,我們再來講講第三部分,使用插件API,實現常見的Source、Transform、Sink插件。
現在我們只需要按照Source、Transform、Sink插件API的定義,實現自己的插件處理邏輯就可以。這裏以生產環境中常用的插件爲例,一起來看下經常會用到的插件有哪些。
常見的 Source
插件有:
- Kafka: 負責從消息隊列 Kafka 中讀取數據
- MySQL Binlog: 負責讀取 MySQL 的 Binlog 日誌
- HDFS: 負責讀取 HDFS 文件數據
- Hive:負責讀取Hive表中的數據
那麼常見的 Transform
有:
- Split:負責字符串切割
- SQL:負責執行SQL語句,通過SQL完成數據處理以及數據聚合
- Json:對數據進行JSON解析
常見的 Sink
插件有:
- ClickHouse
- Elasticsearch
- MySQL
通用引擎的優勢
剛剛講到的這些,就是關於如何打造一個通用的計算引擎的內容,整體而言,這是比較詳細的介紹。那麼,這麼做的優勢是什麼呢?
- 計算邏輯配置化模塊化,很容易實現各種業務邏輯的計算
- 接入新的計算需求,近乎零開發成本
- 既滿足80%的常用需求,又支持20%的個性化需求
- 在高度抽象的API上開發自己的業務邏輯更加簡單/功能更加清晰
- 代碼複用程度高
這裏順便延伸講一下,在有了這些優勢基礎上,如果我們想把這個通用的計算引擎做得更好,可以考慮增加這幾個功能:一個是監控,一個是WebUI。