上篇描述的kafka案例是个库存管理平台。是一个公共服务平台,为其它软件模块或第三方软件提供库存状态管理服务。当然,平台管理的目标必须是共享的,即库存是作为公共资源开放的。这个库存管理平台是一个Kafka消费端独立运行的软件。kafka的生产方即平台的服务对象通过kafka生产端producer从四面八方同时、集中将消息写入kafka。库存管理平台在kafka消费端不间断监控kafka里新的未读过的消息并及时读取,解析消息获取发布者对库存管理的指令,然后按指令更新库存状态。
设计这个库存管理平台最主要的目的先是为了保证库存状态的时效性、准确性,然后才是库存更新的效率。由于库存更新指令的产生是在一个高并发、异类系统、分布式环境里,上篇已经提到多线程环境下更新共享数据会产生的问题。不过通过kafka把并发产生的指令转换成队列然后按顺序单线程逐句执行就能解决主要问题了。现在,平台的数据来源变成kafka消费端口上的一个数据流了,数据的读取和消费自然也变成了逐条的。kafka提供了某种游标机制来记录数据读取的最新位置,防止数据消费过程中的遗漏、重复。记录当前读取位置offset的方式就是所谓数据消费模式代表数据消费不同程度的安全/效率比例,安全系数越高,流量越低。具体读取位置offset可以存放在kafka内部,或者保存在某种数据库表里。简单来讲,数据消费模式分三种:至多一次at-most-once,至少一次at-least-once,只此一次exactly-once。
从由kafka中读出指令到成功完成执行指令整个消息消费过程可能经历多个步骤。每个步骤都可能有失败的可能,从而中断过程影响数据消费结果。保存offset即offset-commit的时间点代表了三种消费模式的特性:
1、至多一次at-most-once:读出数据立即commit-offset,然后才开始消费数据。无论消费过程中发生异常与否,下次都会从新的位置开始读取,过去不再。如果一条数据在消费过程中发生事故中断了过程,那这条数据就没有发生应有的作用,就等于遗失了。
2、至少一次at-least-once:读出数据、消费数据、然后才commit-offset。如果消费过程出现问题中断,那么offset就得不到保存,下次再读取时还是从原先位置重新开始。所以,一条数据有可能被多次读取,造成重复消费的效果。
3、只此一次exactly-once:把保存offse和消费过程放到同一个事务transaction里。这种模式需要数据库事物处理支持,也就是说offset-commit和数据处理都必须在同一种提供事物处理支持的数据库环境里进行。offset-commit只会在确保消费过程成功完成后才进行。
at-most-once和at-least-once都使用kafka内部commit机制保存offset。at-least-once可以利用kafka的自动commit机制实现offset保存,只要通过kafka配置就可以了。下面是这个配置的示范:
val consumerSettings = ConsumerSettings(consumerConfig, new StringDeserializer, new StringDeserializer) .withBootstrapServers(bootstrapServers) .withGroupId(group) .withProperty(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset) .withProperty(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true") .withProperty(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, autoCommitIntervalMs.toString)
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG = "true" 代表开启auto-commit模式。ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG设置了auto-commit之间的毫秒时间间隔。在这个间隔内如果中断消费过程,那么在这个间隔内读取所有数据的offset都未能commit,但其中有些数据已经完成消费了。重启读取就会从这个间隔开始时的offset从头读取,那么之前消费的数据就会再次消费,等于重复消费了。auto-commit间隔设置的越短,重复消费的数据就越少,不过kafka需要更密集的进行commit-offset,运行效率就越低。反之,重复消费的数据量就越大,消费计算精确度越低,但运行效率就会提高。
在alpakka-kafka里用一个普通的Source就可以实现at-least-once消费模式了:
val consumerSettings = ConsumerSettings(consumerConfig, new StringDeserializer, new StringDeserializer) .withBootstrapServers(bootstrapServers) .withGroupId(group) .withProperty(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset) .withProperty(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true") .withProperty(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, autoCommitIntervalMs.toString) var subscription = Subscriptions .topics(topic) val stkTxns = new DocToStkTxns(trace) val curStk = new CurStk(trace) val pcmTxns = new PcmTxns(trace) val plainSource = Consumer .plainSource(consumerSettings,subscription)
run这个plainSource形成的akka-stream就实现了一个完整kafka-reader功能:
plainSource .mapAsync(1) {msg => updateStock(msg) } .toMat(Sink.seq)(Keep.left) .run()
offset-commit在这个reader-stream里是不可控的,是kafka按预先设置自动进行的。
plainSource是一个独立的stream,代表单个reader。为了充分利用平台的硬件资源,首先考虑的是同时运行多个stream,如下:
(1 to numReaders).toList.map { _ => plainSource .mapAsync(1) {msg => updateStock(msg) } .toMat(Sink.seq)(Keep.left) .run() }
这样可以同时运行numReaders条stream。不过,现在设计方案又返回了多线程环境,好像又要面临多并发所产生的一系列问题了。我们来分析分析:首先,前面描述的库存更新多线程竞争问题主要是针对同一门店,同一商品,同时更新库存状态引发的。以上设计中每条stream,即每个reader,如果属于同一个reader-group(group-id相同)的话,应共同分别负责所有partition中的部分partition,是不会共享partition的。那么,写入每个partition的数据是否交叉重复就很关键了。实际上,在上游消息发布阶段决定了消息应该写入的具体partition,如下:
def writeToKafka(posTxn: PosTxns)(implicit producerKafka: ProducerKafka) = { val doc = BizDoc.fromPosTxn(posTxn) if (producerKafka.producerSettings.isDefined) { implicit val producer = producerKafka.akkaClassicSystem.get SendProducer(producerKafka.producerSettings.get) .send(new ProducerRecord[String, String](producerKafka.publisherSettings.topic, doc.shopId, toJson(doc))) } else FastFuture.successful(Completed) }
ProducerRecord[K,V] 的key设定为shopId,具体目标partition由kafka的默认指派算法根据key的值产生,保证同一key值一定会指派给同一个partition。虽然在门店数量>partition数量的情况下每个partition可以包含多个shopId, 但各partition所包含的shopId不会交叉重复。所以,以上多reader同时运行的设计中,只要属于同一个reader-group,shopId就不会相同,就不会产生线程竞争问题。
那么,在同一个reader的消费过程中是否能使用多线程方式呢?上面的例子中使用了mapAsync(parallelism=1),这个代表了stream里的一个阶段。这个阶段容许收到上游数据后以parallelism个future来并行处理,同时可以保证流出下游的数据遵守上游流入数据的顺序。但是,在同一阶段用多线程方式计算方式在遇到同门店、同商品库存更新时同样会产生多线程竞争问题,所以只能取parallelism=1。不过,可以考虑把数据处理过程分割成几个阶段,因为每个阶段流入流出的数据是同循序的,所以可以容许多个阶段在在各自的线程里运算。如:
(1 to numReaders).toList.map { _ => plainSource .mapAsync(1) {msg => produceStkTxns(msg) } asyn.mapAsync(1) {msg => updateCurStock(msg) } asyn.mapAsync(1) {msg => updatePurchase(msg) } .toMat(Sink.seq)(Keep.left) .run() }
可以用asyn.mapAsync来分割异线程域async-boundary以实现多线程运算效果。
下面的完整例子里把异常处理和重启也考虑了进去:
def start = (1 to numReaders).toList.map { _ => RestartSource .onFailuresWithBackoff(restartSource) { () => plainSource } // .viaMat(KillSwitches.single)(Keep.right) .async.mapAsync(1) { msg => for { _ <- FastFuture.successful { log.step(s"AtLeastOnceReaderGroup-msg: $msg")(Messages.MachineId("", "")) } _ <- stkTxns.docToStkTxns(msg.value()) pmsg <- FastFuture.successful { log.step(s"AtLeastOnceReaderGroup-docToStkTxns: $msg")(Messages.MachineId("", "")) msg } } yield pmsg } .async.mapAsync(1) { msg => for { _ <- FastFuture.successful { log.step(s"AtLeastOnceReaderGroup-updateStk: msg: $msg")(Messages.MachineId("", "")) } curstks <- curStk.updateStk(msg.value()) pmsg<- FastFuture.successful { log.step(s"AtLeastOnceReaderGroup-updateStk: curstks-$curstks")(Messages.MachineId("", "")) msg } } yield pmsg } .async.mapAsync(1) { msg => for { _ <- FastFuture.successful { log.step(s"AtLeastOnceReaderGroup-writePcmTxn: msg: $msg")(Messages.MachineId("", "")) } pcm <- pcmTxns.writePcmTxn(msg.value()) pmsg <- FastFuture.successful { log.step(s"AtLeastOnceReaderGroup-updateStk: writePcmTxn-$pcm")(Messages.MachineId("", "")) msg } } yield pmsg } .async.mapAsync(1) { msg => for { _ <- FastFuture.successful { log.step(s"AtLeastOnceReaderGroup-updatePcm: msg: $msg")(Messages.MachineId("", "")) } _ <- pcmTxns.updatePcm(msg.value()) _ <- FastFuture.successful { log.step(s"AtLeastOnceReaderGroup-updateStk: updatePcm-$msg")(Messages.MachineId("", "")) } } yield "Completed" } .toMat(Sink.seq)(Keep.left) .run() }
下面是几个消费模式的测试示范代码:
package com.datatech.txn.server import akka.actor.ActorSystem import scala.concurrent._ import MgoRepo._ import com.typesafe.config.ConfigFactory import scala.jdk.CollectionConverters._ object ConsumeModeTest extends App with JsonConverter { val config_onenode = ConfigFactory.load("onenode") implicit val system = ActorSystem("kafka-sys",config_onenode) var config = ConfigFactory.load() implicit val ec: ExecutionContext = system.dispatcher //mat.executionContext var httpport: Int = 53081 var mongohosts = List("localhost:27017") var elastichost = "http://localhost:9200" var _http_parallelism: Int = 8 var _seednodes: String = "" val txnCfg = ConfigFactory.load("txnserver.conf").getConfig("txn.server") try { mongohosts = txnCfg.getStringList("mongohosts").asScala.toList elastichost = txnCfg.getString("elastichost") _http_parallelism = txnCfg.getInt("http_parallelism") _seednodes = txnCfg.getString("seednodes") httpport = txnCfg.getInt("httpport") } catch { case excp: Throwable => httpport = 53081 mongohosts = List("localhost:27017") elastichost = "http://localhost:9200" _http_parallelism = 8 } implicit val mgoClient = mongoClient(mongohosts) val readerConfig = config.getConfig("akka.kafka.consumer") val readerSettings = ReaderSettings(config.getConfig("kafka-txnserver-consumer")) implicit val idxer = new TxnIndex(elastichost,true) readerSettings.consumeMode.toLowerCase() match { case "atleastonce" => val readerGroup = AtLeastOnceReaderGroup(readerConfig,readerSettings, true) readerGroup.start case "atmostonce" => val readerGroup = AtMostOnceReaderGroup(readerConfig,readerSettings, true) readerGroup.start case "exactlyonce" => val readerGroup = ExactlyOnceReaderGroup(readerConfig,readerSettings, true) readerGroup.start case _ => val readerGroup = AtLeastOnceReaderGroup(readerConfig,readerSettings, true) readerGroup.start } scala.io.StdIn.readLine() idxer.close() scala.io.StdIn.readLine() system.terminate() }