一.Kafka Java Consumer设计原理

KafkaConsumer是双线程设计,用户主线程和心跳线程

用户主线程:

  • 启动Consumer应用程序main方法那个线程
  • 新引入的心跳线程(Heart Thread) 只负责定期给对应的Broker机器发送心跳请求,以标识消费者应用的存活性(liveness)

引入心跳线程的目的:

  • 期望与心跳频率与主线程调用KafkaConsumer.poll方法的频率分开,
  • 从而解耦真实的消息处理逻辑与消费者组成存活性管理

总结:

  • 虽然有心跳线程,但是实际上的消息获取逻辑依然是用户主线程中完成
  • 在消费消息这个层面,我们可以认为KafkaConsumer是单线程设计。

二.Consumer的多线程方案

  • kafkaConsumer类不是线程安全的(thread-safe)。
  • 所有的网络I/o处理都是发生在用户主线程中,因此,使用过程中必须要确保线程安全。
  • 也就说你不能在多个线程中共享同一个kafkaConsumer实例,否则程序会抛出ConcurrentModificationException异常。

例外:

  • kafkaConsumer方法的例外:wakeup();
  • 在其他线程中安全的调用KafkaConsumer.wakeup()唤醒Consumer。

解决方案1:

  • 消费者程序启动多个线程,每个线程维护专属的kafkaConsumer实例。负责完整的消息获取,消息处理流程。

解决方案2:

  • 消费者程序使用单或多线程获取消息,同时创建多个消费线程执行消费处理逻辑。
  • 获取消息的线程可以是一个,也可以是多个
  • 每个线程维护专属的KafkaConsumer实例,处理消息则交由特定的线程池来做,从而实现消息获取与消息处理的真正解耦。

方案

优点

缺点

多线程+多Consumer实例

方便实现

方便实现,速度快 ,无线程间交互开销

易于维护分区内的消费顺序

占用更多系统资源

线程数受限于主题分区数,扩展性差

线程自己处理消息容易超时,引发Rebalance
                                         

单线程+单KafkaConsumer实例

可独立扩展消息获取线程数和Worker线程数

 伸缩性好

实现难度高

难以维护消息分区内的消费顺序

 处理链路拉长,不易于位移提交管理+消息处理Worker线程池

   3点优势:

方案1的优势:

  • 实现简单,使用多线程在每个线程找那个创建专属的kafkaConsumer实例就可以了。
  • 多线程之间彼此没有交互,省去线程且换和线程安全方面的开销。
  • 每个线程使用专属的KafkaConsumer实例来执行消息获取和消息处理逻辑
  • 因此Kafka主题中的每个分区都能保证只被一个线程处理,这样很容易实现分区内的消息消费顺序。

方案 1 的劣势:

  • 每个线程都维护自己的kafkaConsumer实例,必然占用更多的系统资源,比如内存 ,TCP连接等。在资源紧张的系统环境中,方案1的劣势会更加明显。
  • 线程数受限于Consumer订阅主题的总分区数
  • 在一个消费者组中,每个订阅分区只能被组内的一个消费者实例所消费。
  • 假设一个消费者订阅了100个分区,那么方案1最多扩展100个线程,多余的线程无法分配到任何分区,只会白白耗尽系统资源。
  • 每个线程完整的执行消息获取和消息处理逻辑。一旦消息处理逻辑很重,造成消息处理速度慢,就很容易出现不必要的Rebalance,从而引发整个消费者组的消费停滞。

方案2的优势:

  • 将任务切分成消息获取和消息处理两部分,分别由不同线程 处理它们;
  • 比 方案1,方案2的最大优势就是在于它的高伸缩性,就说我们可以独立调节消息获取的线程数以及消息处理的线程数而不必考虑两者之间是否互相影响。
  • 消费速度慢,增加消费获取的线程数,消费速度快,就增加Worker线程池线程数即可。

方案2的缺点:

  • 因为方案将消息获取和消息处理分开了,也就是获取某消息的线程不是处理该消息的线程,因此无法保证分区内的消费顺序。
  • 引入多组线程,使得整个消息消费链路被拉长,最终导致正确位移提交会变得异常困难,导致消息重复呗消费。

方案1的代码:

public class KafkaConsumerRunner implements Runnable{


priavte final AtomicBoolean closed = new AtomicBoolean(false);

priavge final KafkaConsumer conumer;


public void run(){

try{

consumer.subscribe(Arrays.asList("topic")) ;
while(!close.get()){

ConsumerRecords records =
consumer.poll(Duration.ofMillis(10000));
// 执行消息处理逻辑
}

}catch(WakeupException e)

// Ignore exception if closing
if (!closed.get()) throw e;

}finally {
consumer.close();
}

// Shutdown hook which can be called from a separate thread
public void shutdown() {
closed.set(true);
consumer.wakeup();
}

}


方案2的核心代码

private final KafkaConsumer<String,String> consumer;
private ExecutorService exexutors;
...

priavte int workerNum =...;
executors=new ThreadPoolExexutor(
workerNum,workerNum,0L,TimeUnit.MiLLTISCONDS,
new ArraysBlockingQueue<>(1000),
new ThreadPoolExecuter.CallerRunsPOlicy()
);


...


while(true){

ConsumerRecords<String,String> records=consumer.poll(Duration.ofSeconds(1));

for(final ConsumerRecord record:records){

executors.submit(new Worker(record));
}

}
..
  • 当Cnsumer的poll方法返回消息后,由专门的线程池来负责处理具体的消息。
  • 调用poll方法主线程不负责消息处理逻辑,这样就实现了方案2的多线程架构。