7.5 延时队列TTL优化
在这里新增了一个队列 QC,绑定关系如下,该队列不设置TTL 时间(而是通过参数传递的形式来设置TTL时间)
配置文件类代码:
public static final String QUEUE_C = "QC"; @Bean("queueC") public Queue queueC(){ Map <String, Object> arguments = new HashMap <>(); //设置死信交换机 arguments.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE); //设置死信RoutingKey arguments.put("x-dead-letter-routing-key","YD"); //TTL return QueueBuilder.durable(QUEUE_C).withArguments(arguments).build(); } @Bean public Binding queueCBindingX(@Qualifier("queueC") Queue queueC, @Qualifier("xExchange") DirectExchange xExchange){ return BindingBuilder.bind(queueC).to(xExchange).with("XC"); }
生产者代码:
@GetMapping("/sendExpirationMsg/{message}/{ttlTime}") public void sendMsg(@PathVariable String message, @PathVariable("ttlTime") String ttlTime){ log.info("当前时间:{},发送一条时长{}毫秒TTL消息给队列QC:{}",new Date().toString(),ttlTime,message); //rabbitTemplate.convertAndSend("X","XC","消息来自ttl为10s的队列"+message); rabbitTemplate.convertAndSend("X","XC",message,msg->{ //发送消息的时候延迟时长. msg.getMessageProperties().setExpiration(ttlTime); return msg; }); }
发起请求
http://localhost:8080/ttl/sendExpirationMsg/你好1/20000
http://localhost:8080/ttl/sendExpirationMsg/你好2/2000
看起来似乎没什么问题,但是在最开始的时候,就介绍过如果使用在消息属性上设置 TTL 的方式,消息可能并不会按时“死亡“.因为 RabbitMQ 只会检查第一个消息是否过期,如果过期则丢到死信队列, 如果第一个消息的延时时长很长,而第二个消息的延时时长很短,第二个消息并不会优先得到执行。
这也就是为什么第二个延时2秒,却后执行。但这样很不合理.
7.6 Rabbitmq 插件实现延迟队列
上文中提到的问题,确实是一个问题,如果不能实现在消息粒度上的 TTL,并使其在设置的TTL 时间及时死亡,就无法设计成一个通用的延时队列。那如何解决呢,接下来我们就去解决该问题。
7.6.1安装延时队列插件
可去官网下载 (opens new window)rabbitmq_delayed_message_exchange 插件,放置到 RabbitMQ 的插件目录。
进入 RabbitMQ 的安装目录下的 plgins 目录,执行下面命令让该插件生效,然后重启 RabbitMQ
[root@VM-0-6-centos software]# ls erlang-21.3.8.21-1.el7.x86_64.rpm rabbitmq_delayed_message_exchange-3.8.0.ez rabbitmq-server-3.8.8-1.el7.noarch.rpm #移动 cp rabbitmq_delayed_message_exchange-3.8.0.ez /usr/lib/rabbitmq/lib/rabbitmq_server-3.8.8/plugins #安装 rabbitmq-plugins enable rabbitmq_delayed_message_exchange #重启服务 systemctl restart rabbitmq-server
7.6.2实现代码
在这里新增了一个队列delayed.queue,一个自定义交换机 delayed.exchange,绑定关系如下:
1、配置文件类代码:
在我们自定义的交换机中,这是一种新的交换类型,该类型消息支持延迟投递机制消息传递后并不会立即投递到目标队列中,而是存储在 mnesia(一个分布式数据系统)表中,当达到投递时间时,才投递到目标队列中。
Configuration public class DelayedQueueConfig { //定义队列,交换机,routingkey public static final String DELAYED_QUEUE_NAME="delayed.queue"; public static final String DELAYED_EXCHANGE_NAME="delayed.exchange"; public static final String DELAYED_ROUTING_KEY="delayed.routingkey"; //声明交换机,基于插件的 @Bean public CustomExchange delayedExchange(){ Map <String, Object> arguments = new HashMap <>(); //设置 交换机的类型 arguments.put("x-delayed-type", "direct"); /** * 1.交换机的名称 * 2.交换机的类型 * 3.是否可以持久化 * 4.是否自动删除 * 5.其他的参数 */ return new CustomExchange(DELAYED_EXCHANGE_NAME, "x-delayed-message", true, false, arguments); } @Bean public Queue delayedQueue(){ return new Queue(DELAYED_QUEUE_NAME); } @Bean public Binding delayedQueueBindDelayedExchange(@Qualifier("delayedQueue") Queue delayedQueue, @Qualifier("delayedExchange") CustomExchange delayedExchange){ return BindingBuilder.bind(delayedQueue).to(delayedExchange).with(DELAYED_ROUTING_KEY).noargs(); } }
2、生产者代码
//基于插件的消息以及延迟时间 @GetMapping("/sendDelay/{message}/{delayTime}") public void sendMsg(@PathVariable("message") String message, @PathVariable("delayTime") Integer delayTime){ log.info("当前时间:{},发送一条时长{}毫秒消息给延迟队列delayed.queue:{}",new Date().toString(),delayTime,message); rabbitTemplate.convertAndSend(DelayedQueueConfig.DELAYED_EXCHANGE_NAME,DelayedQueueConfig.DELAYED_ROUTING_KEY,message,msg->{ //发型消息的时候 设置延迟时长 ms msg.getMessageProperties().setDelay(delayTime); return msg; } ); }
3、消费者代码
@Slf4j @Component public class DelayQueueConsumer { //监听消息 @RabbitListener(queues = DelayedQueueConfig.DELAYED_QUEUE_NAME) public void receiveDelayQueue(Message message){ String msg = new String(message.getBody()); log.info("当前时间:{},收到延迟队列的消息:{}",new Date().toString(),msg); } }
发送请求:
- http://localhost:8080/ttl/sendDelay/Come on Baby1/20000
- http://localhost:8080/ttl/sendDelay/Come on Baby2/2000
运行结果:
第二个消息被先消费掉了,符合预期结果,
7.7总结
延时队列在需要延时处理的场景下非常有用,使用 RabbitMQ 来实现延时队列可以很好的利用 RabbitMQ 的特性,如:消息可靠发送、消息可靠投递、死信队列来保障消息至少被消费一次以及未被正确处理的消息不会被丢弃。另外,通过 RabbitMQ 集群的特性,可以很好的解决单点故障问题,不会因为 单个节点挂掉导致延时队列不可用或者消息丢失。
当然,延时队列还有很多其它选择,比如利用 Java 的 DelayQueue,利用 Redis 的 zset,利用 Quartz 或者利用 kafka 的时间轮,这些方式各有特点,看需要适用的场景
8.确认发布
在生产环境中由于一些不明原因,导致 RabbitMQ 重启,在 RabbitMQ 重启期间生产者消息投递失败, 导致消息丢失,需要手动处理和恢复。于是,我们开始思考,如何才能进行 RabbitMQ 的消息可靠投递呢?
8.1 发布确认 springboot 版本
确认机制方案:
代码架构图:
在配置文件当中需要添加
spring.rabbitmq.publisher-confirm-type=correlated
NONE 值是禁用发布确认模式,是默认值
CORRELATED 值是发布消息成功到交换器后会触发回调方法
SIMPLE 值经测试有两种效果,其一效果和 CORRELATED 值一样会触发回调方法,其二在发布消息成功后使用 rabbitTemplate 调用 waitForConfirms 或 waitForConfirmsOrDie 方法等待 broker 节点返回发送结果,根据返回结果来判定下一步的逻辑,要注意的点是 waitForConfirmsOrDie 方法如果返回 false 则会关闭 channel,则接下来无法发送消息到 broker;
代码
1.添加配置类:
@Configuration public class ConfirmConfig { //交换机 队列 Routingkey public static final String CONFIRM_EXCHANGE_NAME="confirm_exchange"; public static final String CONFIRM_QUEUE_NAME="confirm_queue"; public static final String CONFIRM_ROUTING_KEY="key1"; @Bean("confirmExchange") public DirectExchange confirmExchange(){ return ExchangeBuilder.directExchange(CONFIRM_EXCHANGE_NAME).durable(true).withArgument("alternate-exchange",BACKUP_EXCHANGE_NAME).build(); } @Bean("confirmQueue") public Queue confirmQueue(){ return QueueBuilder.durable(CONFIRM_QUEUE_NAME).build(); } @Bean public Binding queueBindingExchange(@Qualifier("confirmExchange") DirectExchange confirmExchange, @Qualifier("confirmQueue") Queue confirmQueue){ return BindingBuilder.bind(confirmQueue).to(confirmExchange).with(CONFIRM_ROUTING_KEY); } }
2、消息生产者的回调接口
/** * 回调接口:用于生产者消息发送给交换机,接收成功后通知生产者 */ @Slf4j @Component public class MyCallBack implements RabbitTemplate.ConfirmCallback{ @Autowired private RabbitTemplate rabbitTemplate; /** * 交换机确认回调方法 * 1.发消息 交换机接收到了 回调 * 1.1correlationData:保存回调消息的ID及相关信息 * 1.2交换机收到消息 ack=true * 1.3cause null *2.发消息 交换机接收失败了 回调 * 2.1 corelationData:保存回调消息的ID及相关信息(由生产者传入) * 2.2 交换机收到消息 ack=false * 2.3 cause 失败的原因 * * @param correlationData * @param ack * @param cause */ @Override public void confirm(CorrelationData correlationData, boolean ack, String cause) { String id = correlationData != null ? correlationData.getId():""; if(ack){//如果发送成功 log.info("交换机已经收到ID为{}的消息",id); }else{ log.info("交换机还未收到ID为{}的消息,由于原因:{}",id,cause); } } @PostConstruct //最后执行该方法 public void init(){ //在RabbitTemplete中注入 rabbitTemplate.setConfirmCallback(this); } }
3、消息生产者
@RestController @Slf4j @RequestMapping("/productor") public class ProductorController { @Autowired private RabbitTemplate rabbitTemplate; @GetMapping("/sendMessage/{message}") public void sendMessage(@PathVariable("message") String message){ CorrelationData correlationData = new CorrelationData("1"); rabbitTemplate.convertAndSend(ConfirmConfig.CONFIRM_EXCHANGE_NAME,ConfirmConfig.CONFIRM_ROUTING_KEY,message+"key1",correlationData); log.info("发送消息内容为:{}",message+"key1"); CorrelationData correlationData2 = new CorrelationData("2"); rabbitTemplate.convertAndSend(ConfirmConfig.CONFIRM_EXCHANGE_NAME,ConfirmConfig.CONFIRM_ROUTING_KEY+"abc",message+"key2",correlationData2); log.info("发送消息内容为:{}",message+"key2"); } }
4、消息消费者
//接收消息 @Slf4j @Component public class Consumer { @RabbitListener(queues = ConfirmConfig.CONFIRM_QUEUE_NAME) public void receiveConfirmMessage(Message message){ String msg = new String(message.getBody()); log.info("接收到的队列confirm.queue消息:{}",msg); } }
访问: http://localhost:8080/confirm/sendMessage/你好(opens new window)
结果分析:
正常发送的话,交换机会给出应答.
交换机出错依旧会给出应答:
当发送时队列出错,依旧会给出应答,但无法说明消息是否发送成功.
可以看到,发送了两条消息,第一条消息的 RoutingKey 为 “key1”,第二条消息的 RoutingKey 为 “key2”,两条消息都成功被交换机接收,也收到了交换机的确认回调,但消费者只收到了一条消息,因为第二条消息的 RoutingKey 与队列的 BindingKey 不一致,也没有其它队列能接收这个消息,所有第二条消息被直接丢弃了。
丢弃的消息交换机是不知道的,需要解决告诉生产者消息传送失败
8.2回退消息
Mandatory 参数
rabbitTemplate.setReturnsCallback(myCallBack);
在仅开启了生产者确认机制的情况下,交换机接收到消息后,会直接给消息生产者发送确认消息,如果发现该消息不可路由,那么消息会被直接丢弃,此时生产者是不知道消息被丢弃这个事件的。
那么如何让无法被路由的消息帮我想办法处理一下?最起码通知我一声,我好自己处理啊。通过设置 mandatory 参数可以在当消息传递过程中不可达目的地时将消息返回给生产者。
1、修改配置
#消息退回 spring.rabbitmq.publisher-returns=true
2.修改回调接口
@Slf4j @Component public class MyCallBack implements RabbitTemplate.ConfirmCallback ,RabbitTemplate.ReturnCallback{ @Autowired private RabbitTemplate rabbitTemplate; /** * 交换机确认回调方法 * 1.发消息 交换机接收到了 回调 * 1.1correlationData:保存回调消息的ID及相关信息 * 1.2交换机收到消息 ack=true * 1.3cause null *2.发消息 交换机接收失败了 回调 * 2.1 corelationData:保存回调消息的ID及相关信息(由生产者传入) * 2.2 交换机收到消息 ack=false * 2.3 cause 失败的原因 * * @param correlationData * @param ack * @param cause */ @Override public void confirm(CorrelationData correlationData, boolean ack, String cause) { String id = correlationData != null ? correlationData.getId():""; if(ack){//如果发送成功 log.info("交换机已经收到ID为{}的消息",id); }else{ log.info("交换机还未收到ID为{}的消息,由于原因:{}",id,cause); } } @PostConstruct //最后执行该方法 public void init(){ //在RabbitTemplete中注入 rabbitTemplate.setConfirmCallback(this); rabbitTemplate.setReturnCallback(this); } //可以在当消息传递过程中不可达目的地时,将消息返回给生产者. //只有不可达时,才进行回退. @Override public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) { log.error("消息{},被交换机{}退回,退回原因:{},路由key:{}",message,exchange,replyText,routingKey); } }
访问: http://localhost:8080/confirm/sendMessage/你好啊(opens new window)
结果分析:
8.3 备份交换机:
有了 mandatory 参数和回退消息,我们获得了对无法投递消息的感知能力,在生产者的消息无法被投递时发现并处理。但有时候,我们并不知道该如何处理这些无法路由的消息,最多打个日志,然后触发报警,再来手动处理。而通过日志来处理这些无法路由的消息是很不优雅的做法,特别是当生产者所在的服务有多台机器的时候,手动复制日志会更加麻烦而且容易出错。而且设置 mandatory 参数会增加生产者的复杂性,需要添加处理这些被退回的消息的逻辑。如果既不想丢失消息,又不想增加生产者的复杂性,该怎么做呢?
前面在设置死信队列的文章中,我们提到,可以为队列设置死信交换机来存储那些处理失败的消息,可是这些不可路由消息根本没有机会进入到队列,因此无法使用死信队列来保存消息。 在 RabbitMQ 中,有一种备份交换机的机制存在,可以很好的应对这个问题。
什么是备份交换机呢?备份交换机可以理解为 RabbitMQ 中交换机的“备胎”,当我们为某一个交换机声明一个对应的备份交换机时,就是为它创建一个备胎,当交换机接收到一条不可路由消息时,将会把这条消息转发到备份交换机中,由备份交换机来进行转发和处理,通常备份交换机的类型为 Fanout ,这样就能把所有消息都投递到与其绑定的队列中,然后我们在备份交换机下绑定一个队列,这样所有那些原交换机无法被路由的消息,就会都进 入这个队列了。当然,我们还可以建立一个报警队列,用独立的消费者来进行监测和报警。
代码架构图
1、修改配置类
... //备份交换机/队列 报警队列 public static final String BACKUP_EXCHANGE_NAME="backup_exchange"; public static final String BACK_QUEUE_NAME="backup_queue"; public static final String WARNING_QUEUE_NAME="warning_queue"; @Bean("backupExchange") public FanoutExchange backupExchange(){ return new FanoutExchange(BACKUP_EXCHANGE_NAME); } @Bean("backupQueue") public Queue backupQueue(){ return QueueBuilder.durable(BACK_QUEUE_NAME).build(); } @Bean("warningQueue") public Queue warningQueue(){ return QueueBuilder.durable(WARNING_QUEUE_NAME).build(); } @Bean public Binding backupqueueBindingExchange(@Qualifier("backupExchange") FanoutExchange backupExchange, @Qualifier("backupQueue") Queue backupQueue){ return BindingBuilder.bind(backupQueue).to(backupExchange); } @Bean public Binding warningqueueBindingExchange(@Qualifier("backupExchange") FanoutExchange backupExchange, @Qualifier("warningQueue") Queue warningQueue){ //没有routingKey可以不写. return BindingBuilder.bind(warningQueue).to(backupExchange); } ...
2、报警消费者
@Component @Slf4j public class WarningCousumer { //接收报警消息 @RabbitListener(queues = ConfirmConfig.WARNING_QUEUE_NAME) public void receiveWarningMsg(Message message){ String msg = new String(message.getBody()); log.error("报警发现不可路由消息:{}",msg); } }
之前已写过 confirm.exchange
交换机,由于更改配置,需要删掉,不然会报错
访问: http://localhost:8080/confirm/sendMessage/你好啊
mandatory 参数与备份交换机可以一起使用的时候,如果两者同时开启,消息究竟何去何从?谁优先级高,经过上面结果显示答案是备份交换机优先级高。
9.幂等性,优先级,惰性队列
9.1幂等性
概念
用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。 举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常, 此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣钱 了,流水记录也变成了两条。在以前的单应用系统中,我们只需要把数据操作放入事务中即可,发生错误立即回滚,但是再响应客户端的时候也有可能出现网络中断或者异常等等
消息重复消费
消费者在消费 MQ 中的消息时,MQ 已把消息发送给消费者,消费者在给 MQ 返回 ack 时网络中断, 故 MQ 未收到确认信息,该条消息会重新发给其他的消费者,或者在网络重连后再次发送给该消费者,但实际上该消费者已成功消费了该条消息,造成消费者消费了重复的消息。
解决思路
MQ 消费者的幂等性的解决一般使用全局 ID 或者写个唯一标识比如时间戳 或者 UUID 或者订单消费者消费 MQ 中的消息也可利用 MQ 的该 id 来判断,或者可按自己的规则生成一个全局唯一 id,每次消费消息时用该 id 先判断该消息是否已消费过。
消费端的幂等性保障
在海量订单生成的业务高峰期,生产端有可能就会重复发生了消息,这时候消费端就要实现幂等性, 这就意味着我们的消息永远不会被消费多次,即使我们收到了一样的消息。
业界主流的幂等性有两种操作:a. 唯一 ID+指纹码机制,利用数据库主键去重, b.利用 redis 的原子性去实现
唯一ID+指纹码机制
指纹码:我们的一些规则或者时间戳加别的服务给到的唯一信息码,它并不一定是我们系统生成的,基本都是由我们的业务规则拼接而来,但是一定要保证唯一性,然后就利用查询语句进行判断这个 id 是否存在数据库中,优势就是实现简单就一个拼接,然后查询判断是否重复;劣势就是在高并发时,如果是单个数据库就会有写入性能瓶颈当然也可以采用分库分表提升性能,但也不是我们最推荐的方式。
Redis 原子性
利用 redis 执行 setnx 命令,天然具有幂等性。从而实现不重复消费
9.2优先级队列
- 使用场景
在我们系统中有一个订单催付的场景,我们的客户在天猫下的订单,淘宝会及时将订单推送给我们,如果在用户设定的时间内未付款那么就会给用户推送一条短信提醒,很简单的一个功能对吧。
但是,tmall 商家对我们来说,肯定是要分大客户和小客户的对吧,比如像苹果,小米这样大商家一年起码能给我们创造很大的利润,所以理应当然,他们的订单必须得到优先处理,而曾经我们的后端系统是使用 redis 来存放的定时轮询,大家都知道 redis 只能用 List 做一个简简单单的消息队列,并不能实现一个优先级的场景,所以订单量大了后采用 RabbitMQ 进行改造和优化,如果发现是大客户的订单给一个相对比较高的优先级, 否则就是默认优先级。
- 如何添加?
a.控制台页面添加
b.队列中代码添加优先级
Map<String, Object> params = new HashMap(); params.put("x-max-priority", 10); channel.queueDeclare("hello", true, false, false, params);
c.消息中代码添加优先级
AMQP.BasicProperties properties = new AMQP.BasicProperties().builder().priority(10).
注意事项:
要让队列实现优先级需要做的事情有如下事情:队列需要设置为优先级队列,消息需要设置消息的优先级,消费者需要等待消息已经发送到队列中才去消费因为,这样才有机会对消息进行排序
实战
生产者:
public class Producer { private final static String QUEUE_NAME = "hello";//ctrl+shift+u public static void main(String[] args) throws IOException, TimeoutException { //创建一个工厂 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("192.168.174.128"); factory.setUsername("admin"); factory.setPassword("admin"); // Connection connection = factory.newConnection(); Channel channel = connection.createChannel();//创建信道 Map <String , Object> arguments = new HashMap <>(); //设置优先级的范围 官方允许是0-255 此处设置的是0-10. arguments.put("x-max-priority", 10); channel.queueDeclare(QUEUE_NAME,true,false,false,arguments); for (int i = 0; i < 11; i++) { String message = "info"+i; if(i==5){ AMQP.BasicProperties properties = new AMQP.BasicProperties().builder().priority(5).build(); channel.basicPublish("",QUEUE_NAME,properties,message.getBytes()); }else{ channel.basicPublish("",QUEUE_NAME,null,message.getBytes()); } } System.out.println("消息发送外币"); } }
消费者:
public class Consumer { private final static String QUEUE_NAME = "hello";//ctrl+shift+u public static void main(String[] args) throws Exception{ //创建一个工厂 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("192.168.174.128"); factory.setUsername("admin"); factory.setPassword("admin"); //channer实现自动close接口,自动关闭 Connection connection = factory.newConnection(); Channel channel = connection.createChannel();//创建信道 //声明接收消息 DeliverCallback deliverCallback=( consumerTag, message)->{ System.out.println(new String(message.getBody())); }; //取消消息时的回调 CancelCallback cancelCallback = consumerTag->{ System.out.println("消息消费被中断..."); }; channel.basicConsume(QUEUE_NAME,true,deliverCallback,cancelCallback); }
9.3惰性队列
- 使用场景
RabbitMQ 从 3.6.0 版本开始引入了惰性队列的概念。惰性队列会尽可能的将消息存入磁盘中,而在消费者消费到相应的消息时才会被加载到内存中,它的一个重要的设计目标是能够支持更长的队列,即支持更多的消息存储。当消费者由于各种各样的原因(比如消费者下线、宕机亦或者是由于维护而关闭等)而致使长时间内不能消费消息造成堆积时,惰性队列就很有必要了。
默认情况下,当生产者将消息发送到 RabbitMQ 的时候,队列中的消息会尽可能的存储在内存之中, 这样可以更加快速的将消息发送给消费者。即使是持久化的消息,在被写入磁盘的同时也会在内存中驻留一份备份。当RabbitMQ 需要释放内存的时候,会将内存中的消息换页至磁盘中,这个操作会耗费较长的时间,也会阻塞队列的操作,进而无法接收新的消息。虽然 RabbitMQ 的开发者们一直在升级相关的算法, 但是效果始终不太理想,尤其是在消息量特别大的时候。
- 两种模式
队列具备两种模式:default 和 lazy。默认的为default 模式,在3.6.0 之前的版本无需做任何变更。lazy 模式即为惰性队列的模式,可以通过调用 channel.queueDeclare 方法的时候在参数中设置,也可以通过 Policy 的方式设置,如果一个队列同时使用这两种方式设置的话,那么 Policy 的方式具备更高的优先级。 如果要通过声明的方式改变已有队列的模式的话,那么只能先删除队列,然后再重新声明一个新的。
在队列声明的时候可以通过“x-queue-mode”参数来设置队列的模式,取值为**“default”和“lazy”**。下面示例中演示了一个惰性队列的声明细节:
Map<String, Object> args = new HashMap<String, Object>(); args.put("x-queue-mode", "lazy"); channel.queueDeclare("myqueue", false, false, false, args);
- 内存开销对比
在发送 1 百万条消息,每条消息大概占 1KB 的情况下,普通队列占用内存是 1.2GB,而惰性队列仅仅 占用 1.5MB