全面了解 RocketMQ 的使用和特性
文章目录
- 全面了解 RocketMQ 的使用和特性
- 1. RocketMQ 简介
- 1.1 什么是RocketMQ?
- 1.2 RocketMQ 的特点
- 1.3 RocketMQ 的优势
- 2. RocketMQ 的基础概念
- 3. RocketMQ 的运转流程
- 4. NameServer
- 5. Producer
- 5.1 Producer 启动流程
- 5.2 Producer 发消息流程
- 5.3 自动创建主题的弊端
- 5.4 发送消息故障延迟机制
- 5.5 Producer 的负载均衡
- 5.6 小结一下
- 6. Broker
- 6.1 Broker 的存储
- 6.1.1 CommitLog
- 6.1.2 ConsumeQueue
- 6.1.3 IndexFile
- 6.1.4 对文件的读写
- 6.1.5 存储消息流程
- 6.2 消息刷盘机制
- 6.3 页缓存与内存映射
- 6.4 文件预分配和文件预热
- 6.5 文件预热
- 6.6 Broker 的 HA
- 6.7 小结一下
- 7. Consumer
- 7.1 Consumer 的负载均衡机制
- 7.2 Consumer 消息消费的重试
- 7.3 死信队列
- 7.4 消息的全局顺序和局部顺序
- 7.5 RocketMQ 中消息发送的权衡
- 8. RocketMQ 高可用性机制
- 8.1. 消息消费高可用
- 8.2 消息发送高可用
- 8.3 消息主从复制
- 9. 消费幂等
- 10. RocketMQ 安装
- 10.1 准备工作
- 10.2 启动应用
- 10.3 验证RocketMQ功能
- 10.4 RocketMQ控制台
- 11. SpringBoot环境中使用RocketMQ
1. RocketMQ 简介
1.1 什么是RocketMQ?
RocketMQ作为一款纯java、分布式、队列模型的开源消息中间件,支持事务消息、顺序消息、批量消息、定时消息、消息回溯等。
1.2 RocketMQ 的特点
- 支持发布/订阅(Pub/Sub)和点对点(P2P)消息模型
- 能够保证严格的消息顺序,在一个队列中可靠的先进先出(FIFO)和严格的顺序传递
- 提供丰富的消息拉取模式,支持拉(pull)和推(push)两种消息模式
- pull其实就是消费者主动从MQ中去拉消息。
- push则像rabbit MQ一样,是MQ给消费者推送消息。但是RocketMQ的push其实是基于pull来实现的。它会先由一个业务代码从MQ中pull消息,然后再由业务代码push给特定的应用/消费者。其实底层就是一个pull模式
- 单一队列百万消息的堆积能力。(RocketMQ提供亿级消息的堆积能力,这不是重点,重点是堆积了亿级的消息后,依然保持写入低延迟)
- 支持多种消息协议,如 JMS、MQTT 等
- 分布式高可用的部署架构,满足至少一次消息传递语义(RocketMQ原生就是支持分布式的,而ActiveMQ原生存在单点性)
- 提供 docker 镜像用于隔离测试和云集群部署
- 提供配置、指标和监控等功能丰富的 Dashboard
1.3 RocketMQ 的优势
目前主流的 MQ 主要是 RocketMQ、kafka、RabbitMQ,其主要优势有:
- 支持事务型消息(消息发送和 DB 操作保持两方的最终一致性,RabbitMQ 和 Kafka 不支持)
- 支持结合 RocketMQ 的多个系统之间数据最终一致性(多方事务,二方事务是前提)
- 支持 18 个级别的延迟消息(Kafka 不支持)
- 支持指定次数和时间间隔的失败消息重发(Kafka 不支持,RabbitMQ 需要手动确认)
- 支持 Consumer 端 Tag 过滤,减少不必要的网络传输(即过滤由MQ完成,而不是由消费者完成,RabbitMQ 和 Kafka不支持)
- 支持重复消费(RabbitMQ 不支持,Kafka 支持)
回到顶部
2. RocketMQ 的基础概念
RocketMQ 使用轻量级的NameServer服务进行服务的协调和治理工作,NameServer多节点部署时相互独立互不干扰。每一个rocketMq服务节点(broker节点)启动时都会遍历配置的NameServer列表并建立长链接,broker节点每30秒向NameServer发送一次心跳信息、NameServer每10秒会检查一次连接的broker是否存活。消费者和生产者会随机选择一个NameServer建立长连接,通过定期轮训更新的方式获取最新的服务信息。
整体的架构设计主要分为四大部分,分别是:Producer、Consumer、Broker、NameServer。
- Producer: 就是消息生产者,可以集群部署。它会先和 NameServer 集群中的随机一台建立长连接,得知当前要发送的 Topic 存在哪台 Broker Master上,然后再与其建立长连接,支持多种负载平衡模式发送消息。
- Consumer: 消息消费者,也可以集群部署。它也会先和 NameServer 集群中的随机一台建立长连接,得知当前要消息的 Topic 存在哪台 Broker Master、Slave上,然后它们建立长连接,支持集群消费和广播消费消息。
- Broker: 主要负责消息的存储、查询消费,支持主从部署,一个 Master 可以对应多个 Slave,Master 支持读写,Slave 只支持读。Broker 会向集群中的每一台 NameServer 注册自己的路由信息。
- NameServer: 是一个很简单的 Topic 路由注册中心,支持 Broker 的动态注册和发现,保存 Topic 和 Borker 之间的关系。通常也是集群部署,但是各 NameServer 之间不会互相通信, 各 NameServer 都有完整的路由信息,即无状态。
- Topic: 主题,用于将消息按主题做划分,Producer将消息发往指定的Topic,Consumer订阅该Topic就可以收到这条消息
- Message: 消息,每个message必须指定一个topic,Message 还有一个可选的 Tag 设置,以便消费端可以基于 Tag 进行过滤消息
- Tag: 标签,子主题(二级分类)对topic的进一步细化,用于区分同一个主题下的不同业务的消息
- Queue: Topic和Queue是1对多的关系,一个Topic下可以包含多个Queue,主要用于负载均衡,Queue数量设置建议不要比消费者数少。发送消息时,用户只指定Topic,Producer会根据Topic的路由信息选择具体发到哪个Queue上。Consumer订阅消息时,会根据负载均衡策略决定订阅哪些Queue的消息
- Offset: RocketMQ在存储消息时会为每个Topic下的每个Queue生成一个消息的索引文件,每个Queue都对应一个Offset记录当前Queue中消息条数
回到顶部
3. RocketMQ 的运转流程
- NameServer 先启动
- Broker 启动时向 NameServer 注册,
- 生产者在发送某个主题的消息之前先从 NamerServer 获取 Broker 服务器地址列表(有可能是集群),然后根据负载均衡算法从列表中选择一台Broker 进行消息发送。
- NameServer 与每台 Broker 服务器保持长连接,并间隔 30S 检测 Broker 是否存活,如果检测到Broker 宕机(使用心跳机制, 如果检测超120S),则从路由注册表中将其移除。
- 消费者上线也可以从 NameServer 得知它所要接收的 Topic 是哪个 Broker ,和对应的 Master、Slave 建立连接,接收消息。
回到顶部
4. NameServer
NameServer是一个Broker与Topic路由的注册中心,支持Broker的动态注册与发现。
- Broker管理: 接受Broker集群的注册信息,提供心跳检测机制,检查Broker是否还存活。
- 路由信息管理: 每个NameServer中都保存着Broker集群的整个路由信息和用于客户端查询的队列信息。Producer和Conumser通过NameServer可以获取整个Broker集群的路由信息,从而进行消息的投递和消费。
- 路由注册: 在Broker节点启动时,轮询NameServer列表,与每个NameServer节点建立长连接,发起注册请求。对于Broker,必须明确指出所有NameServer地址,因此NameServer并不能随便扩容。因此,若Broker不重新配置,新增的NameServer对于Broker来说是不可见的,其不会向这个NameServer进行注册。
Broker节点为了证明自己是活着的,为了维护与NameServer间的长连接,会将最新的信息以心跳包的方式上报给NameServer,每30秒发送一次心跳。心跳包中包含 BrokerId、Broker地址(IP+Port)、Broker名称、Broker所属集群名称等等 - 路由剔除: NameServer中有⼀个定时任务,每隔10秒就会扫描⼀次Broker表,查看每一个Broker的最新心跳时间戳距离当前时间是否超过120秒,如果超过,则会判定Broker失效,然后将其从Broker列表中剔除。
- 路由发现: RocketMQ的路由发现采用的是Pull模型。当Topic路由信息出现变化时,NameServer不会主动推送给客户端,而是客户端定时拉取主题最新的路由。默认客户端(是Producer与Consumer)每30秒会拉取一次最新的路由。
- 客户端连接NameServer策略: 客户端(是Producer与Consumer) 在配置时必须要写上NameServer集群的地址,首先采用的是随机策略进行的选择,失败后采用的是轮询策略。
- 随机策略: 客户端首先会生产一个随机数,然后再与NameServer节点数量取模,此时得到的就是所要连接的节点索引。
- 轮询策略: 如果连接失败,则会采用轮询策略,逐个尝试着去连接其它节点。
NameServer的特点就是轻量级,无状态。角色类似于 Zookeeper 的情况,从上面描述知道其主要的两个功能就是:Broker 管理、路由信息管理。
总体而言比较简单,我再贴一些字段,让大家有更直观的印象知道它存储了些什么。
回到顶部
5. Producer
Producer 无非就是消息生产者,那首先它得知道消息要发往哪个 Broker ,于是每 30s 会从某台 NameServer 获取 Topic 和 Broker 的映射关系存在本地内存中,如果发现新的 Broker 就会和其建立长连接,每 30s 会发送心跳至 Broker 维护连接。
并且会轮询当前可以发送的 Broker 来发送消息,达到负载均衡的目的,在同步发送情况下如果发送失败会默认重投两次(retryTimesWhenSendFailed = 2),并且不会选择上次失败的 broker,会向其他 broker 投递。
在异步发送失败的情况下也会重试,默认也是两次 (retryTimesWhenSendAsyncFailed = 2),但是仅在同一个 Broker 上重试。
5.1 Producer 启动流程
然后我们再来看看 Producer 的启动流程看看都干了些啥。
有人可能会问这生产者为什么要启拉取服务、重平衡?
因为 Producer 和 Consumer 都需要用 MQClientInstance,而同一个 clientId 是共用一个 MQClientInstance 的, clientId 是通过本机 IP 和 instanceName(默认值 default)拼起来的,所以多个 Producer 、Consumer 实际用的是一个MQClientInstance。
至于有哪些定时任务,请看下图:
5.2 Producer 发消息流程
我们再来看看发消息的流程,大致也不是很复杂,无非就是找到要发送消息的 Topic 在哪个 Broker 上,然后发送消息。
现在就知道 TBW102 是啥用的,就是接受自动创建主题的 Broker 启动会把这个默认主题登记到 NameServer,这样当 Producer 发送新 Topic 的消息时候就得知哪个 Broker 可以自动创建主题,然后发往那个 Broker。
而 Broker 接受到这个消息的时候发现没找到对应的主题,但是它接受创建新主题,这样就会创建对应的 Topic 路由信息。
5.3 自动创建主题的弊端
自动创建主题那么有可能该主题的消息都只会发往一台 Broker,起不到负载均衡的作用。
因为创建新 Topic 的请求到达 Broker 之后,Broker 创建对应的路由信息,但是心跳是每 30s 发送一次,所以说 NameServer 最长需要 30s 才能得知这个新 Topic 的路由信息。
假设此时发送方还在连续快速的发送消息,那 NameServer 上其实还没有关于这个 Topic 的路由信息,所以有机会让别的允许自动创建的 Broker 也创建对应的 Topic 路由信息,这样集群里的 Broker 就能接受这个 Topic 的信息,达到负载均衡的目的,但也有个别 Broker 可能,没收到。
如果发送方这一次发了之后 30s 内一个都不发,之前的那个 Broker 随着心跳把这个路由信息更新到 NameServer 了,那么之后发送该 Topic 消息的 Producer 从 NameServer 只能得知该 Topic 消息只能发往之前的那台 Broker ,这就不均衡了,如果这个新主题消息很多,那台 Broker 负载就很高了。
所以不建议线上开启允许自动创建主题,即 autoCreateTopicEnable 参数。
5.4 发送消息故障延迟机制
有一个参数是 sendLatencyFaultEnable,默认不开启。这个参数的作用是对于之前发送超时的 Broker 进行一段时间的退避。
发送消息会记录此时发送消息的时间,如果超过一定时间,那么此 Broker 就在一段时间内不允许发送。
比如发送时间超过 15000ms 则在 600000 ms 内无法向该 Broker 发送消息。
这个机制其实很关键,发送超时大概率表明此 Broker 负载高,所以先避让一会儿,让它缓一缓,这也是实现消息发送高可用的关键。
5.5 Producer 的负载均衡
Producer端,每个实例在发消息的时候,默认会轮询所有的Message Queue发送,以达到让消息平均落在不同的Queue上。而由于Queue可以散落在不同的Broker,所以消息就发送到不同的Broker下,如下图:
图中箭头线条上的标号代表顺序,发布方会把第一条消息发送至Queue 0,然后第二条消息发送至Queue 1,以此类推。
5.6 小结一下
Producer 每 30s 会向 NameServer拉取路由信息更新本地路由表,有新的 Broker 就和其建立长连接,每隔 30s 发送心跳给 Broker 。
不要在生产环境开启 autoCreateTopicEnable。
Producer 会通过重试和延迟机制提升消息发送的高可用。
回到顶部
6. Broker
Broker 就比较复杂一些了,但是非常重要。大致分为以下五大模块,我们来看一下官网的图。
- Remoting Module: 整个Broker的实体,负责处理来自clients端的请求。
- Client Manager: 客户端管理器。负责接收、解析客户端(Producer/Consumer)请求,管理客户端。
- Store Service: 存储服务。提供方便简单的API接口,处理消息存储到物理硬盘和消息查询功能。
- HA Service: 高可用服务,提供Master Broker 和 Slave Broker之间的数据同步功能。
- Index Service: 索引服务。根据特定的消息标识(Message key),对投递到Broker的消息进行索引服务,同时也提供根据Message Key对消息进行快速查询的功能。
6.1 Broker 的存储
RocketMQ 存储用的是本地文件存储系统,将所有topic的消息全部写入同一个文件中(commit log),这样保证了IO写入的绝对顺序性,最大限度利用IO系统顺序读写带来的优势提升写入速度。
由于消息混合存储在一起,需要将每个消费者组消费topic最后的偏移量记录下来。这个文件就是consumer queue(索引文件)。所以消息在写入commit log 文件的同时还需将偏移量信息写入consumer queue文件。在索引文件中会记录消息的物理位置、偏移量offse,消息size等,消费者消费时根据上述信息就可以从commit log文件中快速找到消息信息。
Broker 存储结构如下:
6.1.1 CommitLog
消息存储文件,RocketMQ 的所有主题的消息都存在 CommitLog 中,单个 CommitLog 默认 1G,并且文件名以起始偏移量命名,固定 20 位,不足则前面补 0,比如 00000000000000000000 代表了第一个文件,第二个文件名就是 00000000001073741824,表明起始偏移量为 1073741824,以这样的方式命名用偏移量就能找到对应的文件。
所有消息都是顺序写入的,超过文件大小则开启下一个文件。
6.1.2 ConsumeQueue
ConsumeQueue 消息消费队列,可以认为是 CommitLog 中消息的索引,因为 CommitLog 是糅合了所有主题的消息,所以通过索引才能更加高效的查找消息。
ConsumeQueue 存储的条目是固定大小,只会存储 8 字节的 commitlog 物理偏移量,4 字节的消息长度和 8 字节 Tag 的哈希值,固定 20 字节。
在实际存储中,ConsumeQueue 对应的是一个Topic 下的某个 Queue,每个文件约 5.72M,由 30w 条数据组成。
消费者是先从 ConsumeQueue 来得到消息真实的物理地址,然后再去 CommitLog 获取消息。
6.1.3 IndexFile
IndexFile 就是索引文件,是额外提供查找消息的手段,不影响主流程。
通过 Key 或者时间区间来查询对应的消息,文件名以创建时间戳命名,固定的单个 IndexFile 文件大小约为400M,一个 IndexFile 存储 2000W个索引。
6.1.4 对文件的读写
消息写入
一条消息进入到Broker后经历了以下几个过程才最终被持久化。
- Broker根据queueId,获取到该消息对应索引条目要在consumequeue目录中的写入偏移量,即 QueueOffset
- 将queueId、queueOffset等数据,与消息一起封装为消息单元
- 将消息单元写入到commitlog
- 同时,形成消息索引条目
- 将消息索引条目分发到相应的consumequeue
消息拉取
当Consumer来拉取消息时会经历以下几个步骤:
- Consumer获取到其要消费消息所在Queue的消费偏移量offset,计算出其要消费消息的消息offset
- Consumer向Broker发送拉取请求,其中会包含其要拉取消息的Queue、消息offset及消息Tag
- Broker计算在该consumequeue中的queueOffset
- 从该queueOffset处开始向后查找第一个指定Tag的索引条目
- 解析该索引条目的前8个字节,即可定位到该消息在commitlog中的commitlog offset
- 从对应commitlog offset中读取消息单元,并发送给Consumer
6.1.5 存储消息流程
消息到了先存储到 Commitlog,然后会有一个 ReputMessageService 线程接近实时地将消息转发给消息消费队列文件与索引文件,也就是说是异步生成的。
6.2 消息刷盘机制
RocketMQ 提供消息同步刷盘和异步刷盘两个选择。
同步刷盘: 当消息持久化完成后,Broker才会返回给Producer一个ACK响应,可以保证消息的可靠性,但是性能较低。具体流程是,消息写入内存的PAGECACHE后,立刻通知刷盘线程刷盘, 然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,返回消息写成功的状态。
异步刷盘: 只要消息写入PageCache即可将成功的ACK返回给Producer端。在返回写成功状态时,消息可能只是被写入了内存的PAGECACHE,写操作的返回快,吞吐量大;当内存里的消息量积累到一定程度时,统一触发写磁盘动作,快速写入。消息刷盘采用后台异步线程提交的方式进行,降低了读写延迟,提高了RocketMQ的性能和吞吐量。
配置:
同步刷盘与异步刷盘,都是通过Broker配置文件里的flushDiskType参数设置的,这个参数被配置成SYNC_FLUSH(同步)、ASYNC_FLUSH(异步)中的 一个。
关于刷盘我们都知道效率比较低,单纯存入内存中的话效率是最高的,但是可靠性不高,影响消息可靠性的情况大致有以下几种:
- Broker 被暴力关闭,比如 kill -9
- Broker 挂了
- 操作系统挂了
- 机器断电
- 机器坏了,开不了机
- 磁盘坏了
如果都是 1-4 的情况,同步刷盘肯定没问题,异步的话就有可能丢失部分消息,5 和 6就得依靠副本机制了,如果同步双写肯定是稳的,但是性能太差,如果异步则有可能丢失部分消息。
所以需要看场景来使用同步、异步刷盘和副本双写机制。
6.3 页缓存与内存映射
Commitlog 是混合存储的,所以所有消息的写入就是顺序写入,对文件的顺序写入和内存的写入速度基本上没什么差别。
并且 RocketMQ 的文件都利用了内存映射即 Mmap,将程序虚拟页面直接映射到页缓存上,无需有内核态再往用户态的拷贝。
页缓存其实就是操作系统对文件的缓存,用来加速文件的读写,也就是说对文件的写入先写到页缓存中,操作系统会不定期刷盘(时间不可控),对文件的读会先加载到页缓存中,并且根据局部性原理还会预读临近块的内容。
其实也是因为使用内存映射机制,所以 RocketMQ 的文件存储都使用定长结构来存储,方便一次将整个文件映射至内存中。
6.4 文件预分配和文件预热
CommitLog 的大小默认是1G,当超过大小限制的时候需要准备新的文件,而 RocketMQ 就起了一个后台线程 AllocateMappedFileService,不断的处理 AllocateRequest,AllocateRequest 其实就是预分配的请求,会提前准备好下一个文件的分配,防止在消息写入的过程中分配文件,产生抖动。
6.5 文件预热
有一个 warmMappedFile 方法,它会把当前映射的文件,每一页遍历多去,写入一个0字节,然后再调用mlock 和 madvise(MADV_WILLNEED)。
- mlock:可以将进程使用的部分或者全部的地址空间锁定在物理内存中,防止其被交换到 swap 空间。
- madvise:给操作系统建议,说这文件在不久的将来要访问的,因此,提前读几页可能是个好主意。
6.6 Broker 的 HA
从 Broker 会和主 Broker 建立长连接,然后获取主 Broker commitlog 最大偏移量,开始向主 Broker 拉取消息,主 Broker 会返回一定数量的消息,循环进行,达到主从数据同步。
消费者消费消息会先请求主 Broker ,如果主 Broker 觉得现在压力有点大,则会返回从 Broker 拉取消息的建议,然后消费者就去从服务器拉取消息。
6.7 小结一下
- CommitLog 采用混合型存储,也就是所有 Topic 都存在一起,顺序追加写入,文件名用起始偏移量命名。
- 消息先写入 CommitLog 再通过后台线程分发到 ConsumerQueue 和 IndexFile 中。
- 消费者先读取 ConsumerQueue 得到真正消息的物理地址,然后访问 CommitLog 得到真正的消息。
- 利用了 mmap 机制减少一次拷贝,利用文件预分配和文件预热提高性能。
- 提供同步和异步刷盘,根据场景选择合适的机制。
回到顶部
7. Consumer
消费有两种模式,分别是广播模式和集群模式。
1.广播模式:一个分组下的每个消费者都会消费完整的Topic 消息。
一条消息被多个Consumer消费,即使这些Consumer属于同一个Consumer Group,消息也会被Consumer Group中的每一个Consumer都消费一次。
//设置广播模式
consumer.setMessageModel(MessageModel.BROADCASTING);
2.集群模式:一个分组下的消费者瓜分消费Topic 消息。
一个Consumer Group中的所有Consumer平均分摊消费消息(组内负载均衡)
//设置集群模式,也就是负载均衡模式
consumer.setMessageModel(MessageModel.CLUSTERING);
7.1 Consumer 的负载均衡机制
① 集群模式
在集群消费模式下,每条消息只需要投递到订阅这个topic的Consumer Group下的一个实例即可。RocketMQ采用主动拉取的方式拉取并消费消息,在拉取的时候需要明确指定拉取哪一条message queue。
而每当实例的数量有变更,都会触发一次所有实例的负载均衡,这时候会按照queue的数量和实例的数量平均分配queue给每个实例。
默认的分配算法是AllocateMessageQueueAveragely,每个consumer实例平均分配每个consume queue,如下图:
还有另外一种平均的算法是AllocateMessageQueueAveragelyByCircle,也是平均分摊每一条queue,只是以环状轮流分queue的形式,每个consumer实例平均分配每个consume queue,如下图:
需要注意的是,集群模式下,queue都是只允许分配只一个实例,这是由于如果多个实例同时消费一个queue的消息,由于拉取哪些消息是consumer主动控制的,那样会导致同一个消息在不同的实例下被消费多次,所以算法上都是一个queue只分给一个consumer实例,一个consumer实例可以允许同时分到不同的queue。
通过增加consumer实例去分摊queue的消费,可以起到水平扩展的消费能力的作用。而有实例下线的时候,会重新触发负载均衡,这时候原来分配到的queue将分配到其他实例上继续消费。
但是如果consumer实例的数量比message queue的总数量还多的话,多出来的consumer实例将无法分到queue,也就无法消费到消息,也就无法起到分摊负载的作用了。所以需要控制让queue的总数量大于等于consumer的数量。
②广播模式
由于广播模式下要求一条消息需要投递到一个消费组下面所有的消费者实例,所以也就没有消息被分摊消费的说法。在实现上,其中一个不同就是在consumer分配queue的时候,所有consumer都分到所有的queue,每个consumer实例分配每个consume queue。
天然弊端:
RocketMQ 采用一个 consumer 绑定一个或者多个 Queue 模式,假如某个消费者服务器挂了,则会造成部分Queue消息堆积
7.2 Consumer 消息消费的重试
难免会遇到消息消费失败的情况,所以需要提供消费失败的重试,而一般的消费失败要么就是消息结构有误,要么就是一些暂时无法处理的状态,所以立即重试不太合适。
RocketMQ 会给每个消费组都设置一个重试队列,Topic 是 %RETRY%+consumerGroup,并且设定了很多重试级别来延迟重试的时间。
为了利用 RocketMQ 的延时队列功能,重试的消息会先保存在 Topic 名称“SCHEDULE_TOPIC_XXXX”的延迟队列,在消息的扩展字段里面会存储原来所属的 Topic 信息。
delay 一段时间后再恢复到重试队列中,然后 Consumer 就会消费这个重试队列主题,得到之前的消息。
如果超过一定的重试次数都消费失败,则会移入到死信队列,即 Topic %DLQ%" + ConsumerGroup 中,存储死信队列即认为消费成功,因为实在没辙了,暂时放过。
然后我们可以通过人工来处理死信队列的这些消息。
1.顺序消息的重试
对于顺序消息,当消费者消费消息失败后,消息队列 RocketMQ 会自动不断进行消息重试(每次间隔时间为 1 秒),这时,应用会出现消息消费被阻塞的情况。因此,在使用顺序消息时,务必保证应用能够及时监控并处理消费失败的情况,避免阻塞现象的发生。
2.无序消息的重试
对于无序消息(普通、延时、事务消息),当消费者消费消息失败时,可以通过设置返回状态达到消息重试的结果。
无序消息的重试只针对集群消费方式生效;广播方式不提供失败重试特性,即消费失败后,失败消息不再重试,继续消费新的消息。
3.重试次数
消息队列 RocketMQ 默认允许每条消息最多重试 16 次,每次重试的间隔时间如下:
如果消息重试 16 次后仍然失败,消息将不再投递。如果严格按照上述重试时间间隔计算,某条消息在一直消费失败的前提下,将会在接下来的 4 小时 46 分钟之内进行 16 次重试,超过这个时间范围消息将不再重试投递。
注意: 一条消息无论重试多少次,这些重试消息的 Message ID 不会改变。
7.3 死信队列
当一条消息初次消费失败,消息队列RocketMQ会自动进行消息重试;达到最大重试次数后,若消费依然失败,则表明消费者在正常情况下无法正确地消费该消息,此时,消息队列RocketMQ不会立刻将消息丢弃,而是将其发送到该消费者对应的特殊队列中。
在消息队列RocketMQ中,这种正常情况下无法被消费的消息称为死信消息(DeadLetter Message),存储死信消息的特殊队列称为死信队列(Dead-Letter Queue)。
特点:
- 不会再被消费者正常消费。
- 有效期与正常消息相同,均为3天,3天后会被自动删除。因此,请在死信消息产生后的 3 天内及时处理。
- 一个死信队列对应一个Group ID, 而不是对应单个消费者实例
- 如果一个Group ID未产生死信消息,消息队列RocketMQ不会为其创建相应的死信队列。
- 一个死信队列包含了对应Group ID产生的所有死信消息,不论该消息属于哪个Topic。
查看死信信息
- 在控制台查询出现死信队列的主题信息
- 在消息界面根据主题查询死信消息
- 选择重新发送消息
一条消息进入死信队列,意味着某些因素导致消费者无法正常消费该消息,因此,通常需要您对其进行特殊处理。排查可疑因素并解决问题后,可以在消息队列RocketMQ控制台重新发送该消息,让消费者重新消费一次。
7.4 消息的全局顺序和局部顺序
全局顺序就是消除一切并发,一个 Topic 一个队列,Producer 和 Consuemr 的并发都为一。
局部顺序其实就是指某个队列顺序,多队列之间还是能并行的。
可以通过 MessageQueueSelector 指定 Producer 某个业务只发这一个队列,然后 Comsuer 通过MessageListenerOrderly 接受消息,其实就是加锁消费。
在 Broker 会有一个 mqLockTable ,顺序消息在创建拉取消息任务的时候需要在 Broker 锁定该消息队列,之后加锁成功的才能消费。
而严格的顺序消息其实很难,假设现在都好好的,如果有个 Broker 宕机了,然后发生了重平衡,队列对应的消费者实例就变了,就会有可能会出现乱序的情况,如果要保持严格顺序,那此时就只能让整个集群不可用了。
7.5 RocketMQ 中消息发送的权衡
三种发送方式的对比
回到顶部
8. RocketMQ 高可用性机制
RocketMQ分布式集群是通过Master和Slave的配合达到高可用性的。
Master和Slave的区别:在Broker的配置文件中,参数 brokerId的值为0表明这个Broker是Master,大于0表明这个Broker是 Slave,同时brokerRole参数也会说明这个Broker是Master还是Slave。
Master角色的Broker支持读和写,Slave角色的Broker仅支持读,也就是 Producer只能和Master角色的Broker连接写入消息;Consumer可以连接 Master角色的Broker,也可以连接Slave角色的Broker来读取消息。
8.1. 消息消费高可用
在Consumer的配置文件中,并不需要设置是从Master读还是从Slave 读,当Master不可用或者繁忙的时候,Consumer会被自动切换到从Slave 读。有了自动切换Consumer这种机制,当一个Master角色的机器出现故障后,Consumer仍然可以从Slave读取消息,不影响Consumer程序。这就达到了消费端的高可用性。
8.2 消息发送高可用
在创建Topic的时候,把Topic的多个Message Queue创建在多个Broker组上(相同Broker名称,不同 brokerId的机器组成一个Broker组),这样当一个Broker组的Master不可用后,其他组的Master仍然可用,Producer仍然可以发送消息。 RocketMQ目前还不支持把Slave自动转成Master,如果机器资源不足, 需要把Slave转成Master,则要手动停止Slave角色的Broker,更改配置文件,用新的配置文件启动Broker。
8.3 消息主从复制
如果一个Broker组有Master和Slave,消息需要从Master复制到Slave 上,有同步和异步两种复制方式。
1)同步复制:
同步复制方式是等Master和Slave均写成功后才反馈给客户端写成功状态;
在同步复制方式下,如果Master出故障, Slave上有全部的备份数据,容易恢复,但是同步复制会增大数据写入延迟,降低系统吞吐量。
2)异步复制:
异步复制方式是只要Master写成功 即可反馈给客户端写成功状态。
在异步复制方式下,系统拥有较低的延迟和较高的吞吐量,但是如果Master出了故障,有些数据因为没有被写入Slave,有可能会丢失;
3)配置:
同步复制和异步复制是通过Broker配置文件里的brokerRole参数进行设置的,这个参数可以被设置成ASYNC_MASTER、 SYNC_MASTER、SLAVE(从节点配置)三个值中的一个。
实际应用中要结合业务场景,合理设置刷盘方式和主从复制方式, 尤其是SYNC_FLUSH方式,由于频繁地触发磁盘写动作,会明显降低性能。通常情况下,应该把Master和Save配置成ASYNC_FLUSH的刷盘方式,主从之间配置成SYNC_MASTER的复制方式,这样即使有一台机器出故障,仍然能保证数据不丢,是个不错的选择。
回到顶部
9. 消费幂等
【原因分析】
在互联网应用中,尤其在网络不稳定的情况下,消息队列RocketMQ的消息有可能会出现重复,这个重复简单可以概括为以下情况:
- 发送时消息重复
- 当一条消息已被成功发送到服务端并完成持久化,此时出现了网络闪断或者客户端宕机,导致服务端对客户端应答失败。如果此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。
- 投递时消息重复
- 消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。为了保证消息至少被消费一次,消息队列RocketMQ的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且Message ID也相同的消息。
- 负载均衡时消息重复(包括但不限于网络抖动、Broker重启以及订阅方应用重启)
- 当消息队列RocketMQ的Broker或客户端重启、扩容或缩容时,会触发Rebalance,此时消费者可能会收到重复消息。
【解决方案】
因为Message ID有可能出现冲突(重复)的情况,所以真正安全的幂等处理,不建议以 Message ID作为处理依据。 最好的方式是以业务唯一标识作为幂等处理的关键依据,而业务的唯一标识可以通过消息Key进行设置:
Message message = new Message();
message.setKey("ORDERID_100");
SendResult sendResult = producer.send(message);
订阅方收到消息时可以根据消息的Key进行幂等处理:
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> {
try {
for (MessageExt msg : msgs) {
String key = msg.getKeys();
StringUtils.isNotEmpty(redisTemplate.opsForValue().get(key)){
return new RuntimeException("含有重复性消费消息,请重试!");
}
redisTemplate.opsForValue().set(key, key);
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
} catch (Exception e) {
e.printStackTrace();
//重试
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
});
回到顶部
10. RocketMQ 安装
10.1 准备工作
Windows必须先安装64bit的JDK1.8或以上版本。
从RockitMQ官网下载 最新的release包 。我这里下载的版本是v4.7.0
解压到本地目录。
目录结构
上图是rocketmq-all-4.7.0-bin-release.zip 包解压后的目录结构。bin目录下存放可运行的脚本。
10.2 启动应用
RocketMQ默认提供了 windows环境 和 linux环境 下的启动脚本。脚本位于bin目录下,windows的脚本以.cmd为文件名后缀,linux环境的脚本以.sh为文件名后缀。
不过,通常情况下,windows下的脚本双击启动时,都是窗口一闪而过,启动失败。下面的内容就帮大家解决这些问题。
第一步,配置 JAVA_HOME 和 ROCKETMQ_HOME 环境变量
JAVA_HOME 的配置已经是老生常谈,这里不再赘述,不懂的话请自行百度。
ROCKETMQ_HOME 应指向解压后的Readme.md文件所在目录。
上面的图,我的 ROCKETMQ_HOME 应配置为 E:\rocketMq
第二步,启动 NameServer
NameServer的启动脚本是bin目录下的mqnamesrv.cmd。
上文讲过,即使配置好了ROCKETMQ_HOME环境变量,mqnamesrv.cmd的启动通常也以失败告终。
阅读mqnamesrv.cmd脚本,发现其实际上是调用了runserver.cmd脚本来实现启动的动作。
而在runserver.cmd脚本,java的默认启动参数中,启动时堆内存的大小为2g,老旧一点的机器上根本没有这么多空闲内存。
因此,用编辑器修改一下runserver.cmd脚本。将原来的内存参数注释掉(cmd脚本使用rem关键字),修改为:
rem set "JAVA_OPT=%JAVA_OPT% -server -Xms2g -Xmx2g -Xmn1g -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m"
set "JAVA_OPT=%JAVA_OPT% -server -Xms256m -Xmx512m"
启动方式一:直接双击mqnamesrv.cmd脚本启动NameServer。
启动方式二:使用cmd命令启动,首先进入rocketMq的安装目录,再进入bin目录,执行‘start mqnamesrv.cmd’,启动NAMESERVER
NameServer启动显示
看到 The Name Server boot success 字样,表示NameServer己启动成功。
windows环境下,可以在目录%USERPROFILE%\logs\rocketmqlogs下找到NameServer的启动日志。文件名为namesrv.log。
第三步,启动 Broker
Broker的启动脚本是mqbroker.cmd。
与mqnamesrv.cmd脚本类似,mqbroker.cmd是调用runbroker.cmd脚本启动Broker的。
同样的,优化一下runbroker.cmd的启动内存
rem set "JAVA_OPT=%JAVA_OPT% -server -Xms2g -Xmx2g -Xmn1g"
set "JAVA_OPT=%JAVA_OPT% -server -Xms256m -Xmx512m"
此外,Broker脚本启动之前要指定 NameServer的地址。
NameServer默认启动端口是9876,这点可以从NameServer的启动日志中找到记录。
启动方式一:修改mqbroker.cmd脚本,增加NameServer的地址。
rem 添加此行,指定NameServer的地址
set "NAMESRV_ADDR=localhost:9876"
rem 在此行之前添加NameServer的地址
call "%ROCKETMQ_HOME%\bin\runbroker.cmd" org.apache.rocketmq.broker.BrokerStartup %*
双击mqbroker.cmd脚本启动Broker。
启动方式二:不 修改mqbroker.cmd脚本,直接使用cmd命令启动,首先跟启动NameServer一样先进入rocketmq安装目录的bin目录下面,然后执行‘start mqbroker.cmd -n 127.0.0.1:9876 autoCreateTopicEnable=true’启动broker
Broker启动成功
看到 The broker … boot success 字样,表示Broker己启动成功,使用命令启动的界面没有内容显示
与NameServer类似,可以在目录%USERPROFILE%\logs\rocketmqlogs下找到Broker的启动日志。文件名为broker.log。
10.3 验证RocketMQ功能
RocketMQ自带了恬送与接收消息的脚本tools.cmd,用来验证RocketMQ的功能是否正常。
tool.cmd脚本需要带参数执行,无法用简单的双击方式启动。因此,我们打开一个cmd窗口,并跳转到bin目录下。
打开cmd窗口并跳转到bin目录下
启动消费者
与mqbroker.cmd脚本类似,启动tool.cmd命令之前我们要指定NameServer地址。
这里我们采用命令方式指定,并启动消费者。依次执行如下命令:
set NAMESRV_ADDR=127.0.0.1:9876
tools.cmd org.apache.rocketmq.example.quickstart.Consumer
启动消费者成功
启动生产者
再打开一个cmd窗口,依次执行如下命令:
set NAMESRV_ADDR=127.0.0.1:9876
tools.cmd org.apache.rocketmq.example.quickstart.Producer
生产者启动命令
启动成功后,生产者会发送1000个消息,然后自动退出。
生产者发送消息并退出
此时,在消费者界面按下Ctrl + C,就会收到刚刚生产者发出的消息。
消费者接收消息
至此,RocketMQ应用己经可以正常工作,能满足我们开发环境下调试代码的需求。
10.4 RocketMQ控制台
我们实际开发中不愿意总是去编写命令查询,管理,定位rocketmq的消息,我们希望rabbitmq一样的控制台直接打开消息。
下载rocketMq控制台源码:https://github.com/apache/rocketmq-externals.git
下载完后解压到本地电脑
可以看出控制台是使用springboot开发的,最终的控制台项目是 rocketmq-console,由于该项目端口号默认使用的是8080可能会冲突,我们需要修改端口号
找到项目的配置文件,将端口号修改为8088,并且知道rocketmq的nameserver路径为本机
修改保存后我们编辑改项目,使用cmd命令进入rocketmq-console项目,执行mvn clean package -Dmaven.test.skip=true 命令
编译打包成功后在rocketmq-console项目下面就会出现一个target文件夹,进入该文件夹 就会看到我们打包好的jar包
我们直接运行该jar包即可,使用cmd命令进入到该目录,然后执行命令 java -jar rocketmq-console-ng-1.0.0.jar 或者 mvn spring-boot:run 命令
使用浏览器输入 http://127.0.0.1:8088/ 即可进入rocketmq控制台
我们看到的红色的就是消息总数,由于我们执行了 rocketmq自带的验证功能,发送了1000条消息
回到顶部
11. SpringBoot环境中使用RocketMQ
当前项目环境版本为:
SpringBoot 2.2.2.RELEASE
RocketMQ 4.7.0
生产者项目,消费者项目都增加配置文件
<!-- rocketmq -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
<version>4.7.0</version>
</dependency>
MQ生产者配置
mq生产者项目 boot-order-service 端口号 8802
配置文件配置:
spring.application.name=boot-order-service
server.port=8802
# nacos配置地址
nacos.config.server-addr=127.0.0.1:8848
# nacos注册地址
nacos.discovery.server-addr=127.0.0.1:8848
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8
# 是否开启自动配置
rocketmq.producer.isOnOff=on
# 发送同一类消息设置为同一个group,保证唯一默认不需要设置,rocketmq会使用ip@pid(pid代表jvm名字)作为唯一标识
rocketmq.producer.groupName=${spring.application.name}
# mq的nameserver地址
rocketmq.producer.namesrvAddr=127.0.0.1:9876
# 消息最大长度 默认 1024 * 4 (4M)
rocketmq.producer.maxMessageSize = 4096
# 发送消息超时时间,默认 3000
rocketmq.producer.sendMsgTimeOut=3000
# 发送消息失败重试次数,默认2
rocketmq.producer.retryTimesWhenSendFailed=2
新增一个 MQProducerConfigure 配置类,用来初始化MQ生产者
package com.lockie.cloudorder.rocketmq;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @Description: mq生产者配置
*/
@Getter
@Setter
@ToString
@Configuration
@ConfigurationProperties(prefix = "rocketmq.producer")
public class MQProducerConfigure {
public static final Logger LOGGER = LoggerFactory.getLogger(MQProducerConfigure.class);
private String groupName;
private String namesrvAddr;
// 消息最大值
private Integer maxMessageSize;
// 消息发送超时时间
private Integer sendMsgTimeOut;
// 失败重试次数
private Integer retryTimesWhenSendFailed;
/**
* mq 生成者配置
* @return
* @throws MQClientException
*/
@Bean
@ConditionalOnProperty(prefix = "rocketmq.producer", value = "isOnOff", havingValue = "on")
public DefaultMQProducer defaultProducer() throws MQClientException {
LOGGER.info("defaultProducer 正在创建---------------------------------------");
DefaultMQProducer producer = new DefaultMQProducer(groupName);
producer.setNamesrvAddr(namesrvAddr);
producer.setVipChannelEnabled(false);
producer.setMaxMessageSize(maxMessageSize);
producer.setSendMsgTimeout(sendMsgTimeOut);
producer.setRetryTimesWhenSendAsyncFailed(retryTimesWhenSendFailed);
producer.start();
LOGGER.info("rocketmq producer server 开启成功----------------------------------");
return producer;
}
}
MQ消费者配置
mq消费者项目 boot-user-service 端口号 8801
增加配置参数
spring.application.name=boot-user-service
server.port=8801
# nacos配置地址
nacos.config.server-addr=127.0.0.1:8848
# nacos注册地址
nacos.discovery.server-addr=127.0.0.1:8848
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8
# 是否开启自动配置
rocketmq.consumer.isOnOff=on
# 发送同一类消息设置为同一个group,保证唯一默认不需要设置,rocketmq会使用ip@pid(pid代表jvm名字)作为唯一标识
rocketmq.consumer.groupName=${spring.application.name}
# mq的nameserver地址
rocketmq.consumer.namesrvAddr=127.0.0.1:9876
# 消费者订阅的主题topic和tags(*标识订阅该主题下所有的tags),格式: topic~tag1||tag2||tags3;
rocketmq.consumer.topics=TestTopic~TestTag;TestTopic~HelloTag;HelloTopic~HelloTag;MyTopic~*
# 消费者线程数据量
rocketmq.consumer.consumeThreadMin=5
rocketmq.consumer.consumeThreadMax=32
# 设置一次消费信心的条数,默认1
rocketmq.consumer.consumeMessageBatchMaxSize=1
新建一个MQConsumerConfigure 类用来初始化MQ消费者
package com.lockie.bootuser.rocketmq;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: lockie
* @Date: 2020/4/21 10:28
* @Description: mq消费者配置
*/
@Getter
@Setter
@ToString
@Configuration
@ConfigurationProperties(prefix = "rocketmq.consumer")
public class MQConsumerConfigure {
public static final Logger LOGGER = LoggerFactory.getLogger(MQConsumerConfigure.class);
private String groupName;
private String namesrvAddr;
private String topics;
// 消费者线程数据量
private Integer consumeThreadMin;
private Integer consumeThreadMax;
private Integer consumeMessageBatchMaxSize;
@Autowired
private MQConsumeMsgListenerProcessor consumeMsgListenerProcessor;
/**
* mq 消费者配置
* @return
* @throws MQClientException
*/
@Bean
@ConditionalOnProperty(prefix = "rocketmq.consumer", value = "isOnOff", havingValue = "on")
public DefaultMQPushConsumer defaultConsumer() throws MQClientException {
LOGGER.info("defaultConsumer 正在创建---------------------------------------");
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(groupName);
consumer.setNamesrvAddr(namesrvAddr);
consumer.setConsumeThreadMin(consumeThreadMin);
consumer.setConsumeThreadMax(consumeThreadMax);
consumer.setConsumeMessageBatchMaxSize(consumeMessageBatchMaxSize);
// 设置监听
consumer.registerMessageListener(consumeMsgListenerProcessor);
/**
* 设置consumer第一次启动是从队列头部开始还是队列尾部开始
* 如果不是第一次启动,那么按照上次消费的位置继续消费
*/
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
/**
* 设置消费模型,集群还是广播,默认为集群
*/
// consumer.setMessageModel(MessageModel.CLUSTERING);
try {
// 设置该消费者订阅的主题和tag,如果订阅该主题下的所有tag,则使用*,
String[] topicArr = topics.split(";");
for (String topic : topicArr) {
String[] tagArr = topic.split("~");
consumer.subscribe(tagArr[0], tagArr[1]);
}
consumer.start();
LOGGER.info("consumer 创建成功 groupName={}, topics={}, namesrvAddr={}",groupName,topics,namesrvAddr);
} catch (MQClientException e) {
LOGGER.error("consumer 创建失败!");
}
return consumer;
}
}
这个只是初始化操作,实际对消费者对消息处理放在 consumer.registerMessageListener(consumeMsgListenerProcessor); 这个监听类里面了,实际接收消息,处理消息都放在监听类里
新建一个监听类处理消息
测试消息发送接收
生产者 boot-order-service 新建一个controller,再新建一个send方法,发送消息
package com.lockie.cloudorder.rocketmq;
import com.lockie.cloudorder.model.Results;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.client.exception.MQBrokerException;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.exception.RemotingException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @Description:
*/
@RestController
@RequestMapping("/mqProducer")
public class MQProducerController {
public static final Logger LOGGER = LoggerFactory.getLogger(MQProducerController.class);
@Autowired
DefaultMQProducer defaultMQProducer;
/**
* 发送简单的MQ消息
* @param msg
* @return
*/
@GetMapping("/send")
public Results send(String msg) throws InterruptedException, RemotingException, MQClientException, MQBrokerException {
if (StringUtils.isEmpty(msg)) {
return new Results().succeed();
}
LOGGER.info("发送MQ消息内容:" + msg);
Message sendMsg = new Message("TestTopic", "TestTag", msg.getBytes());
// 默认3秒超时
SendResult sendResult = defaultMQProducer.send(sendMsg);
LOGGER.info("消息发送响应:" + sendResult.toString());
return new Results().succeed(sendResult);
}
}
浏览器请求发送send接口 http://127.0.0.1:8802/mqProducer/send?msg=hello
修改topic和tags为MyTopic,MyTags,再发送一次
我们进入rocketmq控制台查看
回到顶部