本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~
一个同学跟我说,昨天他去字节跳动面试,面试官这样问他,“如何解决Kafka消息积压的问题”。
我问道:“那你是如何回答面试官的呢?”
他说:“我跟面试官说的是,如果Kafka的消息出现积压了,那就在生产者端进行限流。”
同学这答案直接把我干崩溃了,按照他的思路,如果一个工厂的产能跟不上,那就让销售团队少去签单。(手动狗头)
接下来我们看下,遇到这种情况到底应该怎么去做,一共有三种方式。
硬件扩容这是最常见的一种处理方式,通过硬件扩容来提升消息流转处理的并行度,进而解决消息积压问题。
扩容前如下图所示:
扩容后为:
当然,如果明确瓶颈点在于消费者的处理能力,而不在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参数进行合理配置。
拉取该批次消息后,接下来将执行消费者的消息处理逻辑,我们可以选择依次穿行处理消息的方式,也可以选择通过线程池的方式并行处理消息。
在绝大多数情况下,后者的消息处理吞吐量会更高一些。
如下图所示:
```
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使用率和负载都很高。
如下图所示:
(1)检查该数据库是否存在SQL慢查询,若存在,可通过explain命令对其进行分析,优化该SQL语句的执行策略。
(2)如果该数据库的CPU使用率和负载较高,是由于读操作导致的,我们可以用增加从库、增加本地缓存、Redis、ES等方式进行优化。
(3)如果该数据库的CPU使用率和负载较高,是由于写操作导致的,我们可以用拆库、分库分表等方案进行优化。
除了数据库成为单点瓶颈外,还有一种很大的可能性,那就在Kafka消费者处理消息的代码逻辑中,存在对一到多个下游服务的调用。
此时,就需要负责下游服务的同事进行优化了。