1. 消息队列
1.1 MQ 的相关概念
1.1.1 什么是 MQ
MQ,从字面意思上看,本质是个队列,FIFO 先入先出,只不过队列中存放的信息内容是 message 而已,还是一种跨进程的通信机制,用于上下游传递消息。在互联网架构中,MQ 是一种非常常见的上下游”逻辑解耦+物理解耦“的消息通信服务。使用了MQ之后,消息发送上游只需要依赖MQ,不用依赖其他服务
1.1.2 为什么要用MQ
1、流量消峰
举个例子,如果订单系统最多能处理一万次订单,这个处理能力应付正常时段的下单时绰绰有余,正常时段,我们下单一秒后就能返回结果,但是高峰期,如果有两万次下单操作系统是处理不了的,只能限制订单超过一万后不允许用户下单。使用消息队列做缓冲,我们可以取消这个限制,把下一秒内下的订单分散成一段时间来处理,这是有些用户可能在下单好几秒之后才能受到下单成功,但是比不能下单的体验要好。
2、应用解耦
以电商应用为例,应用中有订单系统,库存系统,物流系统,支付系统给。用户创建订单后,如果耦合调用库存系统,物流系统,支付系统,任何一个子系统出了故障,都会造成下单操作异常。当转变成基于消息队列的方式后,系统间调用的问题会减少很多,比如物流系统因为发生故障,需要几分钟来修复。在这几分钟的时间里,物流系统要处理的内存被缓存在消息队列中,用户的下单操作可以正常完成,当物流系统恢复后,继续处理订单信息即可,中间用户感受不到物流系统的故障,提升系统的可用性。
1.2 RabbitMQ
1.2.1四大核心概念
生产者
产生数据发送消息的程序是生产者
交换机
交换机是 RabbitMQ 非常重要的一个部件,一方面它接收来自生产者的消息,另一方面它将消息推送到队列中。交换机必须确切知道如何处理它接收到的消息,是将这些消息推送到特定队列还是推动到多个队列,或者是把消息丢弃,这个由交换机类型决定
队列
队列是 RabbitMQ 内部使用的一种数据结构,尽管消息经 RabbitMQ 和应用程序,但是它们只能存储在队列中。队列仅受主机和磁盘限制的约束,本质上是一个大的消息缓冲区。许多生产者可以将消息发送到一个队列,许多消费者可以尝试从一个队列接收数据。这就是我们使用队列的方式。
消费者
消费与接收具有相似的含义。消费者大多时候是一个等待接收消息的程序。==注意:==生产者,消费者和消息中间件有很多时候并不在同一机器上。同一个应用程序既可以是生产者又可以是消费者
一个交换机可以对应多个队列
2. Hello World
在下图中,”P“ 是生产者,”C“ 是消费者,中间的框是一个队列——RabbitMQ 代表使用者保留的消息缓冲区。
3. Work Queue
工作队列的主要思想是避开立即执行资源密集型任务,而不得不等待它完成。相反我们安排任务在之后执行。我们把任务封装为消息并将其发送到队列。在后台运行的工作进程弹出任务并最终执行作业。当有多个工作线程时,这些工作线程将一起处理这些任务
3. 确认发布
3.1 发布确认的策略
3.1.1 开启确认的方法
发布确认默认是没有开启的,如果要开启需要调用方法 confirmSelect,每当你想要使用发布确认,都需要在 channel 上调用该方法
Channel channel = connection.createChannel()
channel.confirmSelect()
3.1.2 单个确认发布
这是一种简单的确认方式,它是一种同步确认发布的方式,也就是发布一个消息之后只有它被确认发布,后序的消息才能继续发布,waitForConfirmsOrdie(long)
这个方法只有在消息被确认的时候才返回,如果在指定时间范围内这个消息没有被确认那么它将抛出异常。
这种确认方式有一个最大的缺点就是:发布速度特别慢,因为如果没有确认发布的消息就会阻塞所有后续消息的发布,这种方式最多提供每秒不超过数百条发布消息的吞吐量。当然对于某些应用程序来说可能已经足够了。
3.1.3 批量确认发布
上面那种方式发送非常慢,与单个等确认消息相比,先发布一批消息然后一起确认可以极大地提高吞吐量,当然这种方式地缺点就是:当发生故障导致发布出现问题时,不知道是哪个消息出现问题了,我们必须将整个批处理保存在内存中,以记录重要的信息而后重新发布消息。当然这种方案仍然是同步,也一样阻塞消息的发布。
3.1.4 异步确认发布
异步确认虽然编程逻辑比上两个要复杂,但是性价比最高,无论是可靠性还是效率都没得说,他是利用会带哦函数来达到消息可靠性传递的,这个中间件也是通过函数回调来保证是否投递成功,下面看异步确认怎么实现。
3.1.5 如何处理异步未确认的消息
在 3.1.4 中,从打印出来的信息可以发现确认的消息并不是完完整整规定的条数,而是中间有间隔的输出。并且在发送1000条消耗的时间计算完成之后还是执行了一段时间的发送,存在一定的问题
最好的解决的解决方案就是把未确认的消息放到一个基于内存的能被发布线程访问的队列,比如说用 ConcurrentLinkedQueue 这队列在 confirm,callbacks 与发布线程之间进行消息的传递。
4 Exchanges
4.1 Exchange 概念
RabbitMQ 消息传递模型的核心思想是:生产者生产的消息从不会直接发送道队列。实际上,通常生产者自己都不知道这些消息传递到了那些队列中。
相反,生产者只能将消息发送到交换机(exchange),交换机工作的内容非常简单,一方面它接收来自生产者的消息,另一方面将它们推入队列。交换机必须确切知道如何处理收到的消息。是应该把这些消息放到特定队列还是说把他们到许多队列中,这就由交换机的类型来决定
4.1.1 Exchange 的类型
直接(direct),主题(topic),标题(header),扇出(fanout)
4.1.2 无名 exchange
在之前对 exchange 一无所知的情况下,仍然能将消息发送到队列。之前能实现的原因是我们使用的是默认交换机,我们通过空字符串进行标识
第一个参数是交换机的名称.空字符串表示默认或无名交换机: 消息能路由发送到队列中其实是由 routingKey(bindingkey) 绑定 key 指定的
4.2 Fanout 实战
4.3 Topic 的要求
发送到类型的 topic 交换机的消息 routing_key 不能随意写,必须满足一定的要求,他必须是一个单词列表,以点号分隔开。这次单词可以是任意单词,比如说:“stock.use.nsd", “sdf.dfd”,“wer.sdfxc.vgfd” 这种类型的。当然这个的那次列表最多不能超过 255 个字节。
在规则列表中,其中有两个替换符是需要注意的
*(星号)可以代替一个单词
#(井号)可以替代零个或多个单词
4.3.1 Topic 匹配案例
下图绑定关系
Q1 --> 绑定的是: 中间带 origin 带 3 个单词的字符串(*.origin.*)
Q2 --> 绑定的是:
最后一个单词是 rabbit 的 3 个单词(*.*.rabbit)
第一个单词是 lazy 的多个单词(lazy.#)# 代表 0 个的单词或者多个单词
quick.orange.rabbit 被队列 Q1Q2 接收到
lazy.orange.ele 被队列 Q1Q2 接收到
qu.orange.abc 被队列 Q1 接收到
lazy.pin.rabbit 虽然满足两个绑定但只被队列 Q2 接收一次
quick.brown.fox 不匹配任何绑定不会被任何队列收到会被丢弃
lazy.orange.mal.rabbit 是四个单词但匹配 Q2
5 死信队列
5.1 死信队列概念
死信,就是无法被消费的消息,一般来说,producer 将消息投递到 broker 或者直接到 queue 里了,consumer 从 queue 去除消息进行消费,但某些时候由于特定的原因导致 queue 中某些消息无法被消费,这样如果没有后序的处理,就变成了死信,有死信自然就有了死信队列。
应用场景:为了保证订单业务的消息数据不丢失,需要使用到 RabbitMQ 的死信队列机制,当消息消费发生异常时,将消息投入死信队列中:用户在商城下单成功并点击去支付后在指定时间未支付时自动失效
5.2 死信的来源
消息 TTL 过期
队列达到最大长度(队列满了,无法在添加数据到 mq 中)
消息被拒绝(basic.reject 或 basic.nack)并且 requeue = false
5.3 死信实战
5.3.1 代码架构图
6 延迟队列
6.1 延迟队列概念
延迟队列,队列内部是有序的,最重要的特性就体现在它的延时属性上,延时队列中的元素是希望在指定时间到了以后或之前取出和处理,简单来说,延时队列就是用来存放需要在指定时间被处理的元素的队列。
6.2 延迟队列的使用场景
- 订单在 10 分钟内未支付则自动起效
- 新创建的店铺,如果在十天内都没有上传过商品,则自动发送消息提醒
- 用户注册成功后,如果三天内没有登陆则进行短信提醒
- 用户发起退款,如果三天内没有得到处理则通知相关运营人员
- 预定会议后,需要在预定的时间前10分钟通知各个与会人员参加会议
这些场景都有一个特点,需要在某个事件发生之后或者之前的指定时间点完成某一项任务,如:发生订单生成事件,在十分钟之后检查订单支付状态,然后将未支付的订单进行关闭;看起来似乎使用定时任务,一直轮询数据,每秒查一次,取出需要被处理的数据据,然后处理不就完事了吗?如果数据量比较小,确实可以这样做,比如:对于如果账单你一周内未支付则进行自动结算这样的需求,如果对于时间不是严格限制,而是宽松意义上的一周,那么每天晚上跑个定时任务检查一下所有未支付的账单,确实也是一个可行的方案。但对于数据量比较大,并且时效性较强的场景,如:订单十分钟内未支付则关闭,短期内未支付的订单数据可能会有很多个,活动期间甚至会达到百万甚至千万级别,对于庞大的数据量仍使用轮询方式并不可取,很可能在一秒内无法完成所有订单的检查,同时会给数据库带来很大的压力,无法满足业务要求而且性能低下。
6.3 队列 TTL
6.3.1 代码架构图
创建两个队列 QA 和 QB ,两者队列 TTL 分别设置为 10S 和 40S,然后在创建一个交换机 X 和死信交换机 Y,他们的类型都是 direct,创建一个死信队列 QD,它们的绑定关系如下
发起一个个请求:http://localhost:8080/ttl/sendMsg/嘻嘻嘻
第一条消息在 10s 后变成了死信消息,然后被消费者消费掉,第二条消息在 40s 之后变成了死信消息,然后被消费掉,着用一个延时队列就打造完成了。
不过,如果这样的话,岂不是每增加一个新的时间需求,就要新增一个队列,这里只有 10s 和 40s 两个时间选项,如果需要一个时候后处理,那么就需要增加 TTL 为一个小时的队列,如果是预定会议室然后提前通知这样的场景,岂不是要增加无数个队列才能满足需求?
6.4 延迟队列优化
6.4.1 代码架构图
在这里新增了一个队列 QC,绑定关系如下,该队列不设置 TTL 时间
// 普通队列名称
public static final String QUEUE_C = "QC";
// 声明 QC
@Bean("queueC")
public Queue queueC(){
Map<String, Object> arguments = new HashMap<>(3);
// 设置死信交换机
arguments.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE);
// 设置死信 RoutingKey
arguments.put("x-dead-letter-routing-key", "YD");
return QueueBuilder.durable(QUEUE_C).withArguments(arguments).build();
}
// 开始发消息 消息 TTL
@GetMapping("/sendExpirationMsg/{message}/{ttlTime}")
public void sendMsg(@PathVariable String message, @PathVariable String ttlTime){
log.info("当前时间:{},发送一条时长{}毫秒TTL信息给队列QC:{}",
new Date().toString(),ttlTime, 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 只会检查第一个消息是否过期如果过期则丢到死信队列,如果第一个消息的延时时长很长,而第二个消息的延时时很短,第二个消息并不会优先得到执行
6.5 Rabbitmq 插件实现延迟队列
上面个提到的问题,确实是一个问题,如果蹦年实现在消息粒度上的 TTL,并使用在设置的 TTL 时间及时死亡,就无法设计成又给通用的延时队列。那如何解决呢?
6.5.1 安装延时队列插件
参考文章:https://www.cnblogs.com/Leebo/p/15180490.html
6.5.2 代码架构图
在这里新增了一个队列 dealyed.queue,一个自定义交换机 delayed.exchange,绑定关系如下
代码演示
/*
config配置
*/
// 队列
public static final String DELAYED_QUEUE_NAME = "delayed.queue";
// 交换机
public static final String DELAYED_EXCHANGE_NAME = "delayed.exchange";
// routingkey
public static final String DELAYED_ROUTING_KEY = "delayed.routingkey";
@Bean
public Queue delayedQueue(){
return new Queue(DELAYED_QUEUE_NAME);
}
//声明交换机 基于插件
@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 Binding delayedQueueBindingDelayedExchange(@Qualifier("delayedQueue") Queue delayedQueue,@Qualifier("delayedExchange")CustomExchange delayedExchange){
return BindingBuilder.bind(delayedQueue).to(delayedExchange).with(DELAYED_ROUTING_KEY).noargs();
}
/*
controller
*/
// 开始发消息 基于插件的 消息 及 延迟的时间
@GetMapping("/sendDelayMsg/{message}/{delayTime}")
public void sendMsg(@PathVariable String message, @PathVariable 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;
});
}
/*
监听队列消费者
*/
// 监听消息
@RabbitListener(queues = DelayedQueueConfig.DELAYED_QUEUE_NAME)
public void receiveDelayQueue(Message message){
String msg = new String(message.getBody());
log.info("当前时间:{},收到延迟队列的消息:{}", new Date().toString(), msg);
}
第二个消息先被消费掉了,符合预期
6.6 总结
延时队列在需要延时处理的场景下非常高有用,使用 RabbitMQ 来实现延时队列可以很好的利用 RabbitMQ 的特性,如:消息可靠发送、消息可靠投递,死信队列来保障消息至少被消费一次以及未被正确处理的消息不会被丢弃,另外,通过 RabbitMQ 集群的特性,可以很好的解决单点故障问题,不会因为单个节点挂掉导致延时队列不可用或者消息丢失。
7 发布确认高级
在生产环境中由于一些不明原因,导致 rabbitmq 重启,在 RabbitMQ 重启期间生产者消息投递失败,导致消息丢失,需要手动处理和恢复。保证 RabbitMQ 的消息可靠投递,在这样比较极端的情况下,RabbitMQ 集群不可用的时候,无法投递的消息怎么处理
7.1 发布确认 Springboot 版本
7.1.1 确认机制方案
7.1.2 代码架构图
7.1.3 配置文件
在配置文件中需要添加
spring.rabbitmq.publisher-confirm-type=correlated
- NONE
- 禁止发布确认
- CORRELATED
- 发布消息成功到交换机后会触发回调函数
- SIMPLE
- 经测试有两种效果,其一效果和 CORRELATED 值一样会触发回调方法,
- 其二在发布消息成功后使用 rabbitTemplate 调用 waitForConfirms 或 waitForConfirmsOrDie 方法等待 broker 节点返回发送结果,根据返回结果来判断下一步的逻辑,要注意的点是 waitForConfirmsOrDie 方法如果返回 false 则会关闭 channel ,则接下来无法发送消息到 broker
7.2 回退消息
7.2.1 Mandatory 参数
**在仅开启来了生产者确认机制的情况下,交换机接收到消息后,会直接给消息生产者发送确认消息,如果发现该消息不可路由,那么消息会被直接丢弃,此时生产者是不知道消息被丢弃这个事件的。**那么如何让无法被路由的消息帮我门想办法处理一下。最起码让我们知道出现了问题。解决方案:可以通过设置 mandatory 参数可以在当消息传递过程中不可达目的的将消息返回给生产者。
在配置文件中加上下面一行
spring.rabbitmq.publisher-returns=true
public class MyCallBack implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback {
// 可以在当消息传递过程中不可达目的地时将消息返回给生产者
// 只有不可达目的地的时候 才进行回退
@Override
public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
log.error("消息{},被交换机{}退回,退回原因:{},路由Key:{}",
new String(message.getBody()), exchange, replyText, routingKey);
}
}
7.3 备份交换机
7.3.1 架构图
7.3.2 配置类
// 备份交换及
public static final String BACKUP_EXCHANGE_NAME = "backup_exchange";
// 备份队列
public static final String BACKUP_QUEUE_NAME = "backup_queue";
// 报警队列
public static final String WARNING_QUEUE_NAME = "warning_queue";
// 声明交换机
@Bean
public DirectExchange confirmExchange(){
return ExchangeBuilder.directExchange(CONFIRM_EXCHANGE_NAME).durable(true)
.withArgument("alternate-exchange", BACKUP_EXCHANGE_NAME).build();
}
@Bean
public Queue warningQueue(){
return QueueBuilder.durable(WARNING_QUEUE_NAME).build();
}
// 进行绑定
@Bean
public Binding backupQueueBindingBackupExchange(@Qualifier("backupQueue") Queue backupQueue,
@Qualifier("backupExchange") FanoutExchange backupExchange){
return BindingBuilder.bind(backupQueue).to(backupExchange);
}
@Bean
public Binding warningQueueBindingBackupExchange(@Qualifier("warningQueue") Queue warningQueue,
@Qualifier("backupExchange") FanoutExchange backupExchange){
return BindingBuilder.bind(warningQueue).to(backupExchange);
}
结果分析
mandatory 参数与备份交换机可以一起使用的时候,如果两者同时开启,消息究竟何去何从?谁优先级高,经过上面结果显示答案是 备份交换机优先级高
8 RabbitMQ 其他知识点
8.1 幂等性
8.1.1 概念
用户对于统一操作发起的一次请求或多次请求的结果是一致的,不会因为多次点击而产生了副作用。例如:支付,用户购买商品后支付,支付扣款成功,但是返回结果的网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣钱,流水记录也变成了2条。在以前的但应用系统中,我们只需要把数据操作放入事务中即可,发生错误立即回滚,但是再响应客户端的时候也有可能出现网络中断或者异常等等。
8.1.2 消费重复消费
消费者再消费 MQ 中的消息时,MQ 已把消息发送给消费者,消费者在给 MQ 返回 ack 时网络中断,故 MQ 未收到确认信息,该条信息会重新发送给其他的消费者,或者在网络重连后再次发送给该消费者,但实际上该消费者已成功消费了该条消息,造成消费者消费了重复的消息。
8.1.3 解决思路
MQ 消费者的幂等性的解决一般使用全局 ID 或者写个唯一标识比如时间戳 或者 UUID 或者订单消费者消费 MQ 中的消息也可以利用 MQ 的该 id 来判断,或者可按自己的规则生成一个全局唯一 id,每次消费消息时用该 id 先判断该消息是否已消费过。
8.2 优先级队列
8.2.1 使用场景
若在系统中有一个订单催付的场景,我们的客户在天猫下的订单,淘宝会及时将订单推送给我们,如果在用户设定的时间内未付款那么就会给用户推送一条短信提醒,功能相对简单,但是 tmall 商家对于我们来说,肯定是要分大小客户,比如 iphone,华为这样大商家一年起码能给我门创造很大的利润,所以理应当然它们的订单必须得到优先处理,而曾经我们的后端系统是 redis 在存放的定时轮询,redis 只能用 List 做一个简简单单的消息队列,并不能实现一个优先级的场景,所以订单量大了之后采用 RabbitMQ 进行改造和优化,如果发现大客户的订单给一个相对比较高的优先级,否则就是默认优先级。
9.2.2 如何添加
a. 控制台页面添加
b. 队列中代码添加优先级
Map<String, Object> params = new HashMap();
params.put("x-max-priority", 10);
channel.queueDeclare("hello", true, true, false, params);
c. 消息中代码添加优先级
AMQP.BasicProperties properties = new AMQP.BasicProperties().builder().prority(5).build();
d. 注意事项
要让队列实现优先级需要做的事情有如下事情:队列需要设置为优先级队列,消息需要设置消息的优先级,消费者需要等待消息已经发送到队列中才去消费,因为,这样才有机会对消息进行排序。