RabbitMQ 学习
高性能异步通讯组件
基础篇
同步调用和异步调用:
同步调用:
余额支付为例
最初完成的功能我们需要同步完成,当支付完成的同时我们需要扣减余额,和更改支付状态。这时这些操作我们需要同步完成进行操作,但是后续的更改订单状态等我们并不需要当时进行修改,可以进行异步修改。
同时随着后续的开发可能还有更多模块。例如:
开放了更多功能这时我们本不需要在一个支付服务里面同时完成这么多需求,我们只需要完成扣减余额和更新支付状态进行返回,其他的服务我们可以异步执行。
但是如果我们同时在一个服务完成,非要让整个代码同步调用执行就会导致一下问题:
- 拓展性差
- 性能下降 (后续的服务需要等待前面的服务完成才能进行)
- 级联失败(中间的某个服务发生错误导致后续的程序也错误)
所以使用同步调用场合就是需要某一些功能要连续性完成,比如付款和扣减余额需要同时完成需要同步调用,但是扣减完成后的一些后续服务并不需要立即获取结果可以后续调用完成这时就不应该用同步调用否则会造成影响。
同步调用的优势:
时效性强,等待到结果后才返回。
异步调用
异步调用其实就是基于消息通知的方式,一般包括三个角色:
- 消息发送者:投递消息的人,就是用来的调用方
- 消息代理:管理、暂存、转发消息
- 消息接收者:接受和处理消息的人,就是原来的服务提供方
这时消息的发送者只需要把需要调用的消息放入到消息代理中就不用管了自己的服务完成了,然后消息的接收者只需要从消息代理中去获取消息然后完成相关联的服务。这样就可以实现解耦了。
这样余额支付就可以修改为:
异步调用的优势:
- 接触耦合,拓展性强
- 无需等待,性能好
- 故障隔离
- 缓存消息,流量削峰填谷
缺点:
- 不能立刻得到调用结果,时效性差
- 不确定下游业务执行是否成功
- 业务安全依赖于Broker(消息代理)的可靠性
RabbitMQ基本介绍
RabbitMQ的整体架构及核心概念:
- publisher:消息发送者
- consumer:消息的消费者
- queue:队列,存储消息
- exchange:交换机,负责路由消息
- virtual-host:虚拟主机,起到数据隔离的作用(类似于数据库的库)
快速入门
首先要在虚拟机上完成RabbitMQ的安装,再进入到浏览器中访问客户端页面端口号一般为15672。一般格式为:ip地址:15672
要求:
- 新建队列hello.queue1和hello.queue2
- 向默认的amp.fanout交换机发送一条消息
- 查看消息是否到达hello.queue1和hello.queue2
- 总结规律
实现:
- 创建两个队列
这里type就选Classic然后设置一个名字就好。
-
通过交换机发送数据
-
发送数据
这时如果我们直接发送数据会遇到一个错误,没有路由。因为交换机只有转发路由消息的功能,并没有存储消息的功能。
- 设置路由,绑定关系
-
再次进行发送数据就会发送成功。
-
查看数据
通过点击某个消息队列进行下列操作进行查看!
数据隔离
我们可以在客户端页面的右上角查看设置的MQ的虚拟主机,默认的有一个名为 “/”的虚拟主机。
同时我们可以添加自己创建的虚拟主机。
案例要求:
- 新建一个用户hmall
- 为hmall用户创建一个virtual host
- 测试不同virtual host之间的数据隔离现象
实现:
-
创建用户
-
切换到hmall用户可以发现没有权限查看之前创建的消息队列,因为那些消息队列并不是新创建的hamll用户虚拟主机里的消息对了,但是由于在创建hmall用户时设置的权限是管理员所以可以查看到有这些消息队列。
-
创建新用户的虚拟主机
这样就可以在自己新创建的虚拟主机里进行测试了。
Java客户端 - 快速入门
进入官网我们可以发现有许多的入门案例demo推荐,但是官网这些普通案例都太过于麻烦了,所以推荐使用Spring AMQP可以简化操作。
入门案例:
- 利用控制台创建队列simple.queue
- 在publisher服务中,利用Spring AMQP直接向simple.queue发送消息
- 在consumer服务中,利用SpringAMQP编写消费者,监听simple.queue队列
AMQP的依赖导入:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
- 先在Rabbit MQ的网页客户端hmall虚拟机下创建一个simple.queue消息队列
- 在yml文件中配置MQ服务端的信息
- 编写测试类发送消息,使用的API是RabbitTemplate。
@SpringBootTest
public class SendMessage {
@Resource
private RabbitTemplate rabbitTemplate;
@Test
void test() {
String queueName = "simple.queue";
String msg = "hello, amqp";
rabbitTemplate.convertAndSend(queueName, msg);
}
}
- 接受消息
这里的参数类型取决于发送时候的发送类型,发送的是String类型接受的就是String类型。
@Component
@Slf4j
public class MqListener {
@RabbitListener(queues = "simple.queue")
public void listenerSimpleQueue(String msg) {
System.out.println("消费者接受到simple的消息:[" + msg + "]");
}
}
这时启动接受者的项目就可以接收到消息。
!注: 这里不管是发送者还是接收者都需要在yml里面配置mq的信息。
Work Queues
Work queues,任务模型。简单的来说就是让多个消费者绑定到一个队列,共同消费队列中的消息。
测试案例:
- 在RabbitMQ的控制台创建一个队列,名为work.queue
- 在publisher服务中定义测试方法,在1秒内产生50条消息,发送到work.queue
- 在consumer服务中定义两个消息监听者,都监听work.queue队列
- 消费者1每秒处理50条消息,消费者2每秒处理5条消息
发送者:
@Test
void testWorkQueue() throws InterruptedException {
String queueName = "work.queue";
for (int i = 1; i <= 50; i++) {
String msg = "hello, workQueue, message_" + i;
rabbitTemplate.convertAndSend(queueName, msg);
Thread.sleep(20);
}
}
消费者:
@RabbitListener(queues = "work.queue")
public void listenerSimpleQueueWork1(String msg) {
System.out.println("消费者1 接受到simple的消息:[" + msg + "]");
}
@RabbitListener(queues = "work.queue")
public void listenerSimpleQueueWork2(String msg) {
System.err.println("消费者2 接受到simple的消息:[" + msg + "]");
}
当消费者接收到消息时我们会发现,这五十条消息并不是每个消费者都会接收到50次,而是两个消费者共同接受五十次。也就是说一个队列绑定了两个消费者,但是这条消息只会消耗一次,并不会两个消费者都对消息进行处理。
同时我们可以观察到发送者发送的消息平均的发送给了每一个消费者,对消费者进行了轮询。
如果不同的消费者处理消息的能力不同。例如:
我们可以让每个消费者处理完之后停留一会。
@RabbitListener(queues = "work.queue")
public void listenerSimpleQueueWork1(String msg) throws InterruptedException {
System.out.println("消费者1 接受到simple的消息:[" + msg + "]");
Thread.sleep(20);
}
@RabbitListener(queues = "work.queue")
public void listenerSimpleQueueWork2(String msg) throws InterruptedException {
System.err.println("消费者2 接受到simple的消息:[" + msg + "]");
Thread.sleep(200);
}
这时我们会发现:
- 处理消息快的消费者1会先处理完消息
- 但是即使处理消息的能力高。但是还是会平均每个消费者处理的消息相同
**但是这样是有问题的:**处理消息能力强的应该多处理一些消息,这样处理能力弱的少处理一点而不是平均处理,有助于提高速度。
消费者消息推送限制
默认情况下,Rabbit会将消息一次轮询投递给绑定在队列的每一个消费者。但是这并没有考虑到消费者已经处理完消息,可能会出现消息堆积。
因此我们需要在yml文件里设置preFetch,确保同一时刻最多投递给消费者一条消息:当消息处理完才能接受下一条,这样处理消息能力强的人就会更快的处理消息,不再是平均分配了。
这时我们就可以发现能力强的消费者1处理的消息更多并且不会提前结束等待消费者2。
Work模型的使用:
- 多个消费者绑定到一个队列,可以加快消息处理的速度
- 同一条消息只会被一个消费者处理
- 通过设置prefetch来控制消费者预取的消息数量,处理完一条再处理下一条,实现能者多劳
Fanout交换机
真正生产环境都会经过exchange来发送消息,而不是直接发送到队列,交换机的类型有以下三种:
- Fanout:广播
- Direct:定向
- Topic:话题
Fanout Exchange会将接收到的消息广播到每一个跟其绑定的queue,所以也叫做广播模式
测试用例:
- 在RabbitMQ控制台中,声明队列fanout.queue1和fanout.queue2
- 在RabbitMQ控制台中,声明交换机hmall.fanout,将两个队列与其绑定
- 在consumer)服务中,编写两个消费者方法,分别监听fanout…queue1和fanout.queue2
- 在oublishert中编写测试方法,向hmall.fanout发送消息
消费者:
@RabbitListener(queues = "fanout.queue1")
public void listenerFanoutQueue(String msg) throws InterruptedException {
System.out.println("消费者1 接受到fanout1的消息:[" + msg + "]");
}
@RabbitListener(queues = "fanout.queue2")
public void listenerFanout2Queue(String msg) throws InterruptedException {
System.out.println("消费者2 接受到fanout2的消息:[" + msg + "]");
}
发送者:
@Test
void testExchange() {
String exchangeName = "hmall.fanout";
String msg = "hello, everyone!";
rabbitTemplate.convertAndSend(exchangeName, null, msg);
}
这样往交换机里发送数据,绑定的两个队列都会收到消息。
交换机的作用:
交换机的作用是什么?
- 接收publisher发送的消息
- 将消息按照规则路由到与之绑定的队列
- FanoutExchangel的会将消息路由到每个绑定的队列
Direct交换机
Direct Exchange会将接收到的消息根据规则路由到指定的Queue,因此称为定向路由。
- 每一个Queue都与Exchange设置一个BindingKey
- 发布者发送消息时,指定消息的RoutingKey
- Exchange:将Direct Exchange会将接收到的消息根据规则路由到指定的Queue,因此称为定向路由。
测试案例:
- 在RabbitMQ控制台中,声明队列direct.queue1和direct.queue2
- 在RabbitMQ控制台中,声明交换机hmall.direct,将两个队列与其绑定
- 在consumer服务中,编写两个消费者方法,分别监听direct.queue1和direct.queue2
- 在publisher中编写测试方法,利用不同的RoutingKey向hmalldirect发送消息
消费者:
@RabbitListener(queues = "direct.queue1")
public void listenerDirectQueue1(String msg) throws InterruptedException {
System.out.println("消费者1 接受到direct1的消息:[" + msg + "]");
}
@RabbitListener(queues = "direct.queue2")
public void listenerDirectQueue2(String msg) throws InterruptedException {
System.out.println("消费者2 接受到direct2的消息:[" + msg + "]");
}
发送者:
在发送消息的第二个参数就是routingKey,通过发送不同的key可以让绑定不同key的消息队列接收消息。
@Test
void testDirect() {
String exchangeName = "hmall.direct";
String msg = "hello, yellow!";
rabbitTemplate.convertAndSend(exchangeName, "yellow", msg);
}
Topic交换机
TopicExchange与DirectExchange类似,区别在于routingKey可以是多个单词的列表,并且以 “.” 分割。
例如:
- china.news代表有中国的新闻消息;
- china.weather 代表中国的天气消息;
- japan.news则代表日本新闻;
- japan.weather 代表日本的天气消息:
这时进行Queue与Exchange指定BindingKey时可以使用通配符:
- #:指代0个或多个的单词
- *:指代一个单词
例如:
案例测试:
- 在RabbitMQ控制台中,声明队列topic.queue1和topic.quewe2
- 在RabbitMQ控制台中,声明交换机hmall.topic,将两个队列与其绑定
- 在consumer服务中,编写两个消费者方法,分别监听topic.queue1和topic.queue2
- 在publisher中编写测试方法,利用不同的RoutingKey向hmall.topic发送消息
消费者:
@RabbitListener(queues = "topic.queue1")
public void listenerTopicQueue1(String msg) throws InterruptedException {
System.out.println("消费者1 接受到topic1的消息:[" + msg + "]");
}
@RabbitListener(queues = "topic.queue2")
public void listenerTopicQueue2(String msg) throws InterruptedException {
System.out.println("消费者2 接受到topic2的消息:[" + msg + "]");
}
发送者:
@Test
void testTopic() {
String exchangeName = "hmall.topic";
String msg = "hello, china!";
rabbitTemplate.convertAndSend(exchangeName, "china.weather", msg);
}
topic和direct基本上相同但是会比direct方便一些,比如要一个消息队列同时绑定有相同部分的key这样topic可以用通配符表示只需要绑定一次,而direct则需要把每个都绑定一次会比较麻烦。
声明队列和交换机
之前我们都是在客户端页面进行创建队列和交换机,现在我们学习在Java代码中创建队列和交换机。
SpringAMQP提供了几个类,用来声明队列、交换机及其绑定关系:
- Queue:用于声明队列,可以用工厂类QueueBuilder构建
- Exchange:用于声明交换机 可以用工厂类ExchangeBuilder构建
- Binding:用于声明队列和交换机的绑定关系,可以用工厂类BindingBuilder构建
方式一
@Configuration
public class FanoutConfiguration {
@Bean
public FanoutExchange fanoutExchange() {
return new FanoutExchange("hmall.fanout");
}
@Bean
public Queue fanoutQueue3() {
return new Queue("fanout.queue3");
}
@Bean
public Queue fanoutQueue4() {
return new Queue("fanout.queue4");
}
@Bean
public Binding bindingBuilder3(Queue fanoutQueue3, FanoutExchange fanoutExchange) {
return BindingBuilder.bind(fanoutQueue3).to(fanoutExchange);
}
@Bean
public Binding bindingBuilder4(Queue fanoutQueue4, FanoutExchange fanoutExchange) {
return BindingBuilder.bind(fanoutQueue4).to(fanoutExchange);
}
}
使用这种方法创建我们会发现,每次一绑定关系都需要重新写一个Bean。而且如果使用direct交换机的话每一个队列只要绑定一个BindKey就需要写一个Bean这样的话我们只写交换机的申明就需要写很多很多的Bean。
方式二
基于注解声明
name后面还有一个参数 durable 确定是否持久化,true确定为持久化。默认为true。
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue1", declare = "true"),
exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
key = {"red,", "blue"}
))
public void listenerDirectQueue1(String msg) throws InterruptedException {
System.out.println("消费者1 接受到direct1的消息:[" + msg + "]");
}
消息转换器
需求:
- 测试利用SpringAMQP发送对象类型的消息
- 声眼一个队列,名为object.queue
- 编写单元测试,向队列中直接发送一条消息,消息类型为Map
- 在控制台查看消息,总结你能发现的问题
发送者:
@Test
void testSendMsgObject() {
Map<String, Object> msg = new HashMap<>();
msg.put("name", "jack");
msg.put("age", 21);
rabbitTemplate.convertAndSend("object.queue", msg);
}
当消息这发送完消息之后,我们在客户端页面查看消息可以发现消息如下,是一堆英文字符。
原因是我们可以查看上一行,content_type后面说的采用的是jdk自带的序列化方式所以将其序列化为了这些字母,同时这样会扩大存储内存。
因此这时候建议采用JSON序列化代替默认的JDK序列化,要做两件事情:
- 在publisher和consumer中都要引入jackson依赖:
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
- 在publisher和consumer中都要配置MessageConverter
@Bean
public MessageConverter jacksonMessageConvertor() {
return new Jackson2JsonMessageConverter();
}
这样就可以完成对象类型的发送
消费者:
发送的是什么类型,接收的时候就要是什么类型。
@RabbitListener(queues = "object.queue")
public void listenerObjectQueue(Map<String, Object> msg) throws InterruptedException {
System.out.println("消费者2 接受到direct2的消息:[" + msg + "]");
}
高级篇
由于在发送消息的时候可能造成消息丢失,所以需要进行消息靠可行的验证。
消息丢失造成的三种情况:
- 生产者丢失
- MQ丢失
- 消费者丢失
所以为了解决以上问题我们需要保证消息的可靠性。
生产者的可靠性
生产者重连
有的时候由于网络波动,可能会出现客户端连接MQ失败的情况。通过配置我们可以开启连接失败后的重连机制:
注意:
当网络不稳定的时候,利用重试机制可以有效提高消息发送的成功率。不过SpringAMQP提供的重试机制是阻塞式的重试,也就是说多次重试等待的过程中,当前线程是被阻塞的,会影响业务性能。如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。
生产者确认
当生产者发送消息后,消费者会返回一个消息,如果收到了就会返回收到了如果没有收到就会返回没有收到,这样如果没有收到的话就可以重新发送消息。
RabbitMQ有Publisher Confirm和Publisher Return两种确认机制。开启确机制认后,在MQ成功收到消息后会返回确认消息给生产者。返回的结果有以下几种情况:
- 消息投递到了MQ,但是路由失败。此时会通过PublisherReturn返回路由异常原因,然后返回ACK,告知投递成功
- 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功。 临时消息就是(non durable)
- 持久消息投递到了MQ,并且入队完成持久化,返回ACK ,告知投递成功
- 其它情况都会返回NACK,告知投递失败
SpringAMQP实现生产者确认
- 在publisher这个微服务的yml文件中添加配置:
配置说明:
- 这里publisher-confirm-type有三种模式可选
- none:关闭confirm机制
- simple:同步阻塞等待MQ的回执消息
- correlated:MQ异步回调方式返回回执消息
- 设置回调函数
每个RabbitTemplate只能配置一个ReturnCallback,因此需要在项目启动过程中配置:
- 发送消息,指定消息ID、消息ConfirmCallback
这里的CorrelationData 里要传一个参数UUID 就是绑定一个id 这样在返回消息的时候可以根据id进行返回,否则可能会找不着。
总结:
如何处理生产者的确认消息?
- 生产者确认需要额外的网络和系统资源开销,尽量不要使用
- 如果一定要使用,无需开启Publisher-Return机制,因为一般路由失败是自己业务问题
- 对于nack消息可以有限次数重试,依然失败则记录异常消息
MQ的可靠性
在默认情况下,RabbitMQ会将接收到的信息保存在内存中以降低消息收发的延迟。这样会导致两个问题
- 一旦MQ宕机,内存中的消息会丢失
- 内存空间有限,当消费者故障或处理过慢时,会导致消息积压,引发MQ阻塞
数据持久化
RabbitMQ实现数据持久化包括3个方面
- 交换机持久化
- 队列持久化
在客户端页面创建时就可以设置是否为持久化。
- 消息的持久化
在客户端页面发送消息时可以选择是否为持久话消息
不过在Java客户端实现,会默认为持久化的消息。
Lazy Queue
从RabbitMQ的3.6.0版本开始,就增加了Lazy Queue的概念,也就是惰性队列。惰性队列的特征如下:
-
接收到消息后直接存入磁盘而非内存(内存中只保留最近的消息,默认2048条
-
消费者要消费消息时才会从磁盘中读取并加载到内存
-
支持数百万条的消息存储
在3.12版本后,所有队列都是Lazy Queue模式,无法更改。
在客户端页面设置Lazy Queue
在Java客户端中声明
通过注解的方式
消费者可靠性
消费者确认机制
为了确认消费者是否成功处理消息,RabbitMQ提供了消费者确认机制(Consumer Acknowledgement)。当消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMO自己消息处理状态。回执有三种可选值:
- **ack:**成功处理消息,RabbitMQ从队列中删除该消息
- **nack:**消息处理失败,RabbitMQ需要再次投递消息
- **reject:**消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息
SpringAMQP已经实现了消息确认功能。并允许我们通过配置文件选择ACK处理方式,有三种方式
- none: 不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用
- manual: 手动模式。需要自己在业务代码中调用api,发送ack或reject,存在业务入侵,但更灵活
- auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack.当业务出现异常时,根据异常判断返回不同结果:
- 如果是业务异常,会自动返回nack
- 如果是消息处理或校验异常,自动返回reject
消息失败处理
当消费者出现异常后,消息会不断requeue (重新入队)到队列,再重新发送给消费者,然后再次异常,再次requeue,无限循环,导致mg的消息处理飙升,带来不必要的压力。
我们可以利用Spring的retrv机制,在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列
-user-images%5Cimage-20231125113825517.png&pos_id=img-5h7kG7b1-1700977508335)
这里会多次进行重试,如果重试次数结束了会自动把消息删除。
在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecoverer接口来处理,它包含三种不同的实现:
- RejectAndDontRequeueRecoverer: 重试耗尽后,直接reject,丢弃消息。默认就是这种方
- ImmediateRequeueMessageRecoverer: 重试耗尽后,返回nack,消息重新入
- RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机 (可以用一个专门处理失败的交换机和队列,进行对错误信息的处理)
这里演示第三种处理策略
- 首先定义接受失败消息的交换机、队列及其绑定关系
- 然后,定义RepublishMessageRecoverer
这里这个配置类我们是选择性的生效只有我们配置了开始消费者重试机制采取这个配置的策略进行发送error交换机等。
所以我们需要在这个类上面加一个条件注解。
类似于这样的:
业务幂等性
幂等:是一个数学概念,用函数表达式来描述是这样的:f(x)=f(f(x))。在程序开发中,则是指同一个业务,执行一次或多次对业务状态的影响是一致的。
保证消息幂等性方案:
唯一消息id
方案一,是给每个消息都设置一个唯一id,利用id区分是否是重复消息:
- 每一条消息都生成一个唯一的id,与消息一起投递给消费者。
- 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库
- 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理
业务判断
方案二,是结合业务逻辑,基于业务本身做判断。以我们的业务为例:我们要在支付后修改订单状态为已支付,应该在修改订单状态前先查询订单状态,判断状态是否是未支付。只有未支付订单才需要修改,其它状态不做处理:
延迟消息
**延迟消息:**生产者发送消息时指定一个时间,消费者不会立刻收到消息,而是在指定时间之后才收到消息。
**延迟任务:**设置在一定时间之后才执行的任务
死信交换机
当一个队列中的消息满足下列情况之一时,就会成为死信 (dead letter):
- 消费者使用basic.reject或 basic.nack声明消费失败,并且消息的requeue参数设置为false
- 消息是一个过期消息(达到了队列或消息本身设置的过期时间),超时无人消费
- 要投递的队列消息堆积满了,最早的消息可能成为死信
如果队列通过dead-letter-exchange属性指定了一个交换机,那么该队列中的死信就会投递到这个交换机中。这个交换机称为死信交换机 (Dead Letter Exchange,简称DLX)。
这样我们给一个simple.queue队列发送一个带过期时间的消息,但是因为这个队列没有消费者最后就变成了死信消息,然后交给死信队列进行处理然后最后交给消费者来消费。整体来看就是一个消息过了设置时间之后才进行处理消费,完成了一个延迟消息。
延迟消息插件
RabbitMQ的官方也推出了一个插件,原生支持延迟消息功能。该插件的原理是设计了一种支持延迟消息功能的交换机当消息投递到交换机后可以暂存一定时间,到期后再投递到队列。
这里需要一个mq的延迟消息插件在网上下载,根据自身mq的版本选择下载。
消费者:
发送者: