kafka事务及幂等性

569 阅读11分钟

事务

消息传输保障有3个等级

  • At most once 最多一次(<=1): 消息不会被重复发送,最多被传输一次,但也有可能一次不传输
  • At least once 最少一次(>=1):消息不会被漏发送,最少被传输一次,但也有可能被重复传输
  • Exactly once 精确的一次(=1): 不会漏传输也不会重复传输,每个消息都传输被一次而且仅仅被传输一次

1 幂等性

幂等简单理解就是对接口的多次调用所产生的结果和调用一次是一致的。生产者在进行重试的时候有可能会重复写入消息,而使用Kafka的幂等性功能之后就可以避免这种情况。

1.1 实现原理

Kafka引入了producer idPID)和序列号(sequence number)这两个概念。

  • PID: 每个新的生产者实例在初始化的时候都会被分配一个PID,这个PID对用户而言是透明的。

  • sequence number: 对于每个PID,消息发送到的每一个分区都有对应的序列号,这些序列号从0开始单调递增。生产者每发送一条消息就会将对应的序列号的值加1。

broker会在内存中维护一个序列号。对于收到的每一条消息,只有当它的序列号的值(SN_new)比broker端中维护的对应的序列号的值(SN_old)大1(即SN_new = SN_old + 1)时,broker才会接收它。

如果SN_new < SN_old + 1,那么说明消息被重复写入,broker可以直接将其丢弃。
如果SN_new > SN_old + 1,那么说明中间有数据尚未写入,出现了乱序,暗示可能有消息丢失,这个异常是一个严重的异常。

幂等性只能保证单个生产者会话中(session)中单分区的幂等。

1.2 生成PID的流程

在执行创建事务时,

Producer<String, String> producer = new KafkaProducer<String, String>(props);

会创建一个Sender,并启动线程,执行run方法,在maybeWaitForProducerId()中生成一个producerId

void run(long now) {

        if (transactionManager != null) {

            try {

                 ........

                if (!transactionManager.isTransactional()) {

                    // 为idempotent producer生成一个producer id

                    maybeWaitForProducerId();

                } else if (transactionManager.hasUnresolvedSequences() && !transactionManager.hasFatalError()) {

                   ........

1.3 配置

  • enable.idempotence=true(此时就会默认把acks设置为all)

2 事务

kafka事务属性是指一系列的生产者生产消息和消费者提交偏移量的操作在一个事务,或者说是是一个原子操作),同时成功或者失败。

2.1 事务的场景

引入事务目的

  • 生产者多次发送消息可以封装成一个原子操作,要么都成功,要么失败
  • consumer-transform-producer模式下,因为消费者提交偏移量出现问题,导致在重复消费消息时,生产者重复生产消息。需要将这个模式下消费者提交偏移量操作和生成者一系列生成消息的操作封装成一个原子操作。

引入事务场景

  • 只有Producer生产消息
  • 消费消息和生产消息并存,这个是事务场景中最常用的情况,就是我们常说的“consume-transform-produce ”模式
  • 只有consumer消费消息,不是事务属性引入的目的,一般不会使用这种情况

2.2 配置

生产者

  • 配置transaction.id属性
  • 不需要再配置enable.idempotence,因为配置了transaction.idenable.idempotence会被设置为true

消费者

  • 消费者需要配置Isolation.level。在consume-trnasform-produce模式下使用事务时,必须设置为READ_COMMITTED
  • 需要消费者的auto.commit设置为false
  • 不能再手动的进行执行consumer#commitSync或者consumer#commitAsyc

isolation.level:
read_uncommitted:表明消费端可以消费未提交的事务,默认值
read_committed:表示消费端应用看不到尚未提交的事务内的消息

2.3 LSO及消息堆积量

LSO特指LastStableOffset, 在开启Kafka事务时,生产者发送了若干消息到broker中,如果生产者没有提交事务,那么对于isolation.level = read_committed的消费者而言是看不到这些消息的,而isolation.level = read_uncommitted则可以看到。事务中的第一条消息的位置可以标记为firstUnstableOffsetLSO≤HW≤LEO

  • 对未完成的事务而言,LSO 的值等于事务中第一条消息的位置(firstUnstableOffset)

image.png

  • 对已完成的事务而言,它的值同 HW 相同 image.png

LSO会影响Kafka消费滞后量(也就是Kafka Lag,消息堆积量)的计算。

image.png

普通的情况Lag = HW – ConsumerOffset

事务的情况: 如果isolation.level = read_uncommitted,Lag的计算方式不受影响;
如果isolation.level = read_committed,则 Lag = LSO – ConsumerOffset

其中 ConsumerOffset 表示当前的消费位移

2.4 事务API

KafkaProducer提供了5个与事务相关的方法

void initTransactions();
void beginTransaction() throws ProducerFencedException;
void sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata> offsets,
                              String consumerGroupId) throws ProducerFencedException;
void commitTransaction() throws ProducerFencedException;
void abortTransaction() throws ProducerFencedException;
  • initTransactions() 初始化事务
  • beginTransaction() 开启事务
  • sendOffsetsToTransaction() 为消费者提供在事务内的位移提交的操作
  • commitTransaction() 提交事务
  • abortTransaction() 中止事务,类似于事务回滚

代码示例

在一个事务内,既有生产消息又有消费消息

   
    public void consumeTransferProduce() {
        // 1.构建生产者
        Producer producer = buildProducer();

        // 2.初始化事务(生成productId),对于一个生产者,只能执行一次初始化事务操作
        producer.initTransactions();

        // 3.构建消费者和订阅主题
        Consumer consumer = buildConsumer();
        consumer.subscribe(Arrays.asList("test"));

        while (true) {

            // 4.开启事务
            producer.beginTransaction();

            // 5.1 接收消息
           ConsumerRecords<String, String> records = consumer.poll(500);

            try {
                // 5.2 do业务逻辑;

                System.out.println("customer Message---");

                Map<TopicPartition, OffsetAndMetadata> commits = Maps.newHashMap();

                for (ConsumerRecord<String, String> record : records) {
                    // 5.2.1 读取消息,并处理消息。print the offset,key and value for the consumer records.
                    System.out.printf("offset = %d, key = %s, value = %s\n",  record.offset(), record.key(), record.value());

                    // 5.2.2 记录提交的偏移量
                   commits.put(new TopicPartition(record.topic(), record.partition()),

                            new OffsetAndMetadata(record.offset()));


                    // 6.生产新的消息。比如外卖订单状态的消息,如果订单成功,则需要发送跟商家结转消息或者派送员的提成消息
                    producer.send(new ProducerRecord<String, String>("test", "data2"));
                }

                // 7.提交偏移量
                producer.sendOffsetsToTransaction(commits, "group0323");

                // 8.事务提交
                producer.commitTransaction();
            } catch (Exception e) {
                // 7.放弃事务
                producer.abortTransaction();
            }
        }
    }

3 幂等性和事务性的关系

3.1 两者关系

事务属性实现前提是幂等性,即在配置事务属性transaction id时,必须还得配置幂等性;但是幂等性是可以独立使用的,不需要依赖事务属性。

  • 幂等性引入了Porducer ID(PID)
  • 事务属性引入了Transaction Id

3.2 配置

  • enable.idempotence = true,transactional.id不设置:只支持幂等性。
  • enable.idempotence = true,transactional.id设置:支持事务属性和幂等性
  • enable.idempotence = false,transactional.id不设置:没有事务属性和幂等性的kafka
  • enable.idempotence = false,transactional.id设置:无法获取到PID,此时会报错

3.3 Transaction ID 、PID 和 epoch

一个应用有一个Transaction ID,同一个应用的不同实例PID是一样的,只是epoch的值不同。如:

image.png

同一个事务ID,只有保证如下顺序epch小producer执行init-transaction和committransaction,然后epoch较大的procuder才能开始执行init-transaction和commit-transaction,如下顺序

image.png 否则会报错

org.apache.kafka.common.errors.ProducerFencedException: Producer attempted an operation with an old epoch. Either there is a newer producer with the same transactionalId, or the producer’s transaction has been expired by the broker.

有了Transaction ID后,Kafka可保证:跨Session的数据幂等发送。 当具有相同Transaction ID的新的Producer实例被创建且工作时,旧的Producer将不再工作。kafka保证了关联同一个事务的所有producer(一个应用有多个实例)必须按照顺序初始化事务、和提交事务,否则就会有问题,这保证了同一事务ID中消息是有序的(不同实例得按顺序创建事务和提交事务)。

事务最佳实践-单实例的事务性

如果应用部署多个实例时,必须保证这些实例生成者的提交事务顺序和创建顺序保持一致才可以,否则就无法成功。在实践中,我们更多的是实现应用单实例的事务性 每次生成producer时,都设置一个不同的Transaction ID

4 事务机制原理

4.1 事务协调器Transaction Coordinator

Kafka 0.11.0.0引入了一个服务器端的模块,名为Transaction Coordinator,用于管理Producer发送的消息的事务性。 Transaction Coordinator将事务状态持久化到Transaction Log中,该log存于一个内部的主题 __transaction_state内。

  • transaction log有多个分区,每个分区都有一个 leader,该 leade对应哪个 kafka broker,哪个 broker 上的 transaction coordinator 就负责对这些分区的写操作;
  • 由于 transaction coordinator 是 kafka broker 内部的一个模块,而 transaction log 是 kakfa 的一个内部 topic, 所以 Kafka 可以通过内部的复制协议和选举机制(replication protocol and leader election processes),来确保 transaction coordinator 的可用性和 transaction state 的持久性;
  • transaction log topic 内部存储的只是事务的最新状态和其相关元数据信息,kafka producer 生产的原始消息,仍然是只存储在kafka producer指定的 topic 中。事务的状态有:Ongoing, Prepare commitCompleted
  • 每个 transactional.id 通过 hash 都对应到 了 transaction log 的一个分区,所以每个 transactional.id 都有且仅有一个 transaction coordinator 负责

Producer并不直接读写Transaction Log,它与Transaction Coordinator通信,然后由Transaction Coordinator将该事务的状态插入相应的Transaction Log

4.2 控制消息

KAFKA日志中除了普通的消息,还有一种消息专门用来标志事务的结束,它就是控制消息control batch; 控制消息跟其他正常的消息一样,都被存储在日志中,但控制消息不会被返回给 consumer 客户端。

  • 控制消息共有两种类型:commit 和 abort,分别用来表征事务已经成功提交或已经被成功终止
  • RecordBatch 中 attributes 字段的第5位用来标志当前消息是否处于事务中,1代表消息处于事务中,0则反之
  • RecordBatch 中 attributes 字段的第6位用来标识当前消息是否是控制消息,1代表是控制消息,0则反之

image.png 由于控制消息总是处于事务中,所以控制消息对应的RecordBatch 的 attributes 字段的第5位和第6位都被置为1
Consumer可通过控制消息是Commit了还是Abort了,然后结合隔离级别isolation.level决定是否应该将相应的消息返回给消费者。

image.png

4.3 事务流程

image.png

流程1 查找Tranaction Corordinator。

Producer向任意一个brokers发送 FindCoordinatorRequest请求来获取Transaction Coordinator的地址。

流程2: 初始化事务 initTransaction

Producer发送InitpidRequest给事务协调器,获取一个Pid 。InitpidRequest的处理过程是同步阻塞的,一旦该调用正确返回,Producer就可以开始新的事务。TranactionalId通过InitpidRequest发送给Tranciton Corordinator,然后在Tranaciton Log中记录这<TranacionalId,pid>的映射关系。除了返回PID之外,还具有如下功能:

  • 对PID对应的epoch进行递增,这样可以保证同一个app的不同实例对应的PID是一样的,但是epoch是不同的。
  • 回滚之前的Producer未完成的事务(如果有)。

流程3:  开始事务beginTransaction

执行Producer的beginTransacion(),它的作用是Producer在本地记录下这个transaction的状态为开始状态。

注意:这个操作并没有通知Transaction Coordinator。

流程4:  Consume-transform-produce loop

流程4.0: 通过Consumtor消费消息,处理业务逻辑

流程4.1: producer向TransactionCordinantro发送AddPartitionsToTxnRequest

在producer执行send操作时,如果是第一次给<topic,partion>发送数据,此时会向Trasaction Corrdinator发送一个AddPartitionsToTxnRequest请求,Transaction Corrdinator会在transaction log中记录下tranasactionId和<topic,partion>一个映射关系,并将状态改为begin。AddPartionsToTxnRequest的数据结构如下:

AddPartitionsToTxnRequest => TransactionalId PID Epoch [Topic [Partition]]
TransactionalId => string

PID => int64

Epoch => int16

Topic => string

Partition => int32

流程4.2: producer#send发送 ProduceRequst

生产者发送数据,虽然没有还没有执行commit或者absrot,但是此时消息已经保存到kafka上,此时已经可以查看到消息了,而且即使后面执行abort,消息也不会删除,只是更改状态字段标识消息为abort状态。

流程4.3: AddOffsetCommitsToTxnRequest

Producer通过KafkaProducer.sendOffsetsToTransaction 向事务协调器器发送一个AddOffesetCommitsToTxnRequests:

AddOffsetsToTxnRequest => TransactionalId PID Epoch ConsumerGroupID
 TransactionalId => string
 PID => int64
 Epoch => int16
 ConsumerGroupID => string

在执行事务提交时,可以根据ConsumerGroupID来推断_customer_offsets主题中相应的TopicPartions信息。

流程4.4: TxnOffsetCommitRequest

Producer通过KafkaProducer.sendOffsetsToTransaction还会向消费者协调器Cosumer Corrdinator发送一个TxnOffsetCommitRequest,在主题_consumer_offsets中保存消费者的偏移量信息。

TxnOffsetCommitRequest   => ConsumerGroupID 
                           PID
                           Epoch
                           RetentionTime
                           OffsetAndMetadata 
 ConsumerGroupID => string
 PID => int64
 Epoch => int32
 RetentionTime => int64
 OffsetAndMetadata => [TopicName [Partition Offset Metadata]]
   TopicName => string
   Partition => int32
   Offset => int64
   Metadata => string

流程5: 事务提交和事务终结(放弃事务)

通过生产者的commitTransaction或abortTransaction方法来提交事务和终结事务,这两个操作都会发送一个EndTxnRequest给Transaction Coordinator。

流程5.1: EndTxnRequest。Producer发送一个EndTxnRequest给Transaction Coordinator,然后执行如下操作:

  • Transaction Coordinator会把PREPARE_COMMIT or PREPARE_ABORT 消息写入到transaction log中记录
  • 执行流程5.2
  • 执行流程5.3

流程5.2: WriteTxnMarkerRequest

WriteTxnMarkersRequest => [CoorinadorEpoch PID Epoch Marker [Topic [Partition]]]
 CoordinatorEpoch => int32
 PID => int64
 Epoch => int16
 Marker => boolean (false(0) means ABORT, true(1) means COMMIT)
 Topic => string
 Partition => int32

  • 对于Producer生产的消息。Tranaction Coordinator会发送WriteTxnMarkerRequest给当前事务涉及到每个<topic,partion>的leader,leader收到请求后,会写入一个COMMIT(PID) 或者 ABORT(PID)的控制信息到data log中
  • 对于消费者偏移量信息,如果在这个事务里面包含_consumer-offsets主题。Tranaction Coordinator会发送WriteTxnMarkerRequest给Transaction Coordinartor,Transaction Coordinartor收到请求后,会写入一个COMMIT(PID) 或者 ABORT(PID)的控制信息到 data log中。

流程5.3: Transaction Coordinator会将最终的COMPLETE_COMMIT或COMPLETE_ABORT消息写入Transaction Log中以标明该事务结束。

  • 只会保留这个事务对应的PID和timstamp。然后把当前事务其他相关消息删除掉,包括PID和tranactionId的映射关系。