kafka之事务

kafka学习之事务

前言

为了实现EOS(exactly once semantics,精确一次处理语义)karka从0.11.0.0版本开始引入了幂等性和事务两个特性来支撑。

场景

  1. 最简单的需求是producer发的多条消息组成一个事务这些消息需要对consumer同时可见或者同时不可见 。
  2. producer可能会给多个topic,多个partition发消息,这些消息也需要能放在一个事务里面,这就形成了一个典型的分布式事务。
  3. kafka的应用场景经常是应用先消费一个topic,然后做处理再发到另一个topic,这个consume-transform-produce过程需要放到一个事务里面,比如在消息处理或者发送的过程中如果失败了,消费位点也不能提交。
  4. producer或者producer所在的应用可能会挂掉,新的producer启动以后需要知道怎么处理之前未完成的事务 。
  5. 流式处理的拓扑可能会比较深,如果下游只有等上游消息事务提交以后才能读到,可能会导致rt非常长吞吐量也随之下降很多,所以需要实现read committed和read uncommitted两种事务隔离级别。

幂等性

幂等这个概念,我们再接口那里就了解过。它简单得说就是对接口得多次调用所产生的结果和调用一次是一致的。再kafka中,生产者再进行重试的时候有可能会重复写入相同的消息(比如,第一次写消息给broker, broker写入日志之后,发送ack失败。生产者误以为broker没收到改消息,所以根据retry 进行重发,这就导致了消息的重复),而使用kafka的幂等性功能之后就可以避免这种情况。

开启幂等性功能的方式很简单,只需要显示地将生产者客户端参数enable.idempotence设置为true就可以(默认位false。

不过如果要确保幂等性功能正常,还需要确保生产者客户端的retries、acks、max.in.filght.request.per.connection这几个参数不被配置错。如果用户没用自定义过上面参数,那么幂等性功能可以完全被保证。

  1. 如果用户自定了retries参数,那么这个参数的值必须大于0,否则会报ConfigException,如果用户没定义这个参数,那么改参数的值就是Integer.MAX_VALUE;(这里可以理解为,一旦事务中出现异常,要确保有能力重试保证消息再次发送出去)

  2. 如果用户自定了max.in.flight.request.per.connection, 要保证不能大于5.默认值是5。如果大于5则会同样会报错。

    max.in.flight.request.per.connection 表示生产者向指定broker发送的消息中,还未到达的个数。类似于tcp协议中滑动窗口里面未到达窗口的概念。它可以用来衡量该客户端一定时间段内到指定broker的网络通信情况。
    
  3. 如果用户自定了acks参数,那么就需要保证这个参数为-1,如果不为-1,同样会报错。(也就是说必须保证消息一定要在broker端落地,保证消息只要到了broker端就不能丢失)

为了实现生产者的幂等性,kafka为此引入了**producer id(PID)和序列号(sequence number)**两个概念。 每个新的生产者实例再初始化的时候都会被分配一个PID, 这个PID对用户而言完全透明的。 对于每个PID, 消息发送到的每一个分区都有对应的序列号,这些序列号从0开始单调递增。 生产者每发送一条消息就会将对应的序列号的值加1;

broker里面可能采用Map,seq>来维护每一个客户端和每一个分区消息的序列号;

broker端会再内存中为每一对维护一个序列号。对于收到的每一条消息,只有当它的序列号的值(SN_new)比broker端中维护的对应序列号的值(SN_old)大1(即SN_new = SN_old+1)时,broker才会接收它。如果SN_new < SN_old+1, 那么说明消息被重复写入, broker可以直接将其丢弃。如果SN_new > SN_old+1, 那么说明中间有数据尚未写入,出现了乱序,暗示可能有消息丢失,对应的生产者会抛出OutOfOrderSequenceException, 这个异常是一个严重的异常,后续的诸如 **send()、beginTransaction()、commitTransaction()**等方法的调用都会抛出IIIegalStateException的异常。

引入序列号来实现幂等也只是针对每一对而言的,也就是说,kafka幂等只保证单个生产者会话(session)中单分区的幂等。

事务性

幂等性并不能跨多个分区运行,而事务可以弥补这个缺陷。事务可以保证对多个分区写入操作的原子性。操作的原子性是指多个操作要么全部成功,要么全部失败,不存在不一致的情况。

对流式应用而言,一个典型的应用模式为“consumer-transform-produce", 这种模式下消费和生产并存: 应用程序从某个主题中消费消息,然后经过一系列操作写入另一个主题,消费者可能再提交消费位移的过程中出现问题而导致重复消费,也有可能生产者重复生产消息。 kafka中的事务可以使应用程序将消费消息,生产消息、提交消费位移当作原子操作来处理,同时成功或者失败,即使该生产或消费跨越多个分区。

up有个业务场景是:1、从一个主题中获取人员位置变化,计算位置变化是否满足条件。2、如果满足条件发送到另一个主题。供消费线程去消费。就是这种consumer-transform-produce的模式。

为了实现事务,应用程序必须提供唯一的transactionalId,这个transactionalId通过客户端参数显示设置。

事务要求生产者开启幂等特性,因此通过将transactional.id 参数设置为非空从而开启事务特性的同时需要将enable.idempotence设置为true。

transactionalId与PID一一对应,两者之间所不同的是transactionalId由用户显示设置,而PID是由kafka内部分配的。 为了保证新的生产者启动后,具有相同transactionalId的旧生产者能够立即失效,每个生产者通过transactionalId获取PID的同时,还会获取一个单调递增的producer epoch(对应下面要讲述的kafkaProducer.initTransactions()方法)。 如果使用同一个transactionalId开启两个生产者,那么前一个生产者会报提示有一个新的生产者利用同一个事务id申请了producer epoch。提示老的生产者它再broker里面已经过期了。

从生产者的角度分析,通过事务,Kafka 可以保证跨生产者会话的消息幂等发送以及跨生产者会话的事务恢复。前者表示具有相同 transactionalId 的新生产者实例被创建且工作的时候,旧的且拥有相同transactionalId的生产者实例将不再工作。后者指当某个生产者实例宕机后,新的生产者实例可以保证任何未完成的旧事务要么被提交(Commit),要么被中止(Abort),如此可以使新的生产者实例从一个正常的状态开始工作。

而从消费者的角度分析,事务能保证的语义相对偏弱。出于以下原因,Kafka 并不能保证已提交的事务中的所有消息都能够被消费:

  • 对采用日志压缩策略的主题而言,事务中的某些消息有可能被清理(相同key的消息,后写入的消息会覆盖前面写入的消息)。
  • 事务中消息可能分布在同一个分区的多个日志分段(LogSegment)中,当老的日志分段被删除时,对应的消息可能会丢失。
  • 消费者可以通过seek()方法访问任意offset的消息,从而可能遗漏事务中的部分消息。
  • 消费者在消费时可能没有分配到事务内的所有分区,如此它也就不能读取事务中的所有消息。

KafkaProducer提供了5个与事务相关的方法,详细如下:

  • ​ **initTransactions()**方法用来初始化事务,这个方法能够执行的前提是配置了transactionalId,如果没有则会报出IllegalStateException:
  • **beginTransaction()**方法用来开启事务;
  • **sendOffsetsToTransaction()**方法为消费者提供在事务内的位移提交的操作;
  • **commitTransaction()**方法用来提交事务;
  • **abortTransaction()**方法用来中止事务,类似于事务回滚。

生产段的事务也要消费端的适配,在消费端存在一个isolation.level的配置。它存在两个配置分别是read_uncommittedread_committed分别代表消费端可以看到未提交的消息(包括已经提交)和只能看到已经提交的消息。一个事务的闭环存在两个操作,分别是上面方法中的commitTransaction()和abortTransaction(),那么这两个动作是通过什么实现的呢?

日志文件中除了普通的消息,还有一种消息专门用来标志一个事务的结束,它就是控制消息(ControlBatch)。控制消息一共有两种类型:COMMIT和ABORT,分别用来表征事务已经成功提交或已经被成功中止。KafkaConsumer 可以通过这个控制消息来判断对应的事务是被提交了还是被中止了,然后结合参数isolation.level配置的隔离级别来决定是否将相应的消息返回给消费端应用。

对于consume-transform-produce 这种应用模式,我们需要使用sendOffsetsToTransaction()api来实现。

代码示例如下:

public static void main(String[] args) {
        KafkaConsumer<String,String> consumer = new KafkaConsumer<String, String>(new Properties());
        consumer.subscribe(Arrays.asList("topic_source"));
        KafkaProducer<String,String> producer = new KafkaProducer<String, String>(new Properties());
        //初始化事务
        producer.initTransactions();
        while (true) {
            ConsumerRecords<String, String> message = consumer.poll(Duration.ofMillis(1000));
            if (!message.isEmpty()) {
                Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
                //开启事务
                producer.beginTransaction();
                //遍历消息所在的分区
                try{
                    for (TopicPartition partition : message.partitions()) {
                        //获取指定分区的消息信息
                        List<ConsumerRecord<String, String>> records = message.records(partition);
                        //遍历每一个分区拉去的消息
                        for (ConsumerRecord<String, String> record : records) {
                            //这里可以做,一些数据转换逻辑
                            //组装发送消息
                            ProducerRecord<String,String> producerRecord = new ProducerRecord<>("topic-sink",record.key()
                                    ,record.value());
                            //消费->生产
                            producer.send(producerRecord);
                        }
                        //获取提交的offset值
                        long lastConsumerOffset = records.get(records.size() - 1).offset();
                        offsets.put(partition,new OffsetAndMetadata(lastConsumerOffset));
                    }
                    //提交offset记录给事务
                    producer.sendOffsetsToTransaction(offsets,"group_id");
                    producer.commitTransaction();
                }catch (Exception e){
                    //log
                    //出现异常,终止事务
                    producer.abortTransaction();
                }
            }
        }
    }

注意:在使用KafkaConsumer的时候要将enable.auto.commit参数设置为false,代码里也不能手动提交消费位移。

事务的实现是通过**事务协调器(TransactionCoordinator)**来实现的。每一个生产者都会被指派一个特定的TransactionCoordinator,所有的事务逻辑包括分派PID(producer id 幂等性的时候说过) 等都是由 TransactionCoordinator 来负责实施的。TransactionCoordinator会将事务状态持久化到内部主题__transaction_state 中。下面就以最复杂的consume-transform-produce的流程为例来分析Kafka事务的实现原理。

kafka之事务_第1张图片

​ 图一 事务流程图

  1. 查找对应的事务协调器(TransactionCoordinator)

    ​ 不同的transactionId 对应不同的事务协调器,事务协调器存在在broker端,所以我们需要根据事务id去broker端寻找这个id对应的事务协调器。客户端通过发送FindCoordinatorRequest请求来实现的,只不过FindCoordinatorRequest中的coordinator_type就由原来的0变成了1,由此来表示与事务相关联。Kafka 在收到 FindCoorinatorRequest 请求之后,会根据 coordinator_key (也就是transactionalId)查找对应的TransactionCoordinator节点。如果找到,则会返回其相对应的node_id、host和port信息。具体查找TransactionCoordinator的方式是根据transactionalId的哈希值计算主题__transaction_state中的分区编号。

    Utils.abs(transactionId.hashCode()) % transactionTopicPartitionCount
    

    其中transactionTopicPartitionCount为主题**__transaction_state中的分区个数,这个可以通过broker端参数transaction.state.log.num.partitions来配置,默认值为50**。

    找到对应的分区之后,再寻找此分区leader副本所在的broker节点,该broker节点即为这个transactionalId对应的TransactionCoordinator节点(协调器最后其实就是一个broker节点)。

  2. 获取PID

    在找到TransactionCoordinator节点之后,就需要为当前生产者分配一个PID了。凡是开启了幂等性功能的生产者都必须执行这个操作,不需要考虑该生产者是否还开启了事务。生产者获取PID的操作是通过InitProducerIdRequest请求来实现的,InitProducerIdRequest请求体结构如下图示,其中 transactional_id表示事务的 transactionalId,transaction_timeout_ms表示TransactionCoordinaor等待事务状态更新的超时时间,通过生产者客户端参数transaction.timeout.ms配置,默认值为60000
    kafka之事务_第2张图片

  3. 保存PID

    生产者的InitProducerIdRequest请求会被发送给TransactionCoordinator。注意,如果未开启事务特性而只开启幂等特性,那么 InitProducerIdRequest 请求可以发送给任意的 broker(可以理解为任何一个broker都具有生产producer Id的能力)。当TransactionCoordinator第一次收到包含该transactionalId的InitProducerIdRequest请求时,它会把transactionalId和对应的PID以消息(我们习惯性地把这类消息称为“事务日志消息”)的形式保存到主题transaction_state中,如图一步骤2.1所示。这样可以保证<transaction_Id,PID>的对应关系被持久化,从而保证即使TransactionCoordinator宕机该对应关系也不会丢失。存储到主题transaction_state中的具体内容格式如下图所示。

kafka之事务_第3张图片

除了返回PID,InitProducerIdRequest还会触发执行以下任务:

  • 增加该 PID 对应的 producer_epoch。具有相同 PID 但 producer_epoch 小于该producer_epoch的其他生产者新开启的事务将被拒绝。
  • 恢复(Commit)或中止(Abort)之前的生产者未完成的事务。
  1. 开启事务

    通过KafkaProducer的beginTransaction()方法可以开启一个事务,调用该方法后,生产者本地会标记已经开启了一个新的事务,只有在生产者发送第一条消息之后 TransactionCoordinator才会认为该事务已经开启。

  2. Consumer-Transform-Produce

    这个阶段囊括了整个事务的数据处理过程,具体流程包括一下几个步骤

    1) AddPartitionsToTxnRequest

    当生产者给一个新的分区(TopicPartition)发送数据前,它需要先向TransactionCoordinator发送AddPartitionsToTxnRequest请求(AddPartitionsToTxnRequest请求体结构如下图所示),这个请求会让TransactionCoordinator 将<transactionId,TopicPartition>的对应关系存储在主题__transaction_state中,如图一步骤4.1所示。有了这个对照关系之后,我们就可以在后续的步骤中为每个分区设置COMMIT或ABORT标记,如图一步骤5.2所示。

kafka之事务_第4张图片

如果该分区是对应事务中的第一个分区,那么此时TransactionCoordinator还会启动对该事务的计时。

  1. ProduceRequest

这一步骤很容易理解,生产者通过ProduceRequest 请求发送消息(ProducerBatch)到用户自定义主题中,这一点和发送普通消息时相同,如图一步骤4.2所示。和普通的消息不同的是,ProducerBatch中会包含实质的PID、producer_epoch和sequence number。

3) AddOffsetsToTxnRequest

通过KafkaProducer的sendOffsetsToTransaction()方法可以在一个事务批次里处理消息的消费和发送,方法中包含2个参数:Map<TopicPartition,OffsetAndMetadata> offsets和groupId。这 个 方 法 会 向TransactionCoordinator 节 点 发 送 AddOffsetsToTxnRequest 请 求(AddOffsetsToTxnRequest请求体结构如下图所示),TransactionCoordinator收到这个请求之后会通过groupId来推导出在consumer_offsets中的分区,之后TransactionCoordinator会将这个分区保存在transaction_state中,如图一步骤4.3所示。

4) TxnOffsetCommitRequest

这个请求也是sendOffsetsToTransaction()方法中的一部分,在处理完AddOffsetsToTxnRequest之后,生产者还会发送 TxnOffsetCommitRequest 请求给 GroupCoordinator,从而将本次事务中包含的消费位移信息offsets存储到主题__consumer_offsets中,如图一步骤4.4所示。

6.提交或者终止事务

一旦数据被写入成功,我们就可以调用 KafkaProducer 的 commitTransaction()方法或abortTransaction()方法来结束当前的事务。

1)EndTxnRequest

无论调用commitTransaction()方法还是abortTransaction()方法,生产者都会向TransactionCoordinator发送EndTxnRequest请求(txn_id,pid,pepoch,txn_result(1-commit,0-abort)),以此来通知它提交(Commit)事务还是中止(Abort)事务。

TransactionCoordinator在收到EndTxnRequest请求后会执行如下操作:

(1)将PERAPARE_COMMIT或者是PERAPARE_ABORT消息写入主题_transaction_state, 如图一步骤5.1所示;

(2) 通过WriteTxnMarkersRequest请求将commit或者abort信息写入用户所使用的普通主题和_consumer_offsets, 如图一步骤5.2所示。

(3)将COMPLETE_COMMIT或者COMPLETE_ABORT信息写入内部主题_transaction_state,如图一步骤5.3所示。

2) WriteTxnMarkersRequest

WriteTxnMarkersRequest请求是由TransactionCoordinator发向事务中各个分区的leader节点的,当节点收到这个请求后,会在相应的分区中写入控制消息(ControlBatch)。控制消息用来标识事务的终结,它和普通消息一样村春在日志文件中,RecordBatch中的atrributes字段的第6位用来标识当前消息是否是控制消息。如果是控制消息,那么这一位会是1,否则会是0.

attributes字段中的第5位用来标识当前消息是否处于事务中,如果是事务中的消息,那么这一位是1,否则为0。由于控制消息也属于事务中的一部分,所以atrributes字段的第5和第6都是1。这时候ControlBatch中只有一个Record,Record中的timestamp delta字段和 offset delta字段的值都是0,而控制消息的key和value如下图所示:

kafka之事务_第5张图片

key和value的version值都为0,代表版本信息。 key中的type标识控制类型:0 表示ABORT, 1标识COMMIT;value中的coordinator_epoch 表示TransactionCoordinator的版本,TransactionCoordinator切换的时候会更新其值。

3)写入最终的COMPLETE_COMMIT或者COMPLETE_ABORT

TransactionCoordinator将最终的COMPLETE_COMMIT或COMPLETE_ABORT信息写入主题__transaction_state以表明当前事务已经结束,此时可以删除主题__transaction_state中所有关于该事务的消息。由于主题__transaction_state 采用的日志清理策略为日志压缩,所以这里的删除只需将相应的消息设置为墓碑消息即可。

总结:

首先,kafka中的事务是为了保证消息的 exactly once semantics的 精准一次处理语义诞生的,这里和我们理解的数据库事务有些区别(数据库事务是实现了四大原则,这里一定不要一直两者比较,没有意义,还容易记混)。

幂等性确保了单分区的消息的一次处理语义,它通过pid 和消息序列号来实现,当客户端发送到broker端的消息序列号小于等于broker端缓存的pid对应的seq的时候,忽略这个消息并保持,保证了消息不会被重复消费,同时幂等性的设置保证了kafka的客户端重试=-1,也保证了消息肯定能到达,这样就满足了 exactly once的语义。

broker端缓存了的序列号队列。没收到一个消息都要对比一下。

但是幂等性有一个缺点,就是broker端只记录了生产者pid和某一个分区的序列号,如果多个和客户端或者客户端发送的主题的分区分布在不同的broker,那么幂等性就失效了

事务弥补了上面这个缺点。事务保证了多个分区写入的原子性。(这里可以理解为任何写操作,包括提交位移,写新消息等)。

up理解的事务原理就是,通过事务协调器统一管理不同客户端发送到不同分区的操作。因为事务管理器中记录了这个操作,所有当客户端不管是commit还是abort得时候都可以通过事务协调器来查找同一个事务发送的分区,从而进行commit或者abort。

客户端或者客户端发送的主题的分区分布在不同的broker,那么幂等性就失效了**。

事务弥补了上面这个缺点。事务保证了多个分区写入的原子性。(这里可以理解为任何写操作,包括提交位移,写新消息等)。

up理解的事务原理就是,通过事务协调器统一管理不同客户端发送到不同分区的操作。因为事务管理器中记录了这个操作,所有当客户端不管是commit还是abort得时候都可以通过事务协调器来查找同一个事务发送的分区,从而进行commit或者abort。

你可能感兴趣的:(kafka)