版权声明:本文为转载文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/weixin_39468305/article/details/106774777
-
消费者消费速率跟不上怎么办?
-
消费组是什么?重平衡是什么?
-
消费者数据丢失了怎么办?重复消费了怎么办?
-
怎么指定位置消费?比如我铁了心要
2020-05-20 13:14
开始消费。
如果有任一问题回答不出来,那你就不能说自己掌握了Kafka Consumer。老老实实看完这篇文章吧骚年。
从简单Demo说起
首先还是从简单的demo说起,如果你完全没使用过kafka消费者,那么先运行一下这个demo吧:
public void consume() {
Properties properties = PropertiesConfig.getConsumerProperties();
properties.put("group.id", "my_group");
properties.put("bootstrap.server","192.168.1.9:9092");
properties.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
//先创建一个 KafkaConsumer 对象。
Consumer<String, String> consumer = new KafkaConsumer<>(properties);
//订阅主题列表 可以匹配正则表达式 如subscribe("test.*");
consumer.subscribe(Collections.singletonList("customerTopic"));
try {
//消费者是一个长期运行的应用程序,它通过轮询的方式向 Kafka 请求数据。
while (true) {
ConsumerRecords<String, String> consumerRecords = consumer.poll(Duration.ofMillis(KafkaConfig.pollTimeoutOfMinutes));
if (!consumerRecords.isEmpty()) {
for (ConsumerRecord<String, String> record : consumerRecords) {
// 省略业务逻辑处理
}
}
}
} catch (Exception e) {
}
}
流程不复杂,首先是配置一些参数然后创建消费者实例,然后进行主题订阅。循环那一部分简单说下,消费者是如何知道生产者发送了数据呢?其实生产者产生的数据消费者是不知道的,所以KafkaConsumer 是采用轮询的方式定期去 Kafka Broker 中进行数据的检索,如果有数据就用来消费,如果没有就再继续轮询等待。如果用刨根问底的思想去看待这个简单的demo,你还会问参数group.id是什么?顾名思义是组的概念。
消费组
如果有1000个面包,让一个人解决,他需要至少一年的时间,而让1000个人解决,只需要一分钟的时间。同理,100w条消息如果只让一个消费者来消费,可能需要一个小时,而如果你让50个消费者来消费,只需要5分钟。这50个消费者实例组成的集合就是消费组。
ConsumerGroup:消费者组,指的是多个消费者实例组成一个组来消费一组主题。为什么要引入消费者组呢?从面包的例子不难得出是效率问题。再看下这两张图你就明白了:
主要是为了提升消费者端的吞吐量。多个消费者实例同时消费,加速整个消费端的吞吐量(TPS)。所以我们得出:在用户产生消息特别多的时候,消费者吃不消,可以继续增加消费者(无非就是单机多线程或者分布式)。
可是事实往往没有你想象的简单,Kafka的分区只能被消费者组中的其中一个消费者去消费,组员之间不能重复消费。 也就是分区:消费者是N:1的关系。
比如你看下面这张图,就有消费者闲置了。
这个知识点非常重要!告诉我们分区和消费者的对应关系。所以我们应该提前规划好topic的分区数,否则可能导致消费速率无法提高。另外也告诉我们如何设计消费组?理想情况下,Consumer 实例的数量应该等于该 Group 订阅主题的分区总数。
现在你应该可以回答开头的问题了:消费者消费速率跟不上怎么办?首先你可以使用多进程或者多线程消费,但是有个度,这个度就是分区。我们将在下一篇文章重点说一下kafka多线程消费如何实现
Kafka另外一个很重要的特性就是,只需写入一次消息,可以支持任意多的应用读取这个消息。任意多的应用其实就是任意多的group.id。如下图所示:
消费组是个双刃剑,给我们带来利好的同时、也给我们带来了"重平衡"这个臭名昭著的破玩意,生产很多问题都是由于它引起的。我们将在下一篇文章重点说一下重平衡这个鬼东西。
到现在,你彻底入门了Kafka消费者,基础知识其实就那么点。但是很多问题我们不得不考虑,比如怎么保证消息不丢失不重复呢?
保证消息不丢失
回忆一下我们在聊生产者的时候说到保证消息不丢失的一个方法,一定要使用带回调函数的send方法,这里的回调其实就是一个通知作用,通知我们消息有没有发送成功。那么消费者也是如此呀,我们需要知道消费有没有成功,如果没有成功,我们需要进行补处理。
Consumer 程序有个“位移”的概念,表示的是这个 Consumer 当前消费到的 Topic 分区的位置。就好像书签一样,需要书签你才可以快速找到你上次读书的位置。Kafka默认是自动提交位移的(enable.auto.commit=true
)。
自动提交可能会有问题,因为自动提交是发生在消费者poll方法调用后每隔5秒(由auto.commit.interval.ms
指定)提交一次位移。
假如你poll了0-30条消息,处理到第20条时kafka就自动提交了offset,但是在处理21条的时候出现了异常,当你再次拉取数据时,由于之前是自动提交的offset,所以是从30条之后开始拉取数据,这也就意味着21-30条的数据发生了丢失。
消费端保证不丢数据,最重要就是保证offset的准确性。我们能做的,就是确保消息消费完成再手动提交offset。Consumer 端有个参数 ,设置enable.auto.commit= false
,并且采用手动提交位移的方式,我们用手动提交位移的方式改造一下代码:
ConsumerRecords<String, String> consumerRecords = consumer.poll(Duration.ofMinutes(KafkaConfig.pollTimeoutOfMinutes));
if (!consumerRecords.isEmpty()) {
boolean batchHandleResult = true;
for (ConsumerRecord<String, String> record : consumerRecords) {
KafkaMessage kafkaMessage = JSON.parseObject(record.value(), KafkaMessage.class);
//处理业务逻辑 返回成功或者失败
boolean handleResult = handle(kafkaMessage);
if (!handleResult) {batchHandleResult = false;}
}
//手动提交位移
if (batchHandleResult) {
consumer.commitSync(Duration.ofMinutes(KafkaConfig.pollTimeoutOfMinutes));
} else {
//消费失败处理
}
}
这样做,即使处理消息的过程中发生了异常,由于没有提交位移,下次消费时还会从上次的位移处重新拉取消息,不会发生消息丢失的情况。如果在处理数据时发生了异常,可以进行重试,比如重试3次。还是失败的话,就把这条数据给缓存起来,可以是redis、DB、file等,也可以把这条消息存入专门用于存储失败消息的topic中,让其它的consumer专门处理失败的消息。
消费位移是一个需要深究的东西,比如既可以手动同步提交也可以手动异步提交、而两者结合才是最完美的方式;另外一般可以把消费位移存储在数据库中,并且指定位移消费。。我们将在下一篇文章重点说一下消费位移的那些事。
保证消息不重复
即使采用手动提交位移也不能保证消息不重复的问题。假设你处理完业务逻辑,准备提交offset的时候,程序挂了,导致消费位移没有提交,重启之后就会重新消费了。所以消息队列其实是提供了at least once(至少一次) + 幂等性,而这个幂等性交给我们去处理。
我觉得最好的方式就是:从业务逻辑设计上入手,将消费的业务逻辑设计成具备幂等性的操作。 借助业务消息本身及下游组件的幂等性来做。比如有些组件,mysql、hbase、elasticsearch
天然就支持幂等操作。利用mysql数据库的唯一约束(主键)实现幂等,我相信不用多说你也知道了。所以我还是举个HBase的例子吧:
在华泰证券中Kafka的幂等性是如何保证的?在接收端,启动专门的消费者拉取 kafka 数据存入 hbase。hbase 的 rowkey 的设计主要包括 security_id(股票id)和 timestamp(行情数据时间)。消费线程从 kafka 拉取数据后反序列化,然后批量插入 hbase,只有插入成功后才往 kafka 中持久化 offset。这样的好处是,如果在中间任意一个阶段发生报错,程序恢复后都会从上一次持久化 offset 的位置开始消费数据,而不会造成数据丢失。如果中途有重复消费的数据,则插入 hbase 的 rowkey 是相同的,数据只会覆盖不会重复
,最终达到数据一致。
但是,不是所有的业务都能设计成天然幂等的。这时候你还可以考虑一种通用性最强,适用范围最广的实现幂等性方法:记录并检查操作,也称为“Token 机制或者 GUID(全局唯一 ID)机制”,实现的思路特别简单:在执行数据更新操作之前,先检查一下是否执行过这个更新操作。
具体的实现方法是,在发送消息时,给每条消息指定一个全局唯一的 ID,消费时,先根据这个 ID 检查这条消息是否有被消费过,如果没有消费过,才更新数据,然后将消费状态置为已消费。
想法很简单,但是在分布式系统中,这个方法其实是非常难实现的。首先,给每个消息指定一个全局唯一的 ID 就是一件不那么简单的事儿,方法有很多,但都不太好同时满足简单、高可用和高性能,或多或少都要有些牺牲。更加麻烦的是,在“检查消费状态,然后更新数据并且设置消费状态”中,三个操作必须作为一组操作保证原子性,才能真正实现幂等,否则就会出现 Bug。
比如说,对于同一条消息:“全局 ID 为 8,操作为:给 ID 为 666 账户增加 100 元”,有可能出现这样的情况:
-
t0 时刻:Consumer A 收到条消息,检查消息执行状态,发现消息未处理过,开始执行“账户增加 100 元”;
-
t1 时刻:Consumer B 收到条消息,检查消息执行状态,发现消息未处理过,因为这个时刻,Consumer A 还未来得及更新消息执行状态。
这样就会导致账户被错误地增加了两次 100 元,这是一个在分布式系统中非常容易犯的错误,一定要引以为戒。
(以上是原作者的,我个人是有一些疑问的,按道理来讲不会出现同一个消息被两个消费者实例消费的情况,只需要解决同一消费者重复消费的问题即可,即使消费者挂掉了把分区分给了别的实例,也不应该会存在两个消费者同时消费一条消息的情况,本人小白,了解的不深,有能解答的小伙伴,欢迎评论,让我也可以学习一下)。
对于这个问题,当然我们可以用事务来实现,也可以用锁来实现,但是在分布式系统中,无论是分布式事务还是分布式锁都是比较难解决问题。因此一般不推荐使用。
使用Kafka消费消息虽然简单,可是考虑的问题可就不是这么简单了。消费组和重平衡、位移提交和位移管理、不重复不丢失的保障、以及参数调优都是非常重要的章节。
在本篇文章已经带你把这些内容都过了一遍,你应该能做到心中有数,但是接下来还会有几篇文章单独讲解一些核心内容:
-
大名鼎鼎又臭名昭著的消费组和重平衡
-
Kafka消费位移的那些事
-
Kafka多线程消费的思考和实例