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