Apache Spark结构化流处理与Apache Kafka实时端对端整合

编者按

本文翻译自DataBricks官方博客,主要描述了Apache Spark 2.0中推出的新功能Structured Streaming(结构化流处理)从Kafka中读取消息,实时处理后再写入不同的下游系统的使用示例。

结构化流处理API使得以一种兼具一致性和容错性的方法开发被称为连续应用的端到端流处理应用成为可能。它让开发者不用再去深究流处理本身的细节,而且允许开发者使用类似Spark SQL中的熟悉概念,比如DataFrames和DataSets。由于上述原因,很多人有兴趣仔细研究一些使用案例。从入门,到ETL,再到复杂的数据格式,都已经有了很多材料涉及了。结构化流处理API同样也可以和一些第三方的组件整合,比如Kafka,HDFS,S3,RDBMS等等。

在这篇文章中,我会讲解与Kafka的端到端整合,从中处理消息,进行简单到复杂的基于window的ETL,以及将输出放到不同的接收系统中,诸如内存,控台,文件,数据库以及回到Kafka中。对于将输出写到文件的情况,本文也会讨论如何将新数据写到分区表中。

Connecting to a Kafka Topic

与Kafka Topic连接

假设你有个可以连接的Kafka集群,你想用Spark的结构化流处理功能来接收并处理一个topic来的消息。Databricks平台已经包含了Apache Kafka 0.10的结构化流处理功能连接器,所以建立一个信息流读取消息就变得很容易了:

import org.apache.spark.sql.functions.{get_json_object, json_tuple}
 
var streamingInputDF = 
  spark.readStream
    .format("kafka")
    .option("kafka.bootstrap.servers", "<server:ip")
    .option("subscribe", "topic1")     
    .option("startingOffsets", "latest")  
    .option("minPartitions", "10")  
    .option("failOnDataLoss", "true")
    .load()
import org.apache.spark.sql.functions.{get_json_object, json_tuple}
streamingInputDF: org.apache.spark.sql.DataFrame = [key: binary, value: binary ... 5 more fields]

读取流数据的时候有一些可以设定的选项,这些选项的细节可以在这里找到

我们可以快速的看一下我们刚刚创建的streamingInputDF这个DataFrame的schema

Schema中包含了keyvaluetopicpartitionoffsettimestamptimestampType这些域。我们可以从中选择我们需要处理的域。value域中包含了我们真正的数据,timestamp是消息接受的时间戳。在基于window处理的情况下,我们不要把这个timestamp域和消息中真正含有的时间戳搞混了,后者大部分情况下才是我们关心的。

流处理ETL

在我们将流处理设置好了之后,我们就可以对其做需要的ETL来产生有意义的结论。注意streamingInputDF是一个DataFrame。因为Dataframe本质上说是无类型的行数据集,所以我们也可以对其做类似的操作。

假设一些ISP访问的JSON数据被推送到上述的Kafka <topic>。比如一个数据点可能是这样的:

val value = """{"city": "<CITY>", 
  "country": "United States", 
  "countryCode": "US", 
  "isp": "<ISP>", 
  "lat": 0.00, 
  "lon": 0.00, 
  "region": "CA", 
  "regionName": "California", 
  "status": "success", 
  "hittime": "<TIMPSTAMP>", 
  "zip": "<ZIP>" 
}"""

接下来我们就可以快速的做一些有意思的分析了,比如多少用户是从某一个邮编地区来的,用户通过哪个ISP进入等等。我们可以进一步建立一些数据仪表盘来跟我们的公司分享,下面让我们深度分析一下:

import org.apache.spark.sql.functions._

var streamingSelectDF = 
  streamingInputDF
   .select(get_json_object(($"value").cast("string"), "$.zip").alias("zip"))
    .groupBy($"zip") 
    .count()

display(streamingSelectDF)

注意在上述的命令中,我们可以把邮编从JSON消息中提取出来,把他们group起来再计数,这些步骤全部是我们一边从kafka的topic读取数据一边实时处理的。在我们得到计数结果后我们把结果显示出来,这个过程会在后端开始一个流处理程序处理新进来的消息并且不断的显示更新的结果。这张自动更新的图表就可以在Databricks的平台上作为一个访问权限可控的数据仪表盘和公司其他人分享了。

基于Window的处理

我们现在已经让parse,select,groupBy和count这些查询持续的在运行了,接下来如果我们想知道每个邮编的在10分钟内的总流量,并且从每个小时的第2分钟开始每5分钟跟新一次该怎么办呢?

在这个例子中,进入的JSON数据包含一个表示时间戳的域'hittime', 我们可以用这个域来查询每10分钟的总流量。

注意在结构化流处理中,基于window的处理被认为是一种groupBy操作。下面的饼状图代表了每10分钟窗口的流量。

import org.apache.spark.sql.functions._

var streamingSelectDF = 
  streamingInputDF
   .select(get_json_object(($"value").cast("string"), "$.zip").alias("zip"), get_json_object(($"value").cast("string"), "$.hittime").alias("hittime"))
   .groupBy($"zip", window($"hittime".cast("timestamp"), "10 minute", "5 minute", "2 minute"))
   .count()

输出选项

至此,我们已经看到最终结果自动被显示出来了。如果我们对于输出选项需要更多的控制,有多种输出模式可以供使用。比如,如果我们需要debug,你可能选择控制台输出。如果我们希望数据一边被处理我们能一边实时查询数据,内存输出则是合理的选择。类似的,输出也可以被写到文件,外部数据库,甚至可以重新流入Kafka。

我们来详细过一遍这些选项。

内存

这种情况下,数据被作为内存中的数据表存储起来。从内存中,用户可以对数据集用SQL进行查询。数据表的名字可以通过queryName选项来制定。注意我们继续使用上述基于window处理例子中的streamingSelectDF

import org.apache.spark.sql.streaming.ProcessingTime

val query =
  streamingSelectDF
    .writeStream
    .format("memory")        
    .queryName("isphits")     
    .outputMode("complete") 
    .trigger(ProcessingTime("25 seconds"))
    .start()


基于此你可以做更多有意思的分析,就像你对普通的数据表的做法一样,而同时数据会自动被更新。

Console
控制台

这种情况下,输出被直接打印到控台或者stdout日志

In this scenario, output is printed to console/stdout log.

import org.apache.spark.sql.streaming.ProcessingTime

val query =
  streamingSelectDF
    .writeStream
    .format("console")        
    .outputMode("complete") 
    .trigger(ProcessingTime("25 seconds"))
    .start()

文件

这种情景是将输出长期存储的最佳方法。不像内存或者控台这样的接收系统,文件和目录都是具有容错性的。所以,这个选项还要求一个“检查点”目录来存放一些为了容错性需要的状态.

import org.apache.spark.sql.streaming.ProcessingTime

val query =
  streamingSelectDF
    .writeStream
    .format("parquet")
    .option("path", "/mnt/sample/data")
    .option("checkpointLocation", "/mnt/sample/check"))
    .trigger(ProcessingTime("25 seconds"))
    .start()

数据被存储下来之后就可以像其他数据集一样在Spark中被查询了。

val streamData = spark.read.parquet("/mnt/sample/data")
streamData.filter($"zip" === "38908").count()

另一个输出到文件系统的好处是你可以动态地基于任何列对接受的消息进行分区。在上述例子中,我们可以基于‘zipcode’或者‘day’进行分区。这可以让查询变得更快,因为通过引用一个个分区,一大部分数据都可以被跳过。

import org.apache.spark.sql.functions._

var streamingSelectDF = 
  streamingInputDF
   .select(get_json_object(($"value").cast("string"), "$.zip").alias("zip"),    get_json_object(($"value").cast("string"), "$.hittime").alias("hittime"), date_format(get_json_object(($"value").cast("string"), "$.hittime"), "dd.MM.yyyy").alias("day"))
    .groupBy($"zip") 
    .count()
    .as[(String, String)]

接下来我们可以把输入数据按照‘zip’和‘day’分区

import org.apache.spark.sql.streaming.ProcessingTime

val query =
  streamingSelectDF
    .writeStream
    .format("parquet")
    .option("path", "/mnt/sample/test-data")
    .option("checkpointLocation", "/mnt/sample/check")
    .partitionBy("zip", "day")
    .trigger(ProcessingTime("25 seconds"))
    .start()

我们来看看输出文件夹是什么样的

现在,分区的数据可以直接在数据集和DataFrames被使用,如果一个数据表创建的时候指向了这些文件被写入的文件夹,Spark SQL可以用来查询这些数据。

%sql CREATE EXTERNAL TABLE  test_par
    (hittime string)
    PARTITIONED BY (zip string, day string)
    STORED AS PARQUET
    LOCATION '/mnt/sample/test-data'

这种方法需要注意的一个细节是数据表需要被加入一个新的分区,数据表中的数据集才能被访问到


%sql ALTER TABLE test_par ADD IF NOT EXISTS
    PARTITION (zip='38907', day='08.02.2017') LOCATION '/mnt/sample/test-data/zip=38907/day=08.02.2017'

分区引用也可以被预先填满,这样随时文件在其中被创建,他们可以立即被访问。

%sql select * from test_par


现在你就可以对这个自动更新的数据表作分析了,与此同时数据在正确的分区中被存储下来。

数据库
Databases

我们经常想要把流处理输出写到像MySQL这样的外部数据库中。目前,结构化流处理API还不支持写入外部数据库。但是,在支持加入后,API的选项会像.format("jdbc").start("jdbc:mysql/..")这么简单。同时,我们可以用‘foreach’输出来写入数据库。让我们来写一个自定义的JDBCSink来继承ForeachWriter来实现集中的方法。

import java.sql._

class  JDBCSink(url:String, user:String, pwd:String) extends ForeachWriter[(String, String)] {
      val driver = "com.mysql.jdbc.Driver"
      var connection:Connection = _
      var statement:Statement = _
      
    def open(partitionId: Long,version: Long): Boolean = {
        Class.forName(driver)
        connection = DriverManager.getConnection(url, user, pwd)
        statement = connection.createStatement
        true
      }

      def process(value: (String, String)): Unit = {
        statement.executeUpdate("INSERT INTO zip_test " + 
                "VALUES (" + value._1 + "," + value._2 + ")")
      }

      def close(errorOrNull: Throwable): Unit = {
        connection.close
      }
   }

我们现在就可以使用我们的JDBCSink了:

val url="jdbc:mysql://<mysqlserver>:3306/test"
val user ="user"
val pwd = "pwd"

val writer = new JDBCSink(url,user, pwd)
val query =
  streamingSelectDF
    .writeStream
    .foreach(writer)
    .outputMode("update")
    .trigger(ProcessingTime("25 seconds"))
    .start()


批处理完成后,每个邮编的总数就会被插入/更新到我们的MySQL数据库中了

As batches are complete, counts by zip could be INSERTed/UPSERTed into MySQL as needed.

Kafka

跟写入数据库类似,现在的结构化流处理API还不原生支持"kafka"输出格式,但是下一版中这个功能会被加上。与此同时,我们可以创建自定义的类KafkaSink来继承ForeachWriter,我们来看看代码是怎么样的:

import java.util.Properties
import kafkashaded.org.apache.kafka.clients.producer._
import org.apache.spark.sql.ForeachWriter


 class  KafkaSink(topic:String, servers:String) extends ForeachWriter[(String, String)] {
      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 results = new scala.collection.mutable.HashMap[String, String]
      var producer: KafkaProducer[String, String] = _

      def open(partitionId: Long,version: Long): Boolean = {
        producer = new KafkaProducer(kafkaProperties)
        true
      }

      def process(value: (String, String)): Unit = {
          producer.send(new ProducerRecord(topic, value._1 + ":" + value._2))
      }

      def close(errorOrNull: Throwable): Unit = {
        producer.close()
      }
   }

下面我们就可以使用这个writer:

val topic = "<topic2>"
val brokers = "<server:ip>"

val writer = new KafkaSink(topic, brokers)

val query =
  streamingSelectDF
    .writeStream
    .foreach(writer)
    .outputMode("update")
    .trigger(ProcessingTime("25 seconds"))
    .start()

你现在就能看到我们在将消息流入Kafka的topic2.我们每个批处理后会把更新后的zipcode:count传回Kafka。另一件需要注意的事情是流处理Dashboard会提供流入数据量和处理速率的对比,批处理时间和用来产生Dashboard的原始数据。这些在debug问题和监控系统的时候会很有用。

在Kafka的consumer组件里面,我们可以看到:


这里,我们把输出运行在“更新”模式中。随着消息被处理,在一次批处理中被更新的邮编会被送回Kafka,没被更新的邮编则不会被发送。你也可以“完全”模式运行,类似我们在上面数据库的例子里那样,这样所有的邮编的最近的计数都会被发送,即使有些邮编的总数与上次批处理比并没有变化。

结论

本文概述了结构化流处理API和Kafka的整合,描述了如果用这套API和不同的数据输入和输出系统一起使用。这里用到的一些概念对于其他流处理系统也同样相关,比如端口,目录等等。比如你想从端口源中读取数据并且将处理好的消息发送到MySQL,将文中的例子修改一下输入输出流就能做到。另外,像例子里使用的ForeachWriter也可以用来把输出数据同时写到多个下游系统中。我会在后续的文章中详细描述写入多个下游系统的方法。

本文中的代码示例都可以通过Databricks Notebook得到。你可以注册免费的Databricks Community Edition来实验结构化流处理功能。如果任何问题,欢迎联系我们

最后,推荐下面两篇作为延伸阅读:

Real-time Streaming ETL with Structured Streaming in Apache Spark 2.1
Working with Complex Data Formats with Structured Streaming in Apache Spark 2.1