1.核心概念
Producer:类似发信人,消息发送者
包括:同步发送、异步发送、顺序发送、单向发送;
生产者组:同一类Producer组成一个集合,发送同一类消息且逻辑一致,用于在事务中给broker提供回查服务
Consumer:类似收信人,消息接收者
两种消费形式:式拉取式消费、推动式消费;
两种消息模式:集群消费(Clustering)和广播消费(Broadcasting)
消费者组:同一类Consumer组成一个集合
- 按照组的形式记录消费者位移;
- 不同组会消费同一个topic数据,每组内一条消息只会被其中一个consumer消费;
Broker:类似邮局,代理服务器
除了暂存和传输消息,也存储消息相关的元数据,包括消费者组、消费偏移进度、主题、队列消息等
Broker Server是RocketMQ真正的业务核心,包含多个重要子模块:
- Remoting Module:整个Broker的实体,负责处理来自clients端的请求
- Client Manager:负责管理客户端(Producer/Consumer)和维护Consumer的Topic订阅信息
- Store Service:提供方便简单的API接口处理消息存储到物理硬盘和查询功能
- HA Service:高可用服务,提供Master Broker 和 Slave Broker之间的数据同步功能
- Index Service:根据特定的Message key对投递到Broker的消息进行索引服务,以提供消息的快速查询
架构模式
-
普通集群:给每个节点分配一个固定角色
- master负责响应客户端请求,并存储消息;
- slave负责对master消息进行同步保存,并响应部分客户端的读请求;
- 各个节点的角色无法进行切换,master挂了,这一组Broker就不可用。
-
Dledger高可用集群:RocketMQ4.5版本引入,集群会随机选出一个节点作为master,当master节点挂了后,会从slave自动选出一个节点升级为master(raft算法)。
Name Server:类似各个邮局的管理机构,管理Broker
- Broker Server启动时向所有的Name Server注册自己的服务信息,并且后续通过心跳请求的方式保证这个服务信息的实时性
- 生产者或消费者通过Name Server查找各主题相应的Broker IP列表
- 多个Namesrver实例组成集群,但相互独立,没有信息交换
- NameServer中任意的节点挂了,只要有一台服务节点正常,整个路由服务就不会有影响
Topic:一类消息的集合
- 每个主题包含若干条消息,每条消息只能属于一个主题,RocketMQ消息订阅的基本单位;
- Topic只是一个逻辑概念,同一个Topic下的消息,会分片保存到不同的Broker上,而每一个分片单位,称为MessageQueue
Message Queue:用于并行发送和接收消息,存储消息的物理地址
FIFO特性的队列结构,生产者发送消息与消费者消费消息的最小单位
Message:生产和消费数据的最小单位
- 每条消息必须属于一个主题Topic
- 每个消息拥有唯一的Message ID,且可以携带具有业务标识的Key
- 有一个Tag标签,用于区分同一主题下不同类型的消息
2.参数配置
1.RocketMQ的JVM内存配置
以runbroker.sh中G1GC配置为例
JAVA_OPT="${JAVA_OPT} -XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:G1ReservePercent=25 -XX:InitiatingHeapOccupancyPercent=30 -XX:SoftRefLRUPolicyMSPerMB=0"
JAVA_OPT="${JAVA_OPT} -Xlog:gc*:file=${GC_LOG_DIR}/rmq_srv_gc_%p_%t.log:time,tags:filecount=5,filesize=30M"
-XX:+UseG1GC: 使用G1垃圾回收器,
-XX:G1HeapRegionSize=16m 将G1的region块大小设为16M
-XX:G1ReservePercent:在G1的老年代中预留25%空闲内存,默认值是10%,RocketMQ把这个参数调大了
-XX:InitiatingHeapOccupancyPercent=30:当堆内存使用率达到30%后就会启动G1垃圾回收器尝试回收垃圾,默认值是45%。RocketMQ把这个参数调小了,避免垃圾对象过多,一次垃圾回收时间太长的问题
gc*:file…filecount=5,filesize=30M:定制了GC的日志文件,确定GC日志文件的地址、打印的内容以及控制每个日志文件的大小为30M并且只保留5个文件
2.RocketMQ的其他一些核心参数
例如在conf/dleger/broker-n0.conf中有一个参数:sendMessageThreadPoolNums=16
表明RocketMQ内部用来发送消息的线程池的线程数量是16个,其实这个参数可以根据机器的CPU核心数进行适当调整,例如如果你的机器核心数超过16个,就可以把这个参数适当调大。
3.Linux内核参数定制
- ulimit,需要进行大量的网络通信和磁盘IO。
- vm.extra_free_kbytes,告诉VM在后台回收(kswapd)启动的阈值与直接回收(通过分配进程)的阈值之间保留额外的可用内存。RocketMQ使用此参数来避免内存分配中的长延迟。(与具体内核版本相关)
- vm.min_free_kbytes,如果将其设置为低于1024KB,将会巧妙的将系统破坏,并且系统在高负载下容易出现死锁。
- vm.max_map_count,限制一个进程可能具有的最大内存映射区域数。RocketMQ将使用mmap加载CommitLog和ConsumeQueue,因此建议将为此参数设置较大的值。
- vm.swappiness,定义内核交换内存页面的积极程度。较高的值会增加攻击性,较低的值会减少交换量。建议将值设置为10来避免交换延迟。
- File descriptor limits,RocketMQ需要为文件(CommitLog和ConsumeQueue)和网络连接打开文件描述符。建议设置文件描述符的值为655350。
这些参数在CentOS7中的配置文件都在 /proc/sys/vm目录下。
另外,RocketMQ的bin目录下有个os.sh里面设置了RocketMQ建议的系统内核参数,可以根据情况进行调整
3.核心原理
1.读写队列
- 写队列会创建对应的存储文件,负责消息写入。读队列会记录Consumer的Offset,负责消息读取。
- Topic权限: 2:禁写禁订阅(死信队列);4:可订阅,不能写;6:可写可订阅。
- 通常都需要设置读队列等于写队列。只有在MessageQueue进行缩减的时候,例如原来四个队列,现在缩减成两个。可以先缩减写队列,待空出来的读队列上的消息都被消费完后,再缩减读队列,保证平稳的缩减队列。
2.消息持久化
RocketMQ消息采用磁盘文件保存消息,默认路径在${user_home}/store目
录。(可以在broker.conf中自行指定)
目录结构:
- CommitLog:存储消息的元数据,所有消息都会顺序存入到CommitLog文件当中。CommitLog由多个文件组成,每个文件固定大小1G。以第一条消息的偏移量命名文件。
- ConsumerQueue:存储消息在CommitLog的索引。一个MessageQueue一个文件,记录当前MessageQueue被哪些消费者组消费到了哪一条CommitLog。
- IndexFile:为了消息查询提供了一种通过key或时间区间来查询消息的方法。这种通过IndexFile来查找消息的方法不影响发送与消费消息的主流程。
- checkpoint:数据存盘检查点。主要记录commitlog文件、ConsumeQueue文件以及IndexFile文件最后一次刷盘的时间戳。
- config/*.json:将RocketMQ的一些关键配置信息进行存盘保存。包括:Topic配置、消费者组配置、消费者组消息偏移量Offset等等一些信息。
- abort:判断程序是否正常关闭的一个标识文件。正常情况:会在启动时创建,而关闭服务时删除。但如果遇到服务器宕机,或者kill -9这样一些非正常关闭服务的情况,abort文件就不会删除。因为RocketMQ就可以判断上一次服务是非正常关闭的,后续就会做一些数据恢复的操作
- lock:文件夹已经被rocketmq占用,如果系统有多个rocketmq服务,提示已占用,启动失败
消息整体结构图
1、CommitLog文件存储所有消息实体
- 所有生产者发过来的消息,都会依次存储到Commitlog文件中,可以减少查找目标文件的时间,让消息以最快的速度落盘。
- CommitLog文件大小固定(1GB),但是其中存储的每个消息单元长度是不固定的,具体格式可参考org.apache.rocketmq.store.CommitLog。
- RocketMQ在每次存CommitLog文件时,都会去检查当前CommitLog文件空间是否足够,不够就创建一个新的CommitLog文件,文件名为当前消息的偏移量。
2、ConsumeQueue文件主要是加速消费者的消息索引
- 每个文件夹对应RocketMQ中的一个MessageQueue,文件夹下的文件记录每个MessageQueue中消息在CommitLog文件当中的偏移量。
- 消费者通过ComsumeQueue文件,可以快速找到CommitLog文件中的消息记录。消费者在ConsumeQueue文件当中的消费进度,会保存在config/consumerOffset.json文件中。
- 每个ConsumeQueue文件固定由30万个固定大小20byte的数据块组成(大约6M)。 数据块包括:msgPhyOffset(8byte,消息在文件中的起始位置) + msgSize(4byte,消息在文件中占用的长度) + msgTagCode(8byte,消息的tag的Hash值)。
- 在ConsumeQueue.java有一个常量CQ_STORE_UNIT_SIZE=20,这个常量表示一个数据块的大小。
3、IndexFile文件主要是辅助消息检索
- 消息消费按照MeessageId或者MessageKey来检索文件,比如RocketMQ管理控制台的消息轨迹功能,ConsumeQueue文件就不够用了。
- 文件名比较特殊,用时间命名的一个固定大小的文件。
- indexHeader(固定40byte) + slot(固定500W个,每个固定20byte) + index(最多500W*4个,每个固定20byte) 三部分组成。
3.删除过期文件
- 判断过期文件
RocketMQ判断文件是否过期的唯一标准就是非当前写文件的保留时间,
并不关心文件的消息是否被消费。所以,RocketMQ的消息堆积是有时间限度的,配置在broker.conf中fileReservedTime属性(文件保留时间,默认48小时)。
- 删除过期文件
RocketMQ内部有一个定时任务,对文件进行扫描,并触发文件删除操作。配置在broker.conf的deleteWhen属性,默认凌晨四点 - 另外,RocketMQ还会检查服务器磁盘空间是否足够,如果磁盘空间的使用率达到阈值,也会触发过期文件删除。RocketMQ官方建议 broker的磁盘空间不要少于4G
4.高效文件写
4.1 零拷贝
1.CPU拷贝和DMA拷贝
操作系统的内存空间分为用户态和内核态。用户态的应用程序无法直接操作硬件,需要转换成内核空间,才能操作硬件(为了保护操作系统安全)。因此,应用程序与网卡、磁盘等硬件进行数据交互时,就需要在用户态和内核态之间来回的复制数据。CPU进行数据复制任务的分配、调度等管理步骤,早先这些IO接口都是由CPU独立负责,所以当发生大规模的数据读写操作时,CPU的占用率会非常高。
之后,操作系统为了避免CPU被各种IO调用占满,引入了DMA(直接存储器存储),由DMA负责频繁的IO操作。DMA是一套独立指令集,不占用CPU计算资源。CPU不参数据复制工作,只管理DMA权限。
但是,数据复制过程中需要借助数据总线。当系统IO操作频繁时,会造成总线冲突,影响数据读写性能。所以,又引入了Channel通道,一个完全独立的处理器,专门负责IO操作。Channel有自己的IO指令,与CPU无关,更适合大型的IO操作,性能更高。Java应用层与零拷贝相关的操作都是通过Channel的子类实现,其实借鉴了操作系统中的概念。
所以,零拷贝技术,并不是不拷贝,而是尽量减少CPU拷贝,提高性能。
2.mmap文件映射机制
在一次普通读写操作中,需要进行四次数据拷贝:内核态与用户态间的状态切换数据拷贝(性能较慢的CPU拷贝两次),磁盘与内核态间的数据拷贝(优化后的DMA拷贝两次)。mmap文件映射方式是在用户态不再保存文件真实数据,只保存文件映射,包括文件内存起始地址,文件大小等。真实的数据,直接通过操作映射,在内核态完成数据复制。
在JDK的NIO包中,java.nio.HeapByteBuffer映射的是JVM的一块堆内内存,由一个byte数组缓存数据内容,所有的读写操作需要先操作这个byte数组。这是没有使用零拷贝的普通文件读写机制。
而NIO包中的java.nio.DirectByteBuffer实现类则映射的是一块堆外内存。没有任何数据结构保存数据内容,只保存一个内存地址。所有对数据的读写操作,都通过unsafe魔法类直接交由内核完成,这其实就是mmap的读写机制。
在java中大量使用了mmap文件映射,在Linux机器上通过jps查看运行的进程ID,再使用lsof -p {PID}的方式查看文件的映射情况
cwd 表示程序的工作目录。rtd 表示用户的根目录。 txt表示运行程序的指令。
下面的1u表示Java应用的标准输出,2u表示Java应用的标准错误输出,默认的/dev/pts/1是linux当中的伪终端。通常服务器上会写 java xxx 1>text.txt 2>&1 这样的脚本,就是指定这里的1u,2u。
总之,mmap机制适合操作小文件,如果文件太大,映射信息也会过大。建议的映射文件大小不超过2G 。RocketMQ的CommitLog文件固定保持在1G,也是为了方便文件映射
3.sendFile机制
以磁盘复制数据到网卡为例,早期的sendfile实现机制还是依靠CPU进行页缓存与socket缓存区间的数据拷贝。后期在拷贝过程中,不拷贝文件内容,只拷贝一个带有文件位置和长度等信息的文件描述符FD,大大减少了传递的数据。真实的数据内容,由DMA控制器从页缓存打包异步发送到socket中。
sendfile机制在内核态直接完成数据复制,不需要用户态参与,所以这种机制的传输效率是非常稳定的。sendfile机制非常适合大数据的复制转移。
4.2 顺序写
如果应用程序往磁盘写文件时磁盘空间不连续,会产生很多碎片,在磁盘多个扇区间进行大量的随机写。大量的寻址操作,严重影响写数据性能。顺序写机制在磁盘中提前申请一块连续的磁盘空间,每次写数据时直接在之前写入的地址后面接着写,避免寻址操作。
Kafka官方曾说明,顺序写的性能基本能够达到内存级别。如果配备固态硬盘,顺序写的性能甚至有可能超过写内存。
org.apache.rocketmq.store.CommitLog#DefaultAppendMessageCallback中的doAppend方法。在这个方法中,会以追加的方式将消息先写入到一个堆外内存byteBuffer中,然后再通过fileChannel写入到磁盘。
4.3 刷盘机制
在操作系统层面,当应用程序写入一个文件时,文件内容并不直接写入到硬件,而是先写入操作系统的一个缓存PageCache中。PageCache缓存以4K大小为单位,缓存文件的具体内容。这些写入到PageCache中的文件,在应用程序看来,是已经完全落盘了的,可以正常修改、复制等等。但是,PageCache依然是内存状态,所以一断电就会丢失。因此,需要将内存状态的数据写入到磁盘当中,这样数据才能真正完成持久化,断电也不会丢失。这个过程称为刷盘。
操作系统只会在某些特定的时刻将PageCache写入到磁盘,例如正常关机时,就会完成PageCache刷盘。另外,在Linux中,对于有数据修改的PageCache,会标记为Dirty(脏页)状态。当Dirty Page的比例达到一定的阈值,就会触发一次刷盘操作。
但是,只要操作系统的刷盘操作不是即时执行,那就避免不了非正常宕机时的数据丢失问题。因此,操作系统也提供了一个系统调用,应用程序可以自行调用这个系统调用,完成PageCache的强制刷盘(在Linux中是fsync)。
RocketMQ也设计了两种刷盘机制,同步刷盘和异步刷盘
- 同步刷盘:在返回写成功状态时,消息已经被写入磁盘。消息写入内存的PAGECACHE后,立刻通知刷盘线程刷盘, 然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,返回消息写成功的状态。同步刷盘机制会更频繁的调用fsync,所以吞吐量相比异步刷盘会降低,但是数据的安全性会得到提高。
- 异步刷盘:在返回写成功状态时,消息可能只是被写入了内存的PAGECACHE,写操作的返回快,吞吐量大;当内存里的消息量积累到一定程度时,统一触发写磁盘动作,快速写入。
- 配置方式:通过Broker配置文件里的flushDiskType 参数设的,这个参数被配置成SYNC_FLUSH、ASYNC_FLUSH(默认)中的一个。
5.消息主从复制
消息复制的方式分为同步复制和异步复制
- 同步复制:
同步复制是等Master和Slave都写入消息成功后才反馈给客户端写入成功的状态。
在同步复制下,如果Master节点故障,Slave上有全部的数据备份,这样容易恢复数据。但是同步复制会增大数据写入的延迟,降低系统的吞吐量。 - 异步复制:
异步复制是只要master写入消息成功,就反馈给客户端写入成功的状态。然后再异步的将消息复制给Slave节点。
在异步复制下,系统拥有较低的延迟和较高的吞吐量。但是如果master节点故障,而有些数据没有完成复制,就会造成数据丢失。 - 配置方式:
消息复制方式是通过Broker配置文件里的brokerRole参数进行设置的,这个参数可以被设置成ASYNC_MASTER(默认)、 SYNC_MASTER、SLAVE三个值中的一个。
6.负载均衡
1.Producer负载均衡
Producer发消息时,默认会采用递增取模的方式轮询往目标Topic下所有不同broker的MessageQueue发消息。
生产者在发消息时,可以指定一个MessageQueueSelector,通过这个对象将消息发送到自己指定的MessageQueue上,保证消息局部有序。
2.Consumer负载均衡
Consumer也是以MessageQueue为单位进行负载均衡。分为集群模式和广播模式
- 集群模式
在集群模式下,每条消息只需要投递到订阅这个topic的Consumer Group下的一个实例即可。RocketMQ采用主动拉取的方式拉取并消费消息,在拉取的时候需要明确指定拉取哪一条message queue。
每当实例的数量有变更,都会触发一次所有实例的负载均衡,按照queue的数量和实例的数量平均分配queue给每个实例。
每次分配时,都会将MessageQueue和消费者ID进行排序后,再用不同的分配算法进行分配。内置的分配的算法共有六种,分别对应AllocateMessageQueueStrategy下的六种实现类,可以在consumer中直接set来指定。默认情况下使用的是最简单的平均分配策略。
- AllocateMachineRoomNearby: 将同机房的Consumer和Broker优先分配在一起。
通过一个machineRoomResolver对象定制Consumer和Broker的机房解析规则。然后引入另外一个分配策略对同机房的Broker和Consumer进行分配。一般用简单的平均分配策略或者轮询分配策略。
源码中有测试代码AllocateMachineRoomNearByTest。
在示例中:
Broker的机房指定方式:messageQueue.getBrokerName().split(“-”)[0],
Consumer的机房指定方式:clientID.split(“-”)[0]
clinetID的构建方式:见ClientConfig.buildMQClientId方法。按他的测试代码应该是要把clientIP指定为IDC1-CID-0这样的形式。
- AllocateMessageQueueAveragely:平均分配。将所有MessageQueue平均分给每一个消费者
- AllocateMessageQueueAveragelyByCircle: 轮询分配。轮流的给一个消费者分配一个MessageQueue。
- AllocateMessageQueueByConfig: 不分配,直接指定一个messageQueue列表。类似于广播模式,直接指定所有队列。
- AllocateMessageQueueByMachineRoom:按逻辑机房的概念进行分配。又是对BrokerName和ConsumerIdc有定制化的配置。
- AllocateMessageQueueConsistentHash。源码中有测试代码
- AllocateMessageQueueConsitentHashTest。这个一致性哈希策略只需要指定一个虚拟节点数,是用的一个哈希环的算法,虚拟节点是为了让Hash数据在换上分布更为均匀。
- 广播模式
广播模式下,每一条消息都会投递给订阅了Topic的所有消费者实例,也就没有消息分配这一说。而在实现上,就是在Consumer分配Queue时,所有Consumer都分到所有的Queue。
广播模式实现关键是将消费者的消费偏移量不再保存到broker,而是保存到客户端,由客户端自行维护自己的消费偏移量。
7.消息重试
广播模式不存在消息重试,只有通过设置返回状态达到普通消息重试的结果。
1.如何让消息进行重试
集群消费模式下消费失败后,需要在消息监听器接口的实现中进行配置实现消息重试。有三种配置方式:
- 返回ConsumeConcurrentlyStatus.RECONSUME_LATER(推荐)
- 返回null
- 抛出异常
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
//处理消息
doConsumeMessage(msgs);
//方式1:返回 ConsumeConcurrentlyStatus.RECONSUME_LATER,消息将重试
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
//方式2:返回 null,消息将重试
return null;
//方式3:直接抛出异常, 消息将重试
throw new RuntimeException("Consumer Message exceotion");
}
如果希望消费失败后不重试,可以直接返回ConsumeConcurrentlyStatus.CONSUME_SUCCESS。
2.重试消息如何处理
重试的消息会进入一个 “%RETRY%”+ConsumeGroup 的队列。
RocketMQ默认允许每条消息最多重试16次,每次重试的间隔时间如下:
重试次数 | 与上次重试的间隔时间 | 重试次数 | 与上次重试的间隔时间 |
---|---|---|---|
1 | 10 秒 | 9 | 7 分钟 |
2 | 30 秒 | 10 | 8 分钟 |
3 | 1 分钟 | 11 | 9 分钟 |
4 | 2 分钟 | 12 | 10 分钟 |
5 | 3 分钟 | 13 | 20 分钟 |
6 | 4 分钟 | 14 | 30 分钟 |
7 | 5 分钟 | 15 | 1 小时 |
8 | 6 分钟 | 16 | 2 小时 |
重试时间与延迟消息的延迟级别对应,取的是延迟级别的后16级别(共18级)
messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
测试重试时间可以将源码中的org.apache.rocketmq.example.quickstart.Consumer里的消息监听器返回状态改为ConsumeConcurrentlyStatus.RECONSUME_LATER测试。
重试次数:
如果消息重试16次后仍然失败,消息将不再投递,进入死信队列。
一条消息无论重试多少次,这些重试消息的MessageId始终都是一样的。
RocketMQ可以定制重试次数。例如通过consumer.setMaxReconsumeTimes(20);将重试次数设定为20次。当定制的重试次数超过16次后,消息的重试时间间隔永远为最大的2小时。
关于MessageId:
在老版RocketMQ,一条消息无论重试多少次,MessageId始终都一样。在4.9.1版本中,每次重试MessageId都会重建。
配置覆盖:
设置消息最大重试次数对相同GroupID下所有Consumer实例有效,最后启动的Consumer会覆盖之前启动的Consumer配置。
8.死信队列
当一条消息消费失败,RocketMQ自动重试消息。如果消息超过最大重试次数,RocketMQ不会立刻将这个消息丢弃,而会将其发送到这个消费者组对应的特殊队列:死信队列。
RocketMQ默认重试次数是16次。见源码org.apache.rocketmq.common.subscription.SubscriptionGroupConfig中的retryMaxTimes属性。
这个重试次数可以在消费者端进行配置。 例如DefaultMQPushConsumer实例中有个setMaxReconsumeTimes方法指定重试次数。
死信队列的名称是%DLQ%+ConsumGroup
死信队列特征
- 一个死信队列对应一个ConsumGroup,而不是对应某个消费者实例。
- 如果一个ConsumeGroup没有产生死信队列,RocketMQ就不会为其创建相应的死信队列。
- 一个死信队列包含了这个ConsumeGroup里的所有死信消息,而不区分该消息属于哪个Topic。
- 死信队列中的消息不会再被消费者正常消费。
- 死信队列的有效期跟正常消息相同,默认3天,对应broker.conf中的fileReservedTime属性。超过这个最长时间的消息都会被删除,而不管消息是否消费过。
通常,一条消息进入死信队列,意味着该消息在处理过程中出现了比较严重的错误,无法自行恢复。需要人工查看死信队列中的消息,排查错误原因,处理死信消息,比如转发到正常的Topic重新进行消费,或者丢弃。
默认创建的死信队列中的消息在控制台和消费者中都无法读取。这是因为这些默认的死信队列,他们的权限perm被设置成了2:禁读(这个权限有三种 2:禁读,4:禁写,6:可读可写)。需要手动将死信队列的权限配置成6,才能被消费(可以通过mqadmin指定或者web控制台)。
9.消息幂等
幂等概念
在MQ系统中,对于消息幂等有三种实现语义:
- at most once 最多一次:每条消息最多只会被消费一次
- at least once 至少一次:每条消息至少会被消费一次
- exactly once 刚刚好一次:每条消息都只会确定的消费一次
其中,at most once最好保证,RocketMQ中通过异步发送、sendOneWay等方式保证最多一次。
而at least once这个语义,RocketMQ也有同步发送、事务消息等很多方式能够保证。
而exactly once是MQ中最理想也是最难保证的一种语义,需要有非常精细的设计才行。RocketMQ只能保证at least once,保证不了exactly once。所以,使用RocketMQ时,需要由业务系统自行保证消息的幂等性。但是,对于exactly once语义,阿里云的商业版RocketMQ明确有API支持,至于如何实现的,就不得而知了。
幂等必要性
- 发送时消息重复
当一条消息已被成功发送到服务端并完成持久化,此时出现了网络闪断或者客户端宕机,导致服务端对客户端应答失败。 如果此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。 - 投递时消息重复
消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。 为了保证消息至少被消费一次,消息队列 RocketMQ的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。 - 负载均衡时消息重复(包括但不限于网络抖动、Broker 重启以及订阅方应用重启)
当消息队列 RocketMQ 的 Broker 或客户端重启、扩容或缩容时,会触发Rebalance,此时消费者可能会收到重复消息。
处理方式
在RocketMQ中是无法保证每个消息只被投递一次,所以要在业务上自行保证消息消费的幂等性。
而要处理这个问题,RocketMQ的每条消息都有一个唯一的MessageId,这个参数在多次投递的过程中是不会改变的,所以业务上可以用这个MessageId来作为判断幂等的关键依据。
但是,这个MessageId是无法保证全局唯一的,也会有冲突的情况。所以在一些对幂等性要求严格的场景,最好是使用业务上唯一的一个标识比较靠谱,例如订单ID。而这个业务标识可以使用Message的Key来进行传递。