面试官:如何解决Kafka消息积压的问题?

277 阅读5分钟

本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~

一个同学跟我说,昨天他去字节跳动面试,面试官这样问他,“如何解决Kafka消息积压的问题”。

我问道:“那你是如何回答面试官的呢?”

他说:“我跟面试官说的是,如果Kafka的消息出现积压了,那就在生产者端进行限流。”

同学这答案直接把我干崩溃了,按照他的思路,如果一个工厂的产能跟不上,那就让销售团队少去签单。(手动狗头)

接下来我们看下,遇到这种情况到底应该怎么去做,一共有三种方式。

硬件扩容这是最常见的一种处理方式,通过硬件扩容来提升消息流转处理的并行度,进而解决消息积压问题。
扩容前如下图所示:

图片

扩容后为:

1.png

也就是把Broker、分区和消费者统统扩容一遍。

当然,如果明确瓶颈点在于消费者的处理能力,而不在Broker上的话,也可以只扩容消费者。
如下图所示:

图片

为了避免产生歧义,这里再解释一下主题、分区和消费者之间的关系。

假设主题A有3个分区,在消费者组中只有消费者1,那消费者1会接收到主题A全部3个分区的消息。

假设主题A有3个分区,在消费者组中有消费者1和消费者2,那可能出现的情况是,消费者1会接收到主题A的2个分区的消息,消费者2会接收到主题A的1个分区的消息。

假设主题A有3个分区,在消费者组中有消费者1、消费者2和消费者3,那么每个消费者可以分配到一个分区。

如果我们往消费者组中增加更多的消费者,使其数量超过主题的分区数量,那么有一部分消费者就会被闲置,不会接收到任何消息。

消费者并行化我们都知道,消费者是通过调用poll()方法拉取一批消息进行处理的,默认值为500,可根据max.poll.records参数进行合理配置。

拉取该批次消息后,接下来将执行消费者的消息处理逻辑,我们可以选择依次穿行处理消息的方式,也可以选择通过线程池的方式并行处理消息。

在绝大多数情况下,后者的消息处理吞吐量会更高一些。

如下图所示:

2.png

代码实现方式如下:

```
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
import java.util.concurrent.*;

public class KafkaConsumerWithThreadPool {

    private static final String TOPIC = "your_topic";
    private static final String BOOTSTRAP_SERVERS = "localhost:9092";
    private static final String GROUP_ID = "demo-group";
    private static final int CONCURRENCY = 4; // 线程池大小

    public static void main(String[] args) {
        // 1. 初始化消费者配置
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP_SERVERS);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, GROUP_ID);
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); // 关闭自动提交
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");

        // 2. 创建消费者和线程池
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        ExecutorService threadPool = Executors.newFixedThreadPool(CONCURRENCY);

        // 3. 订阅主题
        consumer.subscribe(Collections.singleton(TOPIC));

        try {
            while (true) {
                // 4. 拉取消息(长轮询)
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));

                // 5. 将消息分发给线程池处理
                for (ConsumerRecord<String, String> record : records) {
                    threadPool.submit(new MessageProcessor(record));
                }

                // 6. 异步提交偏移量(根据业务需求选择提交策略)
                consumer.commitAsync((offsets, exception) -> {
                    if (exception != null) {
                        System.err.println("提交失败: " + exception.getMessage());
                    }
                });
            }
        } finally {
            // 7. 关闭资源
            threadPool.shutdown();
            consumer.close();
        }
    }

    // 消息处理任务(实现Runnable)
    static class MessageProcessor implements Runnable {
        private final ConsumerRecord<String, String> record;

        public MessageProcessor(ConsumerRecord<String, String> record) {
            this.record = record;
        }

        @Override
        public void run() {
            try {
                // 业务处理逻辑
                System.out.printf("线程[%s] 处理消息: partition=%d, offset=%d, key=%s, value=%s%n",
                        Thread.currentThread().getName(),
                        record.partition(),
                        record.offset(),
                        record.key(),
                        record.value());

                // 模拟业务处理耗时
                Thread.sleep(1000);
            } catch (Exception e) {
                System.err.println("处理失败: " + e.getMessage());
            }
        }
    }
}

这里再说说Kafka消费者提交偏移量的部分,我们可以选择自动提交偏移量还是手动提交偏移量,通过enable.auto.commit参数设置即可。

在手动提交偏移量中,又分为手动同步提交和手动异步提交两种方式。

同步提交在 Broker对提交请求作出回应之前,应用程序会一直阻塞,这时也会影响消息处理吞吐量的。

因此,我们在上面的代码中,选择的是通过consumer.commitAsync()的方式异步提交偏移量。

消费者专项优化这里要说的是,并非硬件扩容 + 消费者并行化能够解决所有消息积压的问题。

还有一种比较大可能性是,在Kafka消费者处理消息的代码逻辑中,存在一个单点的瓶颈,此时我们就需要case by case地对其进行专项优化了。

比如,在Kafka消费者处理消息的代码逻辑中,会频繁地对数据库进行读写操作,导致数据库服务器的CPU使用率和负载都很高。

如下图所示:

3.png

对于这种情况,我们有三种优化方案,分别是:

(1)检查该数据库是否存在SQL慢查询,若存在,可通过explain命令对其进行分析,优化该SQL语句的执行策略。

(2)如果该数据库的CPU使用率和负载较高,是由于读操作导致的,我们可以用增加从库、增加本地缓存、Redis、ES等方式进行优化。

(3)如果该数据库的CPU使用率和负载较高,是由于写操作导致的,我们可以用拆库、分库分表等方案进行优化。

除了数据库成为单点瓶颈外,还有一种很大的可能性,那就在Kafka消费者处理消息的代码逻辑中,存在对一到多个下游服务的调用。

此时,就需要负责下游服务的同事进行优化了。