图解kafka-并发消费设计实践

 

目录

设计总览

主线程类:Mainthread

子线程类:subThread

子线程的设计实例

主线程的设计实例

主执行循环 (run() 方法):

给子线程分发任务dispatchRecords()方法 

检查子线程消费状态checkSubThread()方法

提交消费位移方法commitFinishedOffset()

重平衡监听处理方法onPartitionsRevoked()


设计总览

在多线程模式中,将并行消费的职责分解到两个 Java 类

主线程类:Mainthread

主线程类负责整体的消费流程控制和协调,具体职责包括:

  1. 订阅 Topic 和拉取消息: 主线程通过 KafkaConsumer 订阅指定的 Topic,并从中拉取数据。

  2. 创建子线程并分配任务: 使用 Java 线程池机制,主线程创建多个具体的子线程(ConsumerSubTask),并将不同的 Partition 的数据分发给这些子线程处理。

  3. 管理子任务的执行状态: 主线程负责监控和管理每个 ConsumerSubThread 的执行状态,确保它们按照预期进行消费。

  4. 提交消费位移: 主线程在适当的时机,根据消费情况提交消费位移,以确保消息被正确地标记为已消费。

子线程类:subThread

子线程类负责具体的消息消费任务,具体职责包括:

  1. 消费指定 Partition 的数据: 每个 ConsumerSubThread 被分配了一个或多个 Partition,负责从这些 Partition 消费数据。

  2. 维护子任务的执行状态: 子线程需要维护自身消费任务的执行状态,包括消费进度、处理错误、重试机制等。

  3. 处理消费逻辑: 根据业务需求,子线程实现具体的消费逻辑,可能涉及数据处理、持久化等操作。

子线程的设计实例

由于每个分区只能交给一个消费子线程处理,因此确保每个分区的消息按顺序处理。

为了避免低效利用CPU资源或者过多的线程开销

  1. 线程池管理: 使用线程池来管理消费子线程,这样可以有效地重用线程资源,提高CPU利用率。线程池可以动态地调整线程数量,根据当前负载情况和分区数量来分配适当的线程资源。

  2. 分区合并: 对于分区数量较少的情况,可以考虑将多个分区的消费任务合并到一个消费子线程中处理。这样可以减少线程数量,提高每个线程的处理效率。

  3. 动态分配: 根据系统负载和实时分区的数量,动态调整线程池的大小和分配策略,以达到最佳的性能和资源利用率。

代码设计实例

/**
 * 这个类表示消费特定分区的Kafka记录的任务。
 * 每个ConsumerSubThread实例顺序处理一组ConsumerRecords,并维护最后处理消息的偏移量。
 */
public class ConsumerSubThread implements Runnable {
    private final List<ConsumerRecord<String, String>> records;
    private final AtomicLong currentOffset = new AtomicLong(-1); // 初始为-1,表示尚未处理任何记录
    private final CompletableFuture<Long> taskCompletion;
    private static final Lock startStopLock = new ReentrantLock();
    private static volatile boolean isTaskStarted = false;
    private static volatile boolean isTaskStopped = false;
    private static volatile boolean isTaskFinished = false;

    /**
     * 构造一个ConsumerSubThread,使用要处理的ConsumerRecords列表。
     *
     * @param records 要处理的ConsumerRecords列表。
     */
    public ConsumerSubThread(List<ConsumerRecord<String, String>> records) {
        this.records = records;
        this.taskCompletion = new CompletableFuture<>();
    }

    /**
     * 运行消费者子线程以顺序处理列表中的每个记录。
     * 更新当前消费的偏移量并标记任务完成。
     */
    @Override
    public void run() {
        startStopLock.lock();
        try {
            if (isTaskStopped) {
                return;
            }
            isTaskStarted = true;
        } finally {
            startStopLock.unlock();
        }

        // 处理每条记录并更新当前偏移量
        for (ConsumerRecord<String, String> record : records) {
            if (isTaskStopped) {
                break;
            }
            // 更新偏移量以处理下一条消息
            currentOffset.set(record.offset() + 1);
        }

        // 标记任务已完成,并使用最终偏移量完成taskCompletion future
        isTaskFinished = true;
        taskCompletion.complete(currentOffset.get());
    }

    /**
     * 获取当前消费者子线程的当前消费偏移量。
     *
     * @return 当前消费偏移量。
     */
    public long getCurrentConsumedOffset() {
        return currentOffset.get();
    }
}

主线程的设计实例

主执行循环 (run() 方法)

public void run() {
    try {
        // 订阅 Kafka 主题
        consumer.subscribe(Collections.singletonList("msjx-topic"), this);
        while (!taskStopped.get()) {
            // ① 拉取记录
            ConsumerRecords<String, String> fetchedRecords = consumer.poll(Duration.of(500, ChronoUnit.MILLIS));
            // ② 分发拉取到的记录给相应的 ConsumerSubTask 线程进行处理
            dispatchFetchedRecords(fetchedRecords);
            // ③ 检查任务进度:控制消费速率
            checkTaskProcess();
            // ④ 提交已完成记录的位移,按照分区提交
            commitFinishedOffset();
        }
    } catch (Exception e) {
        log.error("run() 方法执行异常", e);
    } finally {
        consumer.close();
    }
}
  • ① 拉取记录:主线程调用 Kafka 消费者的 poll() 方法,从订阅的主题中拉取记录,超时设定为 500 毫秒。这是一个非阻塞操作。

  • ② 分发记录:拉取到的记录(ConsumerRecords)会被分发给 ConsumerSubTask 线程进行处理。每个 ConsumerSubTask 处理特定分区的数据,确保每个分区的数据由一个线程按顺序处理,以保持顺序性。

  • ③ 检查任务进度:这涉及监控每个 ConsumerSubTask 的执行进度。主线程确保每个分区在上一轮分配的数据被其对应的 ConsumerSubTask 完全处理后,再进行下一次 poll() 操作。这种控制有助于调节消费速率,并确保没有未处理的记录。

  • ④ 提交位移:一旦记录由 ConsumerSubTask 处理并确认,主线程会提交位移(指示每个分区的消费位置),将其存储到 Kafka 中。这一步确保在发生故障时,不会从头重新消费记录,维护至少一次的传递语义。

给子线程分发任务dispatchRecords()方法 

private void dispatchRecords(ConsumerRecords<String, String> records) {
    if (records.count() > 0) {
        List<TopicPartition> partitionsToPause = new ArrayList<>();

        /**
         * ① 按照 partition 划分给 ConsumerSubThread 子线程处理
         *    每个 ConsumerSubThread 子线程可以处理多个 partition,但每个 partition 只能被一个 ConsumerSubThread 子线程处理
         */
        records.partitions().forEach(partition -> {
            List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
            log.debug("{} 拉取到的记录数 {}", partition, partitionRecords.size());
            ConsumerSubThread subThread = new ConsumerSubThread(partitionRecords);
            partitionsToPause.add(partition);
            
            /**
             * ② 使用 ExecutorService.submit() 方法提交子线程到线程池执行,
             *    每个子线程可以处理一个或多个分区的数据
             */
            executor.submit(subThread);
            
            // 将每个分区与其对应的子线程映射关系存储到 activeTasks 中
            activeTasks.put(partition, subThread);
        });

        /**
         * ③ 在调用 KafkaConsumer.poll() 方法时,暂停主线程拉取 partitionsToPause 中分区的数据,
         *    以确保在子线程处理完之前不会再次拉取这些分区的新数据
         */
        consumer.pause(partitionsToPause);
    }
}
  • ① 将拉取到的记录按照分区划分,分配给不同的 ConsumerSubTask 子线程处理

    • records.partitions().forEach(partition -> { ... }) 迭代拉取到的每个分区。
    • List<ConsumerRecord<String, String>> partitionRecords = records.records(partition); 获取当前分区的所有记录。
    • ConsumerSubTask subTask = new ConsumerSubTask(partitionRecords); 创建一个 ConsumerSubTask 实例,用于处理当前分区的记录。
    • partitionsToPause.add(partition); 将当前分区加入到暂停列表,以便在后续暂停主线程对该分区的数据拉取。
  • ② 使用 ExecutorService.submit() 方法提交子任务到线程池中执行

    • executor.submit(subTask);subTask 提交给线程池 executor 执行,实现并发处理。
    • activeTasks.put(partition, subTask); 将当前分区与对应的 subTask 映射关系存储到 activeTasks 中,用于后续管理和跟踪。
  • ③ 在调用 KafkaConsumer.poll() 方法时,暂停主线程拉取暂停列表中的分区数据

    • consumer.pause(partitionsToPause); 调用 Kafka 消费者的 pause() 方法,暂停主线程对 partitionsToPause 列表中分区的数据拉取。
    • 这确保了在主线程将分区分配给子任务后,主线程在子任务完成处理前不会再次拉取这些分区的新数据,从而保证了消息的顺序性和避免重复消费的问题。

检查子线程消费状态checkSubThread()方法

private void checkSubThread() {
    List<TopicPartition> finishedTasksPartitions = new ArrayList<>();
    
    // ① 检查每个子线程是否完成了分配的分区数据消费
    activeTasks.forEach((topicPartition, consumerSubThread) -> {
        if (consumerSubThread.isTaskFinished()) {
            finishedTasksPartitions.add(topicPartition);
        }

        // ② 收集每个分区已经消费的最大偏移量
        long offset = consumerSubThread.getCurrentConsumedOffset();
        if (offset > 0) {
            offsetsToCommit.put(topicPartition, new OffsetAndMetadata(offset));
        }
    });

    // ③ 从活跃任务中移除已完成的分区->子线程映射关系
    finishedTasksPartitions.forEach(topicPartition -> activeTasks.remove(topicPartition));
    
    /**
     * ④ 恢复已完成任务的分区数据拉取:
     *   在调用 KafkaConsumer.pause() 方法暂停拉取某个 partition 的数据后,
     *   主线程的 poll() 方法将不再返回该分区的记录。
     *   调用 KafkaConsumer.resume() 方法恢复该分区后,
     *   主线程的 poll() 方法才会继续拉取该分区的数据。
     */
    consumer.resume(finishedTasksPartitions);
}
  • ① 判断子线程是否已经消费完分配的分区数据:

    • consumerSubThread.isTaskFinished() 方法用于判断当前的 ConsumerSubThread 是否已经消费完分配的所有分区数据。如果是,则将对应的 topicPartition 添加到 finishedTasksPartitions 列表中。
    • 由于一个 ConsumerSubThread 可能处理多个分区,必须确保所有分配的分区数据都被消费完毕,才能认为任务完成。
  • ② 返回每个分区已经消费完的最大 offset:

    • consumerSubThread.getCurrentConsumedOffset() 方法返回当前 ConsumerSubThread 在每个分区上已经消费的最大 offset。
    • 如果 offset > 0,则将该 offset 与其对应的 topicPartition 放入 offsetsToCommit 映射中,用于后续提交消费位移。
  • ③ 移除 activeTasks 映射中已经执行完的 partition->consumerSubThread 映射关系:

    • 遍历 finishedTasksPartitions 列表,从 activeTasks 映射中移除已经处理完成的 topicPartition -> consumerSubThread 映射关系。
    • 这确保了不再跟踪已经完成的任务,从而释放相关资源。
  • ④ 恢复 poll 已经执行完的 partition 数据:

    • consumer.resume(finishedTasksPartitions) 方法调用 KafkaConsumerresume() 方法,恢复之前暂停的 partitionsToPause 中的分区。
    • 这样,主线程在调用 poll() 方法时会重新开始拉取这些分区的新数据,从而实现持续的消息处理流程。

提交消费位移方法commitFinishedOffset()

public class ConsumerMainThread implements Runnable, ConsumerRebalanceListener {
    // 禁用自动提交 offset
    props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

    /**
     * 提交已处理的消费位移
     */
    private void commitFinishedOffset() {
        try {
            long currentTimeMillis = System.currentTimeMillis();
            // 每隔 8 秒提交一次消费位移
            if (currentTimeMillis - lastCommitTime > 8000) {
                if (!offsetsToCommit.isEmpty()) {
                    // 同步提交消费位移
                    consumer.commitSync(offsetsToCommit);
                    // 清空待提交的位移信息
                    offsetsToCommit.clear();
                }
                // 更新最后提交时间
                lastCommitTime = currentTimeMillis;
            }
        } catch (Exception e) {
            log.error("提交消费位移失败!", e);
        }
    }
}
  • 禁用自动提交 offset:

    • 在消费者配置中设置 ENABLE_AUTO_COMMIT_CONFIGfalse,以禁用消费者的自动偏移量提交功能。这样可以避免在发生 Rebalance(重新分配分区)后,部分数据被重新消费的问题。
  • 提交已处理的消费位移:

    • commitFinishedOffset() 方法负责定期提交消费者已经处理完的消费位移。
    • 每隔 8 秒钟会检查一次是否需要提交位移信息。
    • 如果 offsetsToCommit 集合非空,则调用 consumer.commitSync(offsetsToCommit) 同步提交消费位移,并在提交后清空 offsetsToCommit 集合。
    • lastCommitTime 变量记录了上一次提交位移的时间,确保提交操作的频率控制在合理范围内,避免过多的提交请求。

重平衡监听处理方法onPartitionsRevoked()

public class ConsumerMainThread implements Runnable, ConsumerRebalanceListener {

    /**
     * 当分区被撤销时调用,停止处理已撤销分区的所有子任务,并提交其消费位移。
     *
     * @param partitions 撤销的分区集合
     */
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        // ① 停止处理已撤销分区记录的所有子任务
        Map<TopicPartition, ConsumerSubTask> stoppedTask = new HashMap<>();
        for (TopicPartition partition : partitions) {
            // 从活跃任务中移除该分区对应的子任务,并停止任务
            ConsumerSubTask removeTask = activeTasks.remove(partition);
            if (removeTask != null) {
                removeTask.stopTask();  // 停止任务的方法
                stoppedTask.put(partition, removeTask);  // 记录已停止的任务
            }
        }

        // ② 等待停止的任务完成对当前记录的处理,并提交消费位移
        stoppedTask.forEach((topicPartition, consumerSubTask) -> {
            // 等待任务完成并获取最后处理的位移
            long offset = consumerSubTask.waitForCompletion();
            // 如果成功获取有效的位移,准备提交消费位移
            if (offset > 0) {
                offsetsToCommit.put(topicPartition, new OffsetAndMetadata(offset));
            }
        });

        // ③ 收集已撤销分区的消费位移
        Map<TopicPartition, OffsetAndMetadata> revokedPartitionOffsets = new HashMap<>();
        partitions.forEach(topicPartition -> {
            // 从待提交位移中移除撤销的分区对应的位移
            OffsetAndMetadata offset = offsetsToCommit.remove(topicPartition);
            if (offset != null) {
                revokedPartitionOffsets.put(topicPartition, offset);  // 收集需要提交的消费位移
            }
        });

        // ④ 提交撤销分区的消费位移,可以把消费位移提交到 DB 或者默认的 __consumer_offsets 中
        try {
            consumer.commitSync(revokedPartitionOffsets);  // 同步提交消费位移
        } catch (Exception e) {
            log.error("提交撤销分区的消费位移失败", e);  // 提交失败时记录错误日志
        }
    }
}
  • 处理 Rebalance 时撤销分区的逻辑:

    • onPartitionsRevoked() 方法是实现了 ConsumerRebalanceListener 接口的回调方法,用于在发生 Rebalance 前处理撤销分区的逻辑。其目的是在停止消费之前确保提交已消费的位移信息。
  • ① 停止处理已撤销分区记录的所有子线程:

    • 遍历 partitions 集合,停止和移除处理被撤销分区的所有 ConsumerSubThread 实例。停止的子线程通过调用 stopThread() 方法停止处理该分区的数据,并将其添加到 stoppedThreads 集合中备用。
  • ② 等待停止的子线程完成对当前记录的处理:

    • 对于每个停止的子线程,通过调用 waitForCompletion() 方法等待其处理完当前记录。如果子线程确实处理了记录并返回有效的偏移量,则将该偏移量添加到 offsetsToCommit 中,以便稍后提交。
  • ③ 收集已撤销分区的消费位移信息:

    • 遍历 partitions 集合,从 offsetsToCommit 中移除撤销分区的位移信息,并将其存储在 revokedPartitionOffsets 中。
  • ④ 提交撤销分区的消费位移:

    • 尝试使用 consumer.commitSync(revokedPartitionOffsets) 同步提交 revokedPartitionOffsets 中的消费位移信息。如果提交过程中出现异常,则记录错误日志。
  • 7
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Kafka提供了一个Java客户端库`kafka-clients`,其中包含用于创建和管理消费者的类和方法。下面是一个示例,展示如何使用`kafka-clients`中的消费者类来消费Kafka消息: ```java import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.clients.consumer.KafkaConsumer; import org.apache.kafka.common.TopicPartition; import java.time.Duration; import java.util.Collections; import java.util.Properties; public class KafkaConsumerExample { public static void main(String[] args) { String bootstrapServers = "localhost:9092"; String groupId = "my-consumer-group"; String topic = "my-topic"; // 配置消费者属性 Properties properties = new Properties(); properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); properties.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); 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"); // 创建消费者实例 KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties); // 订阅主题 consumer.subscribe(Collections.singletonList(topic)); // 或者指定特定的分区进行订阅 // TopicPartition partition = new TopicPartition(topic, 0); // consumer.assign(Collections.singleton(partition)); // 开始消费消息 while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000)); for (ConsumerRecord<String, String> record : records) { // 处理消息 System.out.println("Received message: " + record.value()); } } } } ``` 在上述示例中,首先配置了消费者的属性,包括Kafka集群地址、消费者组ID以及消息的反序列化器。然后创建了一个`KafkaConsumer`对象,并使用`subscribe`方法订阅了一个主题(或者可以使用`assign`方法指定特定的分区进行订阅)。 最后,在一个无限循环中调用`poll`方法来获取消息记录,然后遍历处理每条消息。 需要注意的是,消费者需要定期调用`poll`方法以获取新的消息记录。另外,消费者还可以使用`commitSync`或`commitAsync`方法手动提交消费位移,以确保消息被成功处理。 希望以上示例对你理解如何使用`kafka-clients`库中的消费者类来消费Kafka消息有所帮助!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值