RabbitMQ 原理解析

什么是RabbitMQ?

RabbitMQ是基于 AMQP 0-9-1 协议模型实现的一个消息队列服务,消息流转符合下图基本原则
生产者(producer)将消息发送至RabbitMQ中的 交换机(exchange), 交换机会根据不同的路由规则将消息转发至 队列(queue),队列再将消息投递给队列的 消费者(consumer)

交换机(Exchange)

交换机可以理解为是一个中转站,它负责将消息根据一定的条件和规则分发到队列(queue)上
 

交换机存在的价值

你可能会想,从最基本的生产消费角度出发,生产者完全可以直接将消息发送到队列,队列将消息投递给消费者即可,为什么还需要通过交换机来进行消息转发呢?
 
事实上,它是可以间接的实现这种需求的,通过将消息投递到一个空的交换机,并设置路由键(routing_key)为队列名称时,RabbitMQ会将消息直接分发给路由键同名的队列中。
 
我们考虑这样一个场景,生产者需要将消息发送到多个队列时,如果没有交换机,生产者则需要书写冗余的代码来将消息发送至多个队列,这显然不够优雅,而交换机的存在则可以解决这个问题,生产者只需将消息发送至交换机,由交换机来决定将消息分发给一个或多个队列。
 
我们考虑另外一个场景,如果生产者直接将消息发送至队列,当我们需要更换队列时,我们需要更改发送端的代码来进行队列切换,而交换机的存在可以让我们轻松的实现这个需求,我们只需要将原队列从交换机上解绑,并将新的队列绑定到这个交换机上即可。
 
从多个实际场景可以看出,交换机的存在是有价值的,并且交换机还有多个不同的类型。下面给大家介绍一下RabbitMQ中存在的交换机有哪些!
 

交换机类型

直连交换机 (Direct exchange)

这是最常用的交换机类型,我们可以将队列绑定到直连交换机上,并指定一个路由键(routing_key),当交换机收到匹配路由键的消息时,直接将消息转发至绑定的0个或多个队列中,这取决于消息携带的路由键存在多少个绑定队列,这意味着直连交换机可以实现广播的能力,不过单纯的广播我们可以直接使用 扇形交换机,后面会进行介绍,那将更加优雅。
需要注意的是,RabbitMQ存在一个默认的交换机(名称是一个空字符串),所有队列被声明时都会自动绑定到这个交换机,绑定的路由键就是队列名,这意味着生产者可以将消息发送至这个默认交换机,并通过指定路由键的方式来自由地将消息直接发送到指定队列,如下图所示
 

扇形交换机 (Fanout exchange)

不同于直连交换机,扇形交换机完全不会理会路由键与绑定关系,而是一股脑儿的将消息转发至绑定自己的所有队列,即使生产者发送时指定了路由键也是一样。
扇形交换机用于广播消息,多个消费者分别声明自己的专属队列,并将队列绑定到该交换机,即可实现一个消息被多个消费者一起消息。
 

主题交换机 (Topic exchange)

相较于 直连交换机 的路由键匹配模式 与 扇形交换机 的无脑广播模式 来说,主题交换机就相对智能的多,它既可以实现指定路由键的转发,也可以实现优雅的广播机制,更重要的是,它可以实现模糊匹配路由键的订阅机制
发送到主题交换机的消息路由键必须是一个由.分隔开的词语列表,如:"big.dog", "small.cat", "black.small.dog"。词语的个数可以随意,但是不要超过255字节。
队列绑定到交换机时的绑定键也必须是这样的格式。但是绑定键支持使用两个通配符:
  • * (星号) 用来表示一个单词.
  • # (井号) 用来表示任意数量(零个或多个)单词。
我们回头分析下上面的图例,已知一个主题交换机(xxx),绑定了三个队列queue1,queue2,queue3,绑定键分别为 big.*,small.*,# 。当我们发送一个消息给交换机,并且指定路由键为 big.dog 时,消息将被转发至 queue1 与 queue3,当指定路由键为 small.cat 时,消息将被转发至 queue2 与 queue3
 

头交换机 (Headers exchange)

大部分场景下,以上三种交换机已经足以满足需求,但是在某些复杂场景下还是不能满足,比如当我们的消息路由很复杂时,难以使用一个路由键和绑定键来描述完整的路由规则。
RabbitMQ提供了基于消息头(Headers)的路由模式,它可以在将队列绑定至交换机时,指定仅当消息头与规则任意匹配 或者 完全匹配时才将消息转发至该队列
通过对比 直连交换机 我们可以更好的理解 头交换机,相比来说,直连交换机使用额外的路由键进行规则匹配,而 头交换机 则使用 消息头 进行规则匹配。
 

交换机的属性

我们在声明交换机时,可以为其指定相应的属性来满足相关需求
  • Name(交换机名称)
  • Durability (是否持久化交换机,这个参数将决定MQ服务重启后,交换机是否还存在)
  • Auto-delete (当所有与之绑定的消息队列都完成了对此交换机的使用后,删掉它)
  • Arguments(额外的参数,可被相关插件使用)
 

队列(Queue)

队列中存储着从交换机转发过来的消息,并将消息投递给订阅此队列的消费者,当一个消息被投递成功后,将从队列中被删除
 

队列的属性

  • Name (队列名称)
  • Durable(是否持久化队列,这将决定MQ服务重启后,队列是否还存在)
  • Exclusive(是否独占,只被一个连接(connection)使用,而且当连接关闭后队列即被删除)
  • Auto-delete(当最后一个消费者退订后即被删除)
  • Arguments(一些消息代理用他来完成类似与TTL的某些额外功能)
需要注意的是,在声明一个队列时,如果队列已经存在,并且属性完全相同,那么此次声明不会对原有队列产生任何影响。如果声明中的属性与已存在队列的属性有差异,那么一个错误代码为406的通道级异常就会被抛出。
 

绑定交换机(Binding)

在一个队列能被投入使用之前,我们需要将它绑定到至少一个交换机上,并指定一个绑定键(Binding),交换机在收到消息时,匹配消息携带的路由键与绑定键是否匹配,如果匹配,则会将消息转发至该队列,如果不匹配任何绑定键,则不会转发到任何队列上,并将消息退回给生产者或者直接忽略该消息。
 

消费者

知道了RabbitMQ的基本工作原理后,我们继续来看一个应用实例是如何完成队列的订阅与消息消费的。
 

单队列消费

我们考虑一个最简单的场景:消费单个队列,整个流程模型可以参考下图,解释一下,应用与MQ服务建立一个连接(Connection),并在连接上建立一个管道(Channel),通过管道订阅一个队列(queue)并绑定消费函数(Consumer)
下面是这个场景的代码实现示例(这里贴了相对简洁的Python代码):
#!/opt/homebrew/bin/python3
# -*- coding: UTF-8 -*-

import pika
import _thread
import time

MQ_CONFIG = {
        "host": "10.80.20.209",
        "port": 5672,
        "vhost": "/",
        "user": "dc_base",
        "passwd": "xxxxxx"
}

exchange = 'python-test-exchange'
queue = 'python-test-queue'
routing_key = 'tester'

credentials = pika.PlainCredentials(MQ_CONFIG.get("user"), MQ_CONFIG.get("passwd"))
parameters = pika.ConnectionParameters(MQ_CONFIG.get("host"), MQ_CONFIG.get("port"), MQ_CONFIG.get("vhost"), credentials)

# 与rabbitMQ建立Connection连接
connection = pika.BlockingConnection(parameters)
# 在Connnction上创建一个Channel管道
channel = connection.channel()

# 声明交换机
channel.exchange_declare(exchange=exchange, exchange_type='direct')
# 声明队列
channel.queue_declare(queue=queue)
# 队列绑定交换机
channel.queue_bind(exchange=exchange, queue=queue, routing_key=routing_key)

# 这是消息的消费逻辑
def callback(ch, method, properties, body):
        print(body.decode())

# 通过channel向rabbitMQ订阅这个队列
channel.basic_consume(queue, callback, True)
# 开始监听
channel.start_consuming()
 

多队列消费

我们继续考虑一个稍微复杂一点的场景,当一个应用需要同时消费多个队列时,我们就需要在连接(Connection)上创建多个管道(Channel),一般情况下,每个管道都有一个专属的线程进行管理维护,在管道中订阅队列(queue)并绑定消费函数(Consumer)
下面是这个场景的代码实现示例,示例中我们订阅了两个不同的队列,并且其中一个队列采取了双消费者来实现并发消费。以下代码结构整体与图中的结构雷同。
package com.idanchuang.component.mq.amqp.rabbit;

import com.rabbitmq.client.*;

import java.io.IOException;
import java.util.HashMap;
import java.util.concurrent.CountDownLatch;

/**
 * @author yjy
 * Created at 2022/3/23 1:56 下午
 */
public class ComplexConsumer {

    private static final CountDownLatch LATCH = new CountDownLatch(1);

    public static void main(String[] args) throws Exception {
        // 通过ConnectionFactory创建Connection
        ConnectionFactory connectionFactory = new ConnectionFactory();
        connectionFactory.setUsername("dc_base");
        connectionFactory.setPassword("xxxxx");
        connectionFactory.setHost("10.80.20.209");
        connectionFactory.setVirtualHost("/");
        Connection connection = connectionFactory.newConnection();

        // 声明交换机
        String exchange = "python-test-exchange";
        Channel channel = connection.createChannel();
        channel.exchangeDeclare(exchange, "direct");

        // 绑定键
        String bindingKey = "tester";

        // 通过channel声明一个队列,并绑定至交换机
        channel = connection.createChannel();
        String queue1 = "amqp-test-queue1-" + System.currentTimeMillis();
        channel.queueDeclare(queue1, false, true, true, new HashMap<>());
        channel.queueBind(queue1, exchange, bindingKey);
        // 通过这个channel中订阅声明的queue,并通过多线程添加两个消费者,实现并发消费
        startConsume(queue1, new MyConsumer(channel), channel);
        startConsume(queue1, new MyConsumer(channel), channel);

        // 通过一个新的channel声明一个新的队列,并绑定至交换机
        channel = connection.createChannel();
        String queue2 = "amqp-test-queue2-" + System.currentTimeMillis();
        channel.queueDeclare(queue2, false, true, true, new HashMap<>());
        channel.queueBind(queue2, exchange, bindingKey);
        // 通过这个channel中订阅声明的queue,并配置一个消费者
        startConsume(queue2, new MyConsumer(channel), channel);

        System.out.println("Listening...");
        LATCH.await();
    }

    private static void startConsume(String queue, Consumer consumer, Channel channel) {
        Thread thread1 = new Thread(() -> {
            try {
                channel.basicConsume(queue, true, consumer);
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        thread1.start();
    }

    private static class MyConsumer extends DefaultConsumer {

        public MyConsumer(Channel channel) {
            super(channel);
        }

        @Override
        public void handleDelivery(String consumerTag,
                                   Envelope envelope,
                                   AMQP.BasicProperties properties,
                                   byte[] body) throws IOException
        {
            // 处理消息
            System.out.println(getConsumerTag() + " > 收到消息: " + new String(body));
        }

    }
}
 

多vhost消费

让我们考虑这样一个场景,一个应用(application)想要消费两个队列(queue)的消息,但是这两个队列却不在同一个vhost下,这时候一个连接(Connection)就无法解决问题了,我们必须创建多个连接来适配这个需求了,如下图
多vhost的消费代码整体上与单vhost是相同的,只是每个vhost需要创建单独的链接(Connection),剩下的管道与队列等逻辑完全相同,如下:
package com.idanchuang.component.mq.amqp.rabbit;

import com.rabbitmq.client.*;

import java.io.IOException;
import java.util.HashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeoutException;

/**
 * @author yjy
 * Created at 2022/3/23 1:56 下午
 */
public class ComplexVhostConsumer {

    private static final CountDownLatch LATCH = new CountDownLatch(1);

    public static void main(String[] args) throws Exception {
        // 通过ConnectionFactory创建Connection
        ConnectionFactory connectionFactory = new ConnectionFactory();
        connectionFactory.setUsername("dc_base");
        connectionFactory.setPassword("xxxx");
        connectionFactory.setHost("10.80.20.209");
        connectionFactory.setVirtualHost("/");
        Connection connection = connectionFactory.newConnection();
        runVhost(connection);

        // 通过ConnectionFactory创建Connection
        connectionFactory.setVirtualHost("test-amqp");
        Connection connection2 = connectionFactory.newConnection();
        runVhost(connection2);

        System.out.println("Listening...");
        LATCH.await();
    }

    private static void runVhost(Connection connection) throws Exception {
        // 声明交换机
        String exchange = "python-test-exchange";
        Channel channel = connection.createChannel();
        channel.exchangeDeclare(exchange, "direct");

        // 绑定键
        String bindingKey = "tester";

        // 通过channel声明一个队列,并绑定至交换机
        channel = connection.createChannel();
        String queue1 = "amqp-test-queue1-" + System.currentTimeMillis();
        channel.queueDeclare(queue1, false, true, true, new HashMap<>());
        channel.queueBind(queue1, exchange, bindingKey);
        // 通过这个channel中订阅声明的queue,并通过多线程添加两个消费者,实现并发消费
        startConsume(queue1, new MyConsumer(channel), channel);
        startConsume(queue1, new MyConsumer(channel), channel);

        // 通过一个新的channel声明一个新的队列,并绑定至交换机
        channel = connection.createChannel();
        String queue2 = "amqp-test-queue2-" + System.currentTimeMillis();
        channel.queueDeclare(queue2, false, true, true, new HashMap<>());
        channel.queueBind(queue2, exchange, bindingKey);
        // 通过这个channel中订阅声明的queue,并配置一个消费者
        startConsume(queue2, new MyConsumer(channel), channel);

    }

    private static void startConsume(String queue, Consumer consumer, Channel channel) {
        Thread thread1 = new Thread(() -> {
            try {
                channel.basicConsume(queue, true, consumer);
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        thread1.start();
    }

    private static class MyConsumer extends DefaultConsumer {

        public MyConsumer(Channel channel) {
            super(channel);
        }

        @Override
        public void handleDelivery(String consumerTag,
                                   Envelope envelope,
                                   AMQP.BasicProperties properties,
                                   byte[] body) throws IOException
        {
            // 处理消息
            System.out.println(getConsumerTag() + " > 收到消息: " + new String(body));
        }

    }
}

 

确认消费

RabbitMQ在消息投递/消费时,存在一个确认机制(Acknowledgement,简称ack),这是为了让MQ服务端明确的知道是否可以将这条消息进行删除。
RabbitMQ有两种消息确认机制,手动ACK / 自动ACK

手动ACK

服务端将消息投递给消费者后,由消费者来决定是否消费完成,如果确认消息已被消费,需要主动向服务端发送一个ACK消息,届时服务端会将这条消息标记为已消费的状态。

 

 

 

自动ACK

服务端将消息投递给消费者后,服务端直接将消息标记为已消费的状态,至于消息有没有被消费成功,服务端已不再关心,这意味着在极端情况下,消息不能保证一定被消费成功。

拒绝消费

在开启手动ACK的情况下,当消费者收到一条消息时,大部分时候可以处理成功并愉快的发送ACK给服务端,但是也可能会有处理失败的情况,假如消费者认为该消息自己处理不了,可以向服务端发送 NACK,来告知服务端重新投递该消息
 

生产者

相对于消费者来说,生产者则简单得多,但需要注意的是,应该避免在多线程环境下使用同一个管道进行消息的发送,因为这可能导致一些意想不到的问题出现(虽然RabbitMQ的Channel默认实现中在basicPublish内添加了同步锁保证了消息发送的并发安全,但是我们不能保证Channel中的其他所有功能都是并发安全的),在实际业务中,我们可以维护一个消息发送线程池,为每个线程绑定一个特定的管道,来实现并发发送。

消息发送

我们可以将消息发送至任意一个管道(Channel),并指定目标交换机(Exchange)与 路由键(RoutingKey)即可,MQ服务端的交换机将负责消息的下一步去向!客户端发送的案例代码如下(未涉及多线程):
#!/opt/homebrew/bin/python3
# -*- coding: UTF-8 -*-

import pika

MQ_CONFIG = {
        "host": "10.80.20.209",
        "port": 5672,
        "vhost": "test-amqp",
        "user": "dc_base",
        "passwd": "xxx"
}

credentials = pika.PlainCredentials(MQ_CONFIG.get("user"), MQ_CONFIG.get("passwd"))
parameters = pika.ConnectionParameters(MQ_CONFIG.get("host"), MQ_CONFIG.get("port"), MQ_CONFIG.get("vhost"), credentials)
connection = pika.BlockingConnection(parameters)
channel = connection.channel()

exchange = 'python-test-exchange'

channel.exchange_declare(exchange=exchange, exchange_type='direct')


def send(body, exchange, routing_key):
        channel.basic_publish(exchange=exchange, routing_key=routing_key, body=body)


print("start send")
send('hello world 1', exchange, 'tester')
send('hello world 2', exchange, 'tester')
send('hello world 3', exchange, 'tester')
send('hello world 4', exchange, 'tester')
send('hello world 5', exchange, 'tester')

connection.close()

 

确认发送成功

对于生产者客户端来说,当一个消息被成功写入管道(Channel)后,一般情况下生产者就认为消息已经发送成功了,事实上,消息还未被真正到达交换机,对于可靠性较高的消息而言,生产者可能需要确认消息已经被成功发送到交换机中中,这时候就需要AMQP的Confirm机制出马了。
我们在管道上绑定了一个回调函数(ConfirmListener),并在发送消息前,通过 confirmSelect 通知服务端回调下一个消息的发送结果,随后发送真实的消息。
当消息成功到达交换机后,服务端会返回一个 ACK 来触发客户端的 handleAck 函数,反之则会触发 handleNack 函数
package com.idanchuang.component.mq.amqp.rabbit;

import com.rabbitmq.client.*;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * @author yjy
 * Created at 2022/3/23 1:56 下午
 */
public class SimpleSender {

    public static void main(String[] args) throws Exception {
        // 通过ConnectionFactory创建Connection
        ConnectionFactory connectionFactory = new ConnectionFactory();
        connectionFactory.setUsername("dc_base");
        connectionFactory.setPassword("xxxx");
        connectionFactory.setHost("10.80.20.209");
        connectionFactory.setVirtualHost("/");
        Connection connection = connectionFactory.newConnection();

        // 声明交换机
        String exchange = "python-test-exchange";
        Channel channel = connection.createChannel();
        channel.exchangeDeclare(exchange, "direct");

        // 路由键
        String routingKey = "tester";

        final Semaphore semaphore = new Semaphore(0);
        final AtomicBoolean success = new AtomicBoolean();

        // 给管道设置消息发送成功确认监听器
        channel.addConfirmListener(new ConfirmListener() {
            @Override
            public void handleAck(long deliveryTag, boolean multiple) throws IOException {
                success.set(true);
                semaphore.release();
            }
            @Override
            public void handleNack(long deliveryTag, boolean multiple) throws IOException {
                success.set(false);
                semaphore.release();
            }
        });
        // 发送消息前告知服务端,回调下一个消息的发送结果
        channel.confirmSelect();
        // 发送消息
        channel.basicPublish(exchange, routingKey, null, "hello world1!".getBytes(StandardCharsets.UTF_8));
        // 等待结果
        semaphore.acquire();
        System.out.println("消息发送 > " + (success.get() ? "成功" : "失败"));

        // 再来一次
        channel.confirmSelect();
        channel.basicPublish(exchange, routingKey, null, "hello world2!".getBytes(StandardCharsets.UTF_8));
        semaphore.acquire();
        System.out.println("消息发送 > " + (success.get() ? "成功" : "失败"));

        // 结束
        connection.close();
    }

}

 

确认路由成功

当生产者将消息成功发送到交换机后,一般情况下生产者就认为消息已经发送成功了,事实上,消息还需要经过路由到达对应的queue才算真正的成功,对于可靠性较高的消息而言,生产者可能需要确认消息已经被路由到队列中,这时候就需要AMQP的返回机制出马了。
我们在管道上绑定了一个回调函数(ReturnListener),并在发送消息时指定 mandatory 参数为 true,
当交换机找不到可以路由的队列时(比如消息指定的路由键未绑定任何队列),将会触发 handleReturn 函数,此时业务可以对这个无法被路由转发的消息进行后续处理,或者告警。
package com.idanchuang.component.mq.amqp.rabbit;

import com.rabbitmq.client.*;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * @author yjy
 * Created at 2022/3/23 1:56 下午
 */
public class ReturnableSender {

    public static void main(String[] args) throws Exception {
        // 通过ConnectionFactory创建Connection
        ConnectionFactory connectionFactory = new ConnectionFactory();
        connectionFactory.setUsername("dc_base");
        connectionFactory.setPassword("xxxxx");
        connectionFactory.setHost("10.80.20.209");
        connectionFactory.setVirtualHost("/");
        Connection connection = connectionFactory.newConnection();

        // 声明交换机
        String exchange = "python-test-exchange1";
        Channel channel = connection.createChannel();
        channel.exchangeDeclare(exchange, "direct");

        // 路由键
        String routingKey = "tester";

        // 给管道设置消息路由失败处理函数
        channel.addReturnListener(new ReturnListener() {
            @Override
            public void handleReturn(int replyCode,
                                     String replyText,
                                     String exchange,
                                     String routingKey,
                                     AMQP.BasicProperties properties,
                                     byte[] body)
                    throws IOException {
                System.out.println("消息路由失败,被退回来啦! body: " + new String(body));
            }
        });

        // 发送消息
        channel.basicPublish(exchange, routingKey, true, null, "hello world1!".getBytes(StandardCharsets.UTF_8));

        Thread.sleep(3000L);
        // 结束
        connection.close();
    }

}
 

参考

RabbitMQ中文文档:http://rabbitmq.mr-ping.com/description.html
posted @ 2022-03-23 20:54  EEEEET  阅读(810)  评论(0编辑  收藏  举报