Kafka提供了生产者发送消息的幂等性和事务特性来阻止消息的重复,这两种方式均适用于不同的应用场景,其中:
- 消息的幂等性
适用于消息在写入到服务器日志后,由于网络故障,生产者没有及时收到服务端的ACK
消息,生产者误以为消息没有持久化到服务端,导致生产者重复发送该消息,造成了消息的重复现象,而幂等性就是为了解决该问题。
- 生产者事务
生产者事务有两种典型的用途,一种是将多个消息的提交操作作为一个原子操作,要么全部提交成功,要么全部提交失败,还有一种场景就是用于防止consume -> processor -> produce
场景下的消息重复问题,即主题A
的消息在被消费后,在processor
进行处理后,再通过生产者将处理后的消息发送到主题B
。仅仅依靠幂等性只能够保证主题A
的生产者不重复发送消息,而无法保证消息在processor
处理后并且发送到主题B
后,如果当前processor
宕机,并且在此之前没有及时提交主题A
的消费位移,在processor
再次启动后,主题A
的消息被processor
重复消费,并再次进行处理,从而导致主题B
有重复的消息。
上图中,由于消费位移没有及时提交给主题A
,导致processor
重启后,会再次拉取消息X
,并重复上述流程。Kafka的事务就是为了解决这种场景导致的主题B
中消息Y
重复的问题。
本文将详细介绍这两种方式。
1 - 消息幂等
前面提到过,消息幂等是为了防止生产者没有及时收到ACK
情况下重复发送消息导致队列中消息的重复。注意这里的ACK
并非是TCP协议的ACK
,而是Kafka在完成消息的持久化后,向生产者发送的应用层的ACK
,表示已经收到消息并完成了同步,TCP层的ACK
只能满足消息已经正确送达,不能保证Kafka已经将消息持久化到日志,下文中的ACK
均表示应用层的ACK
。
为了解决上述问题,Kafka提供了幂等机制,简单说就是对接口的多次调用所产生的结果和调用一次是一致的。
如果需要使用幂等,在构造KafkaProducer
的时候,需要修改以下参数:
enable.idempotence
(即ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG
)为true
- 保证
retries
参数大于0(默认设置为Integer.MAX_VALUE
),retries
参数是消息在发送出去后没有及时收到ACK
时的尝试次数。 max.in.flight.requests.per.connection
参数不能大于5(默认设置为5)acks
参数为-1
,即只有消息同步到所有分区时才会返回ACK
。
在打开enable.idempotence
参数后,其余三个参数都会自动调整至默认值。
实现机制
为了实现生产者的幂等性,Kafka引入了Producer ID
和Sequence Number
两个概念
每个KafkaProducer
在构造时都会被分配出一个Producer ID
,每个分区都有一个序列号,从0开始递增,生产者发送一条消息到分区后,就会将[Producer ID, Partition ID]
(Partition ID
为分区号)对应的序列号的值加上1,这个序列号由服务端维护。
Broker端为每一对[Producer ID, Partition ID]
维护了一个序列号,当Broker端收到一个生产者发送的消息时,只有当该消息的序列号值比当前的值大1,才会接收它。如果差值小于1则说明消息被重复发送了,则会丢弃这条消息。如果差值大于1,则说明中间有消息尚未被写入,此时生产者会抛出OutOfOrderSequenceException
异常。
2- 事务
Kafka事务可以保障一组消息要么全部发送成功,要么全部失败,并且可以跨多个分区工作。
前面提到过,事务的典型使用场景就是consume -> processor -> produce
,在这种模式下消费和生产并存,消费者在提交消费位移的过程中可能出现问题导致重复消费消息。在这种场景下,使用事务可以将以下三步操作合并为一个原子操作:
- 从主题
A
的某个分区消费消息X
- 处理主题
A
的消息X
后,得到消息Y
,并将消息Y
提交到主题B
的某个分区 - 提交主题
A
该分区的消费位移
上述三个操作要么全部成功,要么全部失败。
使用方法
事务的控制需要通过KafkaProducer
对象,在构造KafkaProducer
前,需要设置以下参数:
transactional.id
(ProducerConfig.TRANSACTIONAL_ID_CONFIG
),即事务ID,保持不变即可。enable.idempotence
(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG
),事务幂等性,在设置transactional.id
后该配置默认会设置为true
。- 其它参数和打开幂等时类似。
如果同一时间有两个相同事务ID的KafkaProducer
被构造,那么前一个构造的KafkaProducer
会抛出ProducerFencedException
异常。
KafkaProducer
提供了5个与事务相关的方法:
void initTransactions()
:初始化事务,需要在构造时配置好事务ID,否则会抛出IllegalStateException
void beginTransaction()
:开启一个事务void sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata> offsets, String consumerGroupId)
:向ID为consumerGroupId
的消费者组提交消费位移,包含多个分区的不同位移信息void commitTransaction()
:提交事务void abortTransaction()
:回滚事务
在consume -> processor -> produce
场景下的示例代码:
KafkaConsumer<String, String> consumer = ...; //构造消费者
consumer.subscribe(Collections.singletion("topic-A")); //订阅主题topic-A
KafkaProducer<String, String> producer = ...; //构造生产者
producer.initTransactions(); //初始化事务
while (!Thread.currentThread().isInterrupted()) {
ConsumerRecords<String> records = consumer.poll(1L);
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>(); //待提交的消费位移
try {
for (TopicPartition partition: records.partitions()) {
long offset = -1L;
for (ConsumerRecord<String, String> record : records.records(partition)) {
//提交处理后的消息
ProducerRecord<String, String> newRecord = new ProducerRecord("topic-B", /*省略内容*/);
producer.send(newRecord);
offset = record.offset(); //取最新的消息位移
}
if (offset != -1) {
offsets.put(partition, new OffsetAndMetadata(offset + 1));
}
}
//提交消费位移
producer.sendOffsetsToTransaction(offsets, "consumer-group-id");
producer.beginTransaction(); //提交事务
} catch (Exception e) {
producer.abortTransaction(); //遇到异常回滚事务
}
}
注意初始化KafkaConsumer
的时候需要将enable.auto.commit
设置为false
,即禁止自动提交消费位移。
隔离级别
在初始化KafkaConsumer
的时候,还有一个值得注意的参数就是isolation.level
(ConsumerConfig.ISOLATION_LEVEL_CONFIG
),即事务隔离级别,默认有两种:
read_committed
:消费者不可以消费到尚未提交的事务内的消息read_uncommitted
:消费者可以消费到尚未提交的事务内的消息
事务隔离级别的实现主要是通过一种特殊的消息实现,这个消息被称为ControlBatch
。
例如,在开启事务后,依次提交消息message 1
、message 2
、message 3
,然后提交事务:
当KafkaConsumer
的事务隔离级别为read_committed
时,只有当收到ControlBatch
时,才会将三条消息返回给应用层。如果事务隔离级别为read_uncommitted
,那么即使没有收到ControlBatch
,也会将前面的消息传递给业务层。