[中间件] Kafka 消费不丢失方案设计:线程池和 CountDownLatch (但可能重复)

147 阅读4分钟

理想状态下,Consumer 线程从 Kafka 拉取消息应当是不重复、不丢失的。但 Kafka 的设计更适合 IO 密集型的场景,相应地,可靠性就不能那么完美。

大多数业务场景中,保证不丢失要远比保证不重复重要。本文介绍一种不丢失消费数据的方案:

  1. 引入 线程池 ,在单个 Consumer 内多线程处理一批数据,每一条 record都由一个线程执行。
  2. 采用 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()); 
    }

解决方案

  1. Producerretries(重试次数)设置一个比较合理的值,一般是 3 ,但是为了保证消息不丢失的话一般会设置比较大一点。设置完成之后,当出现网络问题之后能够自动重试消息发送,避免消息丢失。
  2. 设置重试间隔,因为间隔太小的话重试的效果就不明显了,网络波动一次你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 相关组件

  1. 单记录处理方法
    签名如: public void handle(ConsumerRecord<key,value> record)
    用于真正消费一条数据、完成业务逻辑,一般不需要亲自返回 ack 确认。

  2. 一个线程池实例
    用于多线程地执行每一条记录,具体来说是:单记录处理方法 handle()被包裹在一个线程类里(单方法接口Runnable的实现类可简化为 lambda 形式),遍历 List<Record> 每条 record,调用 executor.submit(lambda)完成执行。

    在线程类中调用完 handle()后,执行countDownLatch.countDown();。为了保证一定执行到,用 try-finally 分别包裹两者。

  3. 一个 CountDownLatch 实例
    在批量处理方法中声明一个CountDownLatch实例,传参为拉取到的这一批 records.size()

  4. 批量处理方法
    签名如: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 线程池

常用的线程池实现类包括 ThreadPoolExecutorSchedulerThreadPoolExecutor,也可以继承一个自定义线程池,本文为了保证多态,相关引用变量的类型采用 ExexutorService接口类型接收具体的值。 image.png