最近面試螞蟻金服一面的時候,和面試官聊項目問題的時候,發現我這邊業務實現的top100場景好像沒有實現exactly once語義,我們項目的offset是存儲在zk中,然後業務處理完畢後,最後再提交offset更新到zk,這種時候就會出現一個問題就是如果業務處理完畢,數據已經更新到redis中進行了累加,然後offset更新zk沒成功宕機了,再次重啓的時候就會讀取老的offset導致數據重複消費兩次。由於我們這裏是實時top100,每個批次數據來了需要累加式的更新老的數據,即業務處理不是冪等的,所以這種方式是有問題的(這裏如果業務處理是冪等的,最後提交offset其實最終效果來說和exactly once是一樣的)。
對此,某天早上地鐵上班時看到公衆號推薦的一篇關於分佈式事務的實現方案的文章,受到其中介紹的維護一個第三方表的模式來實現分佈式事務的啓示,我們這裏可以直接用樂觀鎖的思想加上第三個輔助表的形式,來實現我們的Spark Streaming + Kafka +Redis 實現exactly once語義的top100。
樂觀鎖的實現不瞭解的可以自己百度在此不再贅述,我們利用的就是其中的一種實現,給每條記錄添加一個版本號,而這個版本號就是和我們的批次相關聯起來的,這樣來保證每條記錄只被消費一次,話不多說,直接上設計圖:
代碼實現如下:
package main.scala
import com.mmtrix.java.constant.ConfigInfo
import com.mmtrix.java.utils.RedisShardedPool
import com.mmtrix.scala.utils.{SimpleKafkaCluster, SparkStreamUtil, StreamingConfig}
import main.java.MyConstant
import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.fs.{FileSystem, Path}
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.spark.rdd.RDD
import scala.collection.JavaConverters._
import org.apache.spark.streaming.dstream.InputDStream
import org.apache.spark.streaming.kafka.{HasOffsetRanges, OffsetRange}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import redis.clients.jedis.ShardedJedisPipeline
import scala.collection.mutable.ArrayBuffer
class Test {
def processOne(rdd: RDD[ConsumerRecord[String, String]]): Unit = {
rdd.foreachPartition(part => {
val batchRec = ArrayBuffer.empty[ConsumerRecord[String, String]]
while (part.hasNext) {
val rec = part.next()
batchRec.append(rec)
if (batchRec.length == MyConstant.BATCH_SIZE) {
// 批量查詢更新數據
// ...
batchUpd(batchRec)
}
}
})
// 業務指標處理完畢,更新redis中關於該業務指標的參數
...
}
def batchUpd(batchRec: ArrayBuffer[ConsumerRecord[String, String]]): Unit = {
// 批量更新,更新前判斷版本號是否
}
def process(dStream: InputDStream[ConsumerRecord[String, String]], ssc: StreamingContext)(implicit streamingConfig: StreamingConfig, kc: SimpleKafkaCluster) = {
val topic = streamingConfig.topic
val group = streamingConfig.group
val topicKey = group + topic
dStream.foreachRDD(rdd => {
val jedis = RedisShardedPool.getJedis.pipelined()
val entireSta = jedis.hgetAll(MyConstant.BATCH_STATUS)
jedis.sync()
val batchSta = entireSta.get()
val status = batchSta.get("status")
val batchCnt = batchSta.get("batch_cnt")
if (status == "start") { // 讀取到的上個批次狀態爲start,說明上個批次處理異常
val totalIndexSta = jedis.hgetAll(MyConstant.INDEX_STATUS) // 讀取上個批次各個子任務處理狀態
jedis.sync()
val indexSta = totalIndexSta.get()
val oneSta = indexSta.get("one_status") // 指標1的狀態
val oneCnt = indexSta.get("one_cnt") // 指標1的版本號
if (oneSta == "start") {
//說明指標1沒處理完畢任務失敗
processOne(rdd)
// 數據處理完畢,更新one_status和one_cnt,由於用的是hmset,這裏甚至都不需要用事務,因爲兩個指標是一起更新的。
jedis.hmset(MyConstant.INDEX_STATUS, Map("one_status" -> "finish", "one_cnt" -> batchCnt).asJava)
}
val secSta = indexSta.get("sec_status")
val secCnt = indexSta.get("sec_cnt")
// ... 後續處理和指標一完全一致
} else { //應用正常運行,上一輪任務執行正常結束
//先初始化當前批次執行狀態,批次號+1,狀態更新
jedis.hmset(topicKey,Map("status"->"start","batch_cnt"->(batchCnt.toInt+1).toString).asJava)
processOne(rdd)
//processSec(rdd)
//processThird(rdd) ...
}
//執行到這裏,說明以上所有業務已經處理完畢,通過事務方式更新輔助中間數據
val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
updOffsetAnStatus(offsetRanges,batchCnt,jedis,topicKey)
jedis.hmset(topicKey,Map("offset"->"").asJava)
})
}
def updOffsetAnStatus(ranges: Array[OffsetRange],batchCnt:String,jedis:ShardedJedisPipeline,topicKey:String): Unit ={
var map = Map[String,String]("status"->"finish","batch_cnt"->batchCnt)
for (o <- ranges) {
val field = o.partition.toString
val value = o.untilOffset.toString
map += (field -> value)
}
jedis.hmset(topicKey,map.asJava)
}
def start(): Unit = {
def funcToCreateSSC(): StreamingContext = {
val sparkConf = new org.apache.spark.SparkConf().setAppName(ConfigInfo.sparkJobNameConfig)
//sparkConf.set(...)
implicit val streamingConfig = new StreamingConfig
implicit val kc = new SimpleKafkaCluster(streamingConfig.kafkaParams)
val ssc = new StreamingContext(sparkConf, Seconds(ConfigInfo.durationConfig))
val kafkaStream = SparkStreamUtil.createDirectStream(ssc)
process(kafkaStream, ssc)
ssc.checkpoint(ConfigInfo.checkpointDirectoryConfig)
ssc
}
FileSystem.get(new Configuration()).deleteOnExit(new Path(ConfigInfo.checkpointDirectoryConfig))
val ssc = StreamingContext.getOrCreate(ConfigInfo.checkpointDirectoryConfig, funcToCreateSSC)
ssc.start()
ssc.awaitTermination()
}
}
package com.mmtrix.scala.utils
import _root_.kafka.message.MessageAndMetadata
import com.mmtrix.java.utils.RedisShardedPool
import kafka.common.TopicAndPartition
import kafka.serializer.DefaultDecoder
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.spark.SparkException
import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.dstream.InputDStream
import org.apache.spark.streaming.kafka.KafkaUtils
import collection.JavaConverters._
/**
* Created by Administrator on 2016/3/15.
*/
object SparkStreamUtil {
implicit val streamingConfig = new StreamingConfig
implicit val kc = new SimpleKafkaCluster(streamingConfig.kafkaParams)
def createDirectStream(ssc: StreamingContext)(implicit streamingConfig: StreamingConfig, kc: SimpleKafkaCluster): InputDStream[ConsumerRecord[String, String]] = {
//從 redis 上讀取offset開始消費message
val messages = {
var hasConsumed = true
val kafkaPartitionsE = kc.getPartitions(streamingConfig.topicSet)
if (kafkaPartitionsE.isLeft) throw new SparkException("get kafka partition failed:")
val kafkaPartitions = kafkaPartitionsE.right.get // 先從zk讀取當前kafka最新的partition
// 依據zk獲取的topicAndPartition去redis讀取數據,並且要判斷是否是第一次開始消費
val jedis = RedisShardedPool.getJedis
val partitionCnt = kafkaPartitions.size // 記錄從zk讀取的一共有多少個分區,用於判斷是否集羣新增了partition
val topic = streamingConfig.topic
val group = streamingConfig.group
val topicKey = group + topic
val redisPartitionCnt = jedis.hgetAll(group + topic) // 讀取redis中當前這個group在topic下的消費情況
val previousNum = redisPartitionCnt.getOrDefault("partition_num", "0").toInt //之前數據庫中分區信息
if (previousNum == 0) {
//沒有被消費過,則從zk中最新的offset開始消費。
val leaderLatestOffsets = kc.getLatestLeaderOffsets(kafkaPartitions).right.get
// 初始化redis對應分區的offset數據
leaderLatestOffsets.map(tp => {
val offset = tp._2.offset
val partition = tp._1.partition
jedis.hset(topicKey, partition.toString, offset.toString)
})
} else if (previousNum < partitionCnt) { // 新增分區
// 說明分區數改變了,需要新增分區信息到redis
val leaderLatestOffsets = kc.getLatestLeaderOffsets(kafkaPartitions).right.get
leaderLatestOffsets.map(tp => {
val offset = tp._2.offset
val partition = tp._1.partition
val partitionInfo = jedis.hget(topicKey, partition.toString) //獲取數據,判斷當前分區之前是否已經存在
if (partitionInfo.isEmpty) { // 分區數據爲空,說明這個分區是新增的
jedis.hset(topicKey, partition.toString, offset.toString)
}
})
} else if (previousNum > partitionCnt) { //減少了分區
// ...
}
//以上操作完畢後,redis中存儲的一定就是當前需要消費的各個分區中的offset正確數據
val infos = jedis.hgetAll(topicKey).asScala //所有分區offset數據
val offsetRange = infos.map(info => {
val partition = info._1
val offset = info._2
val tp = TopicAndPartition(topic, partition.toInt)
(tp, offset.toLong)
}).toMap
KafkaUtils.createDirectStream[String, String, DefaultDecoder, DefaultDecoder, ConsumerRecord[String, String]](
ssc, streamingConfig.kafkaParams, offsetRange, (mmd: MessageAndMetadata[String, String]) => {
new ConsumerRecord[String,String](mmd.topic,mmd.partition,mmd.key(),mmd.offset)
})
}
messages
}
def main(args: Array[String]): Unit = {
}
}
以上部分redis更新部分內容沒有寫,由於redis採用的是分片模式,所以就把所有狀態都放到一個固定的key下了,然後通過hmset
一次性進行設置,這樣也可以避免用事務,算是可有可無的優化吧。另外一點需要注意的是關於SparkStreamUtil
類的實現,流程控制其實理解了就很好實現,但是這個類還是有點小坑的,這個類需要兼容初次啓動時Redis中沒有相關kafka數據時數據的初始化,以及新增或者刪除分區時的識別,此類初始化數據以及分區的感知都是依賴於kafka自身zk中的元數據信息,當然其實這裏最好的自動實時感知分區變化的方式應該是自定義一個DirectKafkaInputDStream
類型的InputStream
,具體實現參考文章:https://blog.csdn.net/chen20111/article/details/80827226
代碼中還有一個優化的地方就是,由於業務中有多個指標的更新,每個指標更新完畢後,會維護一個對應指標的狀態,這樣假設有十個指標需要更新,然後更新到第五個指標應用掛了,那麼再次重啓時,前四個業務指標部分就可以不需要重複執行了(因爲更新前判斷上個批次這四個指標狀態是finish),這樣可以提高應用中途宕機重啓時的速度。最最最後一點是,由於這裏top100是把狀態管理全部挪到了Redis中,所以其實是完全可以棄用Checkpoint
的,因爲即便宕機了,重啓之後最後一個批次的執行狀態其實都記錄在Redis中了,所以有沒有Checkpoint
都無所謂了的。
(代碼寫的比較急,可能會存在小部分漏洞,歡迎大家指正~)