理想状态下,
Consumer线程从Kafka拉取消息应当是不重复、不丢失的。但 Kafka 的设计更适合 IO 密集型的场景,相应地,可靠性就不能那么完美。大多数业务场景中,保证不丢失要远比保证不重复重要。本文介绍一种不丢失消费数据的方案:
- 引入
线程池,在单个Consumer内多线程处理一批数据,每一条record都由一个线程执行。- 采用
CountDownLatch确保线程池的全部 Handler 线程处理完任务后,才会执行 ack 响应。
一、为什么会丢失消息
1 生产者丢失了消息
生产者(Producer) 调用send方法发送消息之后,消息可能因为网络问题并没有发送过去。因此,不能在send()调用后就判定消息发送成功了。
为了确定消息是否发送成功,我们要判断消息发送的结果。但是要注意的是 Kafka 生产者(Producer) 使用 send 方法发送消息实际上是异步的操作,因此采用回调的方式判断结果:
SendResult<String, Object> sendResult = kafkaTemplate.send(topic, o).get();
if (sendResult.getRecordMetadata() != null) {
logger.info("生产者成功发送消息到" +
sendResult.getProducerRecord().topic() + "-> " +
sendRe sult.getProducerRecord().value().toString());
}
解决方案
- 为
Producer的retries(重试次数)设置一个比较合理的值,一般是 3 ,但是为了保证消息不丢失的话一般会设置比较大一点。设置完成之后,当出现网络问题之后能够自动重试消息发送,避免消息丢失。 - 设置重试间隔,因为间隔太小的话重试的效果就不明显了,网络波动一次你3次一下子就重试完了.
2 ※消费者丢失了消息
一条消息被Producer发送到 Kafka 集群 - 某个 Topic - 某个 Partition时,会被分配一个 offset,消费者拉取到一条消息时,返回该消息的offset,来保证消息消费的顺序性。如果返回 offset 后还没来得及消费, Consumer 线程就挂了,就产生了消息丢失的问题。
3 Kafka 集群丢失了消息
Kafka 为分区(Partition)引入了多副本(Replica)机制。分区(Partition)中的多个副本= 一个leader+多个 follower。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。生产者和消费者只与 leader 副本交互。你可以理解为其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。
试想一种情况:假如 leader 副本所在的 broker 突然挂掉,那么就要从 follower 副本重新选出一个 leader ,但是 leader 的数据还有一些没有被 follower 副本的同步的话,就会造成消息丢失。
二、方案设计:不丢失地消费
1 相关组件
-
单记录处理方法
签名如:public void handle(ConsumerRecord<key,value> record)
用于真正消费一条数据、完成业务逻辑,一般不需要亲自返回 ack 确认。 -
一个线程池实例
用于多线程地执行每一条记录,具体来说是:单记录处理方法handle()被包裹在一个线程类里(单方法接口Runnable的实现类可简化为lambda形式),遍历List<Record>每条record,调用executor.submit(lambda)完成执行。在线程类中调用完
handle()后,执行countDownLatch.countDown();。为了保证一定执行到,用try-finally分别包裹两者。 -
一个
CountDownLatch实例
在批量处理方法中声明一个CountDownLatch实例,传参为拉取到的这一批records.size()。 -
批量处理方法
签名如:protected void batchHandle(List<ConsumerRecord<Object, Object>> records, Acknowledgment acknowledgment)。
该方法直接或被间接地由@KafkaListern修饰,用于主动向Kafka集群拉取一批消息用于消费并返回ack确认信息。遍历完每一条记录的
try { handle(); } finally { countDown(); }后,主调方法才会执行
countDownLatch.await(); // 保证主线程在所有 record 都被执行完后,自身才退出。 acknowledgment.acknowledge(); // 保证主线程在所有 record 都被执行完后,才响应 ack 消息。
三、背景知识介绍
1 CountDownLatch 倒计时锁
2 线程池
常用的线程池实现类包括 ThreadPoolExecutor 和 SchedulerThreadPoolExecutor,也可以继承一个自定义线程池,本文为了保证多态,相关引用变量的类型采用 ExexutorService接口类型接收具体的值。