Spark中的exactly once语义

exactly once指的是在处理数据的过程中,系统有很好的容错性(fault-tolerance),能够保证数据处理不重不丢,每一条数据仅被处理一次。
Spark具备很好的机制来保证exactly once的语义,具体体现在数据源的可重放性、计算过程中的容错性、以及写入存储介质时的幂等性或者事务性。

数据源的可重放性

数据源具有可重放性指的是在出现问题时可以重新获取到需要的数据,一般Spark的数据源分为两种:
第一种为基于文件系统的数据源,如HDFS,这种数据源的数据来自于文件,所以本身就具备了可重放性;
另一种为基于外部接收器的数据源,如Kafka,这种数据源就不一定能够具备可重放性,需要具体考虑。

以Kafka为例,一般与Kafka配合使用的是Spark中的SparkStreaming模块,用来对流式数据进行准实时的处理。而SparkStreaming接入Kafka的数据有两种模式,一种为Receiver模式,一种为Direct模式。

Receiver模式
Receiver模式采用Kafka的高阶consumer API,Kafka自己封装了对数据的获取逻辑,且通过Zookeeper管理offset信息,这种模式在与SparkStreaming对接时,有以下特点:

  1. Kafka中的partition数量与SparkStreaming中的并行度不是一一对应的,SparkStreaming通过创建Receiver去读取Kafka中数据,createStream()方法传入的并发参数代表的是读取Kafka中topic+partition的线程数,并不能提高SparkStreaming读取数据的并行度。
  2. Kafka自己管理offset,Receiver作为一个高层的Consumer来消费数据,其消费的偏移量(offset)由Kafka记录在Zookeeper中,一旦出现错误,那些已经标记为消费过的数据将会丢失。

Receiver模式下,为了解决读取数据时的并行度问题,可以创建多个DStream,然后union起来,具体可参考文章:https://www.jianshu.com/p/c8669261165a;为了解决数据丢失的问题,可以选择开启Spark的WAL(write ahead log)机制,每次处理数据前将预写日志写入到HDFS中,如果节点出现错误,可以从WAL中恢复。但是这种方法其实效率低下,不仅数据冗余(Kafka中有副本机制,Spark中还要存一份),且无法保证exactly once,数据可能重复消费。

无论采取什么方法进行补救,Receiver模式都不能够实现exactly once的语义,其根本原因是Kafka自己管理的offset与SparkStreaming实际处理数据的offset没有同步导致的。

Direct模式
为了解决Receiver模式的弊病,Spark1.3中引入了Direct模式来替代Receiver模式,它使用Kafka的Simple consumer API,由Spark应用自己管理offset信息,以达成exactly once的语义,其特点如下:

  1. Kafka中的partition与SparkStreaming中的partition一一对应,也就是SparkStreaming读取数据的并行度取决于Kafka中partition的数量。
  2. 不依赖Receiver,而是通过低阶api直接找到topic+partition的leader获取数据,并由SparkStreaming应用自己负责追踪维护消费的offset。

由于SparkStreaming自己可以维护offset,所以应用自身消费的数据和偏移量之间的对应关系确定的,数据也是同步的,所以可以实现exactly once的语义。

下面将给出Direct模式下,SparkStreaming应用管理offset的方法案例,其中offset依然是存放在zookeeper中,但是由应用自身来管理的,offset也可以放在Redis、MySQL、HBase中进行管理,根据具体情况进行选择。

createDirectStream方法:

def createDirectStream(ssc:StreamingContext)(implicit streamingConfig: StreamingConfig, kc: SimpleKafkaCluster): InputDStream[(Array[Byte], Array[Byte])] = {
  val topics = streamingConfig.topicSet
  val groupId = streamingConfig.group
   // 首先更新offset
  setOrUpdateOffsets(topics, groupId)
  //从zookeeper上读取offset开始消费message
  val messages = {
    val partitionsE = kc.getPartitions(topics)
    if (partitionsE.isLeft)
      throw new SparkException(s"get kafka partition failed: ${partitionsE.left.get}")
    val partitions = partitionsE.right.get
    val consumerOffsetsE = kc.getConsumerOffsets(groupId, partitions)
    if (consumerOffsetsE.isLeft)
      throw new SparkException(s"get kafka consumer offsets failed: ${consumerOffsetsE.left.get}")
    val consumerOffsets = consumerOffsetsE.right.get
    consumerOffsets.foreach {
      case (tp, n) => println("===================================" + tp.topic + "," + tp.partition + "," + n)
    }
    KafkaUtils.createDirectStream[Array[Byte], Array[Byte], DefaultDecoder, DefaultDecoder, (Array[Byte], Array[Byte])](
      ssc, streamingConfig.kafkaParams, consumerOffsets, (mmd: MessageAndMetadata[Array[Byte], Array[Byte]]) => (mmd.key, mmd.message))
  }
  messages
}

代码中,参数streamingConfig中封装了Kafka的具体参数信息,如topic名称,broker list,消费者组id等。kc则是Simple Consumer API的接口类,封装了具体获取Kafka数据的方法。代码段刚开始的时候就调用了setOrUpdateOffsets()方法来更新offset,确保下面得到的数据是最新的。

setOrUpdateOffsets方法:

private def setOrUpdateOffsets(topics: Set[String], groupId: String): Unit = {
  topics.foreach(topic => {
    var hasConsumed = true
    val partitionsE = kc.getPartitions(Set(topic))
    if (partitionsE.isLeft)
      throw new SparkException(s"get kafka partition failed: ${partitionsE.left.get}")
    val partitions = partitionsE.right.get
    val consumerOffsetsE = kc.getConsumerOffsets(groupId, partitions)
    if (consumerOffsetsE.isLeft) hasConsumed = false
    // 某个groupid首次没有offset信息,会报错,从头开始读
    if (hasConsumed) {// 消费过
      /**
        * 如果streaming程序执行的时候出现kafka.common.OffsetOutOfRangeException,
        * 说明zk上保存的offsets已经过时了,即kafka的定时清理策略已经将包含该offsets的文件删除。
        * 针对这种情况,只要判断一下zk上的consumerOffsets和earliestLeaderOffsets的大小,
        * 如果consumerOffsets比earliestLeaderOffsets还小的话,说明consumerOffsets已过时,
        * 这时把consumerOffsets更新为earliestLeaderOffsets
        */
      val earliestLeaderOffsetsE = kc.getEarliestLeaderOffsets(partitions)
      if (earliestLeaderOffsetsE.isLeft)
        throw new SparkException(s"get earliest leader offsets failed: ${earliestLeaderOffsetsE.left.get}")
      val earliestLeaderOffsets = earliestLeaderOffsetsE.right.get
      val consumerOffsets = consumerOffsetsE.right.get
      // 可能只是存在部分分区consumerOffsets过时,所以只更新过时分区的consumerOffsets为earliestLeaderOffsets
      var offsets: Map[TopicAndPartition, Long] = Map()
      consumerOffsets.foreach({ case(tp, n) =>
        val earliestLeaderOffset = earliestLeaderOffsets(tp).offset
        if (n < earliestLeaderOffset) {
          println("consumer group:" + groupId + ",topic:" + tp.topic + ",partition:" + tp.partition +
            " offsets已经过时,更新为" + earliestLeaderOffset)
          offsets += (tp -> earliestLeaderOffset)
        }
      })
      if (!offsets.isEmpty) {
        kc.setConsumerOffsets(groupId, offsets)
      }
    } else {// 没有消费过
    val reset = streamingConfig.resetSign.toLowerCase
      var leaderOffsets: Map[TopicAndPartition, LeaderOffset] = null
      if (reset == Some("smallest")) {// 从头消费
      val leaderOffsetsE = kc.getEarliestLeaderOffsets(partitions)
        if (leaderOffsetsE.isLeft)
          throw new SparkException(s"get earliest leader offsets failed: ${leaderOffsetsE.left.get}")
        leaderOffsets = leaderOffsetsE.right.get
      } else { // 从最新offset处消费
        val leaderOffsetsE = kc.getLatestLeaderOffsets(partitions)
        if (leaderOffsetsE.isLeft)
          throw new SparkException(s"get latest leader offsets failed: ${leaderOffsetsE.left.get}")
        leaderOffsets = leaderOffsetsE.right.get
      }
      val offsets = leaderOffsets.map {
        case (tp, offset) => (tp, offset.offset)
      }
      kc.setConsumerOffsets(groupId, offsets)
    }
  })
}

最后是updateZKOffsets方法,用于应用输出数据后,更新zk中的offset:

def updateZKOffsets(rdd: RDD[(Array[Byte], Array[Byte])]) : Unit = {
  val groupId = streamingConfig.group
  val offsetsList = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
  for (offsets <- offsetsList) {
    val topicAndPartition = TopicAndPartition(offsets.topic, offsets.partition)
    val o = kc.setConsumerOffsets(groupId, Map((topicAndPartition, offsets.untilOffset)))
    if (o.isLeft) {
      println(s"Error updating the offset to Kafka cluster: ${o.left.get}")
    }
  }
}

对于一个SparkStreaming应用来说,每个批次通过createDirectStream方法来获取zookeeper中最新的offset,然后使用simple kafka api获取数据,消费处理完之后,再通过updateZKOffsets方法,更新这个duration消费的offset至zookeeper,以此过程保证了exactly once的语义。

计算过程中的容错性

以上所述仅保证了在读取数据源过程中的exactly once,数据读取成功后,在Spark应用中做处理时,是怎么保证数据不重不丢的呢?Spark在容错性这一方面交出了令人满意的答卷。撇去Driver与Executor的高可用性不说,Spark应用内部则采用checkpoint和lineage的机制来确保容错性。

lineage
一般翻译为血统,简单来说就是RDD在转化的过程中,由于父RDD与子RDD存在依赖关系(Dependency),从而形成的lineage,也可以理解为lineage串起了RDD DAG。

RDD可以进行缓存,通过调用persist或者cache方法,将RDD持久化到内存或者磁盘中,这样缓存的RDD就可以被保留在计算节点的内存中被重用,缓存是构建Spark快速迭代的关键。

当一个RDD丢失的情况下,Spark会去寻找它的父RDD是否已经缓存,如果已经缓存,就可以通过父RDD直接算出当前的RDD,从而避免了缓存之前的RDD的计算过程,且只有丢失数据的partition需要进行重算,这样Spark就避免了RDD上的重复计算,能够极大的提升计算速度。

缓存虽然可以提升Spark快速迭代计算的速度,但是缓存是会丢失的。

checkpoint
检查点机制就是为了可以切断lineage的依赖关系,在某个重要的节点,将RDD持久化到文件系统中(一般选择HDFS),这样就算之前的缓存已经丢失了,也可以保证检查点数据不会丢失,这样在恢复的时候,会直接从检查点的数据开始进行计算,检查点机制在SparkStreaming这种流式计算中发挥的作用会更大。

可以通过以下源码为入口进一步了解Spark的缓存和检查点机制,RDD在进行计算的时候会调用其iterator方法,在该方法中会首先去读取缓存的数据,如果没有缓存的数据则会去读取checkpoint的数据

final def iterator(split: Partition, context: TaskContext): Iterator[T] = {
  if (storageLevel != StorageLevel.NONE) {
    getOrCompute(split, context)
  } else {
    computeOrReadCheckpoint(split, context)
  }
}

Spark在计算过程中采用的lineage和checkpoint机制相互结合,取长补短,再加上Spark各个组件底层本身就是具有高可用性,所以在Spark应用在转化计算的过程中,可是保证数据处理的exactly once。

写入存储介质的幂等性或事务性

Spark进行数据输出的时候,为了达到exactly once,有两种方式:

  1. 幂等更新
    指多次写入的结果总是写入相同的数据,比较典型的例子是key-value型数据库,即使数据可能多次写入,但是最终的结果也不会影响其正确性,Spark RDD的输出方法saveAsTextFile在输出的时候将RDD转换成为PairRDD,总是将相同的数据写入到文件系统中,而PairRDD的输出方法本身就满足key-value的模型,所以均满足幂等更新。
  2. 事务更新
    指所有的更新都是基于事务的,所以更新都是exactly once。Spark需要用户自己实现事物机制,在foreachRDD方法中,用户可以使用batch time和partition index来创建一个id,使用这个id来确保数据的唯一性,启动事务并使用这个id来更新外部系统数据,如果这个id不存在则提交更新,如果这个id已经存在那么则放弃更新。
dstream.foreachRDD { (rdd, time) =>
  rdd.foreachPartition { partitionIterator =>
    val partitionId = TaskContext.get.partitionId()
    val uniqueId = generateUniqueId(time.milliseconds, partitionId)
    // use this uniqueId to transactionally commit the data in partitionIterator
  }
}

另外,还有一些下游的存储介质本身就不支持幂等或是事务性写入,比如kafka。Spark的task或是stage的失败重做机制以及kafka本身的高可用写入,都会造成一些数据重复,这可能就需要Kafka本身去支持transaction write或者其下游应用去实现去重机制。

最后,exactly once固然是个理想的状态,但其实现成本也是非常高的,在对数据可靠性要求不是很高的场景中,at-least-once甚至丢失少量数据也是可以作为一个选项考虑的。

你可能感兴趣的:(Spark中的exactly once语义)