AMQP 与RabbitMQ入门

132 阅读11分钟

MQ概念

MQ,即Message Queue,消息队列。顾名思义,存储消息的队列。

它就像一个邮箱,我们往里面投递邮件,然后就去忙别的了。剩下的事交给邮局,我们知道它最终会把邮件送到收件人手里。

在这里我们的RabbitMQ就是一个邮箱,而我们要投递的消息就是一封邮件。RabbitMQ与现实中邮局的区别在于它不处理纸张,它接收,存储转发二进制数据 - 消息。

为什么用MQ?

  1. 削峰填谷

    业务请求压力曲线就像一座山峰,有瞬时的高峰,也有低谷,服务器配置如果按照高峰压力配置,在低谷时浪费了服务器资源,若按低峰压力配置,则处理不了高峰的请求,导致崩溃。

    MQ可以缓存瞬时的高压请求,分散一部分请求压力到空闲期处理。平缓服务器压力曲线,使服务器更加稳定。

  2. 异步处理

    通过将复杂耗时的业务异步处理,提高响应速度。

    例如前端用户点击生成一个统计报表,涉及的数据量非常大,可能耗时几十秒,让用户等待体验就很差,可以直接响应“任务下发成功”,后台查询统计处理,等处理好后再修改任务状态。

  3. 系统解耦

    不通服务之间在直接调用的场景下,如果一方修改了地址,那么可能所有连接它的服务都要修改,负担极大。

    通过MQ双方不再直接通信,而是统一和MQ打交道,互相看不到对方的存在。协商好格式,后续的服务客户端都不影响现有服务运行。

两种模型

消息队列有两种模型:

  1. 点对点模型

    使用队列(Queue) 作为消息通信载体;满足生产者与消费者模式,一条消息只能被一个消费者使用。

  2. 发布订阅模型

    使用主题(Topic) 作为消息通信载体,类似于广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者。

RabbiMQ介绍

RabbitMQ是Erlang语言开发的消息队列,实现了AMQP。

AMQP,即Advanced Message Queuing Protocol,高级消息队列协议,是一个提供统一消息服务的应用层标准高级消息队列协议

它源于2004年伦敦的一个金融部门,当时他们想要去解决分布式环境下的通信问题,意识到消息通信才是分布式计算的解决方案。

名词与角色

  • Broker

    接收和分发消息的应用代理,如RabbitMQ服务器。

  • Connection

    publisher/consumer 和 broker 之间的 TCP 连接 。

  • Channel

    Channel 是在 connection 内部建立的逻辑连接,极大减少建立TCP连接的开销 。

  • Exchange

    消息交换机,根据分发规则,根据匹配规则,分发消息到 queue 中去 。

  • Queue

    消息最终存储的位置,等待 consumer 取走 。

  • Binding

    绑定,它的作用就是把 Exchange 和 Queue 按照路由规则绑定起来。

使用Docker安装RabbitMQ

  • 拉取镜像

    docker pull rabbitmq
    
  • 运行

    docker run -d --hostname my-rabbit --name rabbit -p 15672:15672 -p 5672:5672 rabbitmq
    
  • 安装管理界面插件

    # 查看运行的容器
    docker ps 
    # 进入容器
    docker exec -it 镜像ID /bin/bash
    # 打开rabbitmq_management的插件
    rabbitmq-plugins enable rabbitmq_management
    # 退出容器
    exit
    

然后就可打开Web控制台http://localhost:15672/登录查看了,默认账户guest/guest

Hello World

先介绍下这里的术语:

  • Producer生产者

    生产意为发送,一个发送消息的程序就是生产者。

  • Queue队列

    队列就是RabbitMQ中邮箱的名称,本质是一个巨大的消息缓存。生产者可以往里发消息,消费者可以从这里接收消息

    Quene
  • Consumer消费者

    消费和接收的意思相近,消费者就是一个主要接收消息的程序。

引入客户端依赖

<dependency>
    <groupId>com.rabbitmq</groupId>
    <artifactId>amqp-client</artifactId>
    <version>5.7.1</version>
</dependency>

连接工具类

发送和接收都需要建立和RabbitMQ的连接,首先我们封装一个ConnectionUtil连接工具

package org.example;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

/**
 * 连接工具类
 */
public class ConnectionUtil {
      /**
     * 创建频道
     */
    public static Channel createChannel() throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");
        try (Connection connection = factory.newConnection();
             Channel channel = connection.createChannel()) {
            return channel;
        }
    }

    /**
     * 创建保活频道
     */
    public static Channel createAliveChannel() throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();
        return channel;
    }
}

Producer

首先需要声明一个队列,然后就可以往里发送消息了,声明队列是幂等的,只有队列不存在时才会创建,注意使用try-with-resources体获取的channel

       // 使用工具类获取Channel,后续示例忽略
       final Channel channel = ConnectionUtil.createChannel();

       String QUEUE_NAME = "hello";
       /**
         * 声明队列
         * 参数解释:
         * 1、队列名称
         * 2、队列里面的消息是否进行持久化(保存到磁盘) 默认把消息存在内存中
         * 3、该队列是否只供一个消费者进行消费 true表示可以多个消
              费者消费,false表示只能一个消费者消费一般为false,保证能够共享
         * 4、是否自动删除 在最后一个消费者断开连接后,该队列是否自动删除
         * 5、其他参数,后面学习会用到
         */
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);
        String message = "Hello World." + LocalDateTime.now();
        channel.basicPublish("", QUEUE_NAME, null, message.getBytes());

Consumer

消费者只能从队列里取到消息,因此也同样需要声明队列,这里要和发布者保持一致。但是这里channel并不能使用try-with-resource获取,因为我们希望消费者一直监听,如果使用自动关闭资源,那所有链接都被关闭就很尴尬了。

然后我们订阅这个队列,并且提供一个回调,服务器就会异步向我们推送给消息。

一个最简单的发送订阅就完成了。

// 使用工具类获取Channel,后续示例忽略
final Channel channel = ConnectionUtil.createAliveChannel();
String QUEUE_NAME = "hello";

channel.queueDeclare(QUEUE_NAME, false, false, false, null);
System.out.println(" [*] Waiting for messages. To exit press CTRL+C");

// 回调
DeliverCallback deliverCallback = (consumerTag, delivery) -> {
    String message = new String(delivery.getBody(), "UTF-8");
    delivery.getEnvelope().getDeliveryTag();
    System.out.println(" [x] Received '" + message + "'");
};
// 消费
channel.basicConsume(QUEUE_NAME, true, deliverCallback, consumerTag -> {
});

Work Queue工作队列

在这个章节我们会创建工作队列来分配耗时任务给多个工作者 。工作队列的核心思想是避免立即执行资源密集型任务,并不得不等待它完成。相反我们将任务封装为消息并发送到队列,后台的工作进程将它取出,如果启动多个工作者,任务将在他们之前共享。

Producer

在这里我们并没有真是的耗时的任务要处理,像是渲染PDF或者处理图片等等,因此我们用Thread.sleep()来模拟耗时任务,我们用消息中的.来代表任务复杂度,每一个点将耗时1秒来处理,例如这样一个消息Hello...将会耗时3秒处理。

boolean durable = true;
String QUEUE_NAME = "worker-queue";

channel.queueDeclare(QUEUE_NAME, durable, false, false, null);
for (int i = 0; i < 10; i++) {
    String message;
    // 偶数 重任务
    if (i % 2 == 0) {
        message = "Hello World.1.2.3.4.5.6.7.8.9.10.;" + LocalDateTime.now();
    } else {
        message = "Hello World.1;" + LocalDateTime.now();
    }

    channel.basicPublish("", QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN,message.getBytes());
    System.out.println(" [x] Sent '" + message + "'");

在这里我们将声明队列的durable参数设置为true,发送消息时也设置MessageProperties.PERSISTENT_TEXT_PLAIN来实现持久化,以避免当RabbitMQ宕机时消息丢失。

Consumer

消费者需要做一些小改造,它需要为消息的每个点模拟处理一秒。

String QUEUE_NAME = "worker-queue";

channel.queueDeclare(QUEUE_NAME, true, false, false, null);
System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
// 回调
DeliverCallback deliverCallback = (consumerTag, delivery) -> {
    String message = new String(delivery.getBody(), "UTF-8");
    doWork(message);
    // 手动确认
    channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
    System.out.println(" [x] Received '" + message + "'");
};
boolean autoAck = false; // acknowledgment is covered below
channel.basicConsume(QUEUE_NAME, autoAck, deliverCallback, consumerTag -> {
});

这里我们还将订阅时的自动提交参数改为false, 在回调里手动提交basicAck,这样可以避免当消费者在意外挂掉导致的消息丢失。

设置了手动提交后,当服务器接收提交超时(默认30秒)后,它就会将该消息重新加入队列。如果此时有其他消费者,它会马上将消息发给其他消费者

模拟耗时处理方法如下

private static void doWork(String message) {
    for (char c : message.toCharArray()) {
        if (c == '.') {
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

当我们启动两个Consumer,服务器会将消息轮询发给消费者。你可能注意到我们发送消息的时候,偶数总是耗时的任务,奇数总是简单任务,这样会导致其中一个压力巨大,另一个却游手好闲。

为了避免这种情况我们可以使用basicQos方法设置prefetchCount=1,这个动作就是告诉RabbitMQ,一次不要推送超过一个消息。换句话说,就是不要在他还在处理时推送消息,而是推给其他空闲的消费者。

int prefetchCount = 1;
channel.basicQos(prefetchCount);

Fanout

在前面的章节中,每一个消息会发给一个消费者,在这一章中,我们要做完全不一样的事情,我们会将消息发给多个消费者,这种模式就是发布/订阅模式

在RabbitMQ的模型中,生产者从不直接和队列打交道,它只和exchange打交道。exchange可以理解为交换机,负责接收消息,根据规则再将消息分发送到指定队列。

在之前的章节中我们并不知道exchange的存在,没有指定exchange也能发送消息。这是因为,当我们发布时,用了空字符串指定使用默认的exchange-direct exchange。

// exchangeName为“”,使用默认的direct exchange
channel.basicPublish("", "hello", null, message.getBytes());

这里发布时的第一个参数是exchange的名称, 第二个参数是routingKey,当和我们使用fanout 类型exchange时,这个值会被忽略。

RabbitMQ中有有四种exchange,fanout,direct,topic,header。在本章中我们使用fanout类型的exchange,fanout直译为扇出,就像一把扇子,源头是一个点,向外扩展,形象的理解为广播。

Producer

在这里我们会往命名为logs的exchange发送日志信息。

String EXCHANGE_NAME = "logs";

channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.FANOUT);
String message = "info: Hello World!";
channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes("UTF-8"));

Consumer

在上一个例子Worker中,我们指定了队列的名称"hello", "worker-queue"",然后将两个消费者指向了这个队列,此时这个队列中的消息就被两个消费者共享了,因此队列命名很重要。但在本章节中,我们希望每个消费者都可以读到所有的消息。我们需要

  1. 当消费者连接时,需要有一个全新的空队列,名称可以是随机的,最好由服务器指定。

  2. 当消费者断开时,队列可以自动删除。

RabbitMQ提供了一个无参方法来声明匿名队列,除此之外还要让exchange往这个队列里发送消息,队列和exchange之间的关系称为绑定,绑定可以简单理解为,队列对这个exchange的消息感兴趣。

启动两个Consumer,此时他们都能收到消息。

String EXCHANGE_NAME = "logs";

// 声明Exchange和Quene
channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.FANOUT);
String queueName = channel.queueDeclare().getQueue();
channel.queueBind(queueName, EXCHANGE_NAME, "");

// 回调
DeliverCallback deliverCallback = ((consumerTag, delivery) -> {
    String message = new String(delivery.getBody(), "UTF-8");
    System.out.println(" [x] Received '" + message + "'");
});
channel.basicConsume(queueName, true, deliverCallback, consumerTag -> {
});

Direct

在上一个例子fanout中,exchange会给所有的消费者发送信息。在这一章节中,我们会加入一个特性:消费者可以只订阅其中的一个子级。例如我们有些组件只消费错误日志,而有的组件消费所有的日志。这个特性是通过队列和exchange绑定关系的额外参数:路由键routingKey或者称为bindingKey来实现的。

Producer

在这里我们将会使用direct exchange,direct exchange的算法也很简单,当消息发送的routingKey和队列绑定的routingKey匹配上时,消息就被发送过去了。

String EXCHANGE_NAME = "direct_logs";

channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.DIRECT);
// 此处可修改为 info,warning
String rootingkey = "error";
String message = "This is log";
channel.basicPublish(EXCHANGE_NAME, rootingkey, null, message.getBytes(StandardCharsets.UTF_8));

Consumer

启动多个消费者,每个消费者绑定的routingKey列表不一致。我们也可以让每个消费者绑定的routingKey一摸一样,不过那样就和fanout没区别了

String EXCHANGE_NAME = "direct_logs";

channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.DIRECT);
String queueName = channel.queueDeclare().getQueue();

// 通过routingKey过滤exchange数据,可以有多个绑定关系
channel.queueBind(queueName, EXCHANGE_NAME, "error");
System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
DeliverCallback deliverCallback = (consumerTag, delivery) -> {
    String message = new String(delivery.getBody(), "UTF-8");
    System.out.println(" [x] Received '" +
    delivery.getEnvelope().getRoutingKey() + "':'" + message + "'");
};
channel.basicConsume(queueName, true, deliverCallback, consumerTag -> {
});

Topic

如果我们想在direct 的基础上,实现更高的灵活度,例如有的消费不单只关心error日志,还想针对日志的来源类型进行过滤,就可以使用topic exchange。发送给exchange时指定的的routingKey不能是任意字符,它必须是一些单词由·分割,如“kernel.error.log”。topic支持带通配符的routingKey绑定。

  • *可以替代一个单词

  • #可以替代0个或多个单词

我们也可以不用通配符,不过这种情况下它就和directexchange没区别了

Producer

String EXCHANGE_NAME = "topic_logs";

channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.TOPIC);
String routingKey = "lazy.orange.bird";
String message = "test-message";
channel.basicPublish(EXCHANGE_NAME, routingKey, null, message.getBytes(StandardCharsets.UTF_8));
System.out.println(" [x] Sent '" + routingKey + "':'" + message + "'");

Consumer

String EXCHANGE_NAME = "topic_logs";

channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.TOPIC);
String queueName = channel.queueDeclare().getQueue();
// 灵活调换routingKey *.*.rabbit
String bindingKey = "*.orange.*";
channel.queueBind(queueName, EXCHANGE_NAME, bindingKey);
DeliverCallback deliverCallback = (consumerTag, delivery) -> {
    String message = new String(delivery.getBody(), "UTF-8");
    System.out.println(" [x] Received '" +
    delivery.getEnvelope().getRoutingKey() + "':'" + message + "'");
};
channel.basicConsume(queueName, true, deliverCallback, consumerTag -> {
});

在这里我们启动两个消费者。C1订阅*.orange.*,C2订阅*.*.rabbitlazy.#,当生产者发送的routingKeyquick.orange.rabbit,消息会同时发送给C1和C2,而quick.orange.fox仅匹配C1,lazy.brown.fox仅匹配C2。lazy.pink.rabbit同时匹配C2的两个绑定,但它仅会被发送给C2一次。如果都不匹配,消息将会被丢弃。

RPC

rpc涉及很少,这里就不介绍了

以上就是全部内容啦

资料参考或引用自以下链接

RabbitMQ-Tutorial

Java Guide