消息队列--rabbitmq

69 阅读14分钟

一、前言

1.微服务间通讯有同步和异步两种方式:

  • 同步通讯:就像打电话,可以立即得到响应,但是你却不能跟多个人同时通话。
  • 异步通讯:就像发消息,可以同时与多个人发送并接收消息,但是往往响应会有延迟

2.同步通讯

我们之前学习的Feign远程调用就属于同步方式,虽然调用可以实时得到结果,但存在下面的问题:

  • 同步调用的优点:

    • 时效性较强,可以立即得到结果
  • 同步调用的缺点:

    • 耦合度高
    • 性能和吞吐能力下降
    • 有额外的资源消耗
    • 级联失败问题 image.png 注:这里并不是想说明同步通讯没用,我们最常用的还是同步通讯。

3.异步通讯

异步调用则可以避免上述问题:

  • 我们以购买商品为例,用户支付后需要调用订单服务完成订单状态修改,调用物流服务,从仓库分配响应的库存并准备发货,调用短信服务发送短信。

  • 支付服务是事件发布者(publisher),在支付完成后只需要发布一个支付成功的事件(event),事件中带上订单id。

  • 订单服务、短信服务和物流服务是事件订阅者(Consumer),订阅支付成功的事件,监听到事件后完成自己业务即可。

  • 为了解除事件发布者与订阅者之间的耦合,两者并不是直接通信,而是有一个中间人(Broker)。发布者发布事件到Broker,不关心谁来订阅事件。订阅者从Broker订阅事件,不关心谁发来的消息。 image.png Broker 是一个像数据总线一样的东西,所有的服务要接收数据和发送数据都发到这个总线上,这个总线就像协议一样,让服务间的通讯变得标准和可控。

  • 好处:

    • 吞吐量提升:无需等待订阅者处理完成,响应更快速
    • 故障隔离:服务没有直接调用,不存在级联失败问题
    • 调用间没有阻塞,不会造成无效的资源占用
    • 耦合度极低,每个服务都可以灵活插拔,可替换
    • 流量削峰:不管发布事件的流量波动多大,都由Broker接收,订阅者可以按照自己的速度去处理事件
  • 缺点:

    • 架构复杂了,业务没有明显的流程线,不好管理
    • 需要依赖于Broker的可靠、安全、性能

二、MQ常见技术介绍

image.png

三、rabbitmq快速入门

1.安装

我们选择在centos中使用Docker容器化来安装。

(1)先拉取镜像

docker pull rabbitmq:3-management

(2)创建并运行mq容器

docker run \
 -e RABBITMQ_DEFAULT_USER=ssm030927 \
 -e RABBITMQ_DEFAULT_PASS=ssm030927 \
 --name mq \
 --hostname mq1 \
 -p 15672:15672 \
 -p 5672:5672 \
 -d \
 rabbitmq:3-management
  • hostname:主机名,不做集群部署可不配置
  • RABBITMQ_DEFAULT_USER: 管理界面登录账号
  • RABBITMQ_DEFAULT_PASS: 管理界面登录密码
  • 15672 :ui管理平台访问端口
  • 5672 :内部消息通信端口

(3)访问页面

浏览器输入阿里云服务器公网ip + 15672端口访问

image.png

(4)基本架构

image.png

RabbitMQ中的一些角色:

  • publisher:生产者
  • consumer:消费者,可从queue中获取消息
  • exchange:交换机,负责消息路由
  • queue:队列,存储消息
  • virtualHost:虚拟主机,隔离不同租户的exchange、queue、消息的隔离

(5)消息模型

RabbitMQ官方提供了5个不同的Demo示例,对应了不同的消息模型: image.png

(6)HelloWorld案例

image.png 官方的HelloWorld是基于最基础的消息队列模型来实现的,只包括如上三个角色:

  • publisher:消息发布者,将消息发送到队列queue

  • queue:消息队列,负责接受并缓存消息

  • consumer:订阅队列,处理队列中的消息

    基本消息队列的消息发送流程(生产者Publisher):

  • 建立connection

  • 创建channel

  • 利用channel声明队列

  • 利用channel向队列发送消息

public class PublisherTest {
    @Test
    public void testSendMessage() throws IOException, TimeoutException {
        // 1.建立连接
        ConnectionFactory factory = new ConnectionFactory();
        // 1.1.设置连接参数,分别是:主机名、端口号、vhost(虚拟主机)、用户名、密码
        factory.setHost("120.55.95.185");
        factory.setPort(5672);
        factory.setVirtualHost("/"); 虚拟主机默认/
        factory.setUsername("ssm030927");
        factory.setPassword("ssm030927");
        // 1.2.建立连接
        Connection connection = factory.newConnection();

        // 2.创建通道Channel
        Channel channel = connection.createChannel();

        // 3.创建队列
        String queueName = "simple.queue";
        channel.queueDeclare(queueName, false, false, false, null);

        // 4.发送消息
        String message = "hello, rabbitmq!";
        channel.basicPublish("", queueName, null, message.getBytes());
        System.out.println("发送消息成功:【" + message + "】");

        // 5.关闭通道和连接
        channel.close();
        connection.close();

    }
}

发送完成后断开connection,并可在queue中查看该消息

image.png

image.png

基本消息队列的消息接收流程(消费者Consumer):

  • 建立connection
  • 创建channel
  • 利用channel声明队列
  • 定义consumer的消费行为handleDelivery()
  • 利用channel将消费者与队列绑定
public class ConsumerTest {

    public static void main(String[] args) throws IOException, TimeoutException {
        // 1.建立连接
        ConnectionFactory factory = new ConnectionFactory();
        // 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
        factory.setHost("120.55.95.185");
        factory.setPort(5672);
        factory.setVirtualHost("/");
        factory.setUsername("ssm030927");
        factory.setPassword("ssm030927");
        // 1.2.建立连接
        Connection connection = factory.newConnection();

        // 2.创建通道Channel
        Channel channel = connection.createChannel();

        // 3.创建队列
        String queueName = "simple.queue";
        channel.queueDeclare(queueName, false, false, false, null);

        // 4.订阅消息
        channel.basicConsume(queueName, true, new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope,
                                       AMQP.BasicProperties properties, byte[] body) throws IOException {
                // 5.处理消息
                String message = new String(body);
                System.out.println("接收到消息:【" + message + "】");
            }
        });
        System.out.println("等待接收消息。。。。");
    }
}

控制台先打印等待"接收消息。。。。",后打印消息队列中的数据。第四步只是将消费者的回调函数与消息队列绑定,代码接着往下执行,等mq把消息传递过来,回调函数才会执行,这也说明mq异步通信。

说明:消息一旦被消费就会从队列中删除(只能读取一次),RabbitMQ没有消息回溯功能。

image.png

四、SpringAMQP使用

我们可以发现上述使用RabbitMQ实现简单消息队列过程比较麻烦,接下来让我们一起看看Spring AMQP如何简化RabbitMQ开发过程。

1.前言

就像MyBatis封装 JDBC,简化繁杂的开发过程一般,SpringAMQP则是基于RabbitMQ封装的一套模板,并且利用SpringBoot对其实现了自动装配,使用起来非常方便。

2.BasicQueue简单队列

image.png

我们来尝试基于Spring AMQP再次实现上述入门案例

(1)在父工程中引入依赖
<!--AMQP依赖,包含RabbitMQ-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

注:导入此依赖后不再需要RabbitMQ依赖

(2)消息发送

首先配置MQ地址,在publisher服务的application.yml中添加配置:

spring:
  rabbitmq:
    host: 120.55.95.185 #主机ip地址
    port: 5672 #端口,mq内部通信用5672
    virtual-host: / #虚拟主机,默认为/
    username: ssm030927 #用户名
    password: ssm030927 #密码
@SpringBootTest
public class SpringAMQPTest {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Test
    public void testSimpleQueue() {
        String queueName = "simple.queue"; //消息队列名称
        String message = "hello, SpringAmqp"; //消息内容
        rabbitTemplate.convertAndSend(queueName, message); //发送消息
    }
}

image.png

(3)消息接收

添加yaml配置

编写代码

package cn.itcast.mq.listener;

import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Component
public class SpringRabbitListener {

    @RabbitListener(queues = "simple.queue") //指定监听的队列名称,可自动装配下述参数msg
    public void listenSimpleQueueMessage(String msg) {
        System.out.println(msg);
    }
}

3.WorkQueue工作队列

(1)前言

image.png

Work queues任务模型。简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息,提高处理速度。

我们已经知道消息读取后即会从队列中删除,当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。长此以往,消息就会堆积越来越多,无法及时处理。

此时就可以使用work模型,多个消费者共同处理消息处理,速度就能大大提高了。

(2)消息发送

这次我们循环发送,模拟大量消息堆积现象。

@SpringBootTest
public class WorkQueueTest {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Test
    public void test() throws InterruptedException {
        // 队列名称
        String queueName = "simple.queue";
        // 消息
        String message = "hello, message_";
        for (int i = 0; i < 50; i++) {
            // 发送消息
            rabbitTemplate.convertAndSend(queueName, message + i);
            // 控制1s发送50条消息
            Thread.sleep(20);
        }
    }
}
(3)消息接收

要模拟多个消费者绑定同一个队列

@Component
public class WorkQueueListener {

    // 两个消费者绑定到同一个队列

    @RabbitListener(queues = "simple.queue")  //指定监听的队列名称,可自动装配下述参数msg
    public void listenWorkQueueMessage1(String msg) throws InterruptedException {
        System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now());
        // 模拟不同消费者处理能力差异
        Thread.sleep(20);
    }

    @RabbitListener(queues = "simple.queue")  //指定监听的队列名称,可自动装配下述参数msg
    public void listenWorkQueueMessage2(String msg) throws InterruptedException {
        System.err.println("消费者2接收到消息:【" + msg + "】" + LocalTime.now());
        // 模拟不同消费者处理能力差异
        Thread.sleep(200);
    }
}
(4)测试

WorkQueue采用的预取机制,消息发送后,每个消费者先预取25条,再单独执行自己的25条。 可以看到消费者1很快完成了自己的25条消息。消费者2却在缓慢的处理自己的25条消息。 image.png 也就是说消息确实同时被两个消费者处理了,但是平均分配给了每个消费者,并没有考虑到消费者的处理能力。这样显然是有问题的。

(5)优化(能者多劳)

我们可以修改consumer服务的application.yml文件,设置preFetch参数,控制预取消息的上限

spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息

重新启动我们可以发现,模拟性能比较好的1快速的消费了大量消息,而2仅消费了几条消息。 image.png

4.发布、订阅模型

发布订阅模式与之前案例的区别就是允许将同一消息发送给多个消费者image.png

可以看到,在订阅模型中,多了一个exchange角色,而且过程略有变化:

  • Publisher:生产者,也就是要发送消息的程序,但是不再发送到队列中,而是发给交换机

  • Exchange:交换机,。一方面,接收生产者发送的消息。另一方面,知道如何处理消息,例如递交给某个特别队列、递交给所有队列、或是将消息丢弃。到底如何操作,取决于Exchange的类型。Exchange有以下3种类型:

    • Fanout:广播,将消息交给所有绑定到交换机的队列

    • Direct:定向,把消息交给符合指定routing key 的队列

    • Topic:通配符,把消息交给符合routing pattern(路由模式) 的队列

  • Consumer:消费者,与以前一样,订阅队列,没有变化

  • Queue:消息队列也与以前一样,接收消息、缓存消息。

Exchange(交换机)只负责转发消息,不具备存储消息的能力,因此如果没有任何队列与Exchange绑定,或者没有符合路由规则的队列,那么消息会丢失!

Spring提供了一个接口Exchange,来表示所有不同类型的交换机:

image.png

4.1Fanout广播

(1)前言

image.png

在广播模式下,消息发送流程是这样的:

  • 可以有多个队列
  • 每个队列都要绑定到Exchange(交换机)
  • 生产者发送的消息,只能发送到交换机,交换机来决定要发给哪个队列,生产者无法决定
  • 交换机把消息发送给绑定过的所有队列
  • 订阅队列的消费者都能拿到消息

(2)声明队列和交换机

package cn.itcast.mq.config;

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 FanoutConfig {

    //声明交换机
    @Bean
    public FanoutExchange fanoutExchange() {
        return new FanoutExchange("itcast.fanout"); //交换机名为itcast.fanout
    }

    //声明第一个队列
    @Bean
    public Queue queue1() {
        return new Queue("fanout.queue1");
    }

    //绑定队列1到交换机
    @Bean
    public Binding binding1(Queue queue1, FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(queue1).to(fanoutExchange);
    }

    //声明第二个队列
    @Bean
    public Queue queue2() {
        return new Queue("fanout.queue2");
    }

    //绑定队列2到交换机
    @Bean
    public Binding binding2(Queue queue2, FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(queue2).to(fanoutExchange);
    }
}

image.png

image.png

还可以再itcast.fanout交换机中查看与队列的绑定关系

image.png

(3)接收消息

//TODO:3.FanoutExchange广播类型交换机

@RabbitListener(queues = "fanout.queue1")  
public void listenFanoutQueue1Message(String msg) {
    System.out.println("消费者接收到Fanout.queue1的消息:【" + msg + "】");
}

@RabbitListener(queues = "fanout.queue2")  
public void listenFanoutQueue2Message(String msg) {
    System.out.println("消费者接收到Fanout.queue2的消息:【" + msg + "】");
}

(4)发送消息

@Test //把消息发送给交换机
public void testFanoutExchange() {
    //交换机名称
    String exchangeName = "itcast.fanout";
    //消息
    String message = "hello, every one";
    //发送消息
    rabbitTemplate.convertAndSend(exchangeName, "", message); //第二个参数先不写,后面会用
}

绑定的队列都能收到消息

image.png

4.2Direct路由

(1)前言

在Fanout模式中,一条消息,会被所有订阅的队列都消费。但是,在某些场景下,我们希望不同的消息被不同的队列消费。这时就要用到Direct类型的Exchange。 image.png 在Direct模型下:

  • 队列与交换机的绑定,不能是任意绑定了,而是要指定一个RoutingKey也可以多个(路由key)

  • 消息的发送方在向 Exchange发送消息时,也必须指定消息的 RoutingKey

  • Exchange不再把消息交给每一个绑定的队列,而是根据消息的Routing Key进行判断,只有队列的Routingkey与消息的 Routing key完全一致,才会接收到消息。

(2)消息接收

基于@Bean方式声明队列和交换机比较麻烦,需要声明多个,Spring还提供了基于注解方式来声明。

在consumer的SpringAMPQQueueListener类中添加两个消费者,同时基于注解来声明队列和交换机:

//TODO:4.DirectExchange路由类型交换机
@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "direct.queue1"),
        exchange = @Exchange(name = "itcast.direct", type = ExchangeTypes.DIRECT),
        key = {"red", "blue"}
))
public void listenDirectQueue1Message(String msg) {
    System.out.println("消费者接收到direct.queue1的消息:【" + msg + "】");
}

@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "direct.queue2"),
        exchange = @Exchange(name = "itcast.direct", type = ExchangeTypes.DIRECT),
        key = {"red", "yellow"}
))
public void listenDirectQueue2Message(String msg) {
    System.out.println("消费者接收到direct.queue2的消息:【" + msg + "】");
}
  • bindings = @QueueBinding():声明并绑定关系
  • value = @Queue():队列名称
  • exchange = @Exchange():交换机名称,发布订阅模式,类型默认为direct
  • key = {}:以字符串数组形式指定路由key

可以看到在itcast.direct交换机内绑定了四个队列

image.png

(3)消息发送

@Test
public void testDirectExchange() {
    //交换机名称
    String exchangeName = "itcast.direct";
    //消息
    String message = "hello, yellow";
    //发送消息
    rabbitTemplate.convertAndSend(exchangeName, "yellow", message); //第二个参数为Routing key
}

消费者收到direct.queue2的消息

image.png

4.3Top主题

(1)前言

Topic类型的ExchangeDirect相比,都是可以根据RoutingKey把消息路由到不同的队列。只不过Topic类型Exchange可以让队列在绑定Routing key 的时候使用通配符!

Routingkey 一般都是有一个或多个单词组成,多个单词之间以”.”分割,例如: item.insert

通配符规则:

#:匹配0个或多个词

*:匹配1个词

image.png

例如:

  • china.# 可匹配中国的新闻、天气
  • #.news可匹配中国、日本的天气

简化了direct中想要匹配多个key时太繁琐的问题

(2)消息接收

//TODO:5.TopExchage主题类型交换机
@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "top.queue1"),
        exchange = @Exchange(name = "itcast.top", type = ExchangeTypes.TOPIC),
        key = "china.#"  //以china开头
))
public void listenTopQueue1Message(String msg) {
    System.out.println("消费者接收到top.queue1的消息:【" + msg + "】");
}

@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "top.queue2"),
        exchange = @Exchange(name = "itcast.top", type = ExchangeTypes.TOPIC),
        key = "#.news"  //以news结尾
))
public void listenTopQueue2Message(String msg) {
    System.out.println("消费者接收到top.queue2的消息:【" + msg + "】");
}

可以看到itcast.top交换机内部绑定了两个消息队列

image.png

(3)消息发送

@Test
public void testTopExchange() {
    //交换机名称
    String exchangeName = "itcast.top";
    //消息
    String message = "喜报!孙悟空大战哥斯拉,胜!";
    //发送消息
    rabbitTemplate.convertAndSend(exchangeName, "chain.news", message);
}

两个消息队列的Routing Key都与交换机的Routing Key规则匹配,两个消费者都能获取消息

image.png

5.消息转化器

(1)前言

我们可以发现,在SpringAMQP的发送消息中,发送接收消息的类型是Object,也就是说我们可以发送任意对象类型的消息,SpringAMQP会帮我们序列化为字节后发送,接收消息的时候,还会把字节反序列化为Java对象。

image.png

只不过,默认情况下Spring采用的序列化方式是JDK序列化。众所周知,JDK序列化存在下列问题:

  • 数据体积过大
  • 有安全漏洞
  • 可读性差

(2)测试默认转换器

  • 1.首先利用@Bean创建一个object.queue队列
//声明Json处理器案例queue
@Bean
public Queue queue3() {
    return new Queue("object.queue");
}
  • 2.我们修改消息发送的代码,发送一个Map对象:
@Test
public void testSendMap() throws InterruptedException {
    Map<String, Object> map = new HashMap<>();
    map.put("name", "Jack");
    map.put("age", 18);
    rabbitTemplate.convertAndSend("object.queue", map);
}

停止consumer服务

发送消息后查看控制台:

image.png

显然,JDK序列化方式并不合适。我们希望消息体的体积更小、可读性更高,因此可以使用JSON方式来做序列化和反序列化。

(3)配置JSON转换器

  • 1.两个服务中要用到,所以在父工程中引入依赖:
<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
    <version>2.9.10</version>
</dependency>
  • 2.配置消息转换器:

在生产者和消费者的启动类Appication中添加一个Bean即可:

@Bean
public MessageConverter jsonMessageConverter(){
    return new Jackson2JsonMessageConverter();
}

重启启动项目,再次测试效果

image.png

  • 3.编写消息接收方法
//TODO: JSON处理器
@RabbitListener(queues = "object.queue")
public void listenerJSONMessage(Map<String, Object> msg) throws InterruptedException {
    System.out.println(msg);
}

image.png

可以发现配置已经生效