Kafka丢消息与重复消费场景

0 简介

Kafka 最初的设计目的是用于处理海量的日志。

早期的版本中,为了获得极致的性能,在设计方面做了很多的牺牲,比如不保证消息的可靠性,可能会丢失消息,也不支持集群,功能上也比较简陋,这些牺牲对于处理海量日志这个特定的场景都是可以接受的。

随后的几年 Kafka 逐步补齐了这些短板 ,当下的 Kafka 已经发展为一个非常成熟的消息队列产品 ,无论在数据可靠性、稳定性和功能特性等方面都可以满足绝大多数场景的需求。

但是,如果使用不当 ,还是有可能发生丢消息重复消费等情况。

Kafka 为了实现超高的并发性能,大量使用了批量和异步 的设计,也因此,反而导致它同步收发消息的响应时延比较高,因为当客户端发送一条消息的时候,Kafka 并不会立即发送出去,而是要等一会儿攒一批再发送,在它的 Broker 中,很多地方都会使用这种"先攒一波再一起处理"的设计。所以,Kafka 其实不太适合实时的在线业务场景。

golang 一般使用 c.Consumer.Offsets.AutoCommit.Interval的库连接 Kafka,默认配置可见:github.com/Shopify/sar...

框架配参数时是通过:

ini 复制代码
cli, err = skafka.NewClient(cfg.KafkaAddr, func(config *sarama.Config) {
    config.Version = sarama.V2_3_0_0
    //config.Producer.Flush.Frequency = time.Second
})

1 生产者阶段

1.1 生产者丢消息

生产者丢消息一般分为:

  1. producer 把消息发送给 broker,因为网络抖动,消息没有到达 broker;

  2. producer 把消息发送给 broker-leader,leader 接收到消息,在未将消息同步给 follower 之前,挂掉了;

  3. producer 把消息发送给 broker-leader,leader 接收到消息,leader 未成功将消息同步给每个 follower,有消息丢失风险;

  4. 某个 broker 消息尚未从内存缓冲区持久化到磁盘,就挂掉了,这种情况无法通过ack机制感知。

其中,前三种都是可以通过 ack 配置来规避的,生产者客户端可配置:

arduino 复制代码
config := sarama.NewConfig()
config.Producer.RequiredAcks = sarama.WaitForAll

ack参数取值见:github.com/Shopify/sar...

ini 复制代码
const (
    // 不等待ack
    NoResponse RequiredAcks = 0
    // 本地提交成功
    WaitForLocal RequiredAcks = 1
    // 等待所有同步副本提交成功,这副本的数量看服务端的`min.insync.replicas`配置
    WaitForAll RequiredAcks = -1
)

遇到提交消息失败时,生产者可以进行重试。

此时要关注 c.Producer.Retry.Max 最大重试次数、c.Producer.Retry.Backoff 重试时间间隔、c.Producer.Flush.Frequency flush发送频率、c.Producer.Flush.Messages 触发flush的消息量 等等。

不过并不是所有的异常都是可以通过重试来解决的,比如消息太大,超过 max.request.size 参数配置的值时,这种方式就不可行了。

复制代码
Kafka 为了提升性能,使用页缓存机制,将消息写入页缓存而非直接持久化至磁盘,采用了异步批量刷盘机制,也就是说,按照一定的消息量和时间间隔去刷盘,刷盘的动作由操作系统来调度的,如果刷盘之前,Broker 宕机了,重启后在页缓存的这部分消息则会丢失。

1.2 消息重复生产

先来看一个定义:

类型 消息是否会重复 消息是否会丢失 优势 劣势 适用场景
最多一次 生产端发送消息后不用等待和处理服务端响应,消息发送速度会很快。 网络或服务端有问题会造成消息的丢失。 消息系统吞吐量大且对消息的丢失不敏感。例如:日志收集、用户行为等场景。
最少一次 生产端发送消息后需要等待和处理服务端响应,如果失败会重试。 吞吐量较低,有重复发送的消息。 消息系统吞吐量一般,但是绝不能丢消息,对于重复消息不敏感。
有且仅有一次 消息不重复,消息不丢失,消息可靠性很好。 吞吐量较低。 对消息的可靠性要求很高,同时可以容忍较小的吞吐量。

生产者阶段消息重复根本原因其实就是:生产发送的消息没有收到正确的 ack 响应,导致 producer 重试。

比如 broker 落盘成功,但 ack 响应由于网络原因丢失了,生产者进行重试,broker 再次落盘成功。

实际上,就是为了降低消息丢失的概率,反而 增加了重试概率,从而导致消息重复生产。

解决方案是启用 Kafka 的幂等性:enable.idempotence=true 同时要求 ack=all 且 retries>1。

原理:每个 producer 有一个 producer id,服务端会通过这个id关联记录每个producer的状态,每个producer的每条消息会带上一个递增的sequence,服务端会记录每个producer对应的当前最大sequence(producerId + sequence),如果新的消息带上的sequence不大于当前的最大sequence就拒绝这条消息,消息落盘会同时更新最大sequence,这个时候重发的消息会被服务端拒掉从而避免消息重复。

2 消费者阶段

2.1 原理

消费者阶段的异常本质上是对位移(offset) 的不恰当提交。

参考上图,当前一次 poll() 操作所拉取的消息集为 [x+2, x+7],x+2 代表上一次提交的消费位移,说明已经完成了 x+1 之前(包括 x+1 在内)的所有消息的消费,x+5 表示当前正在处理的位置。如果拉取到消息之后就进行了位移提交,即提交了 x+8,那么当前消费 x+5 的时候遇到了异常,在故障恢复之后,我们重新拉取的消息是从 x+8 开始的。也就是说,x+5 至 x+7 之间的消息并未能被消费,如此便发生了消息丢失的现象。

再考虑另外一种情形,位移提交的动作是在消费完所有拉取到的消息之后才执行的,那么当消费 x+5 的时候遇到了异常,在故障恢复之后,我们重新拉取的消息是从 x+2 开始的。也就是说,x+2 至 x+4 之间的消息又重新消费了一遍,故而又发生了重复消费的现象。

2.2 自动提交位移

在 Kafka 中默认的消费位移的提交方式是自动提交,这个由消费者客户端参数 c.Consumer.Offsets.AutoCommit.Enable 配置,sarama 默认值为 true。当然这个默认的自动提交不是每消费一条消息就提交一次,而是定期提交,这个定期的周期时间由客户端参数 c.Consumer.Offsets.AutoCommit.Interval 配置,sarama 默认值为1秒。

假设刚刚提交完一次消费位移,然后拉取一批消息进行消费,在下一次自动提交消费位移之前,消费者崩溃了,那么又得从上一次位移提交的地方重新开始消费,这样便发生了重复消费的现象。

我们可以通过减小位移提交的时间间隔来减小重复消息的窗口大小,但这样并不能避免重复消费的发生,而且也会使位移提交更加频繁。

而当消息拉取和业务处理是分开线程的,则可能会导致消息丢失:

在 sarama 库中的自动提交,是基于被标记过的消息 session.MarkMessage(msg, ""),见:github.com/Shopify/sar...

如果不调用 session.MarkMessage(msg, ""),即使启用了自动提交也没有效果,下次启动消费者会从上一次的 offset 重新消费。

这样的处理,我们可以在业务处理完成后再调用 session.MarkMessage(msg, "") 来最大限度保证不会出现消息丢失场景。

2.3 手动提交位移

Kafka 当然也提供了手动提交位移的方法,并且分为同步提交和异步提交。但这里不打算细讲,理由很简单,会大大增加代码复杂度(对提交失败的重试处理),还无法完全避免重复消费

异步提交不用多说,当异步提交操作未完成时,程序崩溃,自然导致重复消费;

异步提交失败时,也可能导致重复消费:

  • 异步提交 x+1 时失败,走进重试逻辑;
  • 消费者继续处理 x+2,异步提交成功;
  • x+1的提交重试成功,覆盖掉 x+2 的位移,下次将重复拉取 x+2。

而同步提交,会大大降低消费者处理效率,基本可以说是放弃了队列的所有优势。

3 总结

消息丢失的场景,基本可以通过 Kafka服务端配置、生产者配置、消费者消息标记 来最大限度 的保证绝大多数情况 下不丢失消息。但建议设计之初考虑补消息的逻辑。

而消息重复场景,实际上可以说很多是为了对抗消息丢失而导致的,可以说很难完全避免强烈建议 消费者做好幂等性处理。

题外话,Kafka 的这些 broker、topic、partition 设计,在高可用之余,也会导致消息时序无法保证,消费者要注意不能依赖消息时序进行业务处理。

相关推荐
码熔burning几秒前
JVM 面试精选 20 题(续)
jvm·面试·职场和发展
Victor3566 分钟前
Redis(14)Redis的列表(List)类型有哪些常用命令?
后端
Victor3567 分钟前
Redis(15)Redis的集合(Set)类型有哪些常用命令?
后端
卷福同学8 分钟前
来上海三个月,我在马路边上遇到了阿里前同事...
java·后端
bobz9659 小时前
小语言模型是真正的未来
后端
DevYK9 小时前
企业级 Agent 开发实战(一) LangGraph 快速入门
后端·llm·agent
纪莫10 小时前
Kafka如何保证「消息不丢失」,「顺序传输」,「不重复消费」,以及为什么会发送重平衡(reblanace)
kafka
艾伦~耶格尔10 小时前
【集合框架LinkedList底层添加元素机制】
java·开发语言·学习·面试
一只叫煤球的猫10 小时前
🕰 一个案例带你彻底搞懂延迟双删
java·后端·面试
冒泡的肥皂10 小时前
MVCC初学demo(一
数据库·后端·mysql