如何用Spark實現一個通用大數據引擎

Github 上的開源項目 Waterdrop,此項目Star + Fork的有將近1200人,是一個基於Spark和Flink構建的生產環境的海量數據計算產品。Waterdrop的特性包括

  1. 簡單易用,靈活配置,無需開發;
  2. 同時支持流式和離線處理;
  3. 模塊化和插件化,易於擴展;
  4. 支持利用SQL做數據處理和聚合;
  5. 支持選擇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個具體的 SourceTransformSink 案例,實際上你可以參考這些代碼,開發出更多的數據處理邏輯。
這裏我們開始回答第三個問題:如何在Spark上實現通用的數據處理工作流?
前面我們講過了一個數據處理流程的具體案例,接下來面臨的問題是,這個案例和其他的案例有哪些相似之處,哪些地方可以做一下抽象分層,來實現一套通用的計算引擎呢?

使用 Spark 構建通用引擎

我來提供一種方案,供你參考。概括來講,用Spark實現一個通用的計算引擎的步驟是這樣的,我們一起來看下:

  • 第一部分:搭建一套插件API體系,定義完整的 BaseSouce BaseTransform 以及 BaseSink API (SPI)
  • 第二部分:基於插件API體系,開發出對應的流程控制代碼。
  • 第三部分:集成插件API實現常用的SourceTransformSink 插件

第一步:構建一套插件API體系

我們來看一下Waterdrop相關的 API 定義。

在這裏插入圖片描述

再來看下第二部分,基於插件API體系,開發出對應的流程控制代碼。第三部分是使用插件API實現常見的 SourceTransformSink 插件。

接下來,我們來逐個拆解。先來看看第一部分,定義插件接口。這裏我們需要先定義一個最基礎的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

通用引擎的優勢

剛剛講到的這些,就是關於如何打造一個通用的計算引擎的內容,整體而言,這是比較詳細的介紹。那麼,這麼做的優勢是什麼呢?

  1. 計算邏輯配置化模塊化,很容易實現各種業務邏輯的計算
  2. 接入新的計算需求,近乎零開發成本
  3. 既滿足80%的常用需求,又支持20%的個性化需求
  4. 在高度抽象的API上開發自己的業務邏輯更加簡單/功能更加清晰
  5. 代碼複用程度高

這裏順便延伸講一下,在有了這些優勢基礎上,如果我們想把這個通用的計算引擎做得更好,可以考慮增加這幾個功能:一個是監控,一個是WebUI。

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