Kafka+Spark Streaming保证exactly once语义

      在Kafka、Storm、Flink、Spark Streaming等分布式流处理系统中(没错,Kafka本质上是流处理系统,不是单纯的“消息队列”),存在三种消息传递语义(message delivery semantics),分别是:

  • at least once:每条消息会被收到1次或多次。例如发送方S在超时时间内没有收到接收方R的通知(如ack),或者收到了R的报错,就会不断重发消息直至R传回ack。
  • at most once:每条消息会被收到0次或1次。也就是说S只负责向R发送消息,R也没有任何通知机制。无论R最终是否收到,S都不会重发。
  • exactly once:是上面两个的综合,保证S发送的每一条消息,R都会“不重不漏”地恰好收到1次。它是最强最精确的语义,也最难实现

一个Spark Streaming程序由三步组成:输入、处理逻辑、输出。要达到exactly once的理想状态,需要三步协同进行,而不是只与处理逻辑有关。如下图所示:

Kafka与Spark Streaming集成时有两种方法:

  • 基于receiver
  • 基于direct

1、基于receiver

  • 基于receiver的采用kafka高级消费者API,消费者不能自己去维护消费者offset,而且kafka也不关心数据是否丢失。
  • 每个executor进程都会不断拉取消息,并同时保存在executor内存与HDFS上的预写日志(Write-Ahead log,WAL)
  • 当消息写入WAL后,自动更新ZK中的offset

它可以保证At Least Once语义,但无法保证Exactly Once语义。虽然引入了WAL来保证消息不会丢失,但还有可能会出现消息已经写入WAL,但offset更新失败的情况,Kafka就会按上一次的offset重新发送消息。其基本示例如下:

  1. receiver模式依赖zookeeper管理offset。
  2. receiver模式的并行度由spark.streaming.blockInterval决定,默认是200ms。
  3. receiver模式接收block.batch数据后会封装到RDD中,这里的block对应RDD中的partition。
  4. 在batchInterval一定的情况下,减少spark.streaming.Interval参数值,会增大DStream中的partition个数,建议spark.streaming.Interval最低不能低于50ms。
object KafkaWordCount {
  def main(args: Array[String]): Unit = {
    val conf = new SparkConf().setAppName("kafkaWordCount").setMaster("local[*]")
    val ssc = new StreamingContext(conf, Seconds(10))
    // 创建DSteam,需要KafkaDStream
    val zkQuorum = "zk1:2181,zk2:2181,zk3:2181"
    val groupId = "g1"
    val topic = Map[String, Int]("test"-> 1)
 
    val data: ReceiverInputDStream[(String, String)] = KafkaUtils.createStream(ssc, zkQuorum, groupId, topic, StorageLevel.MEMORY_AND_DISK_SER_2)
 
    // 对数据进行处理
    val lines: DStream[String] = data.map(_._2)
    val reduced: DStream[(String, Int)] = lines.flatMap(_.split(" ")).map((_,1)).reduceByKey(_+_)
    reduced.print()
 
    // 启动执行
    ssc.start()
    ssc.awaitTermination()
  }
}

这种方式还会造成数据冗余(Kafka broker中一份数据,spark executor中一分),是吞吐量和内存利用率降低。

2、基于direct

  • 基于direct的方法采用Kafka的简单消费者API,它的流程大大简化。
  • executor不再从Kafka中连续读取消息,也消除了receiver和WAL。
  • Kafka分区与RDD分区一一对应,更可控
  • driver线程只需要每次从Kafka获得批次消息的offset range
  • 然后executor进程根据offset range去读取该批次对应的消息
  • 由于offset在kafka中能唯一确定一条消息,且在外部只能被Streaming程序本身感知到,因此消除了不一致性,达到了Exactly Once

不过由于它采用了简单消费者API,我们需要自己来管理offset。否则一旦程序崩溃,整个流只能从earliest或者latest点恢复。其示例如下:

object DirectKafkaWordCount {
  def main(args: Array[String]) {
    if (args.length < 2) {
      System.err.println(s"""
        |Usage: DirectKafkaWordCount <brokers> <topics>
        |  <brokers> is a list of one or more Kafka brokers
        |  <topics> is a list of one or more kafka topics to consume from
        |
        """.stripMargin)
      System.exit(1)
    }
    StreamingExamples.setStreamingLogLevels()
    val Array(brokers, topics) = args
    // Create context with 2 second batch interval
    val sparkConf = new SparkConf().setAppName("DirectKafkaWordCount")
    val ssc = new StreamingContext(sparkConf, Seconds(2))
    // Create direct kafka stream with brokers and topics
    val topicsSet = topics.split(",").toSet
    val kafkaParams = Map[String, String]("metadata.broker.list" -> brokers)
    val messages = KafkaUtils.createDirectStream[String, String, StringDecoder, StringDecoder](
      ssc, kafkaParams, topicsSet)
    // Get the lines, split them into words, count the words and print
    val lines = messages.map(_._2)
    val words = lines.flatMap(_.split(" "))
    val wordCounts = words.map(x => (x, 1L)).reduceByKey(_ + _)
    wordCounts.print()
    // Start the computation
    ssc.start()
    ssc.awaitTermination()
  }
}

其创建createDirectStream的源码如下:

def createDirectStream[
  K: ClassTag,
  V: ClassTag,
  KD <: Decoder[K]: ClassTag,
  VD <: Decoder[V]: ClassTag] (
    ssc: StreamingContext,
    kafkaParams: Map[String, String],
    topics: Set[String]
): InputDStream[(K, V)] = {
  val messageHandler = (mmd: MessageAndMetadata[K, V]) => (mmd.key, mmd.message)
  // 创建KakfaCluster对象
  val kc = new KafkaCluster(kafkaParams)
  // 根据kc的信息获取数据偏移量
  val fromOffsets = getFromOffsets(kc, kafkaParams, topics)
  new DirectKafkaInputDStream[K, V, KD, VD, (K, V)](
    ssc, kafkaParams, fromOffsets, messageHandler)
}

其首先通过KafkaCluster从Kafka集群获取信息,并创建DirectKafkaInputDStream对象返回;在DirectKafkaInputDStream的compute方法中,其首先从Kafka集群获取数据的偏移量,然后利用获取偏移量创建RDD其DirectKafkaInputDStream的compute方法源码如下:

override def compute(validTime: Time): Option[KafkaRDD[K, V, U, T, R]] = {
  // 计算最近的数据终止偏移量
  val untilOffsets = clamp(latestLeaderOffsets(maxRetries))
  // 利用数据的偏移量创建KafkaRDD
  val rdd = KafkaRDD[K, V, U, T, R](
    context.sparkContext, kafkaParams, currentOffsets, untilOffsets, messageHandler)
  // Report the record number and metadata of this batch interval to InputInfoTracker.
  val offsetRanges = currentOffsets.map { case (tp, fo) =>
    val uo = untilOffsets(tp)
    OffsetRange(tp.topic, tp.partition, fo, uo.offset)
  }
  val description = offsetRanges.filter { offsetRange =>
    // Don't display empty ranges.
    offsetRange.fromOffset != offsetRange.untilOffset
  }.map { offsetRange =>
    s"topic: ${offsetRange.topic}\tpartition: ${offsetRange.partition}\t" +
      s"offsets: ${offsetRange.fromOffset} to ${offsetRange.untilOffset}"
  }.mkString("\n")
  // Copy offsetRanges to immutable.List to prevent from being modified by the user
  val metadata = Map(
    "offsets" -> offsetRanges.toList,
    StreamInputInfo.METADATA_KEY_DESCRIPTION -> description)
  val inputInfo = StreamInputInfo(id, rdd.count, metadata)
  ssc.scheduler.inputInfoTracker.reportInfo(validTime, inputInfo)
  currentOffsets = untilOffsets.map(kv => kv._1 -> kv._2.offset)
  Some(rdd)
}

处理逻辑的保证

       Spark RDD之所以被称为“弹性分布式数据集”,是因为它具有不可变、可分区、可并行计算、容错的特征。一个RDD只能由稳定的数据集生成,或者从其他RDD转换(transform)得来。如果在执行RDD lineage的过程中失败,那么只要元数据不发生变化,无论重新执行多少次lineage,都会得到同样的、确定的结果。

       Spark Streaming的输出一般是靠foreachRDD()算子来实现,它默认是at least once的。如果输出过程中出错,那么就会重复执行知道写入成功。为了让它符合Exactly once,可以施加两种限制之一:幂等性(idempotent write)事务性写入(transactional write)

  • 幂等性写入

幂等性原来是数学里的概念,即f(f(x))=f(x)。幂等写入就是写入多次与写入一次的结果完全相同,可以自动将at least once转化为Exactly once。这对于自带主键或主键组的业务比较合适(如:各类日志、MySQL binlog),并且实现起来简单;但是它要求处理逻辑是map-only的,也就是只能包含转换、过滤等操作,不能包含shuffle、聚合等操作。如果条件更严格,就只能采用事务性写入方法:

stream.foreachRDD { rdd =>
  rdd.foreachPartition { iter =>
    // make sure connection pool is set up on the executor before writing
    SetupJdbc(jdbcDriver, jdbcUrl, jdbcUser, jdbcPassword)

    iter.foreach { case (key, msg) =>
      DB.autoCommit { implicit session =>
        // the unique key for idempotency is just the text of the message itself, for example purposes
        sql"insert into idem_data(msg) values (${msg})".update.apply
      }
    }
  }
}
  • 事务性写入

这里的事务和DBMS中的事务含义基本相同,就是对数据进行一系列访问与更新操作所组成的逻辑块。为了符合事务性的ACID特性,必须引入一个唯一ID标识当前的处理逻辑,并且将计算结果与该ID一起落盘。ID可以由主题、分区、时间、offset等共同组成;事务操作可以在foreachRDD()时进行。如果数据吸入失败,或者offset吸入与当前offset range不匹配,那么这一批次数据都将失败并且回滚:

// localTx is transactional, if metric update or offset update fails, neither will be committed
DB.localTx { implicit session =>
  // store metric data
  val metricRows = sql"""
update txn_data set metric = metric + ${metric}
  where topic = ${osr.topic}
""".update.apply()
  if (metricRows != 1) {
    throw new Exception("...")
  }

  // store offsets
  val offsetRows = sql"""
update txn_offsets set off = ${osr.untilOffset}
  where topic = ${osr.topic} and part = ${osr.partition} and off = ${osr.fromOffset}
""".update.apply()
  if (offsetRows != 1) {
    throw new Exception("...")
  }
}

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值