再均衡的概念以及触发的情况都在上一篇文章中做了说明。再均衡的执行过程是造成重复消费、消息丢失的主要原因。另外消费者偏移量如何提交,如何保证再均衡后,消费者能够从上次执行到的偏移量开始消费,防止重复消费和丢失问题,都将在这篇文章中体现。
接下来书写的基本思路:知道消费者提交偏移量到Kafka的几种方式、再均衡监听器、再均衡监听器配合消费者提交机制做到不丢失消息也不重复消费的解决方案。最后说一下独立消费者的使用。
消费者偏移量提交
自动提交
自动提交配置参数enable.auto.commit
,默认值是true
,表示默认支持自动提交。如果需要指定提交间隔时间,就需要配置auto.commit.interval.ms
参数,默认是5秒。
消费者使用poll()
方法轮询去Kafka中拉取消息数据,同时消费者会有一个异步的线程在定时的向Kafka提交消费者的偏移量,这个偏移量是poll()
最后一次拉取的偏移量。这样是存在问题的。
假设A消费者消费0分区,当A消费者上次提交偏移量是30000,在后续的3秒又执行了三次poll()
操作,每次拉取1000条数据,当时间间隔还没到5秒,后续拉取的数据偏移量就不会提交给Kafka,这个时候如果出现再均衡操作,原来的0分区被分配给了B消费者,这个时候B消费者会根据0分区记录的消费偏移量开始消费,这个时候就会造成A消费者已消费且没有来得及提交偏移量的部分被B重复消费。
同步手动提交
使用手动提交的时候,需要将自动提交关闭,把enable.auto.commit
值设备为false
即可。下面的异步手动提交也是一样要关闭。
在执行业务代码中直接调用commitSync()
方法执行提交偏移量操作,此方法是阻塞的,需要等待Kafka的响应,如果成功直接返回结果,如果失败会抛出异常信息。如果每消费一条消息提交一次,可以防止重复消费问题,但是会对性能造成巨大的消耗,如果等消费一批(比如消费20条)提交一次,这样同样会造成重复消费问题。
异步手动提交
异步提交调用commitAsync()
方法,不阻塞,异步执行提交操作,但是还是存在问题。如现在第一次提交偏移量是2000没有成功,会进行重新发送,在重新发送之前又发生了一次异步提交操作,此时是成功的,提交的偏移量是2100,此时上一个重新发送的提交也成功了,把最新的偏移量重置成了2000,如果在这个时刻发生了再均衡就可能导致2000到2100中间的100条消息被重复消费。
基本的提交方式就是这三种,还有其他的提交方式都是由这三种方式组合,但是重复消费和消息丢失都是会出现的。上面因为提交导致的都是消息重复消费,消息丢失什么时候会发生呢?
消息丢失:假设A消费者poll()
拉取了1000条记录,此时刚好提交偏移量的时间到了,将最新的1000偏移量提交给Kafka,但是此时A消费者拉取的数据还未消费结束就异常退出。此时其他消费者接盘这个分区的消费任务,就会从1000偏移量开始消费,导致A消费者未消费完的数据丢失,没有被消费。
再均衡监听器
消费者群组在进行再均衡操作的时候会通知群组成员,当群组成员执行下一次拉取的时候就会触发再均衡监听器的onPartitionsRevoked
方法,待群组中所有成员执行完此方法后才真正进入再均衡操作。再均衡操作结束后,所有群组成员会执行onPartitionsAssigned
方法。在业务操作中就可以利用这两个方法来实现再均衡前做分区、偏移量持久化逻辑,以及再均衡后从指定偏移量开始读取消息数据操作。但是如何使用呢?
先看一下自定义再均衡器监听器的实现方式:
//实现ConsumerRebalanceListener接口
public class SelfRebalance implements ConsumerRebalanceListener {
//rebalance开始前执行的逻辑
@Override
public void onPartitionsRevoked(Collection<TopicPartition> collection) {
System.out.println("再均衡执行前逻辑……");
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> collection) {
System.out.println("再均衡完成后执行逻辑……");
}
}
实现的两个接口很简单,关键是看怎么去用。在Consumer订阅的时候,指定需要使用的再均衡监听器。(如果在订阅的时候没有指定再均衡监听器,会默认指定NoOpConsumerRebalanceListener
,这个类实现了ConsumerRebalanceListener
接口,但是没有对内部方法做具体的实现,空方法!)
Consumer订阅时指定再均衡监听器:
consumer.subscribe(Collections.singleton("self-rebalance-listener"), new SelfRebalance());
重复消费和消息丢失解决方案
数据安全和性能是对立的,需要安全就要在性能上做出让步。对于Kafka也是一样,如果需要做到不能有一条数据丢失、一条数据重复消费,那么就需要投入大量的数据安全处理逻辑,进而导致性能的下降,降低Kafka本身的吞吐量。如果这个时候还要求大的吞吐量,就需要横向拓展增加分区数量和消费者数量啦。
但是在使用Kafka之前就应该考虑到Kafka存在这方面的问题。因为Kafka主要致力于做大数据的吞吐量。如日志埋点,分析用户行为,定向推送,千人千面。这个时候要保证吞吐量,就需要能够接受再均衡时引起的部分数据重复消费或者丢失问题。
虽然说很多情况下是可以接受重复消费和消息丢失问题,但是保证数据尽可能的安全是必须的。下面来说一下我的思路。具体实现代码不写,用伪代码代替。
生产者代码就不写了,这里主要说的是消费者。先看一下消费者的伪代码。
public static void main(String[] args) throws Exception {
//配置信息
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:9092");//Kafka服务地址
properties.put(ConsumerConfig.GROUP_ID_CONFIG, "groupE");//组名
properties.put(ConsumerConfig.FETCH_MIN_BYTES_CONFIG, 10240);//拉取的最小字节数
properties.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 3000);//拉取的最大等待时间
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);//关闭自动提交
properties = ConsumerConfig.addDeserializerToConfig
(properties, new StringDeserializer(), new StringDeserializer());//指定key、value序列化器
//JDK7推出的功能,将consumer构建放到try上,可以实现自动关闭consumer功能
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties)) {
//这里是订阅一个主题,实际可能会是多个
consumer.subscribe(Collections.singleton("self-rebalance-listener"), new SelfRebalance());
//轮询拉取数据
while (true) {
//每次拉取一定量的数据,具体数据量根据生产者生产的速度、拉取的最小字节数、拉取的最大等待时间决定
ConsumerRecords<String, String> records = consumer.poll(500);
//start------------开始消费
int i = 0;
for (ConsumerRecord<String, String> record : records) {
System.out.println("client1-topic:" + record.topic()
+ ",client1-partition:" + record.partition() + ",client1-offset:" + record.offset());
Thread.sleep(100);//每条记录处理需要100毫秒
if ((++i % 10) == 0) {//每处理10条数据提交一次
//将偏移量缓存到Redis中或者持久化到数据库中
//将偏移量手动提交一次,consumer.commitSync(……);
}
}
if ((i % 10) != 0) { //for循环内是每10条提交一次,防止最后一此循环不满10条,漏提交,在循环外再检查一次
//将偏移量缓存到Redis中或者持久化到数据库中
//将偏移量手动提交一次,consumer.commitSync(……);
}
//end------------结束消费
}
}
}
代码里面注释写的很详细,就不多做赘述,但是有几个注意的点需要稍微说一下。
- 指定拉取的最小字节数和拉取的最大等待时间,这个是为了每次拉取数据都是批量的,如果使用默认值,就是有一条拉取一条
- 在消费消息的过程中,使用手动提交操作,在配置的时候需要将自动提交功能设置为
false
,关闭自动提交 - 开始消费和结束消费做了特殊标志,这里可以考虑事务,保证消费数据入库、偏移量入库、手动提交三步操作原子化和保持数据的一致性
- 在消费的逻辑中采用每处理完10条,提交一次,降低持久化、提交频次,从而降低性能的消耗和资源浪费
消费者代码已经完成,还需要看一下再均衡监听器的代码,这个也是很重要的部分。
public class SelfRebalance implements ConsumerRebalanceListener {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> collection) {
//将最终缓存或者持久化的偏移量提交给Kafka服务
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> collection) {
//通过collection可以获取再均衡后,此消费者被的主题和分区
//根据分配到的主题和分区,到数据库或者缓存内拿取再均衡前对应的偏移量
//根据获取的分区和偏移量信息,让消费者从该偏移量开始消费,使用Consumer的seek(……)方法
}
}
onPartitionsRevoked
可以做的事情远不止持久化偏移量、提交最终偏移量到Kafka这些,可以根据自身的业务做适当的调整。- 再均衡结束后,会将新分配的主题、分区信息放到
onPartitionsAssigned
方法的参数上,可以根据被分配得的主题、分区信息到缓存或者持久化信息中找到对应的偏移量信息,然后让consumer从特定的位置开始消费 - 还有一点需要特别注意,那就是再均衡执行的过程。如下:
- 再均衡操作通知到消费者群组成员
- 消费者成员执行下一次
poll()
方法才会接收到再均衡操作通知信息 poll()
不会再拉取数据而是触发再均衡监听器onPartitionsRevoked
方法(注意:如果上一次拉取1000条数据,消费到500条,有再均衡操作通知,此时当前这个消费者对这个通知是没有感知的,当剩下500条数据都执行结束,调用poll()
拉取下一批数据时才会感知到再均衡通知,此时不会执行拉取动作,而是触发再均衡监听器)- 先执行完先进入再均衡等待状态,此时不会接收消息,待所有消费者成员都进入再均衡等待(消费者间有一个互相等待机制)
- 执行具体再均衡操作
- 将再均衡分配结果发送到所有群组成员,触发
onPartitionsAssigned
方法 onPartitionsAssigned
方法执行结束后,每个消费者对应的主题、分区信息可能会发生变化,再拉取数据就会以新分配的主题、分区信息拉取(此时消费者群组成员没有互相等待机制,谁先执行完,谁先进入正常的拉取数据,消费数据操作)
通过这个解决方案就可以解决重复消费和重复提交的问题,即使是处理出现异常、或者消费者异常退出,也不会造成消息消息丢失问题。增加事务、增加偏移量持久化、增加手动提交等逻辑,都是拉低消费者消费效率、提高性能消耗的问题点。
独立消费者
之前的demo中,写消费者的时候都会加入一个群组信息配置,如果现在有一个消费想单独成体,不与其他消费者组成群组,可以根据自己的需求去消费指定主题、指定分区的消息。那么怎么实现呢?其实并不难,看一下下面的代码:
public static void main(String[] args) {
//配置信息
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:9092");//Kafka服务地址
properties = ConsumerConfig.addDeserializerToConfig
(properties, new StringDeserializer(), new StringDeserializer());//指定key、value序列化器
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties)) {
//获取指定主题的分区信息
List<PartitionInfo> partitionInfos = consumer.partitionsFor("self-rebalance-listener");
List<TopicPartition> topicPartitions = Lists.newArrayList();
if (CollectionUtils.isNotEmpty(partitionInfos)) {
//这里可以根据实际业务需求,消费指定分区信息
partitionInfos.forEach(part ->
topicPartitions.add(new TopicPartition(part.topic(), part.partition()))
);
}
consumer.assign(topicPartitions);//订阅指定分区
//轮询拉取数据
while (true) {
ConsumerRecords<String, String> records = consumer.poll(500);
for (ConsumerRecord<String, String> record : records) {
System.out.println("client1-topic:" + record.topic()
+ ",client1-partition:" + record.partition() + ",client1-offset:" + record.offset());
}
}
}
}
- 在配置信息里面不添加分组信息
- 在订阅的时候先根据主题获取主题下分区信息,然后根据业务需求订阅指定的分区
实现过程很简单,其他不多做赘述。
源码
码云(gitee):https://gitee.com/itcrud/itcrud-note/tree/master/itcrud-note-1-3
重复消费和消息丢失解决方案以及自定义再均衡监听器:com.itcrud.kafka.selfrebalance
包内!!!
独立消费者:com.itcrud.kafka.independentconsumer
包内!!!