【Kafka系列】Kafka Consumer消费时的线程模型?

381 阅读13分钟

1. Kafka中的消息消费API

在讨论Kafka Consumer消费消息时的线程模型之前,我们需要先了解Kafka消息消费的两种方式:​@KafkaListener 注解用于声明一个方法是 Kafka 消息监听器,等待Kafka集群推送的方式来进行消息消费。而 poll 方法则是主动的从Kafka集群取拉取消息。

1.1 消息监听器 (@KafkaListener):

  • 用途:@KafkaListener 用于监听指定主题的消息,当有新消息到达时,对应的方法会被调用。
  • 触发方式:消息监听器是事件驱动的,它并不会主动拉取消息。相反,它等待 Kafka 消息代理将消息推送给它。
  • 适用场景:适用于消息的订阅和异步处理,常用于处理实时的消息事件。

示例:

@KafkaListener(topics = "my-topic", groupId = "my-group")
public void listen(String message) {
    // 处理收到的消息
    System.out.println("Received message: " + message);
}

1.2 poll方法

  • 用途:poll 方法通常用于主动拉取消息,这在 Kafka 消费者端是常见的做法。
  • 触发方式:poll 方法需要显式地调用,用于主动拉取消息,它通常在一个循环中被调用以保持长时间运行。
  • 适用场景:适用于需要更细粒度的消息控制和处理,例如手动提交偏移量、自定义的消息过滤逻辑等。

如果你需要手动调用 Kafka 消费者的 poll 方法,会需求涉及到一些自定义的流程或控制逻辑。在 Spring Kafka 中,你可以通过注入 KafkaConsumer bean,然后手动调用 poll 方法。以下是一个简单的例子:

首先,在你的配置类或服务中注入 KafkaConsumer:

import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Properties;

@Configuration
public class KafkaConfig {

    @Bean
    public Consumer<String, String>; kafkaConsumer() {
        Properties properties = new Properties();
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "my-group");
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");

        return new KafkaConsumer<>(properties);
    }
}

接着,在你的服务类中注入 KafkaConsumer,并手动调用 poll 方法:

import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Collections;

@Service
public class KafkaConsumerService {

    private final Consumer<String, String> kafkaConsumer;

    public KafkaConsumerService(Consumer<String, String> kafkaConsumer) {
        this.kafkaConsumer = kafkaConsumer;
    }

    public void pollMessages() {
        kafkaConsumer.subscribe(Collections.singletonList("my-topic"));

        while (true) {
            // 手动调用 poll 方法
            var records = kafkaConsumer.poll(Duration.ofMillis(100));

            for (ConsumerRecord&lt;String, String&gt; record : records) {
                // 处理收到的消息
                System.out.println("Received message: " + record.value());
            }
        }
    }
}

在这个例子中,pollMessages 方法包含一个无限循环,手动调用了 poll 方法。需要注意的是,在生产环境中,你可能需要实现更复杂的逻辑来管理 KafkaConsumer 的生命周期、异常处理和线程安全。

总体而言,@KafkaListener 更适合简化消息监听和处理,而 poll 方法更适用于对消息的主动控制和处理。本文在探讨Kafka消费者线程模型的时候,以poll方法为主。

2. Kafka消费者消息拉取

​2.1 为什么Kafka Consumer的客户端是线程不安全的?

2.1.1 为什么设计成线程不安全的?

​Kafka 的 Consumer 客户端被设计为非线程安全的,这是因为它是为了在多线程环境中灵活使用而提供的。以下是一些原因:

  1. 设计灵活性: Kafka Consumer 的非线程安全设计使得它可以在多线程环境中更为灵活,允许开发者自由选择如何管理和处理消息。每个线程可以拥有独立的 Consumer 实例,从而避免了对共享状态的复杂同步和锁机制。

  2. 独立状态: Consumer 实例通常需要维护一些状态,比如消费的 offset、分配的 partition 等信息。如果设计为线程安全的,就需要在多线程环境中保证这些状态的一致性,增加了复杂性。通过非线程安全的设计,每个线程可以拥有独立的状态,减少了竞争和锁的使用。

  3. 精细控制: 非线程安全的设计允许开发者更加精细地控制消息的处理逻辑。每个线程可以独立管理消息的拉取和处理过程,从而更好地适应不同的业务场景和需求。

虽然 Kafka Consumer 客户端本身不是线程安全的,但是开发者可以通过创建多个独立的 Consumer 实例,并将它们分配给不同的线程来实现多线程的消息消费。这种设计使得开发者能够根据具体的应用场景和需求进行灵活的定制和优化。

2.1.2 原理​

KafkaConsumer与线程安全的KafkaProducer不同,它是非线程安全的。在执行每个公用方法之前,KafkaConsumer会调用acquire()方法,该方法用于检测是否只有一个线程在进行操作。如果有其他线程正在操作,acquire()将抛出ConcurrentModificationException异常。

acquire()方法的实现如下:

private void acquire() {
    long threadId = Thread.currentThread().getId();
    if (threadId != currentThread.get() && !currentThread.compareAndSet(NO_CURRENT_THREAD, threadId))
        throw new ConcurrentModificationException("KafkaConsumer is not safe for multi-threaded access");
    refcount.incrementAndGet();
}

这实际上通过CAS(Compare And Swap)的方式来获取当前KafkaConsumer的使用权。如果获取不到,则抛出异常。

在执行线程完成后,会调用release()方法来释放KafkaConsumer的使用权:

private void release() {
    if (refcount.decrementAndGet() == 0)
        currentThread.set(NO_CURRENT_THREAD);
}

虽然KafkaConsumer是非线程安全的,但这并不意味着在消费消息时只能以单线程方式执行。可以采用多线程的方式来提高消费者的整体消费能力,这样即使生产者发送消息的速度大于消费者处理消息的速度,也能够更有效地处理消息。

2.2 消息拉取机制

​Kafka采用消息拉取模型,要求消费者通过主动调用KafkaConsumer#poll(java.time.Duration)方法向broker拉取数据。虽然Kafka并未限制获取数据后的消费方式,但为了平衡完备的功能和客户端易用性,将consumer设计为以单线程持续调用poll方法的形式来拉取消息。

poll方法内部并非简单地发送请求给broker并等待响应,然后将消息数据返回给调用方。实际上Fetch内部维护了一个链表ConcurrentLinkedQueue completedFetches;用来缓存已经拉取到的消息数据,具体原理如下图所示:

  • 每次当Kafka Consumer调用poll方法的时候会先从completedFetches缓存中(线程安全的链表)查找是否存在未消费的数据,如果存在未消费的数据,Kafka直接解码后返回。

  • 如果缓冲区中没有未消费数据,则根据订阅的情况向所有相关的broker节点发送异步请求,异步响应的结果都会存储在缓存冲区。

  • 消费者线程会等到知道缓冲区有可用数据或者超时,循环解析缓冲链表中的数据,返回不超过(max.poll.records)的消息。

以上逻辑感兴趣的可以去看源码:​org.apache.kafka.clients.consumer.KafkaConsumer#pollForFetches 和 ​org.apache.kafka.clients.consumer.internals.Fetcher,本文不做源码解析,就不列出代码了。

2.3 消费者相关参数

​绝大部分相关参数都可以在fetch请求构建时找到(查看 FetchRequest.Builder)。与poll方法的一个经典配置项一起,涉及的相关参数主要如下:

  • fetch.min.bytes: 在响应fetch请求时,服务器应返回的最小数据量。如果没有足够的数据可用,请求将等待累积相应数据量后再作响应。默认设置为1字节,表示只要有一个字节的数据可用,或者请求等待超时,fetch请求就会得到响应。将其设置为大于1的值将导致服务器等待累积更大的数据量,从而略微提高服务器的吞吐量,但也会增加一些延迟。

  • fetch.max.bytes: 服务器响应fetch请求时应返回的最大数据量。数据记录由使用者分批获取,如果第一个非空分区中的第一个记录集合大于其值,则仍将返回记录集合,以确保使用者能够正常执行。这并不是一个绝对的最大值,因为 broker 接受的最大记录集合大小由 message.max.bytes(broker配置)或 max.message.bytes(topic配置)定义。请注意,使用者会并行执行多个读取操作。

  • fetch.max.wait.ms: 如果没有足够的数据立即满足 fetch.min.bytes 的要求,服务器在响应fetch请求之前将阻塞的最大时间。

  • max.partition.fetch.bytes: 服务器返回的每个分区的最大数据量。记录由使用者分批获取。如果提取的第一个非空分区中的第一个记录批处理大于此限制,则仍将返回该批处理,以确保使用者能够正常执行。

  • max.poll.records: 单次调用 poll() 返回的最大记录数。

在实际项目应用中,总消费量最高可达到十万每秒的级别。在这种消费速度下,如果采用默认配置,每次fetch请求基本上只返回1条消息记录。此时,单机fetch请求的QPS可以达到4,5k。在消费速度和生产速度匹配的情况下,Kafka的消费延迟(从生产方发送到消费方收到数据)能够维持在一个较低的水平(几十毫秒以内)。这反映了Kafka的高吞吐量和良好的延迟性能。此外,Kafka提供了上述相关配置,可以进行人工调整,以满足不同情况下的使用需求,包括降低CPU占用、提升吞吐量、接受一定延迟提升等。

3. Kafka消费消息的线程模型选择

3.1 单线程消费模型

​单线程消费模型的 Kafka 消费者示例代码如下:

import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.TopicPartition;

import java.time.Duration;
import java.util.Collections;
import java.util.Properties;

public class SingleThreadConsumerExample {
    public static void main(String[] args) {
        // Kafka consumer configuration
        Properties properties = new Properties();
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "your_bootstrap_servers");
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "your_group_id");
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");

        // Create a Kafka consumer
        Consumer<String, String> consumer = new KafkaConsumer<>(properties);

        // Subscribe to topics
        consumer.subscribe(Collections.singletonList("your_topic"));

        // Start consuming messages in a loop
        try {
            while (true) {
                ConsumerRecords<String, String>; records = consumer.poll(Duration.ofMillis(100));
                
                for (ConsumerRecord<String, String> record : records) {
                    // Process the received record
                    System.out.printf("Consumed record with key %s and value %s%n", record.key(), record.value());
                    
                    // Manually commit the offset to mark the message as processed
                    TopicPartition topicPartition = new TopicPartition(record.topic(), record.partition());
                    OffsetAndMetadata offset = new OffsetAndMetadata(record.offset() + 1);
                    consumer.commitSync(Collections.singletonMap(topicPartition, offset));
                }
            }
        } finally {
            // Close the consumer when done
            consumer.close();
        }
    }
}

在这个示例中,Kafka 消费者使用单线程循环地调用 poll 方法来拉取消息。一旦收到消息,它会进行处理,并手动提交偏移量(offset)以标记消息已被处理。​

然而,单线程的消费模型存在吞吐量限制,仅能逐个处理消息,在高负载下难以充分利用系统资源。处理延迟可能增加,特别是对于实时性要求高的应用。此外,该模型难以充分发挥多核处理器的潜力,对于高并发场景可能成为性能瓶颈。对于涉及复杂逻辑或大量计算的任务,单线程可能无法提供足够性能。为解决这些问题,可考虑使用多线程或并发处理模型,以提高吞吐量、降低处理延迟,并更好地利用系统资源。引入多线程时需关注线程安全性,避免潜在的并发问题。

3.2 多线程消费模型

3.2.1 多个消费者拥有自己的Partition

​Kafka 有消费组的概念,每个消费者只能消费所分配到的分区的消息,每一个分区只能被一个消费组中的一个消费者所消费,所以同一个消费组中消费者的数量如果超过了分区的数量,将会出现有些消费者分配不到消费的分区。消费组与消费者关系如下图所示:

消费者程序启动多个线程,每个线程独立管理自己的 KafkaConsumer 实例,负责执行完整的消息获取和处理流程。 每个线程对同一个topic下的同一个或多个 partition 进行消费,形成一个多线程的消费者组。这种架构下,每个线程都充当独立的消费者,共同协作完成对消息的处理任务,下面我们对这个方案进行了简单的实现:

public class KafkaConsumerThread  implements Runnable{

    private KafkaConsumer<String,String> consumer;
    private AtomicBoolean closed = new AtomicBoolean(false);
    public KafkaConsumerThread(){

    }
    // 构造方法 生成自己的consumer
    public KafkaConsumerThread(Properties props) {
        this.consumer = new KafkaConsumer<>(props);
    }

    @Override
    public void run() {
        try {
            // 消费同一主题
            consumer.subscribe(Collections.singletonList("six-topic"));
            // 线程名称
            String threadName = Thread.currentThread().getName();
            while (!closed.get()){
                ConsumerRecords<String, String> records = consumer.poll(3000);
                for (ConsumerRecord<String, String> record : records) {
                    System.out.printf("Context: Thread-name= %s, topic= %s partition= %s, offset= %d, key= %s,value= %s\n",threadName,record.topic(),record.partition(),record.offset(),record.key(),record.value());
                }
            }
        }catch (WakeupException e){
            e.printStackTrace();
        }finally {
            consumer.close();
        }
    }

    /**
     * 关闭消费
     */
    public void shutdown(){
        closed.set(true);
        // wakeup 可以安全地从外部线程来中断活动操作
        consumer.wakeup();
    }

    public static void main(String[] args) {
        Properties props = new Properties();
        props.put("bootstrap.servers", "XXXXXXX:9093");
        props.put("group.id", "thread-1");//消费者组,只要group.id相同,就属于同一个消费者组
        props.put("enable.auto.commit", "true");//自动提交offset
        props.put("auto.offset.reset", "earliest");
        props.put("auto.commit.interval.ms", "1000");
        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("session.timeout.ms", "30000");
        props.put("max.poll.records",6);
        // 运行三个线程,消费同一个topic 这个topic的分区必须大于等于3 否则会有消费者消费不到数据
        for (int i = 0; i < 3 ; i++) {
            new Thread(new KafkaConsumerThread(props),"Thread"+i).start();
        }
    }
}

​上面的代码中我们新建了三个线程,一个消费者组,每个线程消费者得到一个topic的分区去消费消息。

3.2.2 单个消费者多个线程处理消息(Reactor模型)

​因为 Kafka 的 Consumer 客户端是线程不安全的,为了保证线程安全,并提升消费性能,可以在 Consumer 端采用类似 Reactor 的线程模型来消费数据。这种模式下,消息的接受和消息的处理实现了完全的解耦,在满足线程安全的情况下,能够提升消费者的消息消费效率,增加消费者的消息处理能力。下面是使用Reactor模型实现的简单案例:

import org.apache.kafka.clients.consumer.*;

import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
import java.util.concurrent.*;

// Acceptor 类
class Acceptor {
    private final LinkedBlockingQueue<ConsumerRecords<String, String>> messageQueue;
    private final Reactor reactor;

    public Acceptor(LinkedBlockingQueue<ConsumerRecords<String, String>> messageQueue, Reactor reactor) {
        this.messageQueue = messageQueue;
        this.reactor = reactor;
    }

    // 接收 Kafka 消息并通知 Reactor 处理
    public void accept(ConsumerRecords<String, String> records) {
        messageQueue.offer(records);
        reactor.notifyHandlers();
    }
}

// Reactor 类
class Reactor {
    private final Dispatcher dispatcher;
    private final Handler handler;

    public Reactor(Dispatcher dispatcher, Handler handler) {
        this.dispatcher = dispatcher;
        this.handler = handler;
    }

    // 通知 Handlers 处理消息
    public void notifyHandlers() {
        dispatcher.dispatch(handler);
    }
}

// Dispatcher 类
class Dispatcher {
    private final LinkedBlockingQueue<Handler> handlersQueue = new LinkedBlockingQueue<>();

    // 将 Handler 放入队列
    public void dispatch(Handler handler) {
        handlersQueue.offer(handler);
    }

    // 获取一个 Handler
    public Handler getHandler() throws InterruptedException {
        return handlersQueue.take();
    }
}

// Handler 类
class Handler {
    private final LinkedBlockingQueue<ConsumerRecords<String, String>> messageQueue;
    private final Consumer<String, String> consumer;

    public Handler(LinkedBlockingQueue<ConsumerRecords<String, String>> messageQueue, Consumer<String, String> consumer) {
        this.messageQueue = messageQueue;
        this.consumer = consumer;
    }

    // 处理消息
    public void handle() {
        ConsumerRecords<String, String> records = messageQueue.poll();
        if (records != null) {
            for (ConsumerRecord<String, String> record : records) {
                consumer.submit(() -> processRecord(record));
            }
        }
    }

    // 处理单个消息
    private void processRecord(ConsumerRecord<String, String> record) {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.printf("Context: Thread-name= %s, topic= %s partition= %s, offset= %d, key= %s, value= %s\n",
                Thread.currentThread().getName(), record.topic(), record.partition(), record.offset(), record.key(), record.value());
    }
}

public class KafkaReactorConsumer {
    public static void main(String[] args) {
        LinkedBlockingQueue<ConsumerRecords<String, String>> messageQueue = new LinkedBlockingQueue<>();
        Properties props = new Properties();
        props.put("bootstrap.servers", "10.33.68.68:9093");
        props.put("group.id", "reactor-consumer");
        props.put("enable.auto.commit", "true");
        props.put("auto.offset.reset", "earliest");
        props.put("auto.commit.interval.ms", "1000");
        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("session.timeout.ms", "30000");
        props.put("max.poll.records", 5);

        KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(props);
        kafkaConsumer.subscribe(Collections.singletonList("six-topic"));

        Acceptor acceptor = new Acceptor(messageQueue, new Reactor(new Dispatcher(), new Handler(messageQueue, kafkaConsumer)));

        // 创建消息接收 Flux,Flux 类在响应式编程中用于构建和操作异步数据流,能够更轻松地实现事件驱动的异步处理。
        Flux<ConsumerRecords<String, String>> kafkaFlux = Flux.defer(() -> Mono.just(kafkaConsumer.poll(Duration.ofMillis(1000))))
                .repeat()
                .subscribeOn(Schedulers.elastic());

        // 创建处理 Handler 的 Flux
        Flux<Handler> handlerFlux = Flux.defer(() -> Mono.just(acceptor).map(Acceptor::getHandler))
                .repeat()
                .subscribeOn(Schedulers.elastic());

        // 合并两个 Flux 并执行处理逻辑
        Flux.zip(kafkaFlux, handlerFlux)
                .flatMap(tuple -> Mono.fromRunnable(() -> tuple.getT2().handle()))
                .subscribe();

        // 阻塞主线程
        try {
            Thread.sleep(Long.MAX_VALUE);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            kafkaConsumer.close();
        }
    }
}

4. 总结

本文主要介绍了Kafka Consumer拉取消息的机制,以及在我们实际开发中需要考虑采用的线程模型。总体来说,线程模型主要有单线程模型,多线程对应topic的线程模型和类似于Reactor模型的多线程模型。这里最推荐采用的是最后一种方法,实现了消息接受和消息处理的完全解耦合,同时增加了消息的处理效率。

参考文献 [1] ​bbs.huaweicloud.com/blogs/32709… [2] ​cloud.tencent.com/developer/a… [3]​www.tony-bro.com/posts/26039… [4]​blog.csdn.net/prestigedin…