Springboot整合RabbitMq
使用步骤
1、引入spring-boot-starter-amqp的依赖,并配置host主机地址、port端口、virtualHost虚拟主机、用户名、密码等
2、声明交换机、队列、交换机与队列的绑定关系
3、使用RabbitTemplate的convertAndSend方法将消息发送给交换机,交换机收到消息后,路由给所绑定的队列
4、消费者使用@RabbitListener注解方法监听队列,当收到消息时,回调此注解方法
补充(详细)
1、虚拟主机有什么用?
用于数据隔离,不同项目可以创建不同的虚拟主机。但是配置时,需要有对应虚拟主机权限的用户才可以使用指定的虚拟主机
用于数据隔离,不同项目可以创建不同的虚拟主机。但是配置时,需要有对应虚拟主机权限的用户才可以使用指定的虚拟主机
2、当在rabbitmq中已经在web管理后台,通过手动的方式创建了队列,交换机时,启动项目时又去声明队列,交换机时,然后项目停了,然后又去启动项目又去声明队列交换机时。或者后面启动项目时,声明的交换机类型改变时?原来已经存在的交换机或队列是否会被删除?原来里面已经存在的消息是否会被清空掉?
如果消息代理中已经存在对应名称的交换机,那么如果修改了代码中此交换机类型的定义,那么启动的时候就会报错。
3、rabbitTemplate的convertAndSend发送消息时,可以不指定交换机,直接发给队列(会使用默认的交换机,默认的交换机就是根据消息发送时所指定的routekey找到与此routeKey名称相同的消息队列)
4、rabbitTemplate的convertAndSend发送消息时,可以指定交换机,交换机会根据当前交换机类型和此交换机的绑定关系将消息路由给对应绑定的队列
5、rabbitTemplate的convertAndSend发送不同的java类型消息,可以指定消息转换器。在使用@RabbitListener注解方法监听消息时,声明发送的java类型即可
6、在同一项目中可以使用@RabbitmListener注解的1个方法来监听指定的多个消息队列。
7、在同一项目中的多个方法都使用了@RabbitListener注解,并且这几个方法监听的消息队列中有相同的,那么当这些相同消息队列中收到消息时,会负载均衡的交给这几个方法处理(也就是1个消息只会给到其中1个方法处理),这就是work queues工作队列模式。
8、在同一项目中的多个方法都使用了@RabbitListener注解,并且这几个方法监听的消息队列中有相同的,即使这几个方法中处理的效率有高有低(故意在其中某个方法中睡它10s,这个方法的处理效率就低了),但是他们收到的消息数量仍然是按负载均衡分发的。那么肯定要解决这个问题,因此可以加上配置:spring.rabbit.listener.simple.prefetch=1,意思就是消费者每次拉取1条消息,这条消息处理完成之后,消息代理才会将下1条消息发过来,这样就不是按照负载均衡的方式发给这多个方法了,而是能者多劳。
9、在不同的项目中都监听了同1个消息队列,本来这个队列中有消息时,是按负载均衡1个消费者1个来轮流发,但是如果其中某个消费者挂了,剩余的消息是否还按轮流来?当它再次上线,是否继续让它轮流来?
10、在同一队列上有多个消费者在监听,当消息发给某个消费者时,这个消费者在处理时发生了异常,这个消息会被忽略掉?还是交给其它消费者?默认会被忽略掉
11、@RabbitListener可以标注在方法上,指定需要监听的消息队列(可指定多个消息队列),然后在方法参数位置上声明所要处理的消息类型(消息的发送者所发送的类型)。
12、@RabbitListener可以标注在类上,然后在这个类中的某个方法上使用@RabbitHandler注解,并在这个 方法的方法参数位置声明byte[] data来接收消息数据。
13、1个消息队列有多个消费者在监听,每个消息都会只发给其中某1个消费者处理,这样的模型叫工作队列模式
14、交换机类型有默认交换机类型、Fanout类型、Direct类型、Topic类型。
-
Fanout类型交换机:1个Fanout类型交换机可以绑定多个消息队列,当Fanout类型收到1个消息时,会直接发给所绑定的每1个消息队列
-
Direct类型,1个Direct类型交换机可以绑定多个消息队列,每绑定1个消息队列时,需要指定对应的路由key(routeKey),当Direct类型交换机收到1个消息时,会根据消息发送时所指定的路由key发送给routeKey完全匹配到的消息队列,
-
Topic类型,1个Topic类型交换机可以绑定多个消息队列,每绑定1个消息队列时,需要指定对应的通配符(#代指0个或多个单词和*代指1个单词),当Topic类型交换机收到1个消息时,会根据消息发送时所指定的路由key发送给routeKey通配匹配到的消息队列,
15、Spring Amqp提供了声明队列、交换机、队列和交换机绑定关系的类。在sprigboot中如何使用呢?只需要将它们以bean的形式定义出来,并且项目中必须至少使用了1个@RabbitListener注解,那么springboot会帮助我们在rabbitmq消息代理服务器中创建对应的队列、交换机、队列和交换机绑定关系(注意前提:要想以@bean的方式创建队列和交换机,必须至少有一个监听者@RabbitListener,否则即使声明了Queue、Exchange、Binding这些bean,也不会创建成功的队列和交换机的)
- 可以使用QueueBuilder来声明队列
- 可考虑定义为持久
- 当项目重启过程中时,原来已存在的队列仍然能正常收到消息,并且这些消息能正常消费,不会被删除或清空
- 可以使用ExchangeBuilder来声明交换机
- 不同的交换机类型有不同的实现类
- 如果在项目启动前就已经存在了该名称的交换机,并且类型相同,那么就不会创建。
- 如果已经存在了该名称的交换机,但是现在项目代码中又把这个类型改成了其它类型,那么创建此交换机不会成功,仅会打印错误创建的日志,不影响启动
- 可以使用BindingBuilder来声明队列和交换机的绑定关系
声明代码示例
@Configuration
public class QueueConfig {
@Bean
public Queue queue3() {
return QueueBuilder.durable("direct.queue3").build();
}
@Bean
public Exchange exchange3() {
return ExchangeBuilder.directExchange("direct.exchange3").build();
}
@Bean
public Binding binding3() {
return BindingBuilder.bind(queue3()).to(exchange3()).with("A3").noargs();
}
}
16、Spring Amqp还可以使用@RabbitListener注解来声明队列和交换机(这个不需要前提,直接如下声明就会创建),如下声明会创建对应的消息队列,交换机,交换机和队列的绑定,并且当消息队列中有消息时,被注解的方法将会被回调
@RabbitListener(bindings = {
@QueueBinding(
// 队列是否持久: 当消息代理重启时, 非持旧队列将会干掉了。不设置时,默认是持久的。
value = @Queue(value = "direct.queue1",durable = "true"),
// 交换机是否持久: 当消息代理重启时, 非持久队列将会干掉了。
// 不设置时,默认是持久的。
// 默认就是DIRECT类型交换机
exchange = @Exchange(value = "direct.exchange1",type = ExchangeTypes.DIRECT),
// 当发送到direct.exchange1交换机的消息时,所指定的routeKey是red或者是blue时,由此方法处理
key = {"red","blue"}
)
})
public void listenQueue(AddUser addUser) {
// ...
}
17、使用rabbitmqTemplate#convert(exchange, routeKey, object)发送消息时,所发送消息的类型是Object类型。默认支持的类型是Message类型,它有2个属性byte[] body和MessageProperties messageProperties。
如果发送的消息的类型不是Message类型,那么会使用1个消息转换器(org.springframework.amqp.support.converter.MessageConverter),将object对象转换为Message对象。
在spring amqp中默认使用的是SimpleMessageConverter对此消息作序列化处理,它会在object是byte[]时,直接就创建Message,是String时,直接获取字符串转为utf8编码的字节,实现了Serializable时,使用jkd的序列化机制转为byte[],在这3种情况下,都会设置MessageProperties#setContentType为对应的内容类型。在rabbitmq的web后台管理页面看到的是字节数组转base64字符串的形式。在反序列化时,也会使用对应的逆向方式去作反序列化。
可以使用jackson序列化的方式,引入jackson的依赖com.fasterxml.jackson.core的jackson-databind,然后定义org.springframework.amqp.support.converter.Jackson2JsonMessageConverter的bean即可,它会自动生效的。也可以直接创建它,然后将它直接设置给RabbitTemplate就行了
18、docker安装rabbitmq
docker run \
-e RABBITMQ_DEFAULT_USER=guest \
-e RABBITMQ_DEFAULT_PASS=guest \
-v mq-plugins:/plugins \
--name mq \
--hostname mq \
-p 15672:15672 \
-p 5672:5672 \
--network hmall \
-d \
rabbitmq:3.8-management
19、在集群模式下如何保证使用@RabbitListener标注的方法只存在1个消费者呢?只是在声明队列的时候的参数(arguments参数),将x-single-active-consumer设置为True即可。参考:RabbitMQ多消费者实例时,保证只有一个消费者进行消费(单活消费者模式)。
如果要保证消息顺序,可以这样考虑:可以通过添加多个消息队列,每个消息队列只允许1个消费者,并且开启手动确认,并且设置prefetch为1,也就是每个队列每次拉取1条消息,当手动确认完了这条消息,再来处下1条消息,来保证处理消息的顺序。但这样会大大限制并发能力,可以将同1批要保证顺序的消息按顺序的发往同1个消息队列,这可以通过对这1批消息通过某种哈希运算得出相同消息队列id,只要消息的发送是按顺序的,那么消息的消费就是按顺序的,并且增加多个队列就相当于在增加并发度
20、消息可靠性,消息从发送者到mq,再从mq到消费者,其中每个环节都可能发生问题。
-
发送者的可靠性,这部分的可靠性由生产者重连机制和生产者确认机制来保证。
-
生产者重连:由于网络波动的存在,可能会出现客户端连接mq失败的情况。spring amqp提供了开启连接失败后的重连机制(注意:这不是消息发送失败的重试机制,是连接失败的重试机制)。但是,这个重连是阻塞式的重试,在多次重试等待的过程中,当前线程是被阻塞的,因此如果对业务性能有要求,建议禁用重试机制。如果一定要使用,就要合理配置等待时长和重试次数,当然,也可以考虑使用异步线程来执行发送消息的代码
(注意:这里说的重连是项目启动之后,使用rabbitmqTemplate发送消息时,肯定需要连接mq,是这个时候的重连,不是指的,项目启动时的重连。并且使用rabbitmqTemplate发送消息的下1行代码在重连期间是不会执行的,当在试完最大尝试次数还没有连接成功后,就会在当前线程抛出异常,下1行代码不会执行了)
spring: rabbitmq: host: xxx.xx.xx.xx port: 5672 virtual-host: /demo-vh username: guest password: xxxxxx connection-timeout: 1s # 设置mq的连接超时时间 template: retry: enabled: true # 开启超时重连机制 initial-interval: 1000ms # 失败后的初始等待时间 multiplier: 1 # 失败后下次的等待时长倍数 max-attempts: 3 # 最大重连次数
-
生产者确认:rabbitmq提供了Publisher Confiirm和Publisher Return这2种确认机制。开启确认机制后,在mq成功收到消息后,会返回确认消息给生产者。返回的额结果有以下几种情况:
- 消息投递到了mq,但是路由失败。此时会通过publisher Return返回路由异常原因,然后返回ack,告知投递成功
- 临时消息投递到了mq,并且入队成功,返回ack,告知投递成功
- 持久消息投递到了mq,并且入队完成持久化,返回ack告知投递成功
- 其它情况都会返回nack,告知投递失败
配置如下:
spring: rabbitmq: host: xxx.xx.xx.xx port: 5672 virtual-host: /demo-vh username: guest password: xxxxxx connection-timeout: 1s # 设置mq的连接超时时间 template: retry: enabled: true # 开启超时重连机制 initial-interval: 1000ms # 失败后的初始等待时间 multiplier: 1 # 失败后下次的等待时长倍数 max-attempts: 3 # 最大重连次数 publisher-confirms: true # 开启确认机制(注意开启确认机制后,对效率有所影响的哦, # 演示发送大量消息时,建议关掉) publisher-returns: true # 开启确认机制
设置confirmCallback和returnCallback
@Slf4j @Configuration public class RabbitConfig { @Autowired private RabbitTemplate rabbitTemplate; @Bean public MessageConverter messageConverter() { return new Jackson2JsonMessageConverter(); } @PostConstruct public void postProcessTemplate() { // 设置returnCallback rabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback() { /* 1. 当发送消息给mq的交换机, 并且指定1个在此交换机上不存在的绑定关系的routeKey时, 此方法会被回调 2. 当发送消息给mq的交换机, 并且交换机能够根据绑定关系将消息路由到队列时, 这个方法是不会回调的 */ @Override public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) { log.info("统一收到returnedMessage, message: {}, replayCode:{}, replyText: {}," + "exchange: {}, routeKey:{}", message, replyCode, replyText, exchange, routingKey); } }); // 设置confirmCallback rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() { /* 1. 当发送消息给指定的交换机, mq交换机收到消息时, 会回调此方法, 并且传过来的ack为true (无论此交换机后面是否能将此消息路由到队列, 都会回调此方法) 2. 当发送消息给1个不存在交换机时, 会回调此方法, 传过来的ack为false 3. 当发送1个消息时, 此时断网的情况下, 经过一小段时间后, 会回调此方法, 传过来的ack为false */ @Override public void confirm(CorrelationData correlationData, // 能够从此对象中拿到发送消息时的信息 boolean ack, String cause) { log.info("统一收到回执, ack: {}, correlationData: {}, cause: {}", ack, correlationData, cause); } }); log.info("已设置confirmCallback和returnCallback"); } }
发送消息
@RequestMapping("rabbit") @RestController public class RabbitController { @Autowired private RabbitTemplate rabbitTemplate; @GetMapping("sendToMq2") public Object sendToMq2(String content, String targetExchage, String routeKey) { HashMap<String, Object> data = new HashMap<>(); data.put("content", content); rabbitTemplate.convertAndSend(targetExchage, routeKey, data); return "ok"; } @GetMapping("sendToMq3") public Object sendToMq3(String content, String targetExchage, String routeKey) { CorrelationData correlationData = new CorrelationData(); correlationData.setId(UUID.randomUUID().toString()); correlationData .getFuture() .addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() { @Override public void onFailure(Throwable ex) { log.info("失败..."); } /* 接收到回执时, 该方法触发。 */ @Override public void onSuccess(CorrelationData.Confirm result) { log.info("接收到回执, 是否ack: {}, 原因: {}", result.isAck(), result.getReason()); } }); HashMap<String, Object> data = new HashMap<>(); data.put("content", content); rabbitTemplate.convertAndSend(targetExchage, routeKey, data, correlationData); return "ok"; } }
上面我用的spring-boot-starter-amqp版本是2.1.8.RELEASE,在2.7.12版本中,配置有所不同,应如下配置:
spring: rabbitmq: publisher-confirm_type: correlated # 开启publisher confirm机制, 并设置confirm类型 # publisher-confirm-type有3中模式可选 # - none 关闭confirm机制 # - simple 同步阻塞等待mq的绘制消息 # - correlated 异步回调方式返回回执消息 publisher-returns: true # 开启publisher return机制
其中,当publisher-confirm_type: simple时,发布消息成功后使用rabbitTemplate调用waitForConfirms或waitForConfirmsOrDie方法等待broker节点返回发送结果,根据返回结果来判定下一步的逻辑,要注意的点是waitForConfirmsOrDie方法如果返回false则会关闭channel,则接下来无法发送消息到broker;
-
-
mq的可靠性:在默认情况下,rabbitmq会将收到的消息保存到内存中以降低消息收发的延迟。这样会导致2个问题。1个是:一旦mq宕机,内存中的消息会丢失。第二个是:内存空间有限,当消费者故障或处理过慢时,会导致消息积压,引发mq阻塞。
-
数据持久化
- 交换机持久化(如果是非持久化,当mq服务器重启时,会丢失;spring已设置默认持久化,通过durable属性设置)
- 队列持久化 (如果是非持久化,当mq服务器重启时,会丢失;spring已设置默认持久化,通过durable属性设置)
- 消息持久化
- 如果是非持久化,当mq服务器重启时,会丢失;
- 需要发送消息时设置deliveryMode,1为非持久化,2为持久化,可以参考MessageProperties中的deliveryMode属性中使用的MessageDeliveryMode这个枚举类,默认是持久化的。
- 可以使用rabbitTemplate#convertAndSend发送时,指定MessagePostProcessor消息后置处理器,从Message对象中拿到MessageProperties,然后设置MessageProperties的deliveryMode属性)
- 也可以使用MessageBuilder这个构建者来构建消息
-
LazyQueue:从RabbitMO的3.6.0版本开始,就增加了Lazy Queue的概念,也就是惰性队列。性能较之前有很大提升
-
惰性队列的特征如下
- 接收到消息后直接存入磁盘而非内存(内存中只保留最近的消息,默认2048条)
- 消费者要消费消息时才会从磁盘中读取并加载到内存
- 支持数百万条的消息存储
- 在3.12版本后,所有队列都是Lazy Queue模式,无法更改。
-
如何创建惰性队列
-
在rabbitmq后台管理页,在声明队列时,指定Aruguements参数中,添加x-queue-mode为lazy即可
-
代码的方式
@Bean public Queue queue4() { return QueueBuilder .durable("direct.queue4") // .lazy() // 需要2.2版本以上才有直接设置lazy的方法,不过没事,用下面的也是一样的 .withArgument("x-queue-mode","lazy") .build(); }
@RabbitListener(queuesToDeclare = @Queue( name = "lazy.queue2", durable = "true", arguments = @Argument(name = "x-queue-mode", value = "lazy") )) public void lazyQueue2(String msg) { log.info("消费消息: {}", msg); }
-
-
-
rabbitmq如何保证消息的可靠性?
- 首先通过配置可以让交换机、队列、以及发送的消息都持久化。这样队列中的消息会持久化到磁盘,MQ重启消息依然存在
- RabbitMQ在3.6版本引入了LazyQueue,并且在3.12版本后会称为队列的默认模式。LazyQueue会将所有消息都持久化,并且性能有很大提升(测试1000000条,19s就完成了,不会出现page out,并且不会阻塞mq接收新的消息)。
- 开启持久化和生产者确认时,RabbitMO只有在消息持久化完成后才会给生产者返回ACK回执
-
-
消费者的可靠性
-
为了确认消费者是否成功处理消息,RabbitMQ提供了消费者确认机制(Consumer Acknowledgement)当消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMQ自己消息处理状态。回执有三种可选值:
- ack:成功处理消息,RabbitMO从队列中删除该消息
- nack:消息处理失败,RabbitMo需要再次投递消息
- reject:消息处理失败并拒绝该消息,RabbitMO从队列中删除该消息
-
SpringAMQP已经实现了消息确认功能。并允许我们通过配置文件选择ACK处理方式(通过:spring.rabbitmq.listener.simple.acknowledge-mode属性来配置),有如下三种方式
-
none: 不处理。即消息投递给消费者后立刻ack,消息会立刻从Mq删除,不管监听方法是否出现异常。
-
manual: 手动模式。需要自己在业务代码中调用api,发送ack或reject,可以捕获异常控制重试次数,甚至可以控制失败消息的处理方式,存在业务入侵,但更灵活
-
代码示例
@Component @RabbitListener(queues = "test.queue1") public class MessageConsumer { @RabbitHandler public void recivedMessage(Message msg, OrderReturnApplyEntity orderReturnApplyEntity, Channel channel) throws IOException { try { System.out.println("接收到消息:" + msg); int i = 1 / 0; // 确认收到消息,false只确认当前consumer一个消息收到,true确认所有consumer获得的消息 // 当前作为mq的消费端有1个consumeTag的消费者标识, mq每次发送消息时都会标识这条消息的deliveryTag, 这个投递标识会递增。 // 当消费者断开连接后, 又连接上了mq, 此时devliveryTag会从1开始继续递增 // 所以如果要唯一标识消息的话, 就要在发送消息的时候, 指定correlationData的id channel.basicAck(msg.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { if (msg.getMessageProperties().getRedelivered()) { System.out.println("消息重试后依然失败,拒绝再次接收"); // 拒绝消息,不再重新入队 // (如果绑定了死信队列消息会进入死信队列,没有绑定死信队列则消息被丢弃, // 也可以把失败消息记录到redis或者mysql中),也可以设置为true再重试。 channel.basicReject(msg.getMessageProperties().getDeliveryTag(), false); } else { System.out.println("消息消费时出现异常,即将再次返回队列处理"); // Nack消息,重新入队(重试一次)参数二表示是否批量,参数三表示是否重新入队列 channel.basicNack(msg.getMessageProperties().getDeliveryTag(), false, true); } log.error("处理消息发生错误: {}", e); } } }
配置如下:
server: port: 8081 spring: rabbitmq: host: 119.23.61.24 port: 5672 virtual-host: /demo-vh username: guest password: 17E821zj connection-timeout: 1s # 设置mq的连接超时时间 template: retry: enabled: true # 开启超时重连机制 initial-interval: 1000ms # 失败后的初始等待时间 multiplier: 1 # 失败后下次的等待时长倍数 max-attempts: 3 # 最大重连次数 publisher-confirms: true # 开启消息发送确认机制 publisher-returns: true # 开启消息return机制 listener: simple: prefetch: 1 # 每次拉取1个消息, 处理完成后, 再拉取下1个消息, 能者多劳 acknowledge-mode: munual # 消费者确认机制为手动
-
问题1:假设已经配置为manual手动确认模式,在recivedMessage方法中,忘记basicAck或者basicReject或者basicNack, 会怎么样?
测试步骤:在rabbitmq后台手动发1条数据,到test.queue1队列中,在recivedMessage处理消息的方法中,声明该消息,但就是不去(basicAck或者basicReject或者basicNack)。发现,消息处理方法收到了1次消息,因此方法只调用了1次,在rabbitmq的web后台该消息一直处于unacked状态。此时,关闭消费者服务,在rabbitmq的web后台该消息处于ready状态,即待投递。然后,再次启动消费者,此消息又投递了过来,消费者方法又调用了1次,此时再以同样的配置和代码启动另外1个消费者,这个新启动的消费者没有收到这个消息(说明它不会将已投递但未确认的消息投递给这个新的消费者)。然后将原来的消费者停掉,此时发现新启动的消费者立刻收到了这条消息,不过消息仍处于unacked状态。这证明这个消息在发送给1个消费者之后,会等待消费者的回执,如果消费者迟迟不给回执,那就一直等,直到这个消费者挂了,消息才会变为ready待投递状态,才会投递给其它的消费者。
-
测试2:使用basicAck确认收到消息后,消息将从队列中删除。如下代码测试,当收到消息时,使用basicAck(消息投递标记,是否批量确认),批量确认指的是,将deliveryTag小于当前消息投递标记的消息一并确认,这样broker就会清理掉之前未确认的消息,这可以适用于某些情况:既然最后面的消息都确认了,之前的消息确不确认也就没啥关系的情况。
@Slf4j @Configuration public class RabbitConfig { @RabbitListener(bindings = { @QueueBinding( value = @Queue(value = "direct.queue2",durable = "true"), exchange = @Exchange(value = "direct.exchange2", type = ExchangeTypes.FANOUT), key = {"red","blue"} ) }) public void listenQueue(Message message, String msg, Channel channel) { log.info("收到消息====================="); log.info("channel:{}", channel); log.info("msg:{}", msg); log.info("message:{},", new String(message.getBody())); // receivedDeliveryMode-是否持久化的消息, // redelivered-是否重新投递的消息, // receivedRoutingKey-路由key, // deliveryTag-投递唯一标记(从1开始递增, 每次消费者重启后, 继续从1开始) // consumerTag-当前消费者唯一标记(每个消费者都有自己的唯一标记,每次消费者重连后,生成新的标记) // consumerQueue-当前消费者收到消息的队列 log.info("messageProperties:{}", message.getMessageProperties()); // deliveryTag-投递标记(broker用于标记此消息), // multiple-是否批量确认(批量确认会让broker将小于当前消息的deliveryTag的消息给确认掉删了) channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); log.info("处理结束====================="); }
-
测试3:使用basicNack(deliveryTag, mulitiple, requeue) 拒绝签收该消息,第3个参数决定是否让消息重新回到队列,如果消息回到队列后,重新变为ready待投递状态,会选择消费者再次进行进行投递。如果不回到队列,那么broker将会删除此消息,但是如果此队列还绑定了死信交换机,那么此消息将会发给死信交换机。
@Slf4j @Configuration public class RabbitConfig { @RabbitListener(bindings = { @QueueBinding( value = @Queue(value = "direct.queue2",durable = "true"), exchange = @Exchange(value = "direct.exchange2", type = ExchangeTypes.FANOUT), key = {"red","blue"} ) }) public void listenQueue(Message message, String msg, Channel channel) { log.info("收到消息====================="); log.info("channel:{}", channel); log.info("msg:{}", msg); log.info("message:{},", new String(message.getBody())); // receivedDeliveryMode-是否持久化的消息, // redelivered-是否重新投递的消息, // receivedRoutingKey-路由key, // deliveryTag-投递唯一标记(从1开始递增, 每次消费者重启后, 继续从1开始) // consumerTag-当前消费者唯一标记(每个消费者都有自己的唯一标记,每次消费者重连后,生成新的标记) // consumerQueue-当前消费者收到消息的队列 log.info("messageProperties:{}", message.getMessageProperties()); // deliveryTag-投递标记(broker用于标记此消息), // multiple-是否批量确认(批量确认会让broker将小于当前消息的deliveryTag的消息给确认掉删了) // requeue-是否继续入队, // =====================以下是2种情况的代码及对应的解释===================== // 如果不继续入队, 那么broker将会删除这个消息, // 但是如果这个队列绑定了死信交换机,那么会发到该私信交换机中 channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false); // 如果继续入队, 那么消息重新回到队列处于待投递状态, 然后又会投递给当前消费者, // 然后当前消费者又去让这个消息去入队待投递, 然后又投递给当前消费者, // 然后就成了死循环了。 // 此时, 再开1个一样的消费者,再监听此队列,结果2个消费者都死循环了。 //channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true); log.info("处理结束====================="); } }
测试4:使用basicReject(deliveryTag, requeue) 拒绝该消息,与上面使用basicNack(deliveryTag, multiple, requeue)一样的测试结果,只是basicNack方法中比basicReject多了个multiple的参数。
@Slf4j @Configuration public class RabbitConfig { @RabbitListener(bindings = { @QueueBinding( value = @Queue(value = "direct.queue2",durable = "true"), exchange = @Exchange(value = "direct.exchange2", type = ExchangeTypes.FANOUT), key = {"red","blue"} ) }) public void listenQueue(Message message, String msg, Channel channel) { log.info("收到消息====================="); log.info("channel:{}", channel); log.info("msg:{}", msg); log.info("message:{},", new String(message.getBody())); // receivedDeliveryMode-是否持久化的消息, // redelivered-是否重新投递的消息, // receivedRoutingKey-路由key, // deliveryTag-投递唯一标记(从1开始递增, 每次消费者重启后, 继续从1开始) // consumerTag-当前消费者唯一标记(每个消费者都有自己的唯一标记,每次消费者重连后,生成新的标记) // consumerQueue-当前消费者收到消息的队列 log.info("messageProperties:{}", message.getMessageProperties()); // deliveryTag-投递标记(broker用于标记此消息), // requeue-是否继续入队, // =====================以下是2种情况的代码及对应的解释===================== // 如果不继续入队, 那么broker将会删除这个消息, // 但是如果这个队列绑定了死信交换机,那么会发到该私信交换机中 channel.basicReject(message.getMessageProperties().getDeliveryTag(), false); // 如果继续入队, 那么消息重新回到队列处于待投递状态, 然后又会投递给当前消费者, // 然后当前消费者又去让这个消息去入队待投递, 然后又投递给当前消费者, // 然后就成了死循环了。 // 此时, 再开1个一样的消费者,再监听此队列,结果2个消费者都死循环了。 // channel.basicReject(message.getMessageProperties().getDeliveryTag(), true); log.info("处理结束====================="); } }
-
-
auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,
- 当业务正常执行时则自动返回ack。
- 当业务出现异常时,根据异常判断返回不同结果:
-
如果是业务异常,会自动返回nack,并重新入队(就会导致无限重试,导致程序死循环)
- 测试:当消费者监听方法在处理消息的过程中发生NullPointer异常时,会自动对该消息进行nack(并重新入队),然后mq服务器又会再次将此消息投递给此消费者,并且此时mq管理后台中对应队列中该消息的状态时nack(消费者一旦nack,这个消息就变成ready待投递了,然后再次马上又投递给消费者了,就马上变成了nack。测试时,我又开了1个一样的消费者服务,结果2个消费者服务都一直不断的抛出异常),当把消费者停了的时候,此消息又变成ready待投递了
-
如果是消息处理或校验异常,自动返回reject,消息会被删除,不会重新入队,不会导致死循环(在监听方法中,手动抛出MessageConversionException,那么也是跟reject并且不重新入队,一样的效果,消息会被删除,不会导致死循环)
-
测试代码:
-
配置
server: port: 8081 spring: rabbitmq: host: 119.23.61.24 port: 5672 virtual-host: /demo-vh username: guest password: 17E821zj connection-timeout: 1s # 设置mq的连接超时时间 template: retry: enabled: true # 开启超时重连机制 initial-interval: 1000ms # 失败后的初始等待时间 multiplier: 1 # 失败后下次的等待时长倍数 max-attempts: 3 # 最大重连次数 publisher-confirms: true # 开启消息发送确认机制 publisher-returns: true # 开启消息return机制 listener: simple: prefetch: 1 # 每次拉取1个消息, 处理完成后, 再拉取下1个消息, 能者多劳 acknowledge-mode: auto # 消费者确认机制
代码:下面代码就是,故意在确认机制已经是自动确认的配置下,依然确认或拒绝,检查不同情况下,消息的流转情况
@Slf4j @Configuration public class RabbitConfig { @RabbitListener(bindings = { @QueueBinding( value = @Queue(value = "direct.queue2",durable = "true"), exchange = @Exchange(value = "direct.exchange2", type = ExchangeTypes.FANOUT), key = {"red","blue"} ) }) public void listenQueue(Message message, String msg, Channel channel) { log.info("收到消息====================="); log.info("channel:{}", channel); log.info("msg:{}", msg); log.info("message:{},", new String(message.getBody())); // receivedDeliveryMode-是否持久化的消息, // redelivered-是否重新投递的消息, // receivedRoutingKey-路由key, // deliveryTag-投递唯一标记(从1开始递增, 每次消费者重启后, 继续从1开始) // consumerTag-当前消费者唯一标记(每个消费者都有自己的唯一标记,每次消费者重连后,生成新的标记) // consumerQueue-当前消费者收到消息的队列 log.info("messageProperties:{}", message.getMessageProperties()); String body = new String(message.getBody()); long deliveryTag = message.getMessageProperties().getDeliveryTag(); // 以下的原意是指: 方法本身的作用, 并不是在确认模式是自动确认下调用这些方法的作用 if ("1".equals(body)) { // 原意: 确认, 不批量 //(channel会shutdown, 然后会重新连接, 然后消息被删除) channel.basicAck(deliveryTag, false); } else if ("2".equals(body)) { // 原意: 拒绝, 重新入队 //(channel会shutdown, 然后会重新连接, 消息会重新入队, // 然后消费者再次收到此消息, 然后不断循环, 并且中间的过程会抛出异常) channel.basicReject(deliveryTag, true); } else if ("3".equals(body)) { // 原意: 拒绝, 不重新入队(broker将会删除此消息, // 如果该队列还绑定了死信交换机,那么会发往此交换机) //(收到1次消息后, channel会shutdown, 然后会重新连接, 不会再次收到该消息, // 因为消息已经被删除了) channel.basicReject(deliveryTag, false); } else if ("4".equals(body)) { // 原意: 拒绝, 不批量, 重新入队(会再次投递给消费者) //(与2表现几乎一致) channel.basicNack(deliveryTag, false, true); } else if ("5".equals(body)) { // 原意: 拒绝, 批量, 重新入队(对于之前未确认的消息,批量拒绝并重新入队) //(与2表现几乎一致) channel.basicNack(deliveryTag, true, true); } else if ("6".equals(body)) { // 抛出空指针异常, //(收到1次消息, 然后这里抛出异常, 然后会自动nack并重新入队, 然后又收到该消息, 不断循环) throw new NullPointerException("666..."); } else if ("7".equals(body)) { // 抛出消息转换异常, //(抛出异常,消息会被删除, 并且不会重新入队, 不会死循环。 // 同basicReject拒绝消息并且设置不重新入队) throw new MessageConversionException("777..."); } else { // 模拟正常处理 System.out.println("自动确认模式正常处理情况..."); } log.info("处理结束====================="); } }
-
-
失败重试机制:
-
当消费者出现异常后,如果消费者设置的参数让此消息再次回到队列,那么消息会requeue(重新入队)到队列,等待投递,然后就会再投递给消费者,消费者收到该消息后再次异常,由于消费者设置的参数又让此消息再次回到队列,因此就会无限循环,导致mq的消息处理飙升,带来不必要的压力。我们可以利用Spring的retry机制,在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列,尝试作如下配置来设置消费者。
server: port: 8081 spring: rabbitmq: host: xx.xx.xx.xx port: 5672 virtual-host: /demo-vh username: guest password: xx connection-timeout: 1s # 设置mq的连接超时时间 template: #(消息生产者的配置) retry: enabled: true # 开启超时重连机制 initial-interval: 1000ms # 失败后的初始等待时间 multiplier: 1 # 失败后下次的等待时长倍数 max-attempts: 3 # 最大重连次数 publisher-confirms: true # 开启消息发送确认机制 publisher-returns: true # 开启消息return机制 listener: # (消息消费者的配置) simple: prefetch: 1 # 每次拉取1个消息, 处理完成后, 再拉取下1个消息, 能者多劳 acknowledge-mode: auto # 消费者确认机制 ## ===========添加失败重试机制=========== retry: enabled: true # 开启消费者失败重试 initial-interval: 1000ms # 初始的失败等待时长为1s multiplier: 1 # 下次失败的等待时长倍数, # 下次等待时长 = multiplier * last-interval max-attempts: 3 # 最大重试次数(不设置的话, 默认配置的就是3次) stateless: true # true无状态, false有状态。如果业务中包含事务, 这里改为false
当消费者添加如上失败重试机制前,我们发现本来消费者的监听方法抛出空指针异常,然后一直在不断的nack消息(并且设置重新入队参数为true),然后mq又投递过来然后又导致空指针异常,然后又nack消息并重新入队,然后不断的死循环的跑着(同上1个例子配置确认模式为auto,并且监听方法中抛出NullPointerException异常的例子)。加上失败重试机制的配置后,同样是在确认模式为auto,并且监听方法中抛出NullPointerException异常的情况下,发现消费者就拉取了1次消息,然后在本地重试了3次,在这期间mq也并没有投递消息过来,当重试3次都失败后,此消息从消息队列中删除了(重试次数耗尽都失败之后,直接拒绝了该消息,并且不重新入队)。
-
-
失败消息处理策略:在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecoverer接口来处理,它包含三种不同的实现
-
实现方式分类
- RejectAndDontRequeueRecoverer: 重试耗尽后,直接reject,丢弃消息。默认就是这种方式(当重试都失败之后,直接丢弃该消息)
- ImmediateRequeueMessageRecoverer: 重试耗尽后,返回nack,消息重新入队(当重试都失败之后,重新入队,接着继续接收该消息,如果重试都失败之后,又重新入队)
- RepublishMessageRecoverer: 重试耗尽后,将失败消息投递到指定的交换机(当重试都失败之后,发送到指定的交换机,将此消息路由到与此交换机所绑定的队列)
-
RepublishMessageRecoverer使用示例
示例描述:当向direct.queue2发送1个payload为7的消息时,消费者就会只拉取1次该消息,并且会在本地重试3次,如果重试3次都失败(本例中只要消息是7就会抛出空指针异常)之后,就会发送到error.direct交换机,然后根据路由key路由到error.queue消息队列,其中消息的内容就是异常栈的字符串,这样就可以让人工介入处理。并且使用失败消息处理策略后,不会出现无限制:失败重试,然后重发,继续失败重试。
server: port: 8081 spring: rabbitmq: host: xx.xx.xx.xx port: 5672 virtual-host: /demo-vh username: guest password: xxx connection-timeout: 1s # 设置mq的连接超时时间 template: retry: enabled: true # 开启超时重连机制 initial-interval: 1000ms # 失败后的初始等待时间 multiplier: 1 # 失败后下次的等待时长倍数 max-attempts: 3 # 最大重连次数 publisher-confirms: true # 开启消息发送确认机制 publisher-returns: true # 开启消息return机制 listener: simple: prefetch: 1 # 每次拉取1个消息, 处理完成后, 再拉取下1个消息, 能者多劳 acknowledge-mode: auto # 消费者确认机制 ## ===========添加失败重试机制=========== retry: enabled: true # 开启消费者失败重试 initial-interval: 1000ms # 初始的失败等待时长为1s multiplier: 1 # 下次失败的等待时长倍数, # 下次等待时长 = multiplier * last-interval max-attempts: 3 # 最大重试次数(不设置的话, 默认配置的就是3次) stateless: true # true无状态, false有状态。如果业务中包含事务, 这里改为false
@Slf4j @Configuration public class RabbitConfig { @RabbitListener(bindings = { @QueueBinding( value = @Queue(value = "direct.queue2",durable = "true"), exchange = @Exchange(value = "direct.exchange2", type = ExchangeTypes.FANOUT), key = {"red","blue"} ) }) public void listenQueue(Message message, String msg, Channel channel) { log.info("收到消息====================="); log.info("channel:{}", channel); log.info("msg:{}", msg); log.info("message:{},", new String(message.getBody())); // receivedDeliveryMode-是否持久化的消息, // redelivered-是否重新投递的消息, // receivedRoutingKey-路由key, // deliveryTag-投递唯一标记(从1开始递增, 每次消费者重启后, 继续从1开始) // consumerTag-当前消费者唯一标记(每个消费者都有自己的唯一标记,每次消费者重连后,生成新的标记) // consumerQueue-当前消费者收到消息的队列 log.info("messageProperties:{}", message.getMessageProperties()); String body = new String(message.getBody()); long deliveryTag = message.getMessageProperties().getDeliveryTag(); if ("7".equals(body)) { // 抛出消息转换异常, //(抛出异常,消息会被删除, 并且不会重新入队, 不会死循环。 // 同basicReject拒绝消息并且设置不重新入队) throw new MessageConversionException("777..."); } log.info("处理结束====================="); } }
@Slf4j @Configuration // 当开启了消费者失败重试时, 当前配置类才生效 @ConditionalOnProperty(name = "spring.rabbitmq.listener.simple.retry.enabled", havingValue = "true") public class ErrorConfiguration { // 定义1个直连交换机 @Bean public DirectExchange errorExchange(){ return new DirectExchange("error.direct"); } // 定义1个消息队列 @Bean public Queue errorQueue(){ return new Queue("error.queue"); } // 绑定 @Bean public Binding errorBinding(Queue errorQueue, DirectExchange errorExchange){ return BindingBuilder.bind(errorQueue).to(errorExchange).with("error"); } // 消息消费时重试耗尽并且都失败(例如: 确认模式为auto,并且监听方法中抛出NullPointerException异常)时 // 的后续处理策略, // 因为这里返回的是RepublishMessageRecoverer,所以在重试耗尽时发送到指定交换机,并携带指定的路由key @Bean public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate){ log.debug("加载RepublishMessageRecoverer"); return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error"); } }
-
-
-
业务幂等性:通过以上所有的手段,我们可以保证消息至少被消费者消费1次。但是由于网络波动等原因导致消费者消费同一消息多次,这个时候,就需要保证消息的幂等性。所谓的幂等性指的是,消费同一消息多次产生的效果与消费该消息1次的效果是相同的,或者说对业务状态的影响是一致的。
-
唯一消息id方案:
-
给每个消息都设置一个唯一id,利用id区分是否是重复消息:
- 每一条消息都生成一个唯一的id,与消息一起投递给消费者
- 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库
- 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理
使用步骤:消费者和生产者中都配置如下的消息转换器,并且设置createMessageIds属性为true
@Bean public MessageConverter jacksonMessageConvertor(){ Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter(); // 1. 设置此属性后, 会在使用rabbitTemplate发送消息时, // 当未设置消息属性MessageProperties#messageId时,对消息对象的MessageProperties的messageId设 // 置1个uuid值, 用来作为这条消息的标识。 // 2. 当然也可以在使用rabbitTemplate发送消息时, 指定1个MessagePostProcessor, // 来设置MessageProperties#messageId的值 jjmc.setCreateMessageIds(true); return jjmc; }
-
-
业务判断
-
结合业务逻辑,基于业务本身作判断
-
以我们的业务为例:我们要在支付后修改订单状态为已支付,应该在修改订单状态前先查询订单状态,判断状态是否是未支付。只有未支付订单才需要修改,其它状态不做处理:
@Component @RequiredArgsConstructor public class PayStatusListener { private final IOrderService orderService; @RabbitListener(bindings = @QueueBinding( value = @Queue(name = "mark.order.pay.queue", durable = "true"), exchange = @Exchange(name = "pay.topic", type = ExchangeTypes.TOPIC), key = "pay.success" )) public void listenOrderPay(Long orderId) { /* // 1.查询订单 Order order = orderService.getById(orderId); // 2.判断订单状态是否为未支付 if(order == null || order.getStatus() != 1){ // 订单不存在,或者状态异常 return; } // 3.如果未支付,标记订单状态为已支付 orderService.markOrderPaySuccess(orderId); */ // 其实可以使用下面的一步搞定(类似于乐观锁机制) // update order set status = 2 where id = ? AND status = 1 orderService.lambdaUpdate() .set(Order::getStatus, 2) .set(Order::getPayTime, LocalDateTime.now()) .eq(Order::getId, orderId) .eq(Order::getStatus, 1) .update(); } }
-
-
-
-
如何保证支付服务与交易服务之间的订单状态一致性?
- 首先,支付服务会正在用户支付成功以后利用MQ消息通知交易服务完成订单状态同步。
- 其次,为了保证MQ消息的可靠性,我们采用了生产者确认机制、消费者确认、消费者失败重试等策略,确保消息投递和处理的可靠性。同时也开启了MQ的持久化,避免因服务宕机导致消息丢失
- 最后,我们还在交易服务更新订单状态时做了业务幂等判断,避免因消息重复消费导致订单状态异常。
-
如果交易服务消息处理失败,有没有什么兜底方案?
- 我们可以在交易服务设置定时任务,定期查询订单支付状态。这样即便MQ通知失败,还可以利用定时任务作为兜底方案,确保订单支付状态的最终一致性。
-
延迟消息:胜场这发送消息时,指定1个时间,消费者不会立刻收到消息,而是在指定时间之后才收到消息
-
死信交换机方案
- 当一个队列中的消息满足下列情况之一时,就会成为死信 (dead letter)
- 消费者使用basic.reject或 basic.nack声明消费失败,并且消息的requeue参数设置为false
- 消息是一个过期消息(达到了队列或消息本身设置的过期时间),超时无人消费
- 要投递的队列消息堆积满了,最早的消息可能成为死信
- 如果队列通过dead-letter-exchange属性指定了一个交换机,那么该队列中的死信就会投递到这个交换机中。这个交换机称为该队列的死信交换机 (Dead Letter Exchange,简称DLX)
- 当一个队列中的消息满足下列情况之一时,就会成为死信 (dead letter)
-
示例
配置如下:
server: port: 8081 spring: rabbitmq: host: xx.xx.xx.xx port: 5672 virtual-host: /demo-vh username: guest password: xxx connection-timeout: 1s # 设置mq的连接超时时间 template: retry: enabled: true # 开启超时重连机制 initial-interval: 1000ms # 失败后的初始等待时间 multiplier: 1 # 失败后下次的等待时长倍数 max-attempts: 3 # 最大重连次数 publisher-confirms: true # 开启消息发送确认机制 publisher-returns: true # 开启消息return机制 listener: simple: prefetch: 1 # 每次拉取1个消息, 处理完成后, 再拉取下1个消息, 能者多劳 acknowledge-mode: auto # 消费者确认机制 ## ===========添加失败重试机制=========== retry: enabled: true # 开启消费者失败重试 initial-interval: 1000ms # 初始的失败等待时长为1s multiplier: 1 # 下次失败的等待时长倍数, # 下次等待时长 = multiplier * last-interval max-attempts: 3 # 最大重试次数(不设置的话, 默认配置的就是3次) stateless: true # true无状态, false有状态。如果业务中包含事务, 这里改为false
代码如下
@Slf4j @Configuration public class RabbitConfig { /* 死信处理的交换机、队列、绑定等定义 */ @Bean public org.springframework.amqp.core.Exchange dlxExchange() { Exchange exchange = ExchangeBuilder.directExchange("dlx.directExchange") .durable(true) .build(); return exchange; } @Bean public org.springframework.amqp.core.Queue dlxQueue() { Queue queue = QueueBuilder.durable("dlx.queue").build(); return queue; } @Bean public Binding dlxExAndQueueBinding() { // 建立 dlx.directExchange交换机 到 dlx.queue队列 的绑定关系, 并指定路由key为red Binding binding = BindingBuilder.bind(dlxQueue()) .to(dlxExchange()) .with("red") .noargs(); return binding; } /* 让消息成为死信的交换机、队列、绑定等定义 */ // 当向direct.timedExchange交换机发送消息,并且携带red作为路由key,那么此消息会被路由到direct.queue队列 // 并且, 当这个消息设置了过期时间(通过设置MessageProperties#expiration属性), 同时direct.queue又没有消费者, // 那么, 当到了过期时间时, 这个消息会被发送到该队列所绑定的死信交换机, 并携带原消息原来的路由key, // 然后, 我们在下面的监听方法中监听死信队列 @Bean public org.springframework.amqp.core.Exchange directTimedExchange() { Exchange exchange = ExchangeBuilder.directExchange("direct.timedExchange") .durable(true) .build(); return exchange; } @Bean public org.springframework.amqp.core.Queue directQueue() { org.springframework.amqp.core.Queue queue = QueueBuilder.durable("direct.queue") // 通过设置参数, 来指定该队列的死信交换机 .withArgument("x-dead-letter-exchange", "dlx.directExchange") .build(); return queue; } @Bean public Binding exAndQueueBinding() { // 建立 direct.timedExchange 交换机 到 direct.queue 队列 的绑定关系, 并指定路由key为red Binding binding = BindingBuilder.bind(directQueue()) .to(directTimedExchange()) .with("red") .noargs(); return binding; } /* 监听死信队列 */ @RabbitListener(queues = {"dlx.queue"}) public void handleDlxMsg(Message message) { log.info("收到消息====================="); // 可以在此处观察日志的输出时间, 和消息的数据(我设置的消息的数据就是消息的发送时间) log.info("message:{},", new String(message.getBody())); } }
@Slf4j @RequestMapping("rabbit") @RestController public class RabbitController { @Autowired private RabbitTemplate rabbitTemplate; @GetMapping("orderMsg") public Object orderMsg(String expiration, String exchange, String routeKey) { SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); String content = sdf.format(new Date()); rabbitTemplate.convertAndSend(exchange, routeKey, content, new MessagePostProcessor() { @Override public Message postProcessMessage(Message message) throws AmqpException { // 设置消息过期时间 message.getMessageProperties().setExpiration(expiration); return message; } }); return "ok"; } }
测试步骤:
第一步:发送http://localhost:8081/rabbit/orderMsg?expiration=10000&exchange=direct.timedExchange&routeKey=red,发现确实是在10秒后收到消息
第二步:发送http://localhost:8081/rabbit/orderMsg?expiration=5000&exchange=direct.timedExchange&routeKey=red,发现确实是在5秒后收到消息
第三步:发送完http://localhost:8081/rabbit/orderMsg?expiration=5000&exchange=direct.timedExchange&routeKey=red,接着隔1-2秒发送http://localhost:8081/rabbit/orderMsg?expiration=10000&exchange=direct.timedExchange&routeKey=red,发现1个是在5s后收到消息,1个是在10s后收到消息
第四步:发送完http://localhost:8081/rabbit/orderMsg?expiration=10000&exchange=direct.timedExchange&routeKey=red,接着隔1-2秒发送http://localhost:8081/rabbit/orderMsg?expiration=5000&exchange=direct.timedExchange&routeKey=red,2个消息都是隔10s才收到的消息
这足以证明如果采取这种方案是有问题的,必须是处于消息队列顶端的消息队列到期时,才会立马进入死信队列。所以如果要用这种方案的话,最好是分超时队列,不同的超时时间发送的不同的队列,这样就能保证,最先进入队列的消息先超时,后面的消息也都能正常延迟消费。
-
-
延迟插件
-
介绍
- RabbitMO的官方也推出了一个插件,原生支持延迟消息功能。该插件的原理是设计了一种支持延迟消息功能的交换机,当消息投递到交换机后可以在交换机中暂存一定时间,到期后再投递到队列。
- 下载rabbitmq延迟消息插件地址:rabbitmq-delayed-message-exchange
- rabbitmq官方文档中对延迟消息插件的介绍及使用说明
- github上README.md关于延迟消息插件的安装使用说明
-
安装
-
官方介绍的步骤
- 第一步,先下载延迟消息插件
- 第二步,如果需要找到rabbitmq的插件目录,可以执行:rabbitmq-plugins directories -s
- 第三步,将下载的延迟消息插件复制到插件目录
- 第四步,执行开启插件命令:rabbitmq-plugins enable rabbitmq_delayed_message_exchange
-
自己的安装步骤(使用docker安装rabbitmq时,未挂载插件目录),由于自己之前安装rabbitmq之前没有将插件目录挂载出来,所以步骤不一样
- docker exec -it rabbitmq /bin/bash进入到rabbitmq容器中,然后在当前的
/
目录下有个plugins目录,进入可以看到很多.ez结尾的插件 - docker cp ./rabbitmq_delayed_message_exchange-3.8.17.8f537ac.ez 037a6fed1d41:/plugins/,将当前的延迟消息插件复制到rabbitmq容器中的/plugins目录中
- docker exec -it rabbitmq rabbitmq-plugins enable rabbitmq_delayed_message_exchange,在容器外部执行此命令,让rabbitmq容器启用此插件
- docker exec -it rabbitmq /bin/bash进入到rabbitmq容器中,然后在当前的
-
docker已挂载插件目录的rabbitmq容器安装步骤
-
安装rabbitmq
docker run \ -e RABBITMQ_DEFAULT_USER=guest \ -e RABBITMQ_DEFAULT_PASS=guest \ -v mq-plugins:/plugins \ --name mq \ --hostname mq \ -p 15672:15672 \ -p 5672:5672 \ --network hmall \ -d \ rabbitmq:3.8-management
-
docker inspect mq查看,可以看到在Mounts节点中,已将source挂载到了容器中的Destination(即/plugins目录中),然后将.ez的延迟消息插件拷贝到source所代表的文件位置即可
-
docker exec -it rabbitmq rabbitmq-plugins enable rabbitmq_delayed_message_exchange,在容器外部执行此命令,让rabbitmq容器启用此插件
-
-
-
使用:使用的时候,只需要在声明交换机时,设置此交换机的delayed属性为true即可(spring-amqp包须>=1.6),而在发送消息的时候,需要设置MessageProperties#setDelay(Integer)传入需要延迟的时间,单位:毫秒,其实就是设置x-delay头。
代码如下:server: port: 8081 spring: rabbitmq: host: xx port: 5672 virtual-host: /demo-vh username: guest password: xx connection-timeout: 1s # 设置mq的连接超时时间 template: retry: enabled: true # 开启超时重连机制 initial-interval: 1000ms # 失败后的初始等待时间 multiplier: 1 # 失败后下次的等待时长倍数 max-attempts: 3 # 最大重连次数 publisher-confirms: true # 开启消息发送确认机制 publisher-returns: true # 开启消息return机制 listener: simple: prefetch: 1 # 每次拉取1个消息, 处理完成后, 再拉取下1个消息, 能者多劳 acknowledge-mode: auto # 消费者确认机制 ## ===========添加失败重试机制=========== retry: enabled: true # 开启消费者失败重试 initial-interval: 1000ms # 初始的失败等待时长为1s multiplier: 1 # 下次失败的等待时长倍数, # 下次等待时长 = multiplier * last-interval max-attempts: 3 # 最大重试次数(不设置的话, 默认配置的就是3次) stateless: true # true无状态, false有状态。如果业务中包含事务, 这里改为false
@Slf4j @Configuration public class RabbitConfig { /* 第1个延迟队列的相关交换机、队列、绑定、监听方法定义 */ @RabbitListener(bindings = { @QueueBinding(exchange = @Exchange(name = "dly.direct.ex",delayed = "true",durable = "true"), key = "dly", value = @Queue(name = "dly.queue", durable = "true") ) }) public void listenDelayedMsg(Message message, String msg) { log.info("收到消息=====================1111"); // 可以在此处观察日志的输出时间, 和消息的数据(我设置的消息的数据就是消息的发送时间) log.info("message: {}, msg: {}", new String(message.getBody()), msg); } /* 第2个延迟队列的相关交换机、队列、绑定、监听方法定义 */ @Bean public org.springframework.amqp.core.Exchange dly2DirectExchange() { return ExchangeBuilder.directExchange("dly2.direct.ex").delayed().build(); } @Bean public org.springframework.amqp.core.Queue dly2Queue() { return QueueBuilder.durable("dly2.queue").build(); } @Bean public Binding binding() { return BindingBuilder.bind(dly2Queue()).to(dly2DirectExchange()).with("dly2").noargs(); } @RabbitListener(queues = {"dly2.queue"}) public void listenDelayedMsg2(Message message, String msg) { log.info("收到消息=====================2222"); // 可以在此处观察日志的输出时间, 和消息的数据(我设置的消息的数据就是消息的发送时间) log.info("message: {}, msg: {}", new String(message.getBody()), msg); } }
@Slf4j @RequestMapping("rabbit") @RestController public class RabbitController { @Autowired private RabbitTemplate rabbitTemplate; @GetMapping("delayMsg") public Object delayMsg(Integer delayTimeMillis, String exchange, String routeKey) { SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); String content = sdf.format(new Date()); /* 发送消息至指定的交换机, 并指定路由key, 注意延迟消息须如下设置延迟时间 */ rabbitTemplate.convertAndSend(exchange, routeKey, content, new MessagePostProcessor() { @Override public Message postProcessMessage(Message message) throws AmqpException { // 设置延迟时间 message.getMessageProperties().setDelay(delayTimeMillis); return message; } }); return "ok"; } }
测试:
第1个延迟消息测试:
http://localhost:8081/rabbit/delayMsg?delayTimeMillis=5000&exchange=dly.direct.ex&routeKey=dly,
http://localhost:8081/rabbit/delayMsg?delayTimeMillis=10000&exchange=dly.direct.ex&routeKey=dly
第2个延迟消息测试:
http://localhost:8081/rabbit/delayMsg?delayTimeMillis=5000&exchange=dly2.direct.ex&routeKey=dly2,
http://localhost:8081/rabbit/delayMsg?delayTimeMillis=10000&exchange=dly2.direct.ex&routeKey=dly2
总结:这种延迟消息都是需要消耗性能的,每来1个延迟消息,它都需要在mq的内部维护1个时钟,时钟的运行需要CPU不断的运算。当延迟消息很多的时候,对CPU的占用就越高。而延迟消息指定的延迟时间设置的过长,就会给CPU造成额外的压力。因此,延迟消息适用于指定延迟的时间较短的消息。
-
延迟消息优化:上面,我们说到延迟消息适用于指定延迟的时间较短的消息。针对延迟时间较长的消息,我们可以对延迟消息做个优化,将1个长时间的延迟消息拆分成若干个一小段一小段时间的延迟消息,然后针对业务做逻辑。
-
-
这个就是待发送的消息,data是数据,delayMillis中维护了一堆的时间段序列,每次要发消息到mq时,先从这个时间段序列中获取该时间段序列作为消息的延迟时间
@Data public class MultiDelayMessage<T> { /** * 消息体 */ private T data; /** * 记录延迟时间的集合 */ private List<Long> delayMillis; public MultiDelayMessage(T data, List<Long> delayMillis) { this.data = data; this.delayMillis = delayMillis; } public static <T> MultiDelayMessage<T> of(T data, Long ... delayMillis){ return new MultiDelayMessage<>(data, CollUtils.newArrayList(delayMillis)); } /** * 获取并移除下一个延迟时间 * @return 队列中的第一个延迟时间 */ public Long removeNextDelay(){ return delayMillis.remove(0); } /** * 是否还有下一个延迟时间 */ public boolean hasNextDelay(){ return !delayMillis.isEmpty(); } }
下单成功后,我们发送第1个延迟消息到rabbitmq中,
// 1.订单数据 // 2.保存订单详情 // 3.扣减库存 // 4.清理购物车商品 // 5.延迟检测订单状态消息 try { MultiDelayMessage<Long> msg = MultiDelayMessage.of(order.getId(), 10000L, 10000L, 10000L, 15000L, 15000L, 30000L, 30000L); rabbitTemplate.convertAndSend( MqConstants.DELAY_EXCHANGE, MqConstants.DELAY_ORDER_ROUTING_KEY, msg, new DelayMessageProcessor(msg.removeNextDelay().intValue()) ); } catch (AmqpException e) { log.error("延迟消息发送异常!", e); }
监听延迟消息交换机所绑定的队列,当接收到的消息体中,还存在时间段序列时,继续发,如果不存在了,那就说明所有的时间都消耗了
@Component @RequiredArgsConstructor public class OrderStatusCheckListener { private final IOrderService orderService; private final RabbitTemplate rabbitTemplate; @RabbitListener(bindings = @QueueBinding( value = @Queue(value = MqConstants.DELAY_ORDER_QUEUE, durable = "true"), exchange = @Exchange(value = MqConstants.DELAY_EXCHANGE, delayed = "true", type = ExchangeTypes.TOPIC), key = MqConstants.DELAY_ORDER_ROUTING_KEY )) public void listenOrderDelayMessage(MultiDelayMessage<Long> msg) { // 1.查询订单状态 Order order = orderService.getById(msg.getData()); // 2.判断是否已经支付 if (order == null || order.getStatus() == 2) { // 订单不存在或者已经被处理 return; } // TODO 3.去支付服务查询真正的支付状态 boolean isPay = false; // 3.1.已支付,标记订单状态为已支付 if (isPay) { orderService.markOrderPaySuccess(order.getId()); return; } // 4.判断是否存在延迟时间 if (msg.hasNextDelay()) { // 4.1.存在,重发延迟消息 Long nextDelay = msg.removeNextDelay(); rabbitTemplate.convertAndSend( MqConstants.DELAY_EXCHANGE, MqConstants.DELAY_ORDER_ROUTING_KEY, msg, new DelayMessageProcessor(nextDelay.intValue())); return; } // 5.不存在,取消订单 orderService.cancelOrder(order.getId()); } }