kafka具備的分散式、高吞吐、高可用特性,以及所提供的各種訊息消費模式可以保證在一個多節點叢集環境裡訊息被消費的安全性:即防止每條訊息遺漏處理或重複消費。特別是exactly-once消費策略:可以保證每條訊息肯定只被消費一次。換句話說就是在分散式運算環境裡kafka的訊息消費是能保證唯一性的。
但是,保證了訊息讀取唯一性,訊息的處理過程如果也放到分散式運算環境裡仍然會面對資料完整性(data integrity)問題。例如:訊息處理過程是更新銀行賬戶中金額、訊息內容是更新某個賬戶的指令,那麼,對多條針對同一個銀行賬戶的訊息進行並行處理時肯定會引發資料完整性問題。這就是本文重點討論的問題。
我們來看看下面的程式碼:
kfkSource .async.mapAsync(parallelism=8) { msg => updateAccount(msg.value() } .toMat(Sink.fold(0) { (accu, e) => if (e) accu + 1 else accu })(Keep.right) .run()
在上面的例子裡,從kafka佇列裡逐一讀取的訊息可能有多個被並行處理(最多有8個並行執行緒parallelism=8), 如果這8條訊息裡包含相同的賬戶號碼,肯定會產生資料完整性問題。那麼如果:
> kfkSource .async.mapAsync(parallelism=1) { msg => updateAccount(msg.value() } .toMat(Sink.fold(0) { (accu, e) => if (e) accu + 1 else accu })(Keep.right) .run()
用(parallelism=1),這樣每條訊息用單一執行緒處理,犧牲一些效率,能解決問題嗎?答案是:在這臺伺服器上貌似可以。但我們的目的是在一個多節點叢集環境裡進行資料處理。這也應該是我們使用kafka的初衷嘛。在分散式環境裡上面的這段程式碼等於是在多個節點上同時執行,同樣會產生像多執行緒並行運算所產生的問題。
顯然:問題的核心是重複的訊息內容,在上面的例子裡是多條訊息裡相同的銀行賬號。如果相同的賬號在同一個執行緒裡進行處理就可以避免以上問題了。akka actor信箱裡的指令是按序逐個執行的,所以我們如果能保證把相同內容的訊息發給同一個actor就可以解決問題了。為了實現有目的的向actor傳送訊息,可以使用叢集分片(cluster-sharding)。在akka-cluster裡,每一個分片都就等於一個命名的actor。還有一個問題是如果涉及大量的唯一賬號,或者商品號,比如超百萬的唯一編號又該怎麼辦呢?剛才講過:我們只要保證每一種訊息發給同一個分片,多種訊息是可以發個同一個分片的。所以,對於大量編號我們可以通過hash演算法來簡化編號精度,如下:
def hashItemCode(code: String): String = { val arrCode = code.toCharArray var occur : Array[Int] = Array.fill(8)(0) arrCode.foreach { case x if (x >= '0' && x <= '2') => occur(0) = occur(0) + 1 case x if (x >= '3' && x <= '5') => occur(1) = occur(1) + 1 case x if (x >= '6' && x <= '8') => occur(2) = occur(2) + 1 case x if (x == '9' || x == '-' || x == '_' || x == ':') => occur(3) = occur(3) + 1 case x if ((x >= 'a' && x <= 'g') || (x >= 'A' && x <= 'G')) => occur(4) = occur(4) + 1 case x if ((x >= 'h' && x <= 'n') || (x >= 'H' && x <= 'N')) => occur(5) = occur(5) + 1 case x if ((x >= 'o' && x <= 't') || (x >= 'O' && x <= 'T')) => occur(6) = occur(6) + 1 case x if ((x >= 'u' && x <= 'z') || (x >= 'U' && x <= 'Z')) => occur(7) = occur(7) + 1 case _ => occur(7) = occur(7) + 1 } occur.mkString }
這個hashItemCode返回一個字串,代表原編碼code中各種字母發生的頻率,把這個字串作為sharding的entityId。
那麼從kafaka讀取一條訊息後按hashItemCode結果指定傳送給某個分片,下面是一個實際例子:
def toStockWorker(jsonDoc: String) = { val bizDoc = fromJson[BizDoc](jsonDoc) val plu = bizDoc.pluCode val entityId = DocModels.hashItemCode(plu) log.step(s"CurStk-toStockWorker: sending CalcStock to ${entityId} with message: $jsonDoc") val entityRef = sharding.entityRefFor(StockCalculator.EntityKey, entityId) entityRef ! StockCalculator.CalcStock(jsonDoc) }
下面我提供一個exactly-once原始碼作為參考;
(1 to numReaders).toList.map {_ => RestartSource .onFailuresWithBackoff(restartSource) { () => mergedSource } // .viaMat(KillSwitches.single)(Keep.right) .async.mapAsync(1) { msg => //only one message uniq checked for { //and flow down stream newtxn <- curStk.isUniqStkTxns(msg.value()) _ <- FastFuture.successful { log.step(s"ExactlyOnceReaderGroup-futStkTxnExists is ${!newtxn}: ${msg.value()}") } } yield (newtxn,msg) } .async.mapAsyncUnordered(8) { rmsg => //passed down msg for { //can be parrallelly processed cmt <- if (rmsg._1) stkTxns.stkTxnsWithRetry(rmsg._2.value(), rmsg._2.partition(), rmsg._2.offset()).toFuture().map(_ => "Completed") else FastFuture.successful {"stktxn exists!"} pmsg <- FastFuture.successful { log.step(s"ExactlyOnceReaderGroup-stkTxnsWithRetry: committed transaction-$cmt") rmsg } } yield pmsg } .async.mapAsyncUnordered(8) { rmsg => for { _ <- if(rmsg._1) FastFuture.successful {curStk.toStockWorker(rmsg._2.value())} else FastFuture.successful(false) pmsg <- FastFuture.successful { log.step(s"ExactlyOnceReaderGroup-updateStk...") rmsg } } yield pmsg } .async.mapAsyncUnordered(8) { rmsg => for { _ <- if (rmsg._1) FastFuture.successful { pcmTxns.toPcmAggWorker(rmsg._2.value()) } else FastFuture.successful(false) pmsg <- FastFuture.successful { log.step(s"ExactlyOnceReaderGroup-AccumulatePcm...") } } yield "Completed" } .toMat(Sink.seq)(Keep.left) .run() }