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 设计,在高可用之余,也会导致消息时序无法保证,消费者要注意不能依赖消息时序进行业务处理。

相关推荐
MYBOYER1 小时前
Kafka、RabbitMQ、RocketMQ的区别
kafka·rabbitmq·rocketmq
IsPrisoner1 小时前
Go语言安装proto并且使用gRPC服务(2025最新WINDOWS系统)
开发语言·后端·golang
tan180°2 小时前
Linux进程信号处理(26)
linux·c++·vscode·后端·信号处理
有梦想的攻城狮3 小时前
spring中的@MapperScan注解详解
java·后端·spring·mapperscan
大学生小郑3 小时前
Go语言八股之Mysql基础详解
mysql·面试
柚个朵朵4 小时前
Spring的Validation,这是一套基于注解的权限校验框架
java·后端·spring
Asus.Blogs5 小时前
为什么go语言中返回的指针类型,不需要用*取值(解引用),就可以直接赋值呢?
开发语言·后端·golang
C_V_Better5 小时前
Java Spring Boot 控制器中处理用户数据详解
java·开发语言·spring boot·后端·spring
胡子洲5 小时前
Spring Boot 应用中实现基本的 SSE 功能
java·spring boot·后端
贰拾wan5 小时前
【Java-EE进阶】SpringBoot针对某个IP限流问题
java·spring boot·后端·idea