通用方案
当我想了解Kafka如何解决消息的丢失和重复问题时,GPT告诉我的方案如下:
1. 消息不丢失
生产者配置(Producer)
-
acks
参数-
这是最关键的配置。
acks=all
或acks=-1
表示只有当所有的同步副本(ISR, in-sync replicas)都收到消息后,生产者才会认为消息发送成功。这可以确保消息写入成功后不会丢失。
-
-
重试机制(
retries
)-
在网络闪断或者其他异常情况下,可以通过配置
retries
来重试发送消息。这个机制配合幂等性(enable.idempotence
),可以避免消息重复。
-
-
幂等性(Idempotence)
-
启用幂等性可以保证每条消息只会被发送一次且不重复,即使重试了也不会导致消息重复。这需要 Kafka 2.11 及以上版本。
-
Broker 配置
-
副本数(Replication Factor)
-
副本数越多,可靠性越高。如果一个 Broker 宕机,其他副本可以继续提供服务,保证消息不丢失。
-
-
最小同步副本数(min.insync.replicas)
-
这个参数配合
acks=all
使用时,表示至少有两个副本需要确认消息已写入才能认为消息写入成功。这可以避免单个副本宕机导致数据丢失。
-
消费者配置(Consumer)
-
自动提交偏移量(
auto.commit.offset
)-
自动提交偏移量可能导致在消息消费未完成时提交偏移量,从而丢失消息。手动提交偏移量可以确保消息被处理后再确认。
-
2. 消息不重复消费
消费者配置:
-
幂等性设计:消费者在处理消息时需要设计幂等逻辑。
-
在消费消息后执行操作时,应该能够识别重复的消息并忽略重复操作。例如:通过消息的唯一 ID(如 Kafka 消息的
offset
或者key
)来判断消息是否已经处理过。
-
-
手动提交偏移量:确保在消息处理完成后提交偏移量。
-
使用
enable.auto.commit=false
,在确认消息处理成功之后调用commitSync()
或commitAsync()
提交偏移量,确保只有在消息处理成功后才更新偏移量。
-
Broker 配置:
-
事务性消费者(Transactional Consumer)
-
消费者可以配置为只读取提交的事务性消息,避免消费未提交的消息。
-
业务场景和解决方案
真正的问题解决应该结合具体的业务场景,本次在系统的业务场景下结合上述的方案,对Kafka问题尝试优化。A系统有两个业务涉及到Kafka,分别是作为消费者和生产者。
消费者场景
A系统订阅上游系统的三种信息变更消息的Topics,分别对应用户基本信息、用户职务信息、部门基本信息场景,具体内容不必展开,我们把三个Topic分别称作Topic1、2、3。订阅拉取三个topic的消息,然后根据消息类型分别进行不同的处理。
需要解决的问题/实现的业务逻辑
1、异步地每隔一段时间持续获取三个Topic中的新消息
2、保证消费到所有poll的消息,防止消息消费失败后丢失
3、防止重复的消息造成不必要的影响
4、防止消费失败,以及消息消费失败后不能阻塞其他消息
解决方案
1 异步拉取消息
这个的实现比较容易,在创建消费者后,通过线程池来异步启动消息的订阅和循环拉取就可以了。
//定义线程池
private ThreadPoolExecutor consumerPool = new ThreadPoolExecutor(3, 3, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(1));
//创建消费者后异步启动
KafkaConsumer kafkaConsumer = new KafkaConsumer(properties);
SisMqConsumer sisMqConsumer = new SisMqConsumer(kafkaConsumer, Arrays.asList(topicSisPersonBase, topicSisPersonJob, topicSisDept), updaters);
sisMqConsumer.start();
2 防止消息消费丢失
场景描述:
- 在 批量
poll
消息 时,Kafka 默认认为拉取到的消息已经被消费。 - 例如:当前偏移量是 11,
poll
拉取了 5 条消息,偏移量分别为 11, 12, 13, 14, 15。 - 在开启自动提交偏移量的情况下:
- Kafka 会默认将这些消息视为已消费,即便实际处理逻辑可能还没有完成。
- 当
auto.commit.interval.ms
(默认5秒)到期后,Kafka 会自动提交这些消息的下一偏移量 16。
- 如果在消息处理逻辑中出现问题(如只处理了偏移量 11, 12 的消息后失败),后三条消息(偏移量 13, 14, 15)不会再次被消费:
- 因为偏移量 16 已被提交,下次消费者会从 16 开始拉取消息。
- 这会导致 偏移量 13, 14, 15 的消息丢失。
防止消息丢失是最重要的环节,主要通过手动提交偏移量来实现
手动提交偏移量
关闭自动提交偏移量的设置,并且在处理完每次poll的消息后,手动提交偏移量,这样就能保证每次提交偏移量的时候,poll到的消息都被消费过了。
ConsumerRecords<String, byte[]> records = consumer.poll(60000);
log.info("Kafka拉取得消息count的数值为:{}", records.count());
if (records.count() == 0) {
continue;
}
records.forEach(record -> {
String message = new String(record.value());
exec(message); //处理消息的方法
});
consumer.commitSync(); //提交偏移量
3 防止重复消息的影响
防止消息重复对业务造成影响主要解决方案就是在处理消息的业务逻辑中对消息的唯一性做出判断,并及时去重。这里涉及工作业务不做详细介绍,但是可以举个例子。
例如,对于新增用户相关的kafka消息,可以将用户的唯一标识作为消息的唯一标识处理,当用户标识在系统的数据库中出现,便可以认为这条消息是重复的。但是,针对具体业务需要考虑多种不同的场景,增加业务逻辑的合理性和正确性。比如,新增用户再次出现是否是因为二次入职等场景?此时,需要做额外判断。
4 防止消费失败和阻塞
防止消息失败主要手段是引入消息处理的重试机制;防止阻塞是借助日志、死信队列等方式记录失败消息。
防止消息失败
消息处理加入重试的机制,这里简单将最大次数设为三次,如果想实现更灵活的配置方式,可以借助全局配置等手段实现。(比如,由携程开源的分布式配置中心Apollo,具体不再展开)
防止消费阻塞
手动提交偏移量的一个弊端就是有可能实现消费的阻塞,因为偏移量的提交总是在消息处理的逻辑之后,而如果处理过程中出错,导致偏移量无法正常提交,就会一直循环消费这几条问题消息。
防止消费阻塞,即当消息失败次数过多(超过阈值)的时候,将失败消息记录,并跳过该消息的处理,继续处理后续消息,并正常提交偏移量。记录失败消息的常用做法就是借助日志、死信队列等方式。
1、日志记录是最简单有效的方法,消费失败后打印失败错误日志后续通过搜索错误日志,便可以方便地查询失败消息,并进行人工干预。
2、更专业的做法是采用死信队列来存储失败消息。(本文不展开描述死信队列,只做简单介绍)死信队列,顾名思义也是消息队列的一种,专门用于存放消费失败的消息,用于消息的后续人工或自动处理。消费失败的消息重新投入死信队列,做后续自动处理或后续人工干预。
private void exec(String msg) {
log.info("收到kafka消息 : {}", msg);
int retryCount = 0;
int maxRetries = 3;
while (retryCount < maxRetries) {
try {
//处理消息的逻辑
...
break;
} catch (Throwable throwable) {
log.warn("处理kafka消息失败,异常信息为,msg:{}", msg, throwable);
retryCount++;
log.warn("处理失败,正在重试。。。第{}次", retryCount);
if (retryCount == maxRetries) {
//打印错误日志
log.error("处理kafka消息失败,超过最大重试次数,消息为:{}", msg);
//投放死信队列
sendToDeadLetterQueue(msg, throwable);
}
}
}
}
private void sendToDeadLetterQueue(String msg, Throwable throwable) {
// 伪代码:发送消息到死信队列的逻辑
DeadLetterMessage deadLetterMessage = new DeadLetterMessage();
deadLetterMessage.setOriginalMessage(msg);
deadLetterMessage.setErrorMessage(throwable.getMessage());
deadLetterMessage.setTimestamp(System.currentTimeMillis());
// 将消息发送到死信队列,例如 Kafka 死信主题或其他消息系统的 DLQ
deadLetterProducer.send(deadLetterMessage);
}
两种方式根据业务的需求做选择,复杂的不一定是最好的,有时候日志记录就足够用了。