⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 三友的java日记 「三友」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

在众多关于MQ的面试八股文中有这么一道题,“如何保证MQ消息消费的幂等性”。

为什么需要保证幂等性呢?是因为消息会重复消费。

为什么消息会重复消费?

明明已经消费了,为什么消息会被再次被消费呢?

不同的MQ产生的原因可能不一样

本文就以RocketMQ为例,来扒一扒RocketMQ中会导致消息重复消息的原因,最终你会发现,其实消息重复消费算是RocketMQ无奈的“bug”。

消息发送异常时重复发送

首先,我们来瞅瞅RocketMQ发送消息和消费消息的基本原理。

如图,简单说一下上图中的概念:

  • Broker,就是RocketMQ的服务端,如上图就有两个服务实例
  • Topic就是一类消息集合的名字
  • Queue就是Topic的对应的队列,消息都存在Queue上,每个Topic都会有自己的几个Queue

所以,整个消息发送和消费过程大致如下:

  • 生产者在发送消息之前根据负载均衡策略(默认是轮询)选择一个Queue,然后跟这个Queue所在的机器建立连接,把消息发送到这个Queue上
  • 消费者只要消费这个Queue,那么就能消费到消息

在正常情况下,生产者的确是按照这个方式来发送消息的

但是当出现了异常时,这种异常包括消息发送超时、响应超时等等,RocketMQ为了保证消息成功发送,会进行消息发送的重试操作,默认情况下会最多会重试两次

重试操作比较简单,就是选择另一台机器的Queue来发送。

虽然重试操作可以很大程度保证消息能够发送成功,但是同时也会带来消息重复发送的问题。

举个例子,假设生产者向A机器发送消息,发生了异常,响应超时了,但是就一定代表消息没发成功么?

不一定,有可能会出现服务端的确接受到并处理了消息,但是由于网络波动等等,导致生产者接收不到服务端响应的情况,此时消息处理成功了,但是生成者还是以为发生了异常

此时如果发生重试操作,那么势必会导致消息被发送了两次甚至更多次,导致服务端存了多条相同的消息,那么就一定会导致消费者重复消费消息

消费消息抛出异常

在RocketMQ的并发消费消息的模式下,需要用户实现MessageListenerConcurrently接口来处理消息

当消费者获取到消息之后会调用MessageListenerConcurrently的实现,传入需要消费的消息集合msgs,这里提到的msgs很重要

如上代码,当消息消费出现异常的时候,status就会为null,后面就会将status设置成为RECONSUME_LATER

RECONSUME_LATER翻译成功中文就是稍后重新消费的意思

所以从这可以看出,一旦抛出异常,那么消息之后就可以被重复消息。

到这其实可能有小伙伴觉得消息消费失败重新消费很正常,保证消息尽可能消费成功。

对,这句话不错,的确可以在一定程度上保证消费异常的消息可以消费成功。

但是坑不在这,而是前面提到的消费时传入的整个集合中的消息都需要被重新消费。

具体的原因我们接着往下看

当消息处理之后,不论是成功还是异常,都需要对结果进行处理,代码如下

当处理结果为RECONSUME_LATER的时候(异常会设置为RECONSUME_LATER),此时ackIndex会设置成-1,后面循环遍历的时候就会遍历到所有这次消费的消息,然后调用sendMessageBack方法,sendMessageBack方式是用来实现消息重新消费的逻辑,这里就不展开说了。

所以,一旦被消费的一批消息中出现一个消费异常的情况,那么就会导致整批消息被重新消费,从而会导致在出现异常之前的成功处理的消息都会被重复消费,非常坑。

不过好在消费时传入的消息集合中的消息数量是可以设置的,并且默认就是1

也就说默认情况下那个集合中就一条消息,所以默认情况下不会出现消费成功的消息被重复消费的情况。

所以这个参数不要轻易设置,一旦设置大了,就可能导致消息被重新消费。

除了并发消费消息的模式以外,RocketMQ还支持顺序消费消息的模式,也会造成重复消费,逻辑其实差不多,但是在实现消息重新消费的逻辑不一样。

消费者提交offset失败

首先来讲一讲什么是offset。

前面说过,消息在发送的时候需要指定发送到,消息最后会被放到Queue中,其实真正的消息不是在Queue中,Queue存的是每个消息的位置,但是你可以理解为Queue存的是消息。

而消息在Queue中是有序号的,这个序号就被称为offset,从0开始,单调递增1。

比如说,如上图,消息1的offset就是0,消息2的offset就是1,依次类推。

这个offset的一个作用就是用来管理消费者的消费进度。

当消费者在成功消费消息之后,需要将所消费的消息的offset提交给RocketMQ服务端,告诉RocketMQ,这个Queue的消息我已经消费到了这个位置了。

提交offset的代码就在上述第二节提到的处理结果的后面

这样有一个好处,那么一旦消费者重启了或者其它啥的要从这个Queue拉取消息的时候,此时他只需要问问RocketMQ服务端上次这个Queue消息消费到哪个位置了,之后消费者只需要从这个位置开始消费消息就行了,这样就解决了接着消费的问题。

但是RocketMQ在设计的时候,当消费完消息的时候并不是同步告诉RocketMQ服务端offset,而是定时发送。

如图,当消费者消费完消息的时候,会将offset保存到内存中的一个Map数据结构中,所以上面截图的那段代码其实是更新内存中的offset

而在消费者启动的时候会开启一个定时任务,默认是5s一次,会通过网络请求将内存中的每个Queue的消费进度offset发送给RocketMQ服务端。

由于是定时任务,所以就可能出现服务器一旦宕机,导致最新消费的offset没有成功告诉RocketMQ服务端的情况

此时,消费进度offset就丢了,那么消费者重启的时候只能从RocketMQ中获取到上一次提交的offset,从这里开始消费,而不是最新的offset,出现明明消费到了第8个消息,RocketMQ却告诉他只消费到了第5个消息的情况,此时必然会导致消息又出现重复消费的情况。

服务端持久化offset失败

上一节说到,消费者会有一个每隔5s钟的定时任务将每个队列的消费进度offset提交到RocketMQ服务端

当RocketMQ服务端接收到提交请求之后,会将这个消费进度offset保存到内存中

同时为了保证RocketMQ服务端重启消费进度不会丢失,也会开启一个定时任务,默认也是5s一次,将内存中的消费进度持久化到磁盘文件中

所以,整个消费进度offset的数据流转过程如下

当RocketMQ服务端重启之后,会从磁盘中读取文件的数据加载到内存中。

跟消费者产生的问题一样,一旦RocketMQ发生宕机,那么offset就有可能丢失5s钟的数据,RocketMQ服务端一旦重启,消费者从RocketMQ服务端获取到的消息消费进度就比实际消费的进度低,同样也会导致消息重复消费。

主从同步offset失败

在RocketMQ的高可用模式中,有一种名叫主从同步的模式,当主节点挂了之后,从节点可以手动升级为主节点对外提供访问,保证高可用。

在主从同步模式下,从节点默认每隔10s会向主节点发送请求,同步一些元数据,这些元数据就包括消费进度

当从节点获取到主节点的消费进度之后,会将主节点的消费进度设置到自己的内存中,同时也会持久化到磁盘。

所以整个消费进度offset的数据的流转过程就会变成如下

同样,由于也是定时任务,那么一旦主节点挂了,从节点就会丢10s钟的消费进度,此时如果从节点升级为主节点对外提供访问,就会出现跟上面提到的一样的情况,消费者从这个新的主节点中拿到的消费进度比实际的低,自然而然就会重复消费消息。

所以,总的来说,在消费进度数据流转的过程中,只要某个环节出现了问题,都有很有可能会导致消息重复消费。

重平衡

先来讲一讲什么是重平衡,其实重平衡很好理解,我说一下你就明白了。

前面说到,消费者是从队列中获取消息的

在RocketMQ中,有个消费者组的概念,一个消费者组中可以有多个消费者,不同消费者组之间消费消息是互不干扰的,所以前面提到的消费者其实都在消费组下

在同一个消费者组中,消息消费有两种模式:

  • 集群消费模式
  • 广播消费模式

由于RocketMQ默认是集群消费模式,并且绝大多数业务场景都是使用集群消费模式,所以这里就不讨论广播消费模式了。

集群消费模式是指同一条消息只能被这个消费者组消费一次,这就叫集群消费。

并且前面提到提交消费进度给RocketMQ服务端的情况只会集群消费模式下才会有,在广播消费模式不会提给到RocketMQ服务端,仅仅持久化到本地磁盘

同时前面说的消费者提交消费进度真正提交的是消费者组对于这个Queue的消费进度,而不是指具体的某个消费者对于Queue消费进度。

虽然说这里将前面提到的一些含义更深一步,但是并不妨碍前面的理解。

集群消费的实现就是将队列按照一定的算法分配给消费者,默认是按照平均分配的。

如图所示,假设某个topic有4个Queue,有个消费者组订阅了这个topic,这个消费者组有两个消费者1和消费者2,此时每个消费者就可以被分配两个队列,这样就能保证消息正常情况下只会被消费一次。如果只有一个消费者,那么这个消费者就会消费所有队列,很好理解。

接着后面又启动了一个消费者3,此时为了保证刚上线的消费者3能够消费消息,就要进行重平衡操作,重新分配每个消费者消费的队列。

在重平衡之后就可能会出现下面这种情况

如上图,原本被消费者2消费的Queue4被分配给消费者3,此时消费者3就能消费到消息了,这就是重平衡

除了新增消费者会导致重平衡之外,消费者数量减少,队列的数量增加或者减少都会触发重平衡。

在了解了重平衡概念之后,接下来分析一下为什么重平衡会导致消息的重复消费。

假设在进行重平衡时,还未重平衡完之前,消费者2此时还是会按照上面第二节提到的消费消息的逻辑来消费Queue4的消息

当消费者2已经重平衡完成了,发现Queue4自己已经不能消费了,那么此时就会把这个Queue4设置为dropped,就是丢弃的意思

但是由于重平衡进行时消费者2仍然在消费Queue4的消息,但是当消费完之后,发现队列被设置成dropped,那么此时被消费者2消费消息的offset就不会被提交,原因如下代码

这段代码前面已经出现过,一旦dropped被设置成true,这个if条件就通不过,消费进度就不会被提交。

成功消费消息了,但是却不提交消费进度,这就非常坑了。。

于是当消费者3开始消费Queue4的消息的时候,他就会问问RocketMQ服务端,我消费者3所在的消费者组对于Queue4这个队列消费到哪了,我接着消费就行了。

此时由于没有提交消费进度,RocketMQ服务端告诉消费者3的消费进度就会比实际的低,这就造成了消息重复消费的情况。

清理长时间消费的消息

在RocketMQ中有这么一个机制,会定时清理长时间正在消费的消息。

如图,假设有5条消息现在正在被消费者处理,这5条消息会被存在一个集合中,并且是按照offset的大小排序,消息1的offset最小,消息5的offset最大。

RocketMQ消费者启动时会开启一个默认15分钟执行一次的定时任务

这个定时任务会去检查正在处理的消息的第一条消息,也就是图中的消息1,一旦发现消息1已经处理了超过15分钟了,那么此时就会将消息1从集合中移除,之后会隔一定时间再次消费消息1。

这也会有坑,虽然消息1从集合中被移除了,但是消息1并没有消失,仍然被消费者继续处理,但是消息1隔一定时间就会再次被消费,就会出现消息1被重复消费的情况。

这就是清理长时间消费的消息导致重复消费的原因。

但此时又会引出一个新的疑问,为什么要移除这个处理超过15分钟的消息呢?

这就又跟前面提到的消费进度提交有关!

前面说过消息被消费完成之后会提交消费进度,提交的消费进度实际会有两种情况:

第一种就是某个线程消费了所有的消息,当把所有的消息都消费完成之后,就会把消息从集合中全部移除,此时提交的消费进度offset就是图中消息5的offset+1

加1的操作是为了保证如果发生重启,那么消费者下次消费的起始位置就是消息5后面的消息,保证消息5不被重复消费

第二种情况就不太一样了

假设现在有两个线程来处理这5条消息,线程1处理前2条,线程2处理后3条,如图

现在线程1出现了长时间处理消息的情况。

此时线程2处理完消息之后,移除后面三条消息,准备提交offset的时候发现集合中还有元素,就是线程1正在处理的前两条消息,此时线程2提交的offset并不是消息5对应的offset,而是消息1的offset,代码如下

这么做的主要原因就是保证消息1和消息2至少被消费一次。

因为一旦提交了消息5对应的offset,如果消费者重启了,下次消费就会接着从消息5的后面开始消费,而对于消息1和消息2来说,并不知道有没有被消费成功,就有可能出现消息丢失的情况。

所以,一旦集合中最前面的消息长时间处理,那么就会导致后面被消费的消息进度无法提交,那么重启之后就会导致大量消息被重复消费。

为了解决这个问题,RocketMQ引入了定时清理的机制,定时清理长时间消费的消息,这样消费进度就可以提交了。

最后

总得来说,RocketMQ中还是存在很多种导致消息重读消费的情况,并且官方也说了,只是在大多数情况下消息不会重复

所以如果你的业务场景中需要保证消息不能重复消费,那么就需要根据业务场景合理的设计幂等技术方案。

文章目录
  1. 1. 消息发送异常时重复发送
  2. 2. 消费消息抛出异常
  3. 3. 消费者提交offset失败
  4. 4. 服务端持久化offset失败
  5. 5. 主从同步offset失败
  6. 6. 重平衡
  7. 7. 清理长时间消费的消息
  8. 8. 最后