一文聊透Kafka事务(建议收藏)

263 阅读5分钟

本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~

首先要声明的是,KafKa中的事务和数据库中的事务完全是两个概念。

数据库中的事务需要具备ACID(原子性、一致性、隔离性、持久性)特性,且这些特性之间,并不是相互独立,完全穷尽的。

而是通过事务的原子性、持久性和隔离性来实现事务的一致性,让业务状态可以正常流转。

而Kafka的事务则不一样,其用来保证对多个分区写入的原子性,或是Consumer-Transform-Producer流式处理的原子性,保证全部执行成功或失败。

当然,前者的业务场景并不常见,我们主要来聊聊后者的业务场景。

Kafka事务场景

假设这样一种业务场景,服务A通过Kafka中的Topic 1将消息发送给服务B,服务B对消息进行一系列的加工处理,然后通过Kafka中的Topic 2将加工处理过的消息发送给服务C。

这就是典型的Consumer-Transform-Producer模式,服务B需要承载对消息的消费、处理、生产过程。

如下图所示:

在这个过程中,服务B可能会由于提交消费偏移量发生问题,导致重复消费、重复生产消息,或是漏消费、漏生产消息。

举个例子,服务B对消息进行消费,经过一番业务处理后将其发送到主题2中,但还没来得及提交消费偏移量就崩溃了。

此时会触发Kafka的再均衡,新的消费者会再次对消息进行消费,经过一番业务处理后将其发送到主题2中,这就产生了重复消费、重复生产信息。

再举个例子,服务B对消息进行消费,先进行了提交消费偏移量的操作,但还没来得及对其进行业务处理,并将其发送到主题2中就崩溃了。

此时会触发Kafka的再均衡,由于已经提交了消费偏移量,新的消费者不会再对其进行业务处理,并将其发送到主题2中,这就产生了漏消费、漏生产消息。

在这两种情况下,就需要引入Kafka事务来处理了,来保证提交消费偏移量和将消息发送到主题2的操作具备原子性,要么同时成功,要么同时失败。

Kafka事务实例

Kafka提供了五个与事务相关的方法,如下所示:

//初始化事务
void initTransactions();
//开启事务
void beginTransaction()throws ProducerFencedException;
//提交消费偏移量
void sendOffsetsToTransaction (Map<TopicPartition, OffsetAndMetadata> 
offsets,  String consumerGroupId) throws ProducerFencedException:
//提交事务
void commitTransaction()throws ProducerFencedException;
//回滚事务
void abortTransaction()throwsProducerFencedException;

这几个方法的意思还是很好理解的,接下来我们再看看代码示例。

import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;

public class KafkaTransactionalCTPExample {

    private static final String BOOTSTRAP_SERVERS = "localhost:9092";
    private static final String INPUT_TOPIC = "input-topic";
    private static final String OUTPUT_TOPIC = "output-topic";
    private static final String TRANSACTIONAL_ID = "ctp-transactional-producer";
    
    public static void main(String[] args) {
        // 配置消费者属性
        Properties consumerProps = new Properties();
        consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP_SERVERS);
        consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "ctp-consumer-group");
        consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        consumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); // 手动提交偏移量
        consumerProps.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed"); // 只消费已提交的消息
        // 配置生产者属性(开启事务)
        Properties producerProps = new Properties();
        producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP_SERVERS);
        producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        producerProps.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, TRANSACTIONAL_ID);
        producerProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
        try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps);
             KafkaProducer<String, String> producer = new KafkaProducer<>(producerProps)) {
            // 订阅输入主题
            consumer.subscribe(Collections.singletonList(INPUT_TOPIC));
            // 初始化事务
            producer.initTransactions();
            while (true) {
                // 消费消息
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
                if (!records.isEmpty()) {
                    // 开始事务
                    producer.beginTransaction();
                    try {
                        // 记录分区和偏移量,用于事务内提交
                        Map<TopicPartition, OffsetAndMetadata> offsetsToCommit = new HashMap<>();
                        // 处理每条消息并发送到输出主题
                        for (ConsumerRecord<String, String> record : records) {
                            // 消息转换逻辑(示例:添加前缀 "[TRANSFORMED]")
                            String transformedValue = "[TRANSFORMED] " + record.value();
                            // 发送转换后的消息到输出主题
                            ProducerRecord<String, String> outputRecord = 
                                new ProducerRecord<>(OUTPUT_TOPIC, record.key(), transformedValue);

                            producer.send(outputRecord);
                            // 记录当前消息的偏移量(用于事务提交)
                            TopicPartition partition = new TopicPartition(record.topic(), record.partition());
                            OffsetAndMetadata offset = new OffsetAndMetadata(record.offset() + 1);
                            offsetsToCommit.put(partition, offset);
                        }
                        // 在事务内提交消费偏移量(确保消费和生产的原子性)
                        producer.sendOffsetsToTransaction(offsetsToCommit, consumer.groupMetadata());
                        // 提交事务
                        producer.commitTransaction();
                    } catch (Exception e) {
                        // 发生异常时回滚事务
                        producer.abortTransaction();
                        System.err.println("事务回滚: " + e.getMessage());
                    }
                }
            }
        }
    }
}

另外,如果开启Kafka事务的话,还有几个参数的设置需要特别注意。

1、需要将生产者参数enable.idempotence设置为true(默认false),用来开启幂等性,这个是开启事务的前提。

2、需要将消费者参数isolation.level设置为read committed(默认read uncommitted),使消费者端看不到尚未提交事务的消息。

3、需要将消费者参数enable.auto.commit设置为false(默认true),不能自动提交消费偏移量。

问题答疑

这里再说一下两个比较关键的问题。

1、Kafka事务是如何实现的?

是通过Kafka Broker端的事务协调器(TransactionCoordinator)来实现的,上文中所罗列的五个事务相关的API,都是与其进行交互的。

当Kafka生产者和消费者开始一个新的事务时,会先通过事务协调器来注册事务ID,以及后续生产者和消费者的读写操作,都会通过事务协调器来实现事务状态管理、协调事务执行并保证原子性。

下图为Consumer-Transform-Producer模式的事务执行流程:

(图片来自于网络,侵删)

2、Kafka事务的处理范围

kafka事务的原子性,仅限于保证Kafka消息的提交消费偏移量和消息发送,并不能保证其他操作。

比如:对于数据库CRUD的操作,调用下游系统API的操作等等,这些都不在Kafka事务的处理范围内,需要自行保证原子性。