1.初识MQ
- 微服务间通讯有同步和异步两种方式:
- 同步通讯:就像打电话,需要实时响应。
- 异步通讯:就像发邮件,不需要马上回复。
两种方式各有优劣,打电话可以立即得到响应,但是你却不能跟多个人同时通话。发送邮件可以同时与多个人收发邮件,但是往往响应会有延迟。
1.1、同步通讯
Feign调用就属于同步方式,虽然调用可以实时得到结果,但存在下面的问题:
总而言之:
- 同步调用的优点:时效性较强,可以立即得到结果
- 同步调用的问题:
- 耦合度高、性能和吞吐能力下降、有额外的资源消耗、有级联失败问题
1.2、异步通讯
-
异步调用则可以避免上述问题:
我们以如上购买商品为例,用户支付后需要调用订单服务完成订单状态修改,调用物流服务,从仓库分配响应的库存并准备发货。 -
在事件模式中,支付服务是事件发布者(publisher),在支付完成后只需要发布一个支付成功的事件(event),事件中带上订单id,订单服务和物流服务是事件订阅者(Consumer),订阅支付成功的事件,监听到事件后完成自己业务即可。
-
为了解除事件发布者与订阅者之间的耦合,两者并不是直接通信,而是有一个中间人(Broker)。发布者发布事件到Broker,不关心谁来订阅事件。订阅者从Broker订阅事件,不关心谁发来的消息。异步调用:如上完成支付服务并发布支付成功的事件后,不需要去等待订单服务、仓储服务、短信等诸多服务的响应
-
Broker 是一个像数据总线一样的东西,所有的服务要接收数据和发送数据都发到这个总线上,这个总线就像协议一样,让服务间的通讯变得标准和可控。
-
Broker的好处:
- 吞吐量提升:无需等待订阅者处理完成,响应更快速
- 故障隔离:服务没有直接调用,不存在级联失败问题
- 调用间没有阻塞,不会造成无效的资源占用
- 耦合度极低,每个服务都可以灵活插拔,可替换
- 流量削峰:不管发布事件的流量波动多大,都由Broker接收,订阅者可以按照自己的速度去处理事件-
-
缺点:
- 架构复杂了,业务没有明显的流程线,不好管理
- 需要依赖于Broker的可靠、安全、性能
-
而MQ(消息队列)就可以看作是Broker的具体实现,
1.3、MQ技术对比
- MQ,即消息队列(MessageQueue),字面来看就是存放消息的队列,也就是事件驱动架构中的Broker。
- 比较常见的MQ实现:
- ActiveMQ、RabbitMQ、RocketMQ、Kafka
- ActiveMQ、RabbitMQ、RocketMQ、Kafka
- 追求可用性:Kafka、 RocketMQ 、RabbitMQ
- 追求消息可靠性:RabbitMQ、RocketMQ
- 追求吞吐能力:RocketMQ、Kafka
- 追求消息低延迟:RabbitMQ、Kafka
Kafka虽然单机吞吐量高,但却是以牺牲消息的可靠性和延迟 换来的。
2、RabbitMQ快速入门
2.1、RabbitMQ安装
- 在Centos7虚拟机中使用Docker来安装RabbitMQ。
- 开启docker服务后,在线镜像拉取
docker pull rabbitmq:3-management
- 运行拉去的RabbitMQ镜像:
docker run \
-e RABBITMQ_DEFAULT_USER=root \
-e RABBITMQ_DEFAULT_PASS=123456 \
--name mq \
--hostname mq1 \
-p 15672:15672 \
-p 5672:5672 \
-d \
rabbitmq:3-management
- 在Windows端,发送请求:
http://192.168.40.132:15672/
就可来到RabbitMQ的登录页面,Username和Password就是docker run命令后面配置的账户名和密码,登录后就可来到管理界面
2.2、消息模型
2.3、基于SpringAMQP实现的入门案例
1)Publisher发送消息
- 在父工程中引入依赖(Publisher和Consumer都需要引入amqp的依赖)
<!--AMQP依赖,包含RabbitMQ-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
- 配置MQ地址,在publisher服务的application.yml中添加配置:
spring:
rabbitmq:
host: 192.168.40.132 # 主机IP
port: 5672 # rabbitmq的默认端口
virtual-host: /
username: root
password: 123456
- 在publisher服务中编写测试类SpringAmqpTest,并利用RabbitTemplate实现消息发送:
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAmqpTest {
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
public void testSimpleQueue() {
String queueName = "simple.queue";
String message = "hello,RabbitMQ and Spring AMQP!";
rabbitTemplate.convertAndSend(queueName,message);
}
}
运行如上的测试类,在RabbitMQ的可视化管理界面中按照如下步骤,就可以看到Publisher发送的消息:
2)Consumer接收消息
- 同前面接收消息的操作,配置MQ地址,在consumer服务的application.yml中添加配置:
spring:
rabbitmq:
host: 192.168.40.132 # 主机IP
port: 5672 # rabbitmq的默认端口
virtual-host: /
username: root
password: 123456
- 在consumer服务的
cn.itcast.mq.listener
包中新建一个类SpringRabbitListener,代码如下:
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
@Component
public class SpringRabbitListener {
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg){
System.out.println("消费者接收到消息:【" + msg + "】");
}
}
- 启动Consumer模块的服务,即可接收到Publisher存储在"simple.queue"中的消息
消费者接收到消息:【hello,RabbitMQ and Spring AMQP!】
3、SpringAMQP高阶使用
前面的入门案例属于简单队列模型,当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。长此以往,消息就会堆积越来越多,无法及时处理。
- 此时就可以使用work 模型,多个消费者共同处理消息处理,速度就能大大提高了。
3.1、WorkQueue
- Work queues,也被称为(Task queues),任务模型。简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息。简单队列和工作队列都属于点对点模式。
- 消息发送
在Publisher对应的测试类中,添加testWorkQueue方法,使其向队列中不停发送消息,模拟消息堆积
。
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
public void testWorkQueue() throws InterruptedException {
String queueName = "simple.queue";
String message = "hello,message id = ";
for (int i = 1; i <= 50; i++) {
rabbitTemplate.convertAndSend(queueName,message + i);
Thread.sleep(20);
}
}
- 消息接收,修改Consumer模块中的SpringRabbitListener类,添加如下方法,实现多个消费者绑定单个队列
@RabbitListener(queues = "simple.queue")
public void listenWorkQueue1(String msg) throws InterruptedException {
System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now());
Thread.sleep(20);
}
@RabbitListener(queues = "simple.queue")
public void listenWorkQueue2(String msg) throws InterruptedException {
System.err.println("消费者2........接收到消息:【" + msg + "】" + LocalTime.now());
Thread.sleep(200);
}
- 测试
启动ConsumerApplication后,再执行Publisher服务中刚刚编写的发送测试方法testWorkQueue,可以看到消费者1很快完成了自己的25条消息。消费者2却在缓慢的处理自己的25条消息,也就是说消息是平均分配给每个消费者,并没有考虑到消费者的处理能力。这样显然是有问题的,由于预取消息的机制,两个消费者会先从队列中轮流拿取消息,但两个消费者处理消息的能力不同,导致消费者1处理完了拿取的所有消息后,消费者2还要好久才能处理完消息
- 解决:修改Consumer服务的application.yml文件,添加配置:
spring:
rabbitmq:
listener:
simple:
prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
- 由如下的测试结果可见,两个消费者都只是按照自己处理消息的能力去拿取消息,没有造成处理消息能力强的消费者空闲,能力弱的消费者忙碌的状态
- WorkQueue模型总结:
- 多个消费者绑定到一个队列,使用BasicQueue或WorkQueue,同一条消息都只会被一个消费者处理
- 可以在xml配置文件中 设置prefetch来控制消费者预取的消息数量
3.2、发布/订阅
发布/订阅模型图解如下:
可以看到,在订阅模型中,多了一个exchange角色,而且过程略有变化:
- Publisher:生产者,也就是要发送消息的程序,但是不再发送到队列中,而是发给X(交换机)
- Exchange:交换机,一方面,接收生产者发送的消息。另一方面,知道如何处理消息,例如递交给某个特别队列、递交给所有队列、或是将消息丢弃。到底如何操作,取决于Exchange的类型。Exchange有以下3种类型:
- Fanout:广播,将消息交给所有绑定到交换机的队列
- Direct:定向,把消息交给符合指定routing key 的队列
- Topic:通配符,把消息交给符合routing pattern(路由模式) 的队列
- Consumer:消费者,与以前一样,订阅队列,没有变化
- Queue:消息队列也与以前一样,接收消息、缓存消息。
Exchange(交换机)只负责转发消息,不具备存储消息的能力,因此如果没有任何队列与Exchange绑定,或者没有符合路由规则的队列,那么消息会丢失!
1)Fanout交换机
Fanout,译为扇出,在MQ中中可以理解为广播
-
在广播模式下,消息发送流程是这样的:
- 1) 可以有多个队列
- 2) 每个队列都要绑定到Exchange(交换机)
- 3) 生产者发送的消息,只能发送到交换机,交换机来决定要发给哪个队列,生产者无法决定
- 4) 交换机把消息发送给绑定过的所有队列
- 5) 订阅队列的消费者都能拿到消息
-
使用案例:
-
声明交换机和队列
Spring提供了一个接口Exchange,来表示所有不同类型的交换机:
在Consumer模块中,声明队列、交换机,并将队列和交换机之间建立绑定关系
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.FanoutExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitConfig{
// 声明名为 fanout.exchange 的交换机
@Bean
public FanoutExchange fanoutExchange(){
return new FanoutExchange("fanout.exchange");
}
// 声明名为 fanout.queue1的队列
@Bean
public Queue queue1(){
return new Queue("fanout.queue1");
}
// 声明 队列到交换机的绑定关系
@Bean
public Binding fanoutBinding1(Queue queue1,FanoutExchange fanoutExchange){
return BindingBuilder.bind(queue1).to(fanoutExchange);
}
@Bean
public Queue queue2(){
return new Queue("fanout.queue2");
}
// 注入spring容器的bean名称就是对应的方法名
@Bean
public Binding fanoutBinding2(Queue queue2,FanoutExchange fanoutExchange){
return BindingBuilder.bind(queue2).to(fanoutExchange);
}
}
- 在Consumer中, 分别监听fanout.queue1和fanout.queue2两个队列,使其队列有消息时,消费者可以从中拿取消息后处理
@RabbitListener(queues = "fanout.queue1")
public void listenFanoutQueue1(String msg) {
System.out.println("消费者1接收到Fanout消息:【" + msg + "】");
}
@RabbitListener(queues = "fanout.queue2")
public void listenFanoutQueue2(String msg) {
System.out.println("消费者2接收到Fanout消息:【" + msg + "】");
}
- 在Publisher模块中,添加发送消息的方法:
@Test
public void testFanoutExchange() {
// 队列名称
String exchangeName = "fanout.exchange";
// 消息
String message = "hello, every fanout queue!";
rabbitTemplate.convertAndSend(exchangeName, "", message);
}
- 启动Consumer服务后,再运行发送消息的testFanoutExchange方法,在Consumer端即可接收到Publisher发送的消息(通过Fanout交换机,来到队列,再传到消费者)
- 输出结果:
消费者2接收到Fanout消息:【hello, every fanout queue!】
消费者1接收到Fanout消息:【hello, every fanout queue!】
- 在RabbitMQ管理界面中,按照如下截图即可看到交换机和队列的绑定关系
Fanout交换机总结: - 交换机的作用:
- 接收publisher发送的消息
- 将消息按照规则路由到与之绑定的队列
- 不能缓存消息,路由失败,消息丢失
- FanoutExchange的会将消息路由到每个绑定的队列
- 声明队列、交换机、绑定关系的Bean分别是什么
- Queue、FanoutExchange、Binding
2)Direct交换机
-
在Fanout模式中,一条消息,会被所有订阅的队列都消费。但是,在某些场景下,我们希望不同的消息被不同的队列消费。这时就要用到Direct类型的Exchange。
-
在Direct模型下:
- 队列与交换机的绑定,不能是任意绑定了,而是要指定一个
RoutingKey
(路由key) - 消息的发送方在 向 Exchange发送消息时,也必须指定消息的
RoutingKey
。 - Exchange不再把消息交给每一个绑定的队列,而是根据消息的
Routing Key
进行判断,只有队列的Routingkey
与消息的Routing key
完全一致,才会接收到消息
- 队列与交换机的绑定,不能是任意绑定了,而是要指定一个
-
假设存在如下案例需求如下:
1)利用@RabbitListener声明Exchange、Queue、RoutingKey
2)在consumer服务中,编写两个消费者方法,分别监听direct.queue1和direct.queue2
3)在publisher中编写测试方法,向itcast. direct发送消息 -
声明队列、交换机、路由并接收消息
基于@Bean的方式声明队列和交换机比较麻烦,Spring还提供了基于注解方式来声明(在注解中声明队列和交换机,队列到交换机的路由)。在consumer的SpringRabbitListener中添加两个消费者,同时基于注解来声明队列和交换机,代码如下:
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue1"),
exchange = @Exchange(name = "xyl.direct",type = ExchangeTypes.DIRECT),
key = {"China","Singapore","America"}
))
public void listenDirectQueue1(String msg){
System.out.println("消费者接1 ===》 收到direct.queue1的消息:【" + msg + "】");
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue2"),
exchange = @Exchange(name = "xyl.direct",type = ExchangeTypes.DIRECT),
key = {"China","Canada"}
))
public void listenDirectQueue2(String msg){
System.out.println("消费者2 ---> 接收到direct.queue2的消息:【" + msg + "】");
}
- 消息发送
在publisher服务的SpringAmqpTest类中添加如下的测试方法:
@Test
public void testSendDirectExchange() {
// 交换机名称
String exchangeName = "xyl.direct";
// 消息
String message1 = "中华文化,源远流长!";
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "China", message1);
String message2 = "亚洲四小龙之首";
rabbitTemplate.convertAndSend(exchangeName, "Singapore", message2);
}
- 测试,依次启动Consumer模块的服务和testSendDirectExchange测试方法,因为message1发给经由"China"路由发送给两个消费者(两个消费者都有“China”的路由),而message2经由“Singapore”只发给了消费者2(只有消费者2有“Singapore”的路由),结果如下:
总结: - 描述下Direct交换机与Fanout交换机的差异?
- Fanout交换机将消息路由给每一个与之绑定的队列
- Direct交换机根据RoutingKey判断路由给哪个队列
- 如果多个队列具有相同的RoutingKey,则与Fanout功能类似
- 基于@RabbitListener注解声明队列和交换机会用到哪些注解?
- @Queue、@Exchange
3)Topic交换机
-
Topic
类型的Exchange
与Direct
相比,都是可以根据RoutingKey
把消息路由到不同的队列。只不过Topic
类型Exchange
可以让队列在绑定Routing key
的时候使用通配符,通过通配符,消息可以从交换机发送到一个或多个指定的队列 -
Routingkey
一般都是由一个或多个单词组成,多个单词之间以”.”分割,例如:item.insert
-
通配符规则:
#
:匹配一个或多个词
*
:匹配不多不少恰好1个词
举例说明:item.#
:能够匹配item.spu.insert
或者item.spu
;而item.*
:可以匹配到item.spu
,而不能匹配到item.spu.insert
-
再如下图所示:
- Queue1:绑定的是
china.#
,因此凡是以china.
开头的routing key
都会被匹配到。包括china.news和china.weather - Queue2:绑定的是
#.news
,因此凡是以.news
结尾的routing key
都会被匹配。包括china.news和japan.news
- Queue1:绑定的是
-
案例需求如下:
1)并利用@RabbitListener声明Exchange、Queue、RoutingKey
2)在consumer服务中,编写两个消费者方法,分别监听topic.queue1和topic.queue2
3)在publisher中编写测试方法,向itcast. topic发送消息
- 声明队列、交换机、路由并交由消费者接收消息
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.queue1"),
exchange = @Exchange(name = "xyl.topic",type = ExchangeTypes.TOPIC),
key = {"china.#"}
))
public void listenTopicQueue1(String msg){
System.out.println("消费者1 --> 接收到topic.queue1的消息:【" + msg + "】");
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.queue2"),
exchange = @Exchange(name = "xyl.topic",type = ExchangeTypes.TOPIC),
key = {"#.news"}
))
public void listenTopicQueue2(String msg){
System.out.println("消费者2 ==> 接收到topic.queue2的消息:【" + msg + "】");
}
- 消息发送
在publisher服务的SpringAmqpTest类中添加测试方法:
@Test
public void testSendTopicExchange() {
// 交换机名称
String exchangeName = "xyl.topic";
// 消息
String msg1 = "中国青年报,人民日报联合发表评论文章...";
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "china.news", msg1);
String msg2 = "Java生态发展势头依然强劲";
rabbitTemplate.convertAndSend(exchangeName,"java.job.soft.news",msg2);
String msg3 = "中国考研人数再创历史新高";
rabbitTemplate.convertAndSend(exchangeName,"china.people.history",msg3);
}
- 测试结果如下:
消费者1 --> 接收到topic.queue1的消息:【中国青年报,人民日报联合发表评论文章...】
消费者2 ==> 接收到topic.queue2的消息:【中国青年报,人民日报联合发表评论文章...】
消费者2 ==> 接收到topic.queue2的消息:【Java生态发展势头依然强劲】
消费者1 --> 接收到topic.queue1的消息:【中国考研人数再创历史新高】
- Topic交换机总结:
- Topic交换机接收的消息RoutingKey可以是一个或多个单词
- Topic交换机与队列绑定时的bindingKey可以指定通配符,#:代表0个或多个词, *:代表1个词
3.3、消息转换器
Spring会把发送的消息序列化为字节发送给MQ,接收消息的时候,还会把字节反序列化为Java对象。
- 而默认情况下Spring采用的序列化方式是JDK序列化。众所周知,JDK序列化存在下列问题:
- 数据体积过大、有安全漏洞、可读性差
- 在RabbitConfig中声明一个实现消息转换器的队列(同前面实现的队列一样)
// 消息转换器
@Bean
public Queue objectQueue(){
return new Queue("object.queue");
}
- 测试默认转化器:
// 修改消息发送的代码,发送一个Map对象:
@Test
public void testSendMap() throws InterruptedException {
// 准备消息
Map<String,Object> msg = new HashMap<>();
msg.put("name", "Jack");
msg.put("age", 21);
// 发送消息
rabbitTemplate.convertAndSend("object.queue", msg);}
- 发送消息后,在控制台中可以看到发送的消息已经转变为JSON格式的字符串,即Spring会把发送的消息序列化为字节发送给MQ,接收消息的时候,还会把字节反序列化为Java对象。
配置JSON转换器
- 显然,JDK序列化方式并不合适。我们希望消息体的体积更小、可读性更高,因此可以使用JSON方式来做序列化和反序列化。
- 在publisher和consumer两个服务中都引入依赖(可两个服务的父工程的pom文件中引入依赖):
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.9.10</version>
</dependency>
- 配置消息转换器
在两个服务的启动类中添加一个消息转换器的Bean,如在Consumer服务的启动类中:
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class ConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(ConsumerApplication.class, args);
}
// 注意:MessageConverter、Jackson2JsonMessageConverter的包不要导错
@Bean
public MessageConverter jsonMessageConverter(){
return new Jackson2JsonMessageConverter();
}
}
- 在消息监听类SpringRabbitListener中添加如下代码,实现对相应队列的消息监听:
@RabbitListener(queues = "object.queue")
public void listenObjectQueue(Map<String,Object> message){
System.out.println("consumer接收到object.queue的消息:" + message);
}
- 再分别启动Consumer、Publisher服务,运行Publisher中的testSendMap方法,如此可见,已经正常完成消息的发送和接收
总结:
- SpringAMQP中消息的序列化和反序列化是怎么实现的:
- 利用MessageConverter 实现,默认是JDK的序列化方式
- 注意,消息的发送方和接收方必须使用相同的MessageConverter (
org.springframework.amqp.support.converter.MessageConverter
)
消息队列的模式总结: