Spark Streaming全天候实时top N实现

1. 背景介绍

  公司的日志平台是通过spark streaming消费kafka上的数据,解析完毕后直接存入到hdfs,然后到了每天凌晨通过pig脚本来对前一天的hdfs上的全量数据进行统计分析,得出前一天的日志的各项指标。全量的数据量一天通过lzo压缩后有大概4T,解压完估计得有40~50T。然后每天计算的指标有十个,有若干个指标是需要计算top n这种,而且pig脚本代码质量较差,有一些join操作之类的,pig脚本一次运行时间需要4-5个小时,遇到特殊情况日志暴增则会时间更长导致处理时间超过了后续定时任务执行的时间,而后续定时任务是需要pig脚本处理结果进行进一步处理的,从而出现过某个定时任务时间到了但是由于pig脚本未执行完毕导致该任务执行失败。并且还有一点就是pig脚本运行期间集群资源消耗极其严重,所有资源全部被侵占,其它任何任务都无法执行,所以需要对pig脚本转spark streaming做实时处理,这样就可以给客户提供实时top n,并且可以很好的利用白天集群空闲的资源。

2.失败的尝试

2.1 updateStateByKey

  由于之前用的Spark版本比较老,是1.5.1,所以先通过updateStateByKey算子来进行实时top N统计,这个算子是返回的一个StateDStream,那么我们来通过源码看看这个StateDStream是如何保存状态的。

--> new StateDStream(self, newUpdateFunc, partitioner, rememberPartitioner, None)

class StateDStream[K: ClassTag, V: ClassTag, S: ClassTag](
    parent: DStream[(K, V)],
    updateFunc: (Time, Iterator[(K, Seq[V], Option[S])]) => Iterator[(K, S)],
    partitioner: Partitioner,
    preservePartitioning: Boolean,
    initialRDD: Option[RDD[(K, S)]]
  ) extends DStream[(K, S)](parent.ssc) {

  super.persist(StorageLevel.MEMORY_ONLY_SER)

  override def dependencies: List[DStream[_]] = List(parent)

  override def slideDuration: Duration = parent.slideDuration

  override val mustCheckpoint = true

  private [this] def computeUsingPreviousRDD(
      batchTime: Time,
      parentRDD: RDD[(K, V)],
      prevStateRDD: RDD[(K, S)]) = {
    // Define the function for the mapPartition operation on cogrouped RDD;
    // first map the cogrouped tuple to tuples of required type,
    // and then apply the update function
    val updateFuncLocal = updateFunc
    val finalFunc = (iterator: Iterator[(K, (Iterable[V], Iterable[S]))]) => {
      val i = iterator.map { t =>
        val itr = t._2._2.iterator
        val headOption = if (itr.hasNext) Some(itr.next()) else None
        (t._1, t._2._1.toSeq, headOption)
      }
      updateFuncLocal(batchTime, i)
    }
    val cogroupedRDD = parentRDD.cogroup(prevStateRDD, partitioner) ##这一步随着时间递增会越来越耗时
    val stateRDD = cogroupedRDD.mapPartitions(finalFunc, preservePartitioning)
    Some(stateRDD)
  }
  ...

  为了篇幅考虑,省略了compute方法的源码部分,compute的逻辑和之前源码解析部分介绍DStream中的基本逻辑是一样的,只是在StateDStream中获取到上一个批次的RDD之后会和当前批次的RDD一起调用computeUsingPreviousRDD方法,而这个方法中会执行两个rddcogroup,然后对获取到的cogroupRDD执行自定义的更新逻辑,这种方式不好的地方有三点:

  1. 每次都会获取上一个批次的所有记录,cogroup完后需要对所有key进行更新,即便当前批次的RDD中压根没有数据的key,也需要执行一遍更新操作,这样会耗费很多不必要的时间;
  2. 由于每个批次完毕后,上个批次的RDD存的其实是全量的数据,导致随着时间递增,内存消耗越来越大,如果集群资源不够,会导致部分数据需要溢写到磁盘上,如果是需要计算用的数据,那么会严重影响性能。
  3. 随着时间递增,RDD缓存占据的storage越来越大,这部分占了非常多的存储内存,如果是在1.6版本以后,Spark的堆内存是动态分配的,可能会出现某些时候计算内存需要的比较少有空余,然后Storage部分会侵占部分空闲的执行内存,但是之后新的task进入的时候,留给它的可执行的内存就很少了,这个时候需要Storage部分自觉的把侵占的执行内存中的数据给溢写到磁盘,甚至这个过程中可能会触发gc,不论是溢写到磁盘的过程还是触发gc,都会对task的执行有影响,实践过程中发现这个时候影响最大的还是会频繁出发gc,导致gc时间急剧上升。

  由于以上原因在尝试了通过updateStateByKey后选择了放弃,不过这里不是说这个算子一无是处,只是我们这里的场景是需要统计全天的top n,时间跨度太大,如果是时间跨度小点,例如实时统计最近一个小时内的top n,这个时候用这个算子还是可以的,因为每隔一个小时把之前的状态值全部清空就可以了。这部分逻辑的代码如下:

def getHourlyTopN():Unit = {
    val updateFunc = (values: Seq[Int], state: Option[Int]) => {
      //通过checkDelTime方法判断当前批次时间是否是整点,是整点且当前key没有数据则返回None就会自动删除当前这个key的信息
      if (checkDelTime) {
        if (values.isEmpty) {
          None
        }
        Some(values.sum)
      } else {
        val currentCount = values.sum
        val previousCount = state.getOrElse(0)
        Some(currentCount + previousCount)
      }
    }
    val newUpdateFunc = (iterator: Iterator[(String, Seq[Int], Option[Int])]) => {
      iterator.flatMap(t => updateFunc(t._2, t._3).map(s => (t._1, s)))
    }
    val newReferAgg = data.map(line => {
      val key = ...
      (key, 1)
    }).updateStateByKey(newUpdateFunc, new HashPartitioner(ssc.sparkContext.defaultParallelism), true)
    ...
}
2.2 mapWithState

  既然老版本的updateStateByKey不行,那么我们尝试更新Spark版本到2.3.2(项目没有历史负担就是好),然后用mapWithState算子,不过需要注意这个算子还在实验状态,由于这里只是为了测试,所以先不管这个使用一下试试。
  由于mapWithState源码实现比updateStateByKey复杂很多,这里不对源码进行详细介绍,只需要知道mapWithState内部存储上个批次的RDD内部是通过一个OpenHashMapBasedStateMap的map来存储的,这个map里会记录每个key的状态,包括最近一次的更新时间等,它的内部存储用的是OpenHashMap类,这个map的实现只支持insert、update,不支持delete删除操作,通过这个map速度比HashMap快5倍而且占据更少的内存。通过这个map,可以在两个rdd执行更新操作时,不再需要和之前一样执行cogroup而对上一个批次的所有数据进行遍历,而是直接遍历当前批次的RDD数据,获取key,然后通过这个key去上个批次RDD获取的map中查找之前的状态值进行更新。
  但是上面这个实现只是解决了updateStateByKey中的第一个问题,即不再需要遍历没有变化的数据,但是对于内存消耗的问题还是没有解决,运行时间长了,还是会特别消耗集群内存并且导致gc问题。当然和updateStateByKey一样,它还是值得尝试的,起码他解决了问题1,就性能而言是比updateStateByKey要快很多的,而且它还可以设置key的过期时间,设置过期时间为t,则只要时间t以内某个key没有数据更新,就会在下次生成新的stateMap时被无情抛弃。但是这些特性显然不适用于我们的应用所需场景,实现代码如下:

val newUpdateFunc = (word: String, one: Option[Int], state: State[Int]) => {
      val sum = one.getOrElse(0) + state.getOption.getOrElse(0)
      val output = (word, sum)
      //如果设置了过期时间,这里需要加上if判断,否则如果某个key过期了会报错
      if(!state.isTimingOut())state.update(sum) 
      output
    }
    val newReferAgg = data.map(line => {
      val markData = ...
      (markData, 1)
    }).mapWithState(StateSpec.function(newUpdateFunc).timeout(Duration(300)))

3. 自定义状态管理

  由于公司业务需要进行状态管理的指标过多,都是进行全天候实时统计分析的指标,所以导致某个批次假设对于单个指标累计的数据是10G,那么由于不同指标存储的状态的key不一样,无法多个指标公用一个中间状态,所以导致十个指标可能就需要耗费100G的内存来存储十份不同的状态,在这种情况下,使用Spark的updateStateByKey或是mapWithState自带的状态管理机制会存在严重性能问题,所以我们只能自己来对每个批次处理完成后的状态进行自行管理了。
  要实现自行管理,其实就是把每个批次执行完毕后的状态值存到Spark的Executor以外的第三方,例如可以存储到Redis、ignite、Cassandra等nosql数据库中。这里鉴于个人之前项目中有过Redis使用的经验,所以为了实现效率考虑先选用了Redis来进行状态管理,不过中间查阅了部分ignite和cassandra的资料,尤其对ignite十分感兴趣,后面会单独自己进行一番探索,感觉ignite和spark应该可以擦出许多的“Spark”喔!
  这里之前在用updateStateByKeymapWithState进行测试的时候,通过观察webui已经发行执行一段时间,storage缓存的rdd数据会很容易就突破400G,先是尝试了直接使用集群模式,但是发现集群模式居然不支持pipeline,想要实现集群模式的pipeline需要自己拓展实现各种异常重试等机制,所以这里考虑机器内存问题以及效率问题,选择使用多Redis实例组成分片集群的模式进行快速开发测试,最后Redis测试部署在三台服务器上,每台服务器内存150G左右,逻辑CPU为40个,足够支撑短期测试,每台服务器部署8个Redis实例,这里查阅过资料网上对于同一台服务器部署多少个Redis实例最好,歪果网友推荐的是部署的Redis实例最好是逻辑CPU个数的一半的个数,由于Redis是单线程的,这样多个实例可以充分利用服务器的多个CPU提升性能。不过考虑到服务器还有其它应用,这里暂时部署八个实例进行测试足以。
  具体代码实现如下:

val updateRedis = (pipeline: ShardedJedisPipeline, records: Seq[((String, String, String, String, String, String), Int, String)]) => {
      var i = 0
      while (i < records.size) {
        val initKey = records(i)._1
        val cnt = records(i)._2
        val detailKey = new StringBuilder(initKey._1).append(sep).append(initKey._2).append(sep).append(initKey._3).
          append(sep).append(initKey._4).append(sep).append(initKey._5).append(sep).append(initKey._6).toString()

        val domain = initKey._6 + "_1" //防止多个指标的域名相同产生冲突加个后置标签
        pipeline.zincrby(domain, cnt, detailKey)

        pipeline.sadd(REFER_DOMAINS, domain)
        i += 1
      }
    }

    val mappingFunc = (iter: Iterator[((String, String, String, String, String, String), Int)]) => {
      val rsp = RedisShardedPool.getJedis
      val pipeline = rsp.pipelined()
      val records = ArrayBuffer.empty[((String, String, String, String, String, String), Int, String)]
      try {
        while (iter.hasNext) {
          val record = iter.next()
          val key = record._1
          val cnt = record._2
          val redisKey = DigestUtils.sha1Hex(key.toString())
          val newValue = (key, cnt, redisKey)
          records += newValue
          if (records.size == 5000) {
            updateRedis(pipeline, records)
            records.clear()
          }
        }
        updateRedis(pipeline, records)
        pipeline.sync()
        Iterator[Int]()
      } finally {
        rsp.close()
      }
    }
    //由于要mapPartitions将所有批次的数据更新到redis后,需要提取每个域名的top 100,所以直接通过repartition继续执行后续操作,
    //分区设置为1是因为读取全部域名时域名数据很少,直接在一个executor上读取所有domain即可,
    //而且如果多个executor都读取一次全量域名会造成后续处理有问题,读取完全部域名然后重新对全部域名分区到不同节点
    //每个分区分配若干个域名,然后去读取这些域名的top 100,这个过程就相当于spark sql中的group by操作获取top100
    // 读取完每个域名的top 100后,再存入到mongodb中,从而对其它应用提供实时查询。
    data.map(transformData).reduceByKey(_+_).mapPartitions(mappingFunc).repartition(1).mapPartitions(_ => {
      val jedis = RedisShardedPool.getJedis
      try {
        val allDomains = jedis.smembers(REFER_DOMAINS).asScala.toList
        allDomains.iterator
      } finally {
        jedis.close()
      }
      }).repartition(spark.sparkContext.defaultParallelism).mapPartitions(iter => {
      val rsp = RedisShardedPool.getJedis
      try {
        val pp1 = rsp.pipelined()
        val detailKeys = ArrayBuffer.empty[Response[util.Set[Tuple]]]
        while (iter.hasNext) {
          val domain = iter.next()
          val top100 = pp1.zrevrangeByScoreWithScores(domain, MAX_CNT, 0, 0, 100)
          detailKeys.append(top100)
        }
        pp1.sync()
        detailKeys.flatMap(x => {
          val domain100 = x.get()
          val iter = domain100.iterator()
          val arr = ArrayBuffer.empty[TopUrl]
          while (iter.hasNext) {
            val tp = iter.next()
            val record = tp.getElement
            val cnt = tp.getScore
            val _id = DigestUtils.sha1Hex(record)
            val r = CaseUtils.topUrl(_id, keys(0), keys(1), keys(2), keys(3), keys(4), keys(5), cnt)
            arr.append(r)
          }
          arr.toList
        }).iterator
    } finally {
      rsp.close()
    }
}).foreachRDD(rdd => {
    import spark.implicits._
    val writeCfg = WriteConfig(Map(...))
    rdd.toDF().write.mode("overwrite").mongo(writeCfg)
})

  以上代码实现,经过测试运行十分稳定,之前通过updateStateByKeymapWithState测试一分钟一个批次时,即便前期内存充足运行稳定期的平均执行时间都要45-50之间,而通过Redis自定义状态管理后,长时间运行任务稳定在29S,至此,具体的实现基本敲定,当然除了这里的代码实现,还需要考虑Redis分片集群的容错问题等优化,这个后续再进行进一步研究~

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