RocketMQ应用场景
解耦
用户购物积分服务:当我们购物后进行积分的一个累加,在实际开发中,会用到使用订单服务来调用我们的积分服务进行积分的累加,但是如果积分服务出现问题,就会导致我们相应的接口出问题,此时就可以把积分放到MQ中,积分服务作为消费者去处理,也就可以实现我们服务的解耦。
削峰填谷
假如我们有一个商城,它的服务器能处理3000req/s,在晚上8点左右会有一个20分钟的高峰期,请求可能会达到5000req/s,而其他时候请求只有2000req/s,显然此时增加一台服务器做负载均衡并不划算,就可以将请求放到MQ中,服务每次只取3000个以下的请求,而剩余的请求排队进行等待。相当于将超出3000之外的请求给 “削” 掉了,然后填充到后面请求量没那么高的时候,以此类推,将后续排队的请求依次消费掉。
数据分发
假如我们有一个服务A,服务A会有一些下游服务,服务A在处理完毕之后会将数据存储到Redis当中,然后服务A要通知其下游服务进行处理,但是如果此时有服务下线或有新服务上线时需要修改我们的服务A的代码。此时我们可以利用消息中间件,服务A处理完毕后将数据存入Redis,并将处理完毕的消息发送给消息中间件,由消息中间件广播到下游服务,下游服务接收到处理完毕的消息后主动去Redis拉取数据进行处理即可。
不同消息中间件
- ActiveMQ :Apache出品的比较老的中间件,现市场份额较小。
- Kafka :由Apache软件基金会开发的开源流处理平台,其是一个高吞吐量的分布式发布订阅消息系统。
- RabbitMQ :用于分布式系统中存储转发消息,在易用性、扩展性、高可用方面表现较好。适用于数据一致性和可靠性要求较高的场景。
- RocketMQ :阿里团队开源的分布式消息中间件,目前已捐赠给Apache软件基金会,以高性能、低延迟、高可靠等特性被广泛运用。
RocketMQ
角色
- 消息生产者(Producer) :产生消息,并向消息中间件中发送消息的角色
- 消息消费者(Consumer) :从消息中间件中获取消息,并进行业务处理的角色
- 代理服务器(Broker) :实际进行消息接收、消息存储、消息推送的角色
- 命名服务(NameServer):用来存储和管理Broker中的IP和端口,类似于注册中心的功能
- 消息对象(Message):需要传递的参数必须要封装成Message格式才能发送给消息中间件
- 主题(Topic):消息的目的地,用于区分不同类型的消息
- 消息队列(MessageQueue):在主题下面的队列,用于存储消息的数据结构
- 标签(Tag):在消费中可以基于标签进行一个消息的过滤
消费模式
默认情况下消费者是处于一个多线程消费的一个状态,会导致优先拿到执行权或执行完毕的消息被消费,而不是按照我们发送消息的顺序进行消费。
发送方式
- 同步发送:生产者先向中间件中发送消息,中间件对消息进行持久化之后再将结果返回给生产者。也就是说,生产者在执行发送消息之后的逻辑时,中间件就已经对消息进行完持久化了。
- 适用场景:适用于对消息可靠性要求比较高的场景
- 异步发送:生产者向中间件中发送消息,发送后可直接继续执行之后的逻辑,无需等待中间件持久化后并返回。也就是说,当我们执行发送消息之后的逻辑时,消息可能还没到达中间件或还没进行结果的返回。
- 适用场景:适用于对时间比较敏感,希望更快的响应的场景
- 缺点:当我们执行完其他的业务逻辑之后,回调可能才会通知我们发送失败或消息存储失败,需要我们自己去实现消息重试的机制
- 一次性发送:生产者向中间件中发送消息,并不关心消息是否发送成功或存储成功,其性能是最高的。
- 适用场景:例如日志记录,适用于对性能要求较高,且消息是否丢失对结果影响不大的场景
- 缺点:有可能会存在消息丢失的情况,消息可靠性较低。
消息刷盘机制
- 同步刷盘:生产者向中间件发送消息,中间件接收到消息之后,把消息存储到磁盘当中,存储完之后才给消息生成反馈。
- 缺点:速度和性能较慢
- 适用场景:绝对不允许出现消息丢失的场景
- 异步刷盘(默认):生产者向中间件发送消息,中间件接收到消息后,消息并不直接存储到磁盘当中,而是存到操作系统的内存(PageCache)当中,一旦写入成功后就给消息生成反馈,而消息什么时候存储到磁盘当中,取决于操作系统自身的情况,由操作系统的内存(PageCache)来写入到磁盘中,速度更快,性能更高。
- 缺点:可能会出现消息丢失的情况,例如发送消息后,中间件将消息写入内存成功后,对该消息反馈存储成功了,然后操作系统崩溃了,实际并没有将消息写入到磁盘中。
- 适用场景:追求性能,且允许出现消息丢失的场景
可在RocketMQ安装目录下的conf目录下,找到名为broker.conf
的文件,该文件中有一条flushDiskType=ASYNC_FLUSH
的配置,该配置就是指定刷盘机制为异步刷盘,想更改为同步刷盘机制时,只需要将ASYNC_FLUSH
改为SYNC_FLUSH
即可。
消息消费模式
- 集群模式(默认):消费发送的中间件,在消费者做集群,且消费模式为集群模式时,那么消息只会发送给消费者集群中的某一个消费者去消费。
- 广播模式:消息发送的中间件,在消费者做集群,且消费模式为广播模式时,那么消息会发给消费者集群中的所有消费者进行业务处理。
消费者模式可在客户端指定消息消费模式:在RocketMQMessageListener
中添加messageModel
的属性,BROADCASTING
为广播模式,而 CLUSTERING
为集群模式。
消息队列内部结构
假如我们生产者有多个,生产者在同一时间都往中间件中发消息,在多线程没有加锁的情况下,可能会出现多个生产者在同一时间都获取到了当前队列的索引,并且都想向这个索引写入值,那么必定会导致消息的丢失。
而假如我们对队列上锁,会导致其他的消息阻塞,例如有100个线程,在第一个线程写入时,其余99个线程均在阻塞状态,性能较低。而RocketMQ内部为了解决性能的问题,将一个主题下默认分出来4个队列,而多个生产者对四个队列进行操作,性能就是单队列的4倍。而该队列不是分的越多越好,这取决于电脑和服务器的CPU,如果CPU核心数只有4个,那么无论增加多少个队列,还是只有4个核心线程在运作。
当我们想根据CPU的核心数来调整队列时,可在 rocketmq-dashboard
的控制台来更改主题的读写队列数,也可以通过更改rocketmq的配置来更改其默认的队列数。
生产者会在程序内部维护一个index的变量,用于记录存储队列的索引位置。
int 存储队列索引位置 = index++ % 队列长度
延时消息
在RocketMQ4.x版本中,并不支持任意时间的延时,而是通过延时级别来完成的。如下图所示:
该默认级别从1s到2h共18个级别,当我们有该默认配置之外的延时时,可通过更改或新增配置文件中的属性messageDelayLevel
为自定义的延时即可。
RocketMQ4.x工作原理
在中间件内部有一个名为SCHEDULE_TOPIC_XXXX
的主题,该主题下默认有18个队列,而中间件会根据我们发送的延时消息中的延时级别将其发送到对应级别的队列中,它的内部有一个定时器,该定时器会检查每个队列中的消息是否已经到了其对应级别的时间,如果已到时间则会将该消息发送给原本指定的主题下的队列中。例如:我们发送了一个延时级别为2,延时时间为5秒的消息到Test主题,则该消息并不会直接发送到Test主题下,而是会发送到SCHEDULE_TOPIC_XXXX
延时级别为2的队列中,当定时器扫描该消息已经过了5秒,则会将该消息发送到Test主题下,再由消费者进行消费。
注意:该延时消息只代表在中间件中待的时间,而不是实际延时时间
消息过滤
- Tag过滤(默认):发送消息时,主题按特定格式填写:
TopicName:Tag
,通过这种格式就可以去设置我们的Tag,例如我们有一个名为TestTopic的主题,并且想发送一条Tag为Hello的标签,那么发送时就应该写为:TestTopic:Hello
,发送完毕后就可以为该消息设置对应的标签。而在消费者想使用Tag过滤时,需要在@RocketMQMessageListener
注解中添加selectorExpression
的表达式来进行过滤,例如我们只想接收TagA
和TagC
的消息,那么我们可以这么写:selectorExpression="TagA || TagC"
即可。 - SQL过滤:我们在发送消息时,通过设置Message中的Header来为该消息设置属性,而消费者监听消息时就可以在
@RocketMQMessageListener
注解中使用selectorType=SelectorType.SQL92
来指定我们消息过滤的类型,并继续使用selectorExpression
属性来编写我们的表达式,例如我们想过滤消息中数学分数大于90,且英语分数大于80的同学,就可以这么写:selectorExpression="mathScore>90 and englishScore>80"
。(注意:RocketMQ默认是未开启SQL过滤的,如果需要那么就在broker.conf中修改或添加enablePropertyFilter
的值为true即可)
注意:在RocketMQ进行消息过滤时,并不是只从中间件中取出了符合条件的消息进行消费,而是将所有该消费者指定主题中的所有消息均拿了过来,然后在内部通过表达式来过滤出来有效的消息进行消费。
顺序消费
在默认情况下,RocketMQ内部结构中,主题是包含4个队列的,而消息会依次发送到这四个队列中,导致我们无法保证出队的顺序。
如何保证消息的顺序性
- 生产顺序性:将需要顺序消费的消息放到同一个队列当中,保证消息符合先进先出的特性
- 消费顺序性:在消费端采取单线程绑定队列的方式对消息进行消费
假如有一个场景,我们有一个交易完整的流程:订单创建 >> 订单支付 >> 订单发货 >> 订单配送 >> 订单完成,那么我们将这个流程按照顺序交给消费者进行消费就要采用如下写法:
- 生产者在发送消息之前使用
rocketMQTemplate.setMessageQueueSelector(new MessageQueueSelector(){...})
方法来指定我们需要将消息发送到哪一个队列,在MessageQueueSelector
中需要实现一个select方法,发送消息之前,会先调用该select方法来判断需要将消息存储到哪一个队列中。我们可以采用同一订单号取模的形式来判断应该将消息存到哪一个队列中,例如我们订单号为1,就可以使用1(订单号)%队列数量
来取到对应的队列存储消息。 - 生产者调用
rocketMQTemplate.sendOneWayOrderly()
、rocketMQTemplate.syncSendOrderly()
和rocketMQTemplate.asyncSendOrderly()
来发送消息,中间可以传递我们订单号的参数来供上述的select方法进行计算。 - 消费者在监听队列时,在
@RocketMQMessageListener
中通过设置consumeMode
的值为ConsumeMode.ORDERLY
来将我们的消费者使用单线程绑定队列的模式进行消费。
注意事项
- 局部有序:当我们发送消息时,可能会存在两个交易同时进行,例如订单A和订单B,假设他们会存入到不同的队列中,那么发送消息时可能会存在这样的顺序:订单A创建 >> 订单B创建 >> 订单A支付 >> 订单A配送 >> 订单B支付 >> 订单B配送 >> 订单B完成 >> 订单A完成 这样的顺序进行发送,以上发送流程叫做全局发送顺序。而在消费者进行消费时,因为针对每个队列都有且仅有一个线程进行消费,消费的顺序可能并不固定,可能是:订单B创建 >> 订单A创建 >> 订单A支付 >> 订单B支付 >> 订单A配送 >> 订单B配送 >> 订单A完成 >> 订单B完成这样的顺序进行消费,以上顺序被称为全局消费顺序。也就是说,我们只能保证发送到同一个队列中的消息在同一个队列中消费是有序的,但是此时的顺序消费只是局部的,并不能保证全局发送顺序和全局消费顺序保持一致,我们称之为局部有序。
- 全局有序:为了解决上述的情况,我们可以将所有的订单消息按顺序放到同一个队列中,这样就能保证我们全局发送顺序和全局消费顺序保持一致,但是这样的话效率会大幅降低,性能较差,在不需要保证全局发送顺序和全局消费顺序一致的情况下不推荐使用。
其他概念
不同消费组的订阅问题
如上图所示,在集群模式下,假如我们有一个主题,里面共有5条消息,分别存储于主题下的四个队列中,当我们GroupA消费过该主题的消息后,中间件会记录该消费组消费到了哪一个位置,从而避免重复消费,而假如我们有一个新的消费组加入到该队列中,那么新的消费组会对主题下的四个队列中的消息进行重复消费。
而存储每个消费组的消费点位被中间件持久化到 ~/store/config/consumerOffset.json
的文件下,而我们的消息是被存储到 commitLog
中,该文件大小为1G,当超出1G时会建一个新的commitLog来保存我们的消息,默认配置下,会在存储满了之后72小时后的凌晨4点左右对已经满了的commitLog进行清除。
消息重试
- 消息发送重试
- 针对于同步消息,发送失败时默认重试次数为2次
- 针对于异步消息,发送失败时默认重试次数为2次
- 针对于一次性消息,发送失败时不会对其进行重试
- 消息消费重试
- 在广播模式下,消费者消费消息失败后仅会打印消费失败的日志,不会进行重试
- 在集群模式下,消息的重试是基于延时消息完成的,消息重试机制如下:
消息重复消费(幂等性)
- 生产者重复发送:假如我们发送了一条同步消息,且MQ已经收到消息并正在进行存储,由于响应慢还未反馈导致生产者报错,误以为发送失败,进行发送的重试
- 消费者重复消费:在集群模式下,我们在MQ上有3条消息,前两条消息已经被消费了,消费点位记录到2,我们GroupA中的消费者A已经针对消息3消费完毕,本地的消费点位已经记录到3,但是由于一些极端情况,导致本地的消费点位并没有同步到MQ的消费点位中,此时如果有一个消费者加入,拉取到消费点位为2,会重复消费消息3.
针对以上问题,我们就要保证消息的幂等性:对于相同业务标识的消息,消息可以被消费多次,但业务只能执行一次。
解决思路:
流水表:我们的消费者在接收到消息时会进行 业务A >> 业务B >> 业务C 这样的顺序执行,我们可以建立一个流水表,以业务的主键作为该表的主键和其他的一些业务字段,在我们消费者接收消息的方法上加上 @Transactional
注解,在执行业务前,我们先向流水表中插入数据,流程如下:插入流水表 >> 业务A >> 业务B >> 业务C,当我们第一次消费时会向流水表中插入数据并正常执行业务,但是当我们重复消费该消息时,会因为流水表中主键重复导致异常,而我们的@Transactional
注解检测到异常后会对业务进行回滚,以此来保证我们的业务只执行了一次。
死信队列
当我们消息初次消费失败时,RocketMQ会自动进行消息重试,达到最大重试次数后,如果依旧消费失败,RocketMQ不会立即将该消息丢弃,而是放入到该消费者对应的死信队列中,无法被正常消费的消息也被我们称为死信消息(Dead-Letter Message),存储死信消息的特殊队列被称为死信队列(Dead-Letter Queue)。
当我们认为消息不重要时,可任由其放入死信队列。当我们认为消息比较重要时,我们需要人为的对该死信队列进行监听,从死信队列取出消息进行人工补偿。
消息删除
为了避免对资源的浪费,RocketMQ有专门的删除机制来删除过期的文件。
清理机制:如果非当前写文件在 一定时间间隔内没有被再次更新,则会被认定为过期文件,可以被删除,但RocketMQ不会关心该文件上的消息是否已经被全部消费。
我们可以通过Broker的配置文件设置fileReservedTime
来改变我们的文件过期时间,默认为72小时,并且也可以通过 deleteWhen
来设置一天执行一次删除文件的操作,默认为凌晨4点。
消息积压问题
可能会导致消息积压的原因:
- 对线上数据预估不准确,导致生产速度大于消费速度,导致消息的积压
- 突然的消息洪峰,或者突然的消费者崩溃(短时间内无法解决)
针对上述问题的解决方案:
- 增加消费者的数量或增加RocketMQ主题下的队列个数,使消费速度大于等于生产速度
- 思路:优先解决问题,先恢复功能,再把积压的消息快速消费,如下:
- 对业务入口降级,使新消息暂时无法进入到队列中
- 新建临时主题(原队列的5 - 10倍),启动临时程序A,把原来的队列中的消息转移到临时队列中(速度很快),转移后将临时程序关闭。
- 取消业务入口的降级,开放出来使用
- 开启多个临时程序B(集群),快速的将临时队列中的消息消费掉
- 关闭临时程序B(集群)