?在之前的舊版本中,Kafka只能支持兩種語義:At most once和At least once。At most once保證消息不會朝服,但是可能會丟失。在實踐中,很有有業務會選擇這種方式。At least once保證消息不會丟失,但是可能會重復,業務在處理消息需要進行去重。、
?Kafka在0.11.0.0版本支持增加了對冪等的支持。冪等是針對生產者角度的特性。冪等可以保證上生產者發送的消息,不會丟失,而且不會重復
如何實現冪等
HTTP/1.1中對冪等性的定義是:一次和多次請求某一個資源對于資源本身應該具有同樣的結果(網絡超時等問題除外)。也就是說,其任意多次執行對資源本身所產生的影響均與一次執行的影響相同
Methods can also have the property of “idempotence” in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request.
實現冪等的關鍵點就是服務端可以區分請求是否重復,過濾掉重復的請求。要區分請求是否重復的有兩點:
- 唯一標識:要想區分請求是否重復,請求中就得有唯一標識。例如支付請求中,訂單號就是唯一標識
- 記錄下已處理過的請求標識:光有唯一標識還不夠,還需要記錄下那些請求是已經處理過的,這樣當收到新的請求時,用新請求中的標識和處理記錄進行比較,如果處理記錄中有相同的標識,說明是重復交易,拒絕掉
Kafka冪等性實現原理
為了實現Producer的冪等性,Kafka引入了Producer ID(即PID)和Sequence Number。
- PID。每個新的Producer在初始化的時候會被分配一個唯一的PID,這個PID對用戶是不可見的。
- Sequence Numbler。(對于每個PID,該Producer發送數據的每個<Topic, Partition>都對應一個從0開始單調遞增的Sequence Number
Kafka可能存在多個生產者,會同時產生消息,但對Kafka來說,只需要保證每個生產者內部的消息冪等就可以了,所有引入了PID來標識不同的生產者。
對于Kafka來說,要解決的是生產者發送消息的冪等問題。也即需要區分每條消息是否重復。
Kafka通過為每條消息增加一個Sequence Numbler,通過Sequence Numbler來區分每條消息。每條消息對應一個分區,不同的分區產生的消息不可能重復。所有Sequence Numbler對應每個分區
Broker端在緩存中保存了這seq number,對于接收的每條消息,如果其序號比Broker緩存中序號大于1則接受它,否則將其丟棄。這樣就可以實現了消息重復提交了。但是,只能保證單個Producer對于同一個<Topic, Partition>的Exactly Once語義。不能保證同一個Producer一個topic不同的partion冪等。
實現冪等前后對比
標準實現冪等性示例
生產者要使用冪等性很簡單,只需要增加以下配置即可:
enable.idempotence=true
Properties props = new Properties();
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
props.put("acks", "all"); // 當 enable.idempotence 為 true,這里默認為 all
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
KafkaProducer producer = new KafkaProducer(props);
producer.send(new ProducerRecord(topic, "test");
Prodcuer 冪等性對外保留的接口非常簡單,其底層的實現對上層應用做了很好的封裝,應用層并不需要去關心具體的實現細節,對用戶非常友好
冪等性實現生產者流程
此流程只展示了涉及生產者冪等性相關的重要操作
這里重點關注冪等性相關的內容,首先,KafkaProducer啟動時,會初始化一個
TransactionManager 實例,它的作用有以下幾個部分:
- 記錄本地的事務狀態(事務性時必須)
- 記錄一些狀態信息以保證冪等性,比如:每個 topic-partition 對應的下一個 sequence numbers 和 last acked batch(最近一個已經確認的 batch)的最大的 sequence number 等;
- 記錄 ProducerIdAndEpoch 信息(PID 信息)。
冪等性時,Producer 的發送流程如下:
1)調用kafkaProducer的send方法將數據添加到 RecordAccumulator 中,添加時會判斷是否需要新建一個 ProducerBatch,這時這個 ProducerBatch 還是沒有 PID 和 sequence number 信息的;
2)Producer 后臺發送線程 Sender,在 run() 方法中,會先根據 TransactionManager 的 shouldResetProducerStateAfterResolvingSequences() 方法判斷當前的 PID 是否需要重置,重置的原因是因為:如果有topic-partition的batch已經超時還沒處理完,此時可能會造成sequence number 不連續。因為sequence number 有部分已經分配出去了,而Kafka服務端沒有收到這部分sequence number 的序號,Kafka服務端為了保證冪等性,只會接受同一個pid的sequence number 等于服務端緩存sequence number +1的消息,所有這時候需要重置Pid來保證冪等性
synchronized boolean shouldResetProducerStateAfterResolvingSequences() {
/**
* 是否是事務即配置了Tid
* 如果是事務則不需重置Pid
*/
if (isTransactional())
// We should not reset producer state if we are transactional. We will transition to a fatal error instead.
return false;
for (Iterator<TopicPartition> iter = partitionsWithUnresolvedSequences.iterator(); iter.hasNext(); ) {
TopicPartition topicPartition = iter.next();
if (!hasInflightBatches(topicPartition)) {//沒有該分區的消息在發送中
// The partition has been fully drained. At this point, the last ack'd sequence should be once less than
// next sequence destined for the partition. If so, the partition is fully resolved. If not, we should
// reset the sequence number if necessary.
/**
* 判斷SequenceNo是否連續
* 如果連續的,就不需要重置Pid
*/
if (isNextSequence(topicPartition, sequenceNumber(topicPartition))) {
// This would happen when a batch was expired, but subsequent batches succeeded.
iter.remove();
} else {
// We would enter this branch if all in flight batches were ultimately expired in the producer.
log.info("No inflight batches remaining for {}, last ack'd sequence for partition is {}, next sequence is {}. " +
"Going to reset producer state.", topicPartition, lastAckedSequence(topicPartition), sequenceNumber(topicPartition));
return true;
}
}
}
return false;
}
3)Sender線程調用maybeWaitForProducerId()方法判斷是否要申請Pid,如果需要,會阻塞直到成功申請到Pid
ProducerIdAndEpoch producerIdAndEpoch = null;
boolean isTransactional = false;
if (transactionManager != null) {//有事務或者啟用冪等
//事務是否允許向此分區發送消息
if (!transactionManager.isSendToPartitionAllowed(tp))
break;
producerIdAndEpoch = transactionManager.producerIdAndEpoch();
if (!producerIdAndEpoch.isValid())
// we cannot send the batch until we have refreshed the producer id
break;
//是否支持事務
isTransactional = transactionManager.isTransactional();
/**
* 如果該分區的前面還有沒發送完成的Batch,則需要跳過該分區的Batch,等待之前batch發送完成
*/
if (!first.hasSequence() && transactionManager.hasUnresolvedSequence(first.topicPartition))
break;
/**
* 該分區存在發送中的Batch,該Batch有Sequence,和first的不相等。則跳過、
*
* 也即first是個重試的Batch(因為它有Sequence),需要等待該分區發送中的Batch完成
*/
int firstInFlightSequence = transactionManager.firstInFlightSequence(first.topicPartition);
if (firstInFlightSequence != RecordBatch.NO_SEQUENCE && first.hasSequence()
&& first.baseSequence() != firstInFlightSequence)
break;
}
ProducerBatch batch = deque.pollFirst();
/**
* 校驗當前batch是否已經設置了Sequence
* 如果沒有,則需要設置batch的Sequence,增加對應分區的Next Sequence,將batch加入到inflightBatchesBySequence中
*/
if (producerIdAndEpoch != null && !batch.hasSequence()) {
//設置Batch的sequenceNumber 和isTransactional
batch.setProducerState(producerIdAndEpoch, transactionManager.sequenceNumber(batch.topicPartition), isTransactional);
//增加該分區的sequenceNumber,增加值為Batch中消息的個數
transactionManager.incrementSequenceNumber(batch.topicPartition, batch.recordCount);
log.debug("Assigned producerId {} and producerEpoch {} to batch with base sequence " +
"{} being sent to partition {}", producerIdAndEpoch.producerId,
producerIdAndEpoch.epoch, batch.baseSequence(), tp);
//加入到發送隊列中
transactionManager.addInFlightBatch(batch);
}
batch.close();//關閉此batch,不可追加消息
size += batch.records().sizeInBytes();//累計size
ready.add(batch);//加到集合中,最后一起返回出去
batch.drained(now);//更新drainedMs時間戳
5)最后調用sendProduceRequest方法將消息發送出去
冪等性服務端相關的類
BatchMetadata
用來存儲Batch的元數據, BatchMetadata類的幾個重要的字段
- lastSeq:Batch中最后一條消息的seq
- lastOffset: Batch中最后一條消息的offset
- offsetDelta: 第一條消息和最后一條消息的offset之差 lastSeq-offsetDelta可以得到第一條消息的seq,lastOffset-offsetDelta可以得到第一條消息的offset
ProducerStateEntry
用于存儲每個producerId對應的Batch,按照sequence從小到大進行排序,最小的作為頭,最大的作為尾 ,每個producerId的隊列失蹤保持著最多5個Batch,如果超過5個了,就從頭開始remove。
ProducerStateEntryl類的重要字段:
producerId:生產者id,用服務端生成,生產者發送消息時會帶上此字段
batchMetadata:Queue[BatchMetadata]類型,里面存放了服務端收到的該生產者最新的Batch,最多存放5個
producerEpoch: 生產者的年代,默認為-1
ProducerStateEntryl類的核心方法:addBatch()方法,往ProducerStateEntry中添加Batch,此方法首先會判斷是否要更新epoch,如果epoch不一樣,則會清空batchMetadata隊列并更新最新的epoch,然后加batch添加到batchMetadata中,添加前也會先校驗batchMetadata的元素個數是否等于ProducerStateEntry.NumBatchesToRetain,如果相等就剔除掉頭部的BatchMetadata。代碼如下:
def addBatch(producerEpoch: Short, lastSeq: Int, lastOffset: Long, offsetDelta:
Int, timestamp: Long): Unit = {
maybeUpdateEpoch(producerEpoch)//更新producerEpoch,如果producerEpoch不一樣就清空隊列中的Batch
addBatchMetadata(BatchMetadata(lastSeq, lastOffset, offsetDelta, timestamp))
}
def maybeUpdateEpoch(producerEpoch: Short): Boolean = {
if (this.producerEpoch != producerEpoch) {
batchMetadata.clear()//清空Batch
this.producerEpoch = producerEpoch//更新producerEpoch
true
} else {
false
}
}
private def addBatchMetadata(batch: BatchMetadata): Unit = {
if (batchMetadata.size == ProducerStateEntry.NumBatchesToRetain)
batchMetadata.dequeue()//去掉頭部的Batch
batchMetadata.enqueue(batch)
}
findDuplicateBatch()方法用于校驗新產生的消息是否是重復發送。遍歷batchMetadata,如果新產生的Batch的firstSeq和lastSeq都和batchMetadata中緩存的某個Batch一樣,說明是重復的,代碼如下:
/**
* 查找重復的Batch
* 1)與緩存中的Batch,頭和尾序號都一樣的,說明是重復的Batch
*/
def findDuplicateBatch(batch: RecordBatch): Option[BatchMetadata] = {
if (batch.producerEpoch != producerEpoch)
None
else
batchWithSequenceRange(batch.baseSequence, batch.lastSequence)
}
def batchWithSequenceRange(firstSeq: Int, lastSeq: Int): Option[BatchMetadata] =
{
val duplicate = batchMetadata.filter { metadata =>
firstSeq == metadata.firstSeq && lastSeq == metadata.lastSeq
}
duplicate.headOption
}
ProducerAppendInfo
ProducerAppendInfo用于在追加的消息寫到Log之前進行校驗,主要對epoch、sequence number進行校驗
currentEntry:ProducerStateEntry類型,就是pid對應的ProducerStateEntry中batchMetadata尾部對象,用于跟新追加的Batch做比較
validationType:校驗的方式。不同的類型,校驗的規則不一樣
- ValidationType.None:什么也不用校驗。如果請求來自非客戶端(Kakfa內部),則就是這種類型
- ValidationType.EpochOnly:只校驗Epoch。如果Topic是__consumer_offsets就是這種校驗類型
- ValidationType.Full:檢查ProducerEpoch和sequence number
核心方法就是maybeValidateAppend(),根據validationType做不同的校驗
private def maybeValidateAppend(producerEpoch: Short, firstSeq: Int) = {
validationType match {
case ValidationType.None =>
case ValidationType.EpochOnly =>
checkProducerEpoch(producerEpoch)
case ValidationType.Full =>
checkProducerEpoch(producerEpoch)
checkSequence(producerEpoch, firstSeq)
}
}
checkProducerEpoch方法檢查ProducerEpoch是否合法
private def checkProducerEpoch(producerEpoch: Short): Unit = {
if (producerEpoch < updatedEntry.producerEpoch) {
throw new ProducerFencedException(s"Producer's epoch is no longer valid.
There is probably another producer " +
s"with a newer epoch. $producerEpoch (request epoch),
${updatedEntry.producerEpoch} (server epoch)")
}
}
checkSequence方法是一個跟冪等性很重要的方法,此方法就是校驗sequence number的。有以下幾個判斷規則
- 1)如果producerEpoch更新了,則追加的Batch里的appendFirstSeq必須是0
- 2)當currentLastSeq為-1時,說明此生產者還沒有成功追加過消息,appendFirstSeq也必須是0
- 3)appendFirstSeq = currentLastSeq+1,或者當currentLastSeq達到Int的最大值Int.MaxValue時,appendFirstSeq為0
private def checkSequence(producerEpoch: Short, appendFirstSeq: Int): Unit = {
/**
* 如果producerEpoch更新了,appendFirstSeq必須從0開始
*/
if (producerEpoch != updatedEntry.producerEpoch) {
if (appendFirstSeq != 0) {
if (updatedEntry.producerEpoch != RecordBatch.NO_PRODUCER_EPOCH) {
throw new OutOfOrderSequenceException(s"Invalid sequence number for new epoch: $producerEpoch " +
s"(request epoch), $appendFirstSeq (seq. number)")
} else {
throw new UnknownProducerIdException(s"Found no record of producerId=$producerId on the broker. It is possible " +
s"that the last message with the producerId=$producerId has been removed due to hitting the retention limit.")
}
}
} else {
val currentLastSeq = if (!updatedEntry.isEmpty)
updatedEntry.lastSeq
else if (producerEpoch == currentEntry.producerEpoch)
currentEntry.lastSeq
else
RecordBatch.NO_SEQUENCE
//currentLastSeq為-1時,說明該生產者還沒有上送成功過任何消息,appendFirstSeq必須從0開始
if (currentLastSeq == RecordBatch.NO_SEQUENCE && appendFirstSeq != 0) {
// the epoch was bumped by a control record, so we expect the sequence number to be reset
throw new OutOfOrderSequenceException(s"Out of order sequence number for producerId $producerId: found $appendFirstSeq " +
s"(incoming seq. number), but expected 0")
} else if (!inSequence(currentLastSeq, appendFirstSeq)) {//校驗Sequence是否是連續的
throw new OutOfOrderSequenceException(s"Out of order sequence number for producerId $producerId: $appendFirstSeq " +
s"(incoming seq. number), $currentLastSeq (current end sequence number)")
}
}
}
private def inSequence(lastSeq: Int, nextSeq: Int): Boolean = {
nextSeq == lastSeq + 1L || (nextSeq == 0 && lastSeq == Int.MaxValue)
}
ProducerStateManager
用來管理Producer的狀態,里面存儲了各個生產者與ProducerStateEntry的對應關系。每個ProducerStateManager對應一個TopicPartition
producers:用于存儲生產者與ProducerStateEntry的對應關系,key為pid,value為ProducerStateEntry
prepareUpdate()方法返回ProducerAppendInfo對象,用于在寫到Log之前校驗消息
def prepareUpdate(producerId: Long, isFromClient: Boolean): ProducerAppendInfo =
{
val validationToPerform =
if (!isFromClient)
ValidationType.None
else if (topicPartition.topic == Topic.GROUP_METADATA_TOPIC_NAME)
ValidationType.EpochOnly
else
ValidationType.Full
//從隊列中取出最近的ProducerStateEntry
val currentEntry =
lastEntry(producerId).getOrElse(<u>ProducerStateEntry.empty(producerId)</u>)
new ProducerAppendInfo(producerId, currentEntry, validationToPerform)
}
當消息寫入到Log后,調用update方法,更新生產者狀態信息
def update(appendInfo: ProducerAppendInfo): Unit = {
if (appendInfo.producerId == RecordBatch.NO_PRODUCER_ID)
throw new IllegalArgumentException(s"Invalid producer id ${appendInfo.producerId} passed to update " +
s"for partition $topicPartition")
trace(s"Updated producer ${appendInfo.producerId} state to $appendInfo")
val updatedEntry = appendInfo.toEntry
producers.get(appendInfo.producerId) match {
case Some(currentEntry) =>
currentEntry.update(updatedEntry)
case None =>
producers.put(appendInfo.producerId, updatedEntry)
}
appendInfo.startedTransactions.foreach { txn =>
ongoingTxns.put(txn.firstOffset.messageOffset, txn)
}
}
冪等性實現服務端流程
如前面途中所示,當 Broker 收到 ProduceRequest 請求之后,會通過 handleProduceRequest() 做相應的處理,其處理流程如下(這里只講述關于冪等性相關的內容):
1)如果請求是事務請求,檢查是否對 TXN.id 有 Write 權限,沒有的話返回TRANSACTIONAL_ID_AUTHORIZATION_FAILED;
2)如果請求設置了冪等性,檢查是否對 ClusterResource 有 IdempotentWrite 權限,沒有的話返回 CLUSTER_AUTHORIZATION_FAILED;
3)驗證對 topic 是否有 Write 權限以及 Topic 是否存在,否則返回 TOPIC_AUTHORIZATION_FAILED 或 UNKNOWN_TOPIC_OR_PARTITION 異常;
4)檢查是否有 PID 信息,沒有的話走正常的寫入流程;
5)LOG 對象會在 analyzeAndValidateProducerState() 方法先根據 batch 的 sequence number 信息檢查這個 batch 是否重復(server 端會緩存 PID 對應這個 Topic-Partition 的最近5個 batch 信息),如果有重復,這里當做寫入成功返回(不更新 LOG 對象中相應的狀態信息,比如這個 replica 的 the end offset 等);
6)有了 PID 信息,并且不是重復 batch 時,在更新 producer 信息時,會做以下校驗:
- 檢查該 PID 是否已經緩存中存在(主要是在 ProducerStateManager 對象中檢查);
- 如果不存在,那么判斷 sequence number 是否 從0 開始,是的話,在緩存中記錄 PID 的 meta(PID,epoch, sequence number),并執行寫入操作,否則返回 UnknownProducerIdException(PID 在 server 端已經過期或者這個 PID 寫的數據都已經過期了,但是 Client 還在接著上次的 sequence number 發送數據);
- 如果該 PID 存在,先檢查 PID epoch 與 server 端記錄的是否相同;
- 如果不同并且 sequence number 不從 0 開始,那么返回 OutOfOrderSequenceException 異常;
- 如果不同并且 sequence number 從 0 開始,那么正常寫入;
- 如果相同,那么根據緩存中記錄的最近一次 sequence number(currentLastSeq)檢查是否為連續(會區分為 0、Int.MaxValue 等情況),不連續的情況下返回 OutOfOrderSequenceException 異常。
參考文章
https://blog.csdn.net/alex_xfboy/article/details/82988259
http://matt33.com/2018/10/24/kafka-idempotent