目录
前言
微服务一旦拆分,必然会涉及到服务之间的相互调用。各个微服务之间如果采用的是基于OpenFeign的调用,在这种方式中调用者发起请求后需要等待服务提供者返回结果之后,才能执行后面的业务。在此之前,调用者是一直处于阻塞状态的。我们称这种调用方式为同步调用。但有时也需要采用异步调用的方式。
先来看看什么是同步通讯和异步通讯。如图:
-
同步通讯:就如同打视频电话,双方的交互都是实时的。因此同一时刻你只能跟一个人打视频电话。
-
异步通讯:就如同发微信聊天,双方的交互不是实时的,你不需要立刻给对方回应。因此你可以多线操作,同时跟多人聊天。
所以,如果我们的业务需要实时得到服务提供方的响应,则应该选择同步通讯(同步调用)。而如果我们追求更高的效率,并且不需要实时响应,则应该选择异步通讯(异步调用)。
一、初始MQ
1. 同步调用
下图是一个余额支付功能的例子,是基于OpenFeign的同步调用:
上述业务存在一些问题:
首先,拓展性差。如果我想要在原来的业务上增加新的业务,如果采用同步调用的方式,那么支付服务的逻辑就会跟着变化,代码经常变动。
其次,性能下降。因为我们采取的是同步调用,调用者需要等待服务提供者执行完返回结果后,才能继续向下执行,也就是说每次远程调用,调用者都是阻塞等待状态。最终整个业务的响应时长就是每次远程调用的执行时长之和,如果该支付服务拓展了一些功能,每个微服务的执行时长都是50ms,那么支付一次的总时长就是300ms,甚至更长。
最后,会导致级联问题。由于我们是基于OpenFeign调用交易服务、通知服务。当交易服务、通知服务出现故障时,整个事务都会回滚,交易失败。
2. 异步调用
异步调用是基于消息通知的方式,一般包含三个角色:
-
消息发送者:投递消息的人,就是原来的调用方
-
消息Broker(代理):管理、暂存、转发消息,你可以把它理解成微信服务器
-
消息接收者:接收和处理消息的人,就是原来的服务提供方
在异步调用中,发送者不再同步调用接收者的业务接口,而是直接把消息发送到Broker中,接收者根据需要从Broker中读消息。
还是以余额支付业务为例:
因为更新交易流水必须要得到扣减余额的结果,所以此处必须要用到同步调用。将交易服务和通知服务全部使用异步调用,一旦支付完成,我将“支付完成”的消息发到Broker中,后面的业务就跟支付服务没有关系了,也就不需要阻塞等待其他的业务返回结果。现在采用了异步调用,解除了耦合,他们即便执行过程中出现了故障,也不会影响到支付服务。
二、RabbitMQ
RabbitMQ是基于Erlang语言开发的开源消息通信中间件,官网地址:
RabbitMQ: One broker to queue them all | RabbitMQ
可以利用如下命令,将RabbitMQ部署到Docker中:
docker run \
-e RABBITMQ_DEFAULT_USER=itheima \
-e RABBITMQ_DEFAULT_PASS=123321 \
-v mq-plugins:/plugins \
--name mq \
--hostname mq \
-p 15672:15672 \
-p 5672:5672 \
--network hm-net\
-d \
rabbitmq:3.8-management
主要是了解一下RabbitMQ对应的框架:
其中包含几个概念:
-
publisher
:生产者,也就是发送消息的一方 -
consumer
:消费者,也就是消费消息的一方 -
queue
:队列,存储消息。生产者投递的消息会暂存在消息队列中,等待消费者处理 -
exchange
:交换机,负责消息路由。生产者发送的消息由交换机决定投递到哪个队列。 -
virtual host
:虚拟主机,起到数据隔离的作用。每个虚拟主机相互独立,有各自的exchange、queue
三、SpringAMQP
1. 快速入门
为了测试方便,我们也可以直接向队列发送消息,跳过交换机。这种模式一般在测试的时候使用,很少在生产的时候使用。
先在控制台新建一个队列:simple.queue
1.1 消息发送
首先配置MQ地址,在publisher
服务(消息发送者)的application.yml
中添加配置:
spring:
rabbitmq:
host: 192.168.150.101 # 你的虚拟机IP
port: 5672 # 端口
virtual-host: /hmall # 虚拟主机
username: hmall # 用户名
password: 123 # 密码
然后在publisher
服务中编写测试类SpringAmqpTest
,并利用RabbitTemplate
实现消息发送:
@SpringBootTest
public class SpringAmqpTest {
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
public void testSimpleQueue() {
// 队列名称
String queueName = "simple.queue";
// 消息
String message = "hello, spring amqp!";
// 发送消息
rabbitTemplate.convertAndSend(queueName, message);
}
}
可以看到消息已经发送到了队列当中
1.2 消息接收
也是首先配置MQ地址,在consumer
服务(消息接收者)的application.yml
中添加配置:
spring:
rabbitmq:
host: 192.168.150.101 # 你的虚拟机IP
port: 5672 # 端口
virtual-host: /hmall # 虚拟主机
username: hmall # 用户名
password: 123 # 密码
然后在consumer
服务的com.itheima.consumer.listener
包中新建一个类SpringRabbitListener
,代码如下:
@Component
public class SpringRabbitListener {
// 利用RabbitListener注解来声明要监听的队列信息
// 将来一旦监听的队列中有了消息,就会推送给当前服务,调用当前方法,处理消息。
// 可以看到方法体中接收的就是消息体的内容
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg) throws InterruptedException {
System.out.println("spring 消费者接收到消息:【" + msg + "】");
}
}
2. WorkQueues模型
Work queues,任务模型。简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息。
当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。长此以往,消息就会堆积越来越多,无法及时处理。
此时就可以使用work 模型,多个消费者共同处理消息处理,消息处理的速度就能大大提高了。
只需要使用RabbitListener注解来声明多个消费者,指向同一个队列即可,下面的代码两个消费者指向了同一个队列work.queue。
@RabbitListener(queues = "work.queue")
public void listenWorkQueue1(String msg) throws InterruptedException {
System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now());
Thread.sleep(20);
}
@RabbitListener(queues = "work.queue")
public void listenWorkQueue2(String msg) throws InterruptedException {
System.err.println("消费者2........接收到消息:【" + msg + "】" + LocalTime.now());
Thread.sleep(200);
}
但是该模型每个消费者从队列中取出的消息都是平均的,即无论消费者的处理能力如何,他们处理的数据量都是相同的。
可以在yaml文件中添加如下配置:
spring:
rabbitmq:
listener:
simple:
prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
配置之后,处理能力高的消费者就可以处理更多的消息。这样就充分利用了每一个消费者的处理能力,有效避免了队列消息挤压问题。
3. 交换机
在上面的两个测试中,都没有交换机,生产者直接发送消息到队列。而一旦引入交换机,消息发送的模式会有很大变化:
Exchange(交换机)只负责转发消息,并不具备消息存储的能力,因此如果没有任何队列与Exchange绑定,或者没有符合路由规则的队列,那么消息会丢失!
交换机的类型有以下四种:
-
Fanout:广播,将消息交给所有绑定到交换机的队列。我们最早在控制台使用的正是Fanout交换机
-
Direct:订阅,基于RoutingKey(路由key)发送给订阅了消息的队列
-
Topic:通配符订阅,与Direct类似,只不过RoutingKey可以使用通配符
-
Headers:头匹配,基于MQ的消息头匹配,用的较少。
3.1 Fanout交换机
Fanout交换机属于是广播模式,因为该交换机会将消息转发给所有跟它绑定的队列。
3.2 Direct交换机
在Fanout模式中,一条消息,会被所有订阅的队列都消费。但是,在某些场景下,我们希望不同的消息被不同的队列消费。这时就要用到Direct类型的Exchange。
一个队列可以指定多个绑定关键字,也就是bindingKey。
在Direct模型下:
-
队列与交换机的绑定,不能是任意绑定了,而是要指定一个
RoutingKey
(路由key) -
消息的发送方在 向 Exchange发送消息时,也必须指定消息的
RoutingKey
。 -
Exchange不再把消息交给每一个绑定的队列,而是根据消息的
Routing Key
进行判断,只有队列的Routingkey
与消息的Routing key
完全一致,才会接收到消息
消息接收代码如下:
@RabbitListener(queues = "direct.queue1")
public void listenDirectQueue1(String msg) {
System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】");
}
@RabbitListener(queues = "direct.queue2")
public void listenDirectQueue2(String msg) {
System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】");
}
消息发送代码如下,可以看到消息通过交换机发送给了bindingKey为red的队列。
@Test
public void testSendDirectExchange() {
// 交换机名称
String exchangeName = "hmall.direct";
// 消息
String message = "红色警报!日本乱排核废水,导致海洋生物变异,惊现哥斯拉!";
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "red", message);
}
因为两个队列都绑定了red,所以都可以接收到。
如果将red改为blue,因为消费者1绑定了blue,所以只有消费者1才能接收到。
3.3 Topic交换机
Topic
类型的Exchange
与Direct
相比,都是可以根据RoutingKey
把消息路由到不同的队列。只不过Topic
类型Exchange
可以让队列在绑定BindingKey
的时候使用通配符!
BindingKey
一般都是有一个或多个单词组成,多个单词之间以.
分割,例如: item.insert。
通配符规则:
-
#
:匹配一个或多个词 -
*
:匹配不多不少恰好1个词
举例:
-
item.#
:能够匹配item.spu.insert
或者item.spu
-
item.*
:只能匹配item.spu
queue1可以匹配任何以china开头的消息。queue2可以匹配任何以news为结尾的消息。
4. 声明队列和交换机
如果基于RabbitMQ控制台来创建队列、交换机,对于业务流程来说容易出错,所以最好还是在代码中声明队列和交换机。
4.1 利用Bean来声明
在消费者的服务中创建一个配置类,来声明队列和交换机:
@Configuration
public class FanoutConfig {
/**
* 声明交换机
* @return Fanout类型交换机
*/
@Bean
public FanoutExchange fanoutExchange(){
return new FanoutExchange("hmall.fanout");
}
/**
* 第1个队列
*/
@Bean
public Queue fanoutQueue1(){
return new Queue("fanout.queue1");
}
/**
* 绑定队列和交换机
*/
@Bean
public Binding bindingQueue1(Queue fanoutQueue1, FanoutExchange fanoutExchange){
return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
}
/**
* 第2个队列
*/
@Bean
public Queue fanoutQueue2(){
return new Queue("fanout.queue2");
}
/**
* 绑定队列和交换机
*/
@Bean
public Binding bindingQueue2(Queue fanoutQueue2, FanoutExchange fanoutExchange){
return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange);
}
}
上述代码声明了一个Fanout类型的交换机,并声明了两个队列与交换机进行了绑定。
4.2 基于注解进行声明
在这里,我们声明Direct模式的交换机和队列。利用RabbitListener注解,在监听队列的消费者方法上进行声明:
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue1"),
exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
key = {"red", "blue"}
))
public void listenDirectQueue1(String msg){
System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】");
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue2"),
exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
key = {"red", "yellow"}
))
public void listenDirectQueue2(String msg){
System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】");
}
5. 消息转换器
5.1 默认转换器
Spring中有默认的消息转换器,在数据传输时,它会把你发送的消息序列化为字节发送给MQ,接收消息的时候,还会把字节反序列化为Java对象。
只不过,默认情况下Spring采用的序列化方式是JDK序列化。众所周知,JDK序列化存在下列问题:
-
数据体积过大
-
有安全漏洞
-
可读性差
我们在publisher模块的SpringAmqpTest中新增一个消息发送的代码,发送一个Map对象:
@Test
public void testSendMap() throws InterruptedException {
// 准备消息
Map<String,Object> msg = new HashMap<>();
msg.put("name", "柳岩");
msg.put("age", 21);
// 发送消息
rabbitTemplate.convertAndSend("object.queue", msg);
}
发送消息后查看控制台:
5.2 配置JSON转换器
首先,在生产者和消费者的服务模块都引入相关依赖:
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.9.10</version>
</dependency>
配置消息转换器,在生产者和消费者两个服务的启动类中添加一个Bean即可。或者新写一个配置类:
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jackson2JsonMessageConverter.setCreateMessageIds(true);
return jackson2JsonMessageConverter;
}
此时,我们到MQ控制台删除object.queue
中的旧的消息。然后再次执行刚才的消息发送的代码,到MQ的控制台查看消息结构: