7. RabbitMQ 高级
7.1. 过期时间TTL
过期时间TTL表示可以对消息设置预期的时间,在这个时间内都可以被消费者接收获取;过了之后消息将自动被删除。RabbitMQ可以对消息和队列设置TTL。目前有两种方法可以设置。
- 第一种方法是通过队列属性设置,队列中所有消息都有相同的过期时间。
- 第二种方法是对消息进行单独设置,每条消息TTL可以不同。
如果上述两种方法同时使用,则消息的过期时间以两者之间TTL较小的那个数值为准。消息在队列的生存时间一旦超过设置的TTL值,就称为dead message被投递到死信队列, 消费者将无法再收到该消息。
7.1.1. 设置队列TTL
在 spring-rabbitmq-producer\src\main\resources\spring\spring-rabbitmq.xml
文件中添加如下内容:
<!--定义过期队列及其属性,不存在则自动创建-->
<rabbit:queue id="my_ttl_queue" name="my_ttl_queue" auto-declare="true">
<rabbit:queue-arguments>
<!--投递到该队列的消息如果没有消费都将在6秒之后被删除-->
<entry key="x-message-ttl" value-type="long" value="6000"/>
</rabbit:queue-arguments>
</rabbit:queue>
然后在测试类 spring-rabbitmq-producer\src\test\java\com\itheima\rabbitmq\ProducerTest.java
中编写如下方法发送消息到上述定义的队列:
/**
* 过期队列消息
* 投递到该队列的消息如果没有消费都将在6秒之后被删除
*/
@Test
public void ttlQueueTest(){
//路由键与队列同名
rabbitTemplate.convertAndSend("my_ttl_queue", "发送到过期队列my_ttl_queue,6秒内不消费则不能再被消费。");
}
参数 x-message-ttl 的值 必须是非负 32 位整数 (0 <= n <= 2^32-1) ,以毫秒为单位表示 TTL 的值。这样,值 6000 表示存在于 队列 中的当前 消息 将最多只存活 6 秒钟。
如果不设置TTL,则表示此消息不会过期。如果将TTL设置为0,则表示除非此时可以直接将消息投递到消费者,否则该消息会被立即丢弃。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-p2IgjnnT-1606142482604)(assets/31.jpg)]
7.1.2. 设置消息TTL
消息的过期时间;只需要在发送消息(可以发送到任何队列,不管该队列是否属于某个交换机)的时候设置过期时间即可。在测试类中编写如下方法发送消息并设置过期时间到队列:
/**
* 过期消息
* 该消息投递任何交换机或队列中的时候;如果到了过期时间则将从该队列中删除
*/
@Test
public void ttlMessageTest(){
MessageProperties messageProperties = new MessageProperties();
//设置消息的过期时间,5秒
messageProperties.setExpiration("5000");
Message message = new Message("测试过期消息,5秒钟过期".getBytes(), messageProperties);
//路由键与队列同名
rabbitTemplate.convertAndSend("my_ttl_queue", message);
}
expiration 字段以微秒为单位表示 TTL 值。且与 x-message-ttl 具有相同的约束条件。因为 expiration 字段必须为字符串类型,broker 将只会接受以字符串形式表达的数字。
当同时指定了 queue 和 message 的 TTL 值,则两者中较小的那个才会起作用。
7.2. 死信队列
DLX,全称为Dead-Letter-Exchange , 可以称之为死信交换机,也有人称之为死信邮箱。当消息在一个队列中变成死信(dead message)之后,它能被重新发送到另一个交换机中,这个交换机就是DLX ,绑定DLX的队列就称之为死信队列。
消息变成死信,可能是由于以下的原因:
- 消息被拒绝
- 消息过期
- 队列达到最大长度
DLX也是一个正常的交换机,和一般的交换机没有区别,它能在任何的队列上被指定,实际上就是设置某一个队列的属性。当这个队列中存在死信时,Rabbitmq就会自动地将这个消息重新发布到设置的DLX上去,进而被路由到另一个队列,即死信队列。
要想使用死信队列,只需要在定义队列的时候设置队列参数 x-dead-letter-exchange
指定交换机即可。
具体步骤如下面的章节。
7.2.1. 定义死信交换机
在 spring-rabbitmq-producer\src\main\resources\spring\spring-rabbitmq.xml
文件中添加如下内容:
<!--定义定向交换机中的持久化死信队列,不存在则自动创建-->
<rabbit:queue id="my_dlx_queue" name="my_dlx_queue" auto-declare="true"/>
<!--定义广播类型交换机;并绑定上述两个队列-->
<rabbit:direct-exchange id="my_dlx_exchange" name="my_dlx_exchange" auto-declare="true">
<rabbit:bindings>
<!--绑定路由键my_ttl_dlx、my_max_dlx,可以将过期的消息转移到my_dlx_queue队列-->
<rabbit:binding key="my_ttl_dlx" queue="my_dlx_queue"/>
<rabbit:binding key="my_max_dlx" queue="my_dlx_queue"/>
</rabbit:bindings>
</rabbit:direct-exchange>
7.2.2. 队列设置死信交换机
为了测试消息在过期、队列达到最大长度后都将被投递死信交换机上;所以添加配置如下:
在 spring-rabbitmq-producer\src\main\resources\spring\spring-rabbitmq.xml
文件中添加如下内容:
<!--定义过期队列及其属性,不存在则自动创建-->
<rabbit:queue id="my_ttl_dlx_queue" name="my_ttl_dlx_queue" auto-declare="true">
<rabbit:queue-arguments>
<!--投递到该队列的消息如果没有消费都将在6秒之后被投递到死信交换机-->
<entry key="x-message-ttl" value-type="long" value="6000"/>
<!--设置当消息过期后投递到对应的死信交换机-->
<entry key="x-dead-letter-exchange" value="my_dlx_exchange"/>
</rabbit:queue-arguments>
</rabbit:queue>
<!--定义限制长度的队列及其属性,不存在则自动创建-->
<rabbit:queue id="my_max_dlx_queue" name="my_max_dlx_queue" auto-declare="true">
<rabbit:queue-arguments>
<!--投递到该队列的消息最多2个消息,如果超过则最早的消息被删除投递到死信交换机-->
<entry key="x-max-length" value-type="long" value="2"/>
<!--设置当消息过期后投递到对应的死信交换机-->
<entry key="x-dead-letter-exchange" value="my_dlx_exchange"/>
</rabbit:queue-arguments>
</rabbit:queue>
<!--定义定向交换机 根据不同的路由key投递消息-->
<rabbit:direct-exchange id="my_normal_exchange" name="my_normal_exchange" auto-declare="true">
<rabbit:bindings>
<rabbit:binding key="my_ttl_dlx" queue="my_ttl_dlx_queue"/>
<rabbit:binding key="my_max_dlx" queue="my_max_dlx_queue"/>
</rabbit:bindings>
</rabbit:direct-exchange>
7.2.3. 消息过期的死信队列测试
1)发送消息代码
添加 spring-rabbitmq-producer\src\test\java\com\itheima\rabbitmq\ProducerTest.java
方法
/**
* 过期消息投递到死信队列
* 投递到一个正常的队列,但是该队列有设置过期时间,到过期时间之后消息会被投递到死信交换机(队列)
*/
@Test
public void dlxTTLMessageTest(){
rabbitTemplate.convertAndSend("my_normal_exchange", "my_ttl_dlx", "测试过期消息;6秒过期后会被投递到死信交换机");
}
2)在rabbitMQ管理界面中结果
3)流程
具体因为队列消息过期而被投递到死信队列的流程:
7.3. 延迟队列
延迟队列存储的对象是对应的延迟消息;所谓“延迟消息” 是指当消息被发送以后,并不想让消费者立刻拿到消息,而是等待特定时间后,消费者才能拿到这个消息进行消费。
在RabbitMQ中延迟队列可以通过 过期时间
+ 死信队列
来实现;具体如下流程图所示:
在上图中;分别设置了两个5秒、10秒的过期队列,然后等到时间到了则会自动将这些消息转移投递到对应的死信队列中,然后消费者再从这些死信队列接收消息就可以实现消息的延迟接收。
延迟队列的应用场景;如:
-
在电商项目中的支付场景;如果在用户下单之后的几十分钟内没有支付成功;那么这个支付的订单算是支付失败,要进行支付失败的异常处理(将库存加回去),这时候可以通过使用延迟队列来处理
-
在系统中如有需要在指定的某个时间之后执行的任务都可以通过延迟队列处理
1)生产者发送一个消息到x交换机,指定路由为delay.message
2)延时队列绑定x,指定死信交换机(本质也是一个我们指定的普通交换机)和死信之后的路由key
3)最后消费者监听的队列绑定到死信交换机并且路由是之前设定的delay.message
7.4. 消息追踪
消息中心的消息追踪需要使用Trace实现,Trace是Rabbitmq用于记录每一次发送的消息,方便使用Rabbitmq的开发者调试、排错。可通过插件形式提供可视化界面。Trace启动后会自动创建系统Exchange:amq.rabbitmq.trace ,每个队列会自动绑定该Exchange,绑定后发送到队列的消息都会记录到Trace日志。
7.5 消息持久化
-
RabbitMQ默认是不持久队列、消息服务器重启,所有已声明的队列,Exchange,Binding以及队列中的消息都会丢失。
-
通过设置Exchange和MessageQueue的durable属性为true,可以使得队列和Exchange持久化,但是这还不能使得队列中的消息持久化。
-
生产者在发送消息的时候,将delivery mode设置为2,只有以上3个全部设置完成后,才能实现消息持久化。
-
只有durable为true的Exchange和durable为ture的Queues才能绑定,否则在绑定时,RabbitMQ都会抛错的。
-
持久化会对RabbitMQ的性能造成比较大的影响,可能会下降10倍不止。
8. RabbitMQ 集群
RabbitMQ高可用性
RabbitMQ是比较有代表性的,因为是基于主从做高可用性的。
RabbitMQ 三种模式:单机模式,普通集群模式,镜像集群模式
单机模式
就是demo级别的,一般就是本地启动后玩一玩,没有人生产环境中使用。
普通集群模式
- 意思就是在多台机器上启动多个RabbitMQ实例,每台机器启动一个,但是创建的Queue,只会放在一个RabbitMQ实例上,但是每个实例都同步queue元数据,在消费的时候,实际上是连接到另外一个实例上,那么这个实例会从queue所在实例上拉取数据过来,这种方式确实很麻烦,也不怎么好,没做到所谓的分布式 ,就是个普通集群。因为这导致你要么消费每次随机连接一个实例,然后拉取数据,要么固定连接那个queue所在实例消费数据,前者有数据拉取的开销,后者导致单实例性能瓶颈。
- 而且如果那个放queue的实例宕机了,会导致接下来其它实例无法从那个实例拉取,如果 你开启了消息持久化,让rabbitmq落地存储消息的话,消息不一定会丢,得等到这个实例恢复了,然后才可以继续从这个queue拉取数据。
这里没有什么所谓的高可用性可言,这个方案主要就是为了解决吐吞量,就是集群中的多个节点来服务于某个queue的读写操作。
存在两个缺点
- 可能会在RabbitMQ中存在大量的数据传输
- 可用性没有什么保障,如果queue所在的节点宕机,就会导致queue的消息丢失
集群镜像模式
这种模式,才是RabbitMQ的高可用模式,和普通的集群模式不一样的是,你创建的queue无论元数据还是queue里的消息都会存在与多个实例中,然后每次你写消息到queu的时候,都会自动把消息推送到多个实例的queue中进行消息同步。
这样的好处在于,你任何一个机器宕机了,别的机器都可以用。坏处在于,性能开销提升,消息同步所有的机器,导致网络带宽压力和消耗增加,第二就是没有什么扩展性科研,如果某个queue负载很重,你加机器,新增的机器也包含了这个queue的所有数据,并没有办法线性扩展你的queue
那么如何开启集群镜像策略呢?就是在RabbitMQ的管理控制台,新增一个策略,这个策略就是镜像集群模式下的策略,指定的时候,可以要求数据同步到所有的节点,也可以要求就 同步到指定数量的节点,然后再次创建queue的时候,应用这个策略,就会自动将数据同步到其它节点上去了。
集群镜像模式下,任何一个节点宕机了都是没问题的,因为其他节点还包含了这个queue的完整的数据,别的consumer可以到其它活着的节点上消费数据。
9.消息确认机制
尽量使用事务,性能下降严重
1、消息发送确认
1)ConfirmCallback
通过实现ConfirmCallBack接口,消息发送到交换器Exchange后触发回调。
使用该功能需要开启确认,spring-boot中配置如下:
spring.rabbitmq.publisher-confirms = true
(2)ReturnCallback
通过实现ReturnCallback接口,如果消息从交换器发送到对应队列失败时触发(比如根据发送消息时指定的routingKey找不到队列时会触发)
2、消息消费确认
确认模式种类
1.1AcknowledgeMode.NONE:不确认
rabbitmq server默认推送的所有消息都已经消费成功,会不断地向消费端推送消息
AcknowledgeMode.AUTO:自动确认
AcknowledgeMode.AUTO模式下,由spring-rabbit依据消息处理逻辑是否抛出异常自动发送ack(无异常)或nack(异常)到server端。
AcknowledgeMode.MANUAL:手动确认
spring.rabbitmq.listener.simple.acknowledge-mode = manual
10. RabbitMQ 应用与面试
10.1. 消息堆积
当消息生产的速度长时间,远远大于消费的速度时。就会造成消息堆积。
- 消息堆积的影响
- 可能导致新消息无法进入队列
- 可能导致旧消息无法丢失
- 消息等待消费的时间过长,超出了业务容忍范围。
- 产生堆积的情况
- 生产者突然大量发布消息
- 消费者消费失败
- 消费者出现性能瓶颈。
- 消费者挂掉
- 解决办法
- 排查消费者的消费性能瓶颈
- 增加消费者的多线程处理
- 部署增加多个消费者
- 专门的消费者将消息转入数据库,慢慢处理
10.2. 消息丢失
消息丢失的场景主要分为:消息在生产者丢失,消息在RabbitMQ丢失,消息在消费者丢失。
10.2.1. 消息在生产者丢失
场景介绍
消息生产者发送消息成功,但是MQ没有收到该消息,消息在从生产者传输到MQ的过程中丢失,一般是由于网络不稳定的原因。
解决方案
采用RabbitMQ 发送方消息确认机制,当消息成功被MQ接收到时,会给生产者发送一个确认消息,表示接收成功。RabbitMQ 发送方消息确认模式有以下三种:普通确认模式,批量确认模式,异步监听确认模式。spring整合RabbitMQ后只使用了异步监听确认模式。
10.2.2. 消息在MQ丢失
场景介绍
消息成功发送到MQ,消息还没被消费却在MQ中丢失,比如MQ服务器宕机或者重启会出现这种情况
解决方案
持久化交换机,队列,消息,确保MQ服务器重启时依然能从磁盘恢复对应的交换机,队列和消息。
10.2.3. 消息在消费者丢失
场景介绍
消息费者消费消息时,如果设置为自动回复MQ,消息者端收到消息后会自动回复MQ服务器,MQ则会删除该条消息,如果消息已经在MQ被删除但是消费者的业务处理出现异常或者消费者服务宕机,那么就会导致该消息没有处理成功从而导致该条消息丢失。
解决方案
设置为手动回复MQ服务器:
当消费者出现异常或者服务宕机时,MQ服务器不会删除该消息,而是会把消息重发给绑定该队列的消费者,如果该队列只绑定了一个消费者,那么该消息会一直保存在MQ服务器,直到消息者能正常消费为止。本解决方案以一个队列绑定多个消费者为例来说明,一般在生产环境上也会让一个队列绑定多个消费者也就是工作队列模式来减轻压力,提高消息处理效率
10.3. 重复消费
1)场景介绍
为了防止消息在消费者端丢失,会采用手动回复MQ的方式来解决,同时也引出了一个问题,消费者处理消息成功,手动回复MQ时由于网络不稳定,连接断开,导致MQ没有收到消费者回复的消息,那么该条消息还会保存在MQ的消息队列,由于MQ的消息重发机制,会重新把该条消息发给和该队列绑定的消息者处理,这样就会导致消息重复消费。而有些操作是不允许重复消费的,比如下单,减库存,扣款等操作。
MQ重发消息场景:
1.消费者未响应ACK,主动关闭频道或者连接
2.消费者未响应ACK,消费者服务挂掉
2) 解决方案
如果业务是幂等性操作(同一个操作执行多次,结果不变)就算重复消费也没问题,可以不做处理;
如果不幂等性操作,如:下单,减库存,扣款等,那么可以在消费者端每次消费成功后将该条消息id保存到数据库,每次消费前查询该消息id,如果该条消息id已经存在那么表示已经消费过就不再消费否则就消费。
本方案采用redis存储消息id,因为redis是单线程的,并且性能也非常好,提供了很多原子性的命令,本方案使用setnx命令存储消息id。
setnx(key,value):如果key不存在则插入成功且返回1,如果key存在,则不进行任何操作,返回0
10.4. 有序消费消息
场景
以前做过一个MySQL binlog同步系统,压力还是非常大的,日同步数据要达到上亿。常见一点的在于 大数据项目中,就需要同步一个mysql库过来,然后对公司业务的系统做各种的复杂操作。
在mysql里增删改一条数据,对应出来的增删改3条binlog,接着这三条binlog发送到MQ里面,到消费出来依次执行,这个时候起码得保证能够顺序执行,不然本来是:增加、修改、删除,然后被换成了:删除、修改、增加,不全错了呢。
本来这个数据同步过来,应该是最后删除的,结果因为顺序搞错了,最后这个数据被保留了下来,数据同步就出错
- RabbitMQ:一个queue,多个consumer,这不明显乱了
- Kafka:一个topic,一个partition,一个consumer,内部多线程,就会乱套
在消息队列中,一个queue中的数据,一次只会被一个消费者消费掉
但因为不同消费者的执行速度不一致,在存入数据库后,造成顺序不一致的问题
RabbitMQ保证消息顺序性
RabbitMQ:拆分多个queue,每个queue一个consumer,就是多一些queue而已,确实是麻烦,或者就是一个queue,但是对应一个consumer,然后这个consumer内部用内存队列做排队,然后分发给底层不同的worker来处理。
下图为:一个consumer 对应 一个 queue,这样就保证了消息消费的顺序性。
Kafka保证消息消息顺序性
一个topic,一个partition,一个consumer,内部单线程消费,写N个内存,然后N个线程分别消费一个内存queu即可。注意,kafka中,写入一个partition中的数据,一定是有顺序的,
但是在一个消费者的内部,假设有多个线程并发的进行数据的消费,那么这个消息又会乱掉
这样时候,我们需要引入内存队列,然后我们通过消息的key,然后我们通过hash算法,进行hash分发,将相同订单key的散列到我们的同一个内存队列中,然后每一个线程从这个Queue中拉数据,同一个内存Queue也是有顺序的。
百万消息积压在队列中如何处理?
如何解决消息队列的延时以及过期失效问题?消息队列满了以后该怎么处理?有百万消息积压接小时,说说解决思路?
剖析
MQ大幅度积压这件事挺常见的,一般不出,出了的话就是大型生产事故,例如:消费端每次消费之后要写MySQL,结果MySQL挂了,消费端就不动了,或者一直出错,导致消息消费速度极其慢。
场景1:积压大量消息
几千万的消息积压在MQ中七八个小时,这也是一个真实遇到过的一个场景,确实是线上故障了,这个时候要不然就是修复consumer,让他恢复消费速度,然后傻傻的等待几个小时消费完毕,但是很显然这是一种比较不机智的做法。
假设1个消费者1秒消费1000条,1秒3个消费者能消费3000条,一分钟就是18万条,1000万条也需要花费1小时才能够把消息处理掉,这个时候在设备允许的情况下,如何才能够快速处理积压的消息呢?
一般这个时候,只能够做紧急的扩容操作了,具体操作步骤和思路如下所示:
- 先修复consumer的问题,确保其恢复消费速度,然后将现有consumer都停止
- 临时建立好原先10倍或者20倍的queue数量
- 然后写一个临时的分发数据的consumer程序,这个程序部署上去消费积压的数据,消费之后不做耗时的处理,直接均匀轮询写入临时建立好的10倍数量的queue
- 接着临时征用10倍机器来部署consumer,每一批consumer消费一个临时queue的数据
- 这种做法相当于临时将queue资源和consumer资源扩大了10倍,以正常的10倍速度
也就是让消费者把消息,重新写入MQ中,然后在用 10倍的消费者来进行消费。
场景2:大量消息积压,并且设置了过期时间
假设你用的是RabbitMQ,RabbitMQ是可以设置过期时间的,就是TTL,如果消息在queue中积压超过一定的时间,就会被RabbitMQ给清理掉,这个数据就没了。这个时候就不是数据被大量积压的问题,而是大量的数据被直接搞丢了。
这种情况下,就不是说要增加consumer消费积压的消息,因为实际上没有啥积压的,而是丢了大量的消息,我们可以采取的一个方案就是,批量重导,这个之前线上也有遇到类似的场景,就是大量的消息积压的时候,然后就直接丢弃了数据,然后等高峰期过了之后,例如在晚上12点以后,就开始写程序,将丢失的那批数据,写个临时程序,一点点查询出来,然后重新 添加MQ里面,把白天丢的数据,全部补回来。
假设1万个订单积压在MQ里面,没有处理,其中1000个订单都丢了,你只能手动写程序把那1000个订单查询出来,然后手动发到MQ里面去再补一次。
场景3:大量消息积压,导致MQ磁盘满了
如果走的方式是消息积压在MQ里,那么如果你很长时间都没有处理掉,此时导致MQ都快写满了,咋办?
这个时候,也是因为方案一执行的太慢了,只能写一个临时程序,接入数据来消费,然后消费一个丢弃一个,都不要了,快速消费掉所有的消息。然后走第二个方案,到凌晨的时候,在把消息填入MQ中进行消费。