Spark Streaming保证Exactly-Once语义

在流计算引擎如Apache Storm、Apache Kafka(Kafka Streams)、Apache Spark(Spark Streaming、Spark Structured Streaming)、Apache Flink中,经常提到Exactly-Once语义,那Exactly-Once究竟是啥意思?当流计算引擎声称Exactly-Once时,究竟意味着啥?Spark Streaming如何保证Exactly-Once?

关于此,自己有时也百思不得其解,查阅了众多资料,咨询了众多大佬,将自己理解整理成如下笔记。

让人误解的Exactly-Once


Exactly-Once不是指对输入的数据只处理一次,指的是, 在流计算引擎中, 算子给下游的结果是Exactly-Once的(即:给下游的结果有且仅有一个,且不重复、不少算)。

如在Spark Streaming处理过程中,从一个算子(Operator)到另一个算子(Operator),可能会因为各种不可抗力如机器挂掉等原因,导致某些Task处理失败,Spark内部会基于Lineage或Checkpoint启动重试Task去重新处理同样的数据。因不可抗力的存在,流处理引擎内部不可能做到一条数据仅被处理一次。所以,当流处理引擎声称提供Exactly-Once语义时,指的是从一个Operator到另一个Operator,同样的数据,无论重复处理多少次,最终的结果状态是Exactly-Once。

流计算引擎保证Exactly-Once时一般用到的算法


Micro-Batch

典型流处理引擎:Apache Spark(Spark Streaming)。

Spark Streaming将输入的流周期性的划分成一个一个的Batch,然后用Spark批处理的方式,处理每个Batch,一个Batch要么成功,要么失败,失败后重新Replay,Recompute。偶尔可用Checkpoint快照每个RDD状态,恢复时,找到最近的Checkpoint,确定依赖,然后Recompute。

Distributed Snapshot

Distributed Snapshot(分布式快照),简单来说,就是为了保存分布式系统的Global State,当系统Failure Recovery时,从最近一次成功保存的全局快照中恢复每个节点的状态。

典型流处理引擎:Apache Spark(Spark Structured Streaming)、Apache Flink。

Flnk分布式快照是通过Asynchronous Barrier Snapshots算法实现的,该算法借鉴了Chandy-Lamport算法的主要思想,同时也做了一些改进。

Spark Structured Streaming 的Continuous Processing Mode的容错处理使用了基于Chandy-Lamport的分布式快照(Distributed Snapshot)算法。

流处理应用如何保证Exactly-Once


  1. Source支持Replay。

  2. 流计算引擎本身处理能保证Exactly-Once。

  3. Sink支持幂等或事务更新。

Spark Streaming保证Exactly-Once语义


一个Spark Streaming流处理程序,从广义上讲,包含三个步骤。

  1. 接收数据:从Source中接收数据。

  2. 转换数据:用DStream和RDD算子转换。

  3. 储存数据:将结果保存至外部系统。

如果流处理程序需要实现Exactly-Once语义,那么每一个步骤都要保证Exactly-Once。

接收数据

不同的数据源提供不同的保证。

如HDFS中的数据源,直接支持Exactly-Once语义。如使用基于Kafka Direct API从Kafka获取数据,也能保证Exactly-Once。

转换数据

Spark Streaming内部是天然支持Exactly-once语义。任务失败,不论重试多少次,一个算子给另一个算子的结果有且仅有一个,不重不丢。

储存数据

Spark Streaming中的输出操作foreachRDD默认具有At-Least Once语义,因此当任务失败时会重试多次输出,这样就会重复多次写入外部存储。 如果储存数据想实现Exactly-once,有两种途径。

幂等输出

幂等输出,即同样的数据输出多次,结果一样。一般需要借助外部存储中的唯一键实现。具体步骤:

  1. 将kafka参数enable.auto.commit设置为false。

  2. 幂等写入后手动提交offset。这里用checkpoint,不需要手动提交,生产中可用Kafka、Zookeeper、HBase等保存offset。

Spark Streaming 读取Kafka数据并将结果存储到Mysql—幂等

package com.bigdata.spark

import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.SparkConf
import org.apache.spark.sql.SparkSession
import org.apache.spark.streaming.kafka010.{ConsumerStrategies, HasOffsetRanges, KafkaUtils, LocationStrategies}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.slf4j.LoggerFactory
import scalikejdbc.{DB, _}

/**
  * Author: Wang Pei
  * Summary:
  *   SparkStreaming EOS:
  *     Input:Kafka
  *     Process:Spark Streaming
  *     Output:Mysql
  *
  *     保证EOS:
  *       1、偏移量自己管理,即enable.auto.commit=false,这里保存在checkpoint中
  *       2、使用createDirectStream
  *       3、幂等输出:同样的数据,无论输出多次,效果一样。一般要结合外部存储主键、唯一键实现。
  */
object SparkStreamingEOSKafkaMysqlIdempotent {


  @transient lazy val logger = LoggerFactory.getLogger(this.getClass)

  def main(args: Array[String]): Unit = {

    val topic="topic1"
    val group="spark_app1"

    //Kafka配置
    val kafkaParams= Map[String, Object](
      "bootstrap.servers" -> "node1:6667,node2:6667,node3:6667",
      "key.deserializer" -> classOf[StringDeserializer],
      "value.deserializer" -> classOf[StringDeserializer],
      "auto.offset.reset" -> "latest",//latest earliest
      "enable.auto.commit" -> (false: java.lang.Boolean),
      "group.id" -> group)


    val checkpointDir ="/apps/bigdata/spark/app1/checkpoint"

    //这里将偏移量存储在checkpoint中
    //代码升级不能从checkpoint恢复
    //生产中可将offset保存至如Kafka、Zookeeper、HBase、Redis、Mysql中等等
    //注意: 需要将处理逻辑都封装在creatingFunc方法中
    val ssc=StreamingContext.getOrCreate(checkpointDir, createContext(topic,group,checkpointDir,kafkaParams))
    ssc.checkpoint(checkpointDir)

    ssc.start()
    ssc.awaitTermination()


  }

  /**
    *
    * @param topic
    * @param group
    * @param checkpointDir
    * @param kafkaParams
    * @return
    */
  def createContext(topic:String,group:String,checkpointDir:String,kafkaParams:Map[String,Object])():StreamingContext={


    val conf = new SparkConf().setAppName(this.getClass.getSimpleName.replace("$",""))
    val ssc = new StreamingContext(conf,Seconds(5))

    //1)初次启动或重启时,需要从此指定的Partition、Offset开始消费
    //2)运行过程中,每个Partition、Offset保存在内部currentOffsets = Map[TopicPartition, Long]()变量中
    //3)后期Kafka Topic分区动扩展,在运行过程中不能自动感知
    val initOffset=DB.readOnly(implicit session=>{
      sql"select `partition`,offset from kafka_topic_offset where topic =${topic} and `group`=${group}"
        .map(item=> new TopicPartition(topic, item.get[Int]("partition")) -> item.get[Long]("offset"))
        .list().apply().toMap
    })

    val sourceDStream =KafkaUtils.createDirectStream[String,String](
    ssc,
    LocationStrategies.PreferConsistent,
    ConsumerStrategies.Assign[String,String](initOffset.keys,kafkaParams,initOffset)
    )

    sourceDStream.foreachRDD(rdd=>{
    if (!rdd.isEmpty()){
    val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
    offsetRanges.foreach(offsetRange=>{
    println(s"Topic: ${offsetRange.topic},Group: ${group},Partition: ${offsetRange.partition},fromOffset: ${offsetRange.fromOffset},untilOffset: ${offsetRange.untilOffset}")
  })

    //统计分析
    val sparkSession = SparkSession.builder.config(rdd.sparkContext.getConf).getOrCreate()
    import sparkSession.implicits._
    val dataFrame = sparkSession.read.json(rdd.map(_.value()).toDS)
    dataFrame.createOrReplaceTempView("tmpTable")
    val result=sparkSession.sql(
    """
      |select
      |   --每分钟
      |   eventTimeMinute,
      |   --每种语言
      |   language,
      |   -- 次数
      |   count(1) pv,
      |   -- 人数
      |   count(distinct(userID)) uv
      |from(
      |   select *, substr(eventTime,0,16) eventTimeMinute from tmpTable
      |) as tmp group by eventTimeMinute,language
    """.stripMargin
    )

    //幂等写入
    result.rdd.foreachPartition(partition=>{

      ConnectionPool.singleton("jdbc:mysql://node3:3306/bigdata", "", "")

      partition.foreach(row=>{
        DB.autoCommit{implicit session=> {
          //eventTimeMinute+language 是唯一键
          sql"""
            insert into twitter_pv_uv (eventTimeMinute, language,pv,uv)
            value (
                ${row.getAs[String]("eventTimeMinute")},
                ${row.getAs[String]("language")},
                ${row.getAs[Long]("pv")},
                ${row.getAs[Long]("uv")}
                )
            on duplicate key update pv=pv,uv=uv
          """.update.apply()
        }}})})}})
    ssc

}}

事务输出

事务输出,即数据输出和Kafka Offset提交在同一原子性事务中。具体步骤:

  1. 将kafka参数enable.auto.commit设置为false。

  2. 结果存储与Offset提交在同一事务中原子执行。

Spark Streaming 读取Kafka数据并将结果存储到Mysql—事务

package com.bigdata.spark

import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.SparkConf
import org.apache.spark.sql.SparkSession
import org.apache.spark.streaming.kafka010.{ConsumerStrategies, HasOffsetRanges, KafkaUtils, LocationStrategies}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.slf4j.LoggerFactory
import scalikejdbc.{ConnectionPool, DB, _}

/**
  * Author: Wang Pei
  * Summary:
  *   SparkStreaming EOS:
  *     Input:Kafka
  *     Process:Spark Streaming
  *     Output:Mysql
  *
  *     保证EOS:
  *       1、偏移量自己管理,即enable.auto.commit=false,这里保存在Mysql中
  *       2、使用createDirectStream
  *       3、事务输出: 结果存储与Offset提交在Driver端同一Mysql事务中
  */

object SparkStreamingEOSKafkaMysqlAtomic {

  @transient lazy val logger = LoggerFactory.getLogger(this.getClass)

  def main(args: Array[String]): Unit = {

    val topic="topic1"
    val group="spark_app1"

    //Kafka配置
    val kafkaParams= Map[String, Object](
      "bootstrap.servers" -> "node1:6667,node2:6667,node3:6667",
      "key.deserializer" -> classOf[StringDeserializer],
      "value.deserializer" -> classOf[StringDeserializer],
      "auto.offset.reset" -> "latest",//latest earliest
      "enable.auto.commit" -> (false: java.lang.Boolean),
      "group.id" -> group)

    //在Driver端创建数据库连接池
    ConnectionPool.singleton("jdbc:mysql://node3:3306/bigdata", "", "")

    val conf = new SparkConf().setAppName(this.getClass.getSimpleName.replace("$",""))
    val ssc = new StreamingContext(conf,Seconds(5))

    //1)初次启动或重启时,从指定的Partition、Offset构建TopicPartition
    //2)运行过程中,每个Partition、Offset保存在内部currentOffsets = Map[TopicPartition, Long]()变量中
    //3)后期Kafka Topic分区动扩展,在运行过程中不能自动感知
    val initOffset=DB.readOnly(implicit session=>{
      sql"select `partition`,offset from kafka_topic_offset where topic =${topic} and `group`=${group}"
        .map(item=> new TopicPartition(topic, item.get[Int]("partition")) -> item.get[Long]("offset"))
        .list().apply().toMap
    })

    //CreateDirectStream
    //从指定的Topic、Partition、Offset开始消费
    val sourceDStream =KafkaUtils.createDirectStream[String,String](
      ssc,
      LocationStrategies.PreferConsistent,
      ConsumerStrategies.Assign[String,String](initOffset.keys,kafkaParams,initOffset)
    )

    sourceDStream.foreachRDD(rdd=>{
      if (!rdd.isEmpty()){
        val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
        offsetRanges.foreach(offsetRange=>{
          logger.info(s"Topic: ${offsetRange.topic},Group: ${group},Partition: ${offsetRange.partition},fromOffset: ${offsetRange.fromOffset},untilOffset: ${offsetRange.untilOffset}")
        })

        //统计分析
        //将结果收集到Driver端
        val sparkSession = SparkSession.builder.config(rdd.sparkContext.getConf).getOrCreate()
        import sparkSession.implicits._
        val dataFrame = sparkSession.read.json(rdd.map(_.value()).toDS)
        dataFrame.createOrReplaceTempView("tmpTable")
        val result=sparkSession.sql(
          """
            |select
            |   --每分钟
            |   eventTimeMinute,
            |   --每种语言
            |   language,
            |   -- 次数
            |   count(1) pv,
            |   -- 人数
            |   count(distinct(userID)) uv
            |from(
            |   select *, substr(eventTime,0,16) eventTimeMinute from tmpTable
            |) as tmp group by eventTimeMinute,language
          """.stripMargin
        ).collect()

        //在Driver端存储数据、提交Offset
        //结果存储与Offset提交在同一事务中原子执行
        //这里将偏移量保存在Mysql中
        DB.localTx(implicit session=>{

          //结果存储
          result.foreach(row=>{
            sql"""
            insert into twitter_pv_uv (eventTimeMinute, language,pv,uv)
            value (
                ${row.getAs[String]("eventTimeMinute")},
                ${row.getAs[String]("language")},
                ${row.getAs[Long]("pv")},
                ${row.getAs[Long]("uv")}
                )
            on duplicate key update pv=pv,uv=uv
          """.update.apply()
          })

          //Offset提交
          offsetRanges.foreach(offsetRange=>{
            val affectedRows = sql"""
          update kafka_topic_offset set offset = ${offsetRange.untilOffset}
          where
            topic = ${topic}
            and `group` = ${group}
            and `partition` = ${offsetRange.partition}
            and offset = ${offsetRange.fromOffset}
          """.update.apply()

            if (affectedRows != 1) {
              throw new Exception(s"""Commit Kafka Topic: ${topic} Offset Faild!""")
            }
          })
        })
      }
    })

    ssc.start()
    ssc.awaitTermination()
  }
}

你可能感兴趣的:(Spark,Spark)