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 @   EEEEET  阅读(852)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
点击右上角即可分享
微信分享提示