KafkaConsumer对于事务消息的处理

Kafka添加了事务机制以后,consumer端有个需要解决的问题就是怎么样从收到的消息中滤掉aborted的消息。Kafka通过broker和consumer端的协作,利用一系列优化手段极大地降低了这部分工作的开销。

问题

首先来看一下这部分工作的难点在哪。

对于isolation.level为read_committed的消费者来说,它只想获取committed的消息。但是在服务器端的存储中,committed的消息、aborted的消息、以及正在进行中的事务的消息在Log里是紧挨在一起的,而且这些状态的消息可能源于不同的producerId。所以,如果broker对FetchRequest的处理和加入事务机制前一样,那么consumer就需要做很多地清理工作,而且需要buffer消息直到control marker的到来。那么,就无故浪费了很多流量,而且consumer端的内存管理也很成问题。

解决方法

Kafka大体采用了三个措施一起来解决这个问题。

LSO

Kafka添加了一个很重要概念,叫做LSO,即last stable offset。对于同一个TopicPartition,其offset小于LSO的所有transactional message的状态都已确定,要不就是committed,要不就是aborted。而broker对于read_committed的consumer,只提供offset小于LSO的消息。这样就避免了consumer收到状态不确定的消息,而不得不buffer这些消息。

Aborted Transaction Index

对于每个LogSegment(对应于一个log文件),broker都维护一个aborted transaction index. 这是一个append only的文件,每当有事务被abort时,就会有一个entry被append进去。这个entry的格式是:

TransactionEntry =>
    Version => int16
    PID => int64
    FirstOffset => int64
    LastOffset => int64
    LastStableOffset => int64

为什么要有这个index?

这涉及到FetchResponse的消息格式的变化,在FetchResponse里包含了其中每个TopicPartition的记录里的aborted transactions的信息,consumer使用这些信息,可以更高效地从FetchResponse里包含的消息里过滤掉被abort的消息。

 

// FetchResponse v4
FetchResponse => ThrottleTime [TopicName [Partition ErrorCode HighwaterMarkOffset ​LastStableOffset AbortedTransactions​ MessageSetSize MessageSet]]
ThrottleTime => int32
TopicName => string
Partition => int32
ErrorCode => int16
HighwaterMarkOffset => int64
​LastStableOffset => int64
​AbortedTransactions => [PID FirstOffset] PID => int64 FirstOffset => int64 MessageSetSize => int32

 

Consumer端根据aborted transactions的消息过滤

(以下对只针对read_committed的consumer)

consumer端会根据fetch response里提供的aborted transactions里过滤掉aborted的消息,只返回给用户committed的消息。

其核心逻辑是这样的:

首先,由于broker只返回LSO之前的消息给consumer,所以consumer拉取的消息只有两种可能的状态:committed和aborted。

活跃的aborted transaction的pid集合

然后, 对于每个在被fetch的消息里包含的TopicPartition, consumer维护一个producerId的集合,这个集合就是当前活跃的aborted transaction所使用的pid。一个aborted transaction是“活跃的”,是说:在过滤过程中,当前的待处理的消息的offset处于这个这个aborted transaction的initial offset和last offset之间。有了这个活跃的aborted transaction对应的PID的集合(以下简称"pid集合"),在过滤消息时,只要看一下这个消息的PID是否在此集合中,如果是,那么消息就肯定是aborted的,如果不是,那就是committed的。

这个pid集合在过滤的过程中,是不断变化的,为了维护这个集合,consumer端还会对于每个在被fetch的消息里包含的TopicPartition 维护一个aborted transaction构成的mini heap, 这个heap是以aborted transaction的intial offset排序的。

    public static final class AbortedTransaction {
        public final long producerId;
        public final long firstOffset;

        ...
   }


private class PartitionRecords {
        private final TopicPartition partition;
        private final CompletedFetch completedFetch;
        private final Iterator<? extends RecordBatch> batches;
        private final Set<Long> abortedProducerIds;
        private final PriorityQueue<FetchResponse.AbortedTransaction> abortedTransactions;
       
        ...

}


//这个heap的初始化过程,可以看出是按offset排序的
private PriorityQueue<FetchResponse.AbortedTransaction> abortedTransactions(FetchResponse.PartitionData partition) {
if (partition.abortedTransactions == null || partition.abortedTransactions.isEmpty())
return null;

PriorityQueue<FetchResponse.AbortedTransaction> abortedTransactions = new PriorityQueue<>(
partition.abortedTransactions.size(),
new Comparator<FetchResponse.AbortedTransaction>() {
@Override
public int compare(FetchResponse.AbortedTransaction o1, FetchResponse.AbortedTransaction o2) {
return Long.compare(o1.firstOffset, o2.firstOffset);
}
}
);
abortedTransactions.addAll(partition.abortedTransactions);
return abortedTransactions;
}
 

按照Kafka文档里的说法:

  • If the message is a transaction control message, and the status is ​ABORT​, then remove the corresponding PID from the set of PIDs with active aborted transactions. If the status is ​COMMIT​, ignore the message.
  • If the message is a normal message, compare the offset and PID with the head of the aborted transaction minheap. If the PID matches and the offset is greater than or equal to the corresponding initial offset from the aborted transaction entry, remove the head from the minheap and insert the PID into the set of PIDs with aborted transactions.

  • Check whether the PID is contained in the aborted transaction set. If so, discard the record set; otherwise, add it to the records to be returned to the user.

  • 如果收到了一个abort marker(它本身是一个消息,而且单独一个batch),那么就从pid集合里移除这个pid。因为此时这个pid对应的aborted transaction不再是“活跃”的了
  • 如果是普通消息,那就根据这个消息和aborted transaction所在的heap,来更新pid集合
      • 如果消息的pid跟堆顶的pid一样,而且这个消息的offset >= 堆顶的AbortedTransaction里的offset(这是此pid对应的aborted transaction的initial offset),那么当前这个pid对应的transaction就可以判断为一个活跃的aborted transaction,那就堆顶的这个AbortedTransaction移除,把它的pid放入pid集合里
      • 如果不是,就不变更pid集合
      • 然后再次判断这个消息的pid是否在pid集合里,如果是的话,就不把这条消息放在返回给用户的消息集里。

    

但是实际上考虑到batch的问题,情况会比这简单一些。在producer端发送的时候,同一个TopicPartition的不同transaction的消息是不可能在同一个message batch里的, 而且committed的消息和aborted的消息也不可能在同一batch里。因为在不同transaction的消息之间,肯定会有transaction marker, 而transaction marker是单独的一个batch。这就使得,一个batch要不全部被aborted了,要不全部被committed了。所以过滤aborted transaction时就可以一次过滤一个batch,而非一条消息。

 

相关代码为PartitionRecords#nextFetchedRecord()中:

                    if (isolationLevel == IsolationLevel.READ_COMMITTED && currentBatch.hasProducerId()) {
                        // remove from the aborted transaction queue all aborted transactions which have begun
                        // before the current batch's last offset and add the associated producerIds to the
                        // aborted producer set
//从aborted transaction里移除那些其inital offset在当前的batch的末尾之前的那些。
//因为这些transaction开始于当前batch之前,而在处理这个batch之前没有结束,所以它要不是活跃的aborted transaction,要不当前的batch就是control batch
               //这里需要考虑到aborted transaction可能开始于这次fetch到的所有records之前
consumeAbortedTransactionsUpTo(currentBatch.lastOffset()); long producerId = currentBatch.producerId(); if (containsAbortMarker(currentBatch)) { abortedProducerIds.remove(producerId); //如果当前batch是abort marker, 那么它对应的transaction就结束了,所以从pid集合里移除它对应的pid。 } else if (isBatchAborted(currentBatch)) { //如果当前batch被abort了,那就跳过它 log.debug("Skipping aborted record batch from partition {} with producerId {} and " + "offsets {} to {}", partition, producerId, currentBatch.baseOffset(), currentBatch.lastOffset()); nextFetchOffset = currentBatch.nextOffset(); continue; } }

结论

通过对aborted transaction index和LSO的使用,Kafka使得consumer端可以高效地过滤掉aborted transaction里的消息,从而减小了事务机制的性能开销。

posted @ 2018-08-30 22:05  devos  阅读(8397)  评论(1编辑  收藏  举报