1.背景
在实际项目中接入 Kafka 已经成为高并发系统的标配。然而,从简单的"能跑"到"稳定高效地跑",中间有太多坑值得记录和总结。本文结合本人在多个生产项目中使用 Kafka 的经验,围绕以下几个方面展开:消息丢失防范、重复消费控制、性能瓶颈优化、集群运维策略,以及 Topic、分区、副本机制的设计要点。先来看看kafka的基础架构图,有个整体认识:

如果看不懂这个架构图的,可以先根据下面👇🏻链接跳转了解下相关知识点:
集群篇:从零搭建高可用Kafka集群与EFAK监控平台:全流程实战总结
接下我们我们就从生产者、服务端broker、服务端去讲述下实战经验心得。
2.生产者如何提高吞吐量?
下面来看看生产者发送一条消息到kafka服务端的流程:

在消息发送的过程中,涉及到了两个线程------main 线程和 Sender 线程 。在 main 线程中创建了一个双端队列 RecordAccumulator。main 线程将消息发送给 RecordAccumulator,Sender 线程不断从 RecordAccumulator 中拉取消息发送到 Kafka服务端 Broker
可以适当调整以下四个生产者的参数来提高吞吐量:
参数 | 说明 |
---|---|
batch.size | 提交一批数据到缓冲区的最大值,默认 16k 。适当增加该值,可以提高吞吐量,但是如果该值设置太大,会导致数据传输延迟增加。 |
linger.ms | 如果数据迟迟未达到 batch.size,sender 等待 linger.time之后就会发送数据。单位 ms,默认值是 0ms,表示没有延迟。生产环境建议该值大小为 5-100ms 之间。 |
buffer.memory | RecordAccumulator 缓冲区总大小,默认 32m。可以适当增加该值提高缓冲区的存储能力 |
compression.type | 生产者发送的所有数据的压缩方式。默认是 none,也就是不压缩。支持压缩类型:none、gzip、snappy、lz4 和 zstd。 |
这些参数的应用思想都很好理解,就好比我们现实生活中集散中心大巴车拉人,一次拉一个,有人就走。这种方式效率低下,浪费资源。所以一般都是车到了多等一下,等人数差不多才走,这就是参数batch.size和linger.ms
的体现,buffer.memory
其实也好理解,就好比车送到目的地只能容纳100个人,你使劲送过去,收不下,只能目的把这个100个人安顿好,才能接着送,所以适当调大,可以增加吞吐量,至于压缩compression.type
就是让一次可以拉更多的人,就好比让小孩子和大人用一个座位。
3.如何保证消息不丢失?
消息丢失可能发生在生产者发送消息、broker保存消息、消费者消费消息等环节。
3.1 生产者丢失消息
生产者丢失消息是比较常见的场景,生产者发送消息到kafka,因为网络抖动最后发现kakfa没保存,这锅该谁背?答案是生产者,因为 Kafka Producer 是异步发送消息的,也就是说如果你调用的是 producer.send(msg) 这个 API,那么它通常会立即返回,但此时你不能认为消息发送已成功完成。解决办法也很简单:Producer 永远要使用带有回调通知的发送 API,也就是说不要使用 producer.send(msg),而要使用 producer.send(msg, callback) ,通过回调callback才能真正知道消息是否成功发送
设置重试 retries
。这里的 retries 同样是 Producer 的参数,对应前面提到的 Producer 自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了 retries > 0 的 Producer 能够自动重试消息发送,避免消息丢失。
3.2 broker丢失消息
设置acks = all
。acks 是 Producer 的一个参数,代表了你对"已提交"消息的定义。如果设置成 all,表示生产者发送过来的数据,Leader和ISR队列里面的所有节点收到数据后才应答。
参数 | 说明 |
---|---|
acks | 0:生产者发送过来的数据,不需要等数据落盘应答。 1:生产者发送过来的数据,Leader 收到数据后应答。 -1(all):生产者发送过来的数据,Leader+和 isr 队列里面的所有节点收到数据后应答。默认值是-1,-1 和all 是等价的。 |
设置 unclean.leader.election.enable = false
。这是 Broker 端的参数,它控制的是哪些 Broker 有资格竞选分区的 Leader。如果一个 Broker 落后原先的 Leader 太多,那么它一旦成为新的 Leader,必然会造成消息的丢失。故一般都要将该参数设置成 false,即不允许这种情况的发生。
设置 replication.factor >= 3
。这也是 Broker 端的参数。其实这里想表述的是,最好将消息多保存几份,毕竟目前防止消息丢失的主要机制就是冗余。
设置 min.insync.replicas > 1
。这依然是 Broker 端参数,控制的是消息至少要被写入到多少个副本才算是"已提交"。设置成大于 1 可以提升消息持久性。在实际环境中千万不要使用默认值 1。
确保replication.factor > min.insync.replicas
。如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。我们不仅要改善消息的持久性,防止数据丢失,还要在不降低可用性的基础上完成。推荐设置成 replication.factor = min.insync.replicas + 1
3.3 消费者丢失消息
Consumer 程序有个"位移"的概念,表示的是这个 Consumer 当前消费到的 Topic 分区的位置。如果我们一次消费offset为0-9的10条消息,拉取到消息之后就自动提交了位移,但是消费到位移5的时候报错了,那么位移5-9的消息就被丢失了。
解决办法也很简单就是确保消息消费完成再提交 。Consumer 端有个参数 enable.auto.commit
,最好把它设置成 false关闭自动提交,并采用手动提交位移的方式。如果启用了自动提交,Consumer 端还有个参数就派上用场了:auto.commit.interval.ms
。它的默认值是 5 秒,表明 Kafka 每 5 秒会为你自动提交一次位移。
手动提交位移是保证消费者消息消息过程中不丢失消息的核心所在,手动提交分为同步和异步,同步提交会使消费者处于阻塞状态,直到远端的 Broker 返回提交结果。而异步提交它会立即返回,不会阻塞,因此不会影响 Consumer 应用的 TPS。但是异步条件有一个缺点就是发生了异常我们无法立刻感知到并相应逻辑处理,所以代码里面的提交位移逻辑一般是:同步+异步
scss
try {
while (true) {
ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofSeconds(1));
process(records); // 处理消息
kafkaConsumer.commitAsync(); // 使用异步提交规避阻塞
}
} catch (Exception e) {
handle(e); // 处理异常
} finally {
try {
kafkaConsumer.commitSync(); // 最后一次提交使用同步阻塞式提交
} finally {
kafkaConsumer.close();
}
}
这段代码同时使用了 commitSync() 和 commitAsync()。对于常规性、阶段性的手动提交,我们调用 commitAsync() 避免程序阻塞,而在 Consumer 要关闭前,我们调用 commitSync() 方法执行同步阻塞式的位移提交,以确保 Consumer 关闭前能够保存正确的位移数据。将两者结合后,我们既实现了异步无阻塞式的位移管理,也确保了 Consumer 位移的正确性,所以,如果你需要自行编写代码开发一套 Kafka Consumer 应用,那么我推荐你使用上面的代码范例来实现手动的位移提交。
关于提交位移有一个可能发生的异常:CommitFailedException
,顾名思义就是 Consumer 客户端在提交位移时出现了错误或异常,而且还是那种不可恢复的严重异常。这是因为在拉取到消息消费完之后提交位移这期间,消费者组发生了重平衡。关于什么是重平衡可以看后续总结讲述。
4.如何保证消息不会重复消费?
在生产者端可能由于开启了重试机制导致同一条消息被发送了两次,这时候可以让生产者开启幂等性配置参数:enable.idempotence
默认为true, 即开启的。
消费者端就是要保证实际消费消息的位移和提交的位移一致,使用手工同步位移。当然我们也可以在消费消息的代码逻辑保证消费的幂等性:使用唯一索引或者分布式锁都行
5.如何解决消息积压问题
消息积压会导致很多问题,⽐如磁盘被打满、⽣产端发消息导致kafka性能过慢,最后导致出现服务雪崩不可用,解决方案如下:
- 如果是Kafka消费能力不足,则可以考虑增加Topic的分区数,并且同时提升消费组的消费者数量,消费者数 = 分区数。因为主题的一个分区只能被消费者组中一个消费者消费,假如我们消费者组里有3个消费者,但是主题就一个分区,这就白白空着两个消费者无所事事。如果已经是多个消费者对应多个分区了,还是消费比较慢,就说明是消息消息的代码逻辑过重处理过慢,可以引入多线程异步操作,但这时候需要自己控制代码逻辑来保证消费的顺序性,因为一个分区内的消息是有序的,被一个消费者顺序消费,但是当消费者开启多线程处理之后就不能保证顺序消费了。
- 如果是下游的数据处理不及时:提高每批次拉取的数量。批次拉取数据过少(拉取数据/处理时间 < 生产速度),使处理的数据小于生产的数据,也会造成数据积压。比如说可以从一次最多拉取500条,调整为一次最多拉取1000条。简单来说就是在消费能力跟得上的同时,尽量保证消费速度>生产速度,这样就不会堆积了。
6.如何保证消息的有序性。
生产者:在发送时将ack不能设置0,关闭重试,使⽤同步发送,等到发送成功再发送下⼀条。确保消息是顺序发送的。
消费者:消息是发送到⼀个分区中,只能有⼀个消费组的消费者来接收消息。
因此,kafka的顺序消费会牺牲掉性能。
7.什么是重平衡?
Rebalance
就是让一个 Consumer Group 下所有的 Consumer 实例就如何消费订阅主题的所有分区达成共识的过程。在 Rebalance 过程中,所有 Consumer 实例共同参与,在协调者组件的帮助下,完成订阅主题分区的分配。但是,在整个过程中,所有实例都不能消费任何消息,因此它对 Consumer 的 TPS 影响很大。重平衡触发的场景:
- 消费者组订阅的主题的分区数增加了,注意主题分区数只能增加,不能减少
- 消费者组订阅的主题数有变化,可能变多了也可能变少了。
- 消费者组成员有变化,可能变多了也可能变少了。
前两个订阅的分区数增加还是主题数变化,都是一个主动发起Rebalance,我们是能提前感知到的。Consumer 实例增加的情况很好理解,当我们启动一个配置有相同 group.id 值的 Consumer 程序时,实际上就向这个 Group 添加了一个新的 Consumer 实例。此时,Coordinator 会接纳这个新实例,将其加入到组中,并重新分配分区。通常来说,增加 Consumer 实例的操作都是计划内的,可能是出于增加 TPS 或提高伸缩性的需要。但是对于Consumer 实例减少,大部分不是人为操作下线的,更多情况是Consumer 实例会被 Coordinator 错误地认为"已停止"从而被"踢出"Group。如果是这个原因导致的 Rebalance,这种情况就得引起我们重视了。
Coordinator 会在什么情况下认为某个 Consumer 实例已挂从而要退组呢?
当 Consumer Group 完成 Rebalance 之后,每个 Consumer 实例都会定期地向 Coordinator 发送心跳请求,表明它还存活着。如果某个 Consumer 实例不能及时地发送这些心跳请求,Coordinator 就会认为该 Consumer 已经"死"了,从而将其从 Group 中移除,然后开启新一轮 Rebalance。Consumer 端有个参数,叫 session.timeout.ms
,就是被用来表征此事的。该参数的默认值是 145秒,即如果 Coordinator 在 45 秒之内没有收到 Group 下某 Consumer 实例的心跳,它就会认为这个 Consumer 实例已经挂了。可以这么说,session.timout.ms 决定了 Consumer 存活性的时间间隔。
Consumer 端还有一个参数,用于控制 Consumer 实际消费能力对 Rebalance 的影响,即 max.poll.interval.ms
参数。它限定了 Consumer 端应用程序两次调用 poll 方法的最大时间间隔。它的默认值是 5 分钟,表示你的 Consumer 程序如果在 5 分钟之内无法消费完 poll 方法返回的消息,那么 Consumer 会主动发起"离开组"的请求,Coordinator 也会开启新一轮 Rebalance。
8.kafka作为消息队列为什么发送和消费消息这么快?
- 消息分区:不受单台服务器的限制,可以不受限的处理更多的数据
- 顺序读写:磁盘顺序读写,提升读写效率
- 页缓存:把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问
- 零拷贝:减少上下文切换及数据拷贝
- 消息压缩:减少磁盘IO和网络IO
- 分批发送:将消息打包批量发送,减少网络开销