本文首发于公众号:托尼学长,立个写 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事务的处理范围内,需要自行保证原子性。