alpakka-kafka(2)-consumer

雪川大蟲發表於2021-02-22

   alpakka-kafka-consumer的功能描述很簡單:向kafka訂閱某些topic然後把讀到的訊息傳給akka-streams做業務處理。在kafka-consumer的實現細節上,為了達到高可用、高吞吐的目的,topic又可用劃分出多個分割槽partition。分割槽是分佈在kafka叢集節點broker上的。由於一個topic可能有多個partition,對應topic就會有多個consumer,形成一個consumer組,共用統一的groupid。一個partition只能對應一個consumer、而一個consumer負責從多個partition甚至多個topic讀取訊息。kafka會根據實際情況將某個partition分配給某個consumer,即partition-assignment。所以一般來說我們會把topic訂閱與consumer-group掛鉤。這個可以在典型的ConsumerSettings證實:

  val system = ActorSystem("kafka-sys")
  val config = system.settings.config.getConfig("akka.kafka.consumer")
  val bootstrapServers = "localhost:9092"
  val consumerSettings =
    ConsumerSettings(config, new StringDeserializer, new ByteArrayDeserializer)
      .withBootstrapServers(bootstrapServers)
      .withGroupId("group1")
      .withProperty(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest")

我們先用一個簡單的consumer plainSource試試把前一篇示範中producer寫入kafka的訊息讀出來: 

import akka.actor.ActorSystem
import akka.kafka._
import akka.kafka.scaladsl._
import akka.stream.{RestartSettings, SystemMaterializer}
import akka.stream.scaladsl.{Keep, RestartSource, Sink}
import org.apache.kafka.clients.consumer.{ConsumerConfig, ConsumerRecord}
import org.apache.kafka.common.serialization.{ByteArrayDeserializer, StringDeserializer}

import scala.concurrent._
import scala.concurrent.duration._
object plain_source extends App {
  val system = ActorSystem("kafka-sys")
  val config = system.settings.config.getConfig("akka.kafka.consumer")
  implicit val mat = SystemMaterializer(system).materializer
  implicit val ec: ExecutionContext = mat.executionContext
  val bootstrapServers = "localhost:9092"
  val consumerSettings =
    ConsumerSettings(config, new StringDeserializer, new StringDeserializer)
      .withBootstrapServers(bootstrapServers)
      .withGroupId("group1")
      .withProperty(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest")

  val subscription = Subscriptions.topics("greatings")
  Consumer.plainSource(consumerSettings, subscription)
    .runWith(Sink.foreach(msg => println(msg.value())))

  scala.io.StdIn.readLine()
  system.terminate()

}

以上我們沒有對讀出的訊息做任何的業務處理,直接顯示出來。注意每次都會從頭完整讀出,因為設定了 .withProperty(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"),也就是kafka-consumer的auto.offset.reset = "earliest" 。那麼如果需要用讀出的資料進行業務處理的話,每次開始執行應用時都會重複從頭執行這些業務。所以需要某種機制來標註已經讀取的訊息,也就是需要記住當前讀取位置offset。

Consumer.plainSource輸入ConsumerRecord型別:

    public ConsumerRecord(String topic,
                          int partition,
                          long offset,
                          K key,
                          V value) {
        this(topic, partition, offset, NO_TIMESTAMP, TimestampType.NO_TIMESTAMP_TYPE,
                NULL_CHECKSUM, NULL_SIZE, NULL_SIZE, key, value);
    }

這個ConsumerRecord型別裡包括了offset,使用者可以自行commit這個位置引數,也就是說使用者可以選擇把這個offset儲存在kafka或者其它的資料庫裡。說到commit-offset,offset管理機制在kafka-consumer業務應用中應該屬於關鍵技術。kafka-consumer方面的業務流程可以簡述為:從kafka讀出業務指令,執行指令並更新業務狀態,然後再從kafka裡讀出下一批指令。為了實現業務狀態的準確性,無論錯過一些指令或者重複執行一些指令都是不能容忍的。所以,必須準確的標記每次從kafka讀取資料後的指標位置,commit-offset。但是,如果讀出資料後即刻commit-offset,那麼在執行業務指令時如果系統發生異常,那麼下次再從標註的位置開始讀取資料時就會越過一批業務指令。這種情況稱為at-most-once,即可能會執行一次,但絕不會重複。另一方面:如果在成功改變業務狀態後再commit-offset,那麼,一旦執行業務指令時發生異常而無法進行commit-offset,下次讀取的位置將使用前一次的標註位置,就會出現重複改變業務狀態的情況,這種情況稱為at-least-once,即一定會執行業務指令,但可能出現重複更新情況。如果涉及資金、庫存等業務,兩者皆不可接受,只能採用exactly-once保證一次這種模式了。不過也有很多業務要求沒那麼嚴格,比如某個網站統計點選量,只需個約莫數,無論at-least-once,at-most-once都可以接受。

kafka-consumer-offset是一個Long型別的值,可以存放在kafka內部或者外部的資料庫裡。如果選擇在kafka內部儲存offset, kafka配置裡可以設定按時間間隔自動進行位置標註,自動把當前offset存入kafka裡。當我們在上面例子的ConsumerSettings裡設定自動commit後,多次重新執行就不會出現重複資料的情況了:

val consumerSettings =
    ConsumerSettings(config, new StringDeserializer, new StringDeserializer)
      .withBootstrapServers(bootstrapServers)
      .withGroupId("group1")
      .withProperty(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true")        //自動commit
      .withProperty(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000")   //自動commit間隔
      .withProperty(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest")

alpakka-kafka提供了Committer型別,是akka-streams的Sink或Flow元件,負責把offset寫入kafka。如果用Committer的Sink或Flow就可以按使用者的需要控制commit-offset的發生時間。如下面這段示範程式碼: 

 

  val committerSettings = CommitterSettings(system)

  val control: DrainingControl[Done] =
    Consumer
      .committableSource(consumerSettings, Subscriptions.topics("greatings"))
      .mapAsync(10) { msg =>
        BusinessLogic.runBusiness(msg.record.key, msg.record.value)
          .map(_ => msg.committableOffset)
      }
      .toMat(Committer.sink(committerSettings))(DrainingControl.apply)
      .run()

control.drainAndShutdown();

  scala.io.StdIn.readLine()
  system.terminate()

}
object BusinessLogic {
  def runBusiness(key: String, value: String): Future[Done] = Future.successful(Done)
}

上面這個例子裡BusinessLogic.runBusiess()模擬一段業務處理程式碼,也就是說完成了業務處理之後就用Committer.sink進行了commit-offset。這是一種at-least-once模式,因為runBusiness可能會發生異常失敗,所以有可能出現重複運算的情況。Consumer.committableSource輸出CommittableMessage: 

def committableSource[K, V](settings: ConsumerSettings[K, V],
                              subscription: Subscription): Source[CommittableMessage[K, V], Control] =
    Source.fromGraph(new CommittableSource[K, V](settings, subscription))



  final case class CommittableMessage[K, V](
      record: ConsumerRecord[K, V],
      committableOffset: CommittableOffset
  )

  @DoNotInherit sealed trait CommittableOffset extends Committable {
    def partitionOffset: PartitionOffset
  }

Committer.sink接受輸入Committable型別並將之寫入kafka,上游的CommittableOffset 繼承了 Committable。另外,這個DrainingControl型別結合了Control型別和akka-streams終結訊號可以有效控制整個consumer-streams安全終結。

alpakka-kafka還有一個atMostOnceSource。這個Source元件每讀一條資料就會立即自動commit-offset:

  def atMostOnceSource[K, V](settings: ConsumerSettings[K, V],
                             subscription: Subscription): Source[ConsumerRecord[K, V], Control] =
    committableSource[K, V](settings, subscription).mapAsync(1) { m =>
      m.committableOffset.commitInternal().map(_ => m.record)(ExecutionContexts.sameThreadExecutionContext)
    }

可以看出來,atMostOnceSource在輸出ConsumerRecord之前就進行了commit-offset。atMostOnceSource的一個具體使用示範如下:

  import scala.collection.immutable
  val control: DrainingControl[immutable.Seq[Done]] =
    Consumer
      .atMostOnceSource(consumerSettings, Subscriptions.topics("greatings"))
      .mapAsync(1)(record => BussinessLogic.runBusiness(record.key, record.value()))
      .toMat(Sink.seq)(DrainingControl.apply)
      .run()

  control.drainAndShutdown();
  scala.io.StdIn.readLine()
  system.terminate()

所以,使用atMostOnceSource後是不需要任何Committer來進行commit-offset的了。值得注意的是atMostOnceSource是對每一條資料進行位置標註的,所以執行效率必然會受到影響,如果要求不是那麼嚴格的話還是啟動自動commit比較合適。

對於任何型別的交易業務系統來說,無論at-least-once或at-most-once都是不可接受的,只有exactly-once才妥當。實現exactly-once的其中一個方法是把offset與業務資料存放在同一個外部資料庫中。如果在外部資料庫通過事務處理機制(transaction-processing)把業務狀態更新與commit-offset放在一個事務單元中同進同退就能實現exactly-once模式了。下面這段是官方文件給出的一個示範:

  val db = new mongoldb
  val control = db.loadOffset().map { fromOffset =>
    Consumer
      .plainSource(
        consumerSettings,
        Subscriptions.assignmentWithOffset(
          new TopicPartition(topic, /* partition = */ 0) -> fromOffset
        )
      )
      .mapAsync(1)(db.businessLogicAndStoreOffset)
      .toMat(Sink.seq)(DrainingControl.apply)
      .run()
  }

class mongoldb {
  def businessLogicAndStoreOffset(record: ConsumerRecord[String, String]): Future[Done] = // ...
  def loadOffset(): Future[Long] = // ...
}

在上面這段程式碼裡:db.loadOffset()從mongodb裡取出上一次讀取位置,返回Future[Long],然後用Subscriptions.assignmentWithOffset把這個offset放在一個tuple (TopicPartition,Long)裡。TopicPartition定義如下: 

    public TopicPartition(String topic, int partition) {
        this.partition = partition;
        this.topic = topic;
    }

這樣Consumer.plainSource就可以從offset開始讀取資料了。plainSource輸出ConsumerRecord型別:

    public ConsumerRecord(String topic,
                          int partition,
                          long offset,
                          K key,
                          V value) {
        this(topic, partition, offset, NO_TIMESTAMP, TimestampType.NO_TIMESTAMP_TYPE,
                NULL_CHECKSUM, NULL_SIZE, NULL_SIZE, key, value);
    }

這裡面除業務指令value外還提供了當前offset。這些已經足夠在businessLogicAndStoreOffset()裡運算一個單獨的business+offset事務了(transaction)。 

 

 

 

 

 

 

 

 

相關文章