〇、声明
如果你在阅读本文过程中发现任何笔者描述不正确或者欠妥的地方,请您留言,多谢指正!
本文涉及的所有代码都提交到 GIT仓库>>
一、Docker 环境下 RabbitMQ 的安装
笔者在 windows 环境下安装及使用 Docker,下面是 Docker 下使用 RabbitMQ 的几个命令和注意的点。
1 RabbitMQ 安装
# docker pull rabbitmq
# docker run -d --hostname my-rabbit --name rabbit -p 15672:15672 -p 5672:5672 rabbitmq
2 RabbitMQ 用户
RabbitMQ 安装完成后默认只有一个 guest 管理员用户,密码也是 guest,这个用户只能在服务端本地访问,如果要通过网络访问的话,需要知道下列命令
# rabbitmqctl add_user admin 123456
# rabbitmqctl set_user_tags admin administrator
# rabbitmqctl set_permissions -p / admin ".*" ".*" ".*"
# #开启管理页面
# rabbitmq-plugins enable rabbitmq_management
# cd /etc/rabbitmq/conf.d/
# echo management_agent.disable_metrics_collector = false > management_agent.disable_metrics_collector.conf
重启服务
docker restart 容器ID
3 Container 与 Extension
在 windows 下的 Docker 环境出了通过 Images 安装的 RabbitMQ Container 外,还可以直接通过 Extension 安装 RabbitMQ for Docker Desktop 这里对于初学者可能容易出现一个错误点,如果你不知道 Extension 中的RabbitMQ,可能误以为这只是 RabbitMQ Images 安装的 Container 的一个辅助插件,其实不然,RabbitMQ for Docker Desktop 安装之后就直接相当于安装并运行了一个 RabbitMQ Container,也就是说就算你没有安装 RabbitMQ Image 的实例,也会有一个正在运行的 RabbitMQ,这个时候你无论是通过代码还是web访问的都将是 RabbitMQ,如果你同时安装并启动了一个 RabbitMQ Container,你可能会误以为访问的是你安装的那个,然后你在 RabbitMQ Container 中通过命令新建用户等都不能正常登陆和使用,嘿嘿,别问我怎么知道的,太坑了……
二、RabbitMQ 的使用
RabbitMQ 的最基本的功能,你可以类比于 Socket 通讯,他能做到依赖于一台服务器做到不同客户端的通讯,当然 RabbitMQ 不是为了通讯而生,其中的原理我目前也没有去多做了解。 RabbitMQ 官网列出了 7 个小节渐进式的教大家使用。下列按照官方文档一步一步使用 C# 按照官方进程,形成 Demo 代码,当然如果你使用的是其他语言,也很大可能也是可以从他的官网找到对应的用法。访问官网>>
下面描述的每一个小节之间并不是完全独立的,存在一定的关系。
1 单消费者模式:消息接收与转发
在这个用法中,我们需要先声明一个队列,队列基于一个名称作为标识,对于消息的发送者而言,我们始终将消息发送给这个队列,对于消息的接收者而言,始终监听并使用这个队列的消息内容。 在 RabbitMQ 中:
- 消息发送者叫:生产者
- 消息接收者叫:消费者
下列分别给出生成者与消费者的示例代码:生产者输入消息,消费者输出消息。\
代码1: 生产者(发出消息的人)
using RabbitMQ.Client;
using System.Text;
//队列名称
const string QueueName = "job";
while (true)
{
Console.Write("Input: ");
var message = Console.ReadLine();
if (message != null)
{
Send(message);
}
}
/// <summary>
/// 发送
/// </summary>
static void Send(string message)
{
//连接通道构建
var factory = new ConnectionFactory { HostName = "localhost", UserName = "admin", Password = "123456" };
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();
//申明队列
channel.QueueDeclare(QueueName,
durable: false,
exclusive: false,
autoDelete: false,
arguments: null);
//消息
var body = Encoding.UTF8.GetBytes(message);
channel.BasicPublish(string.Empty, QueueName, mandatory: false, basicProperties: null, body: body);
Console.WriteLine($" [x] Sent {message}");
}
代码2: 消费者(接收和处理消息的人)
using RabbitMQ.Client.Events;
using RabbitMQ.Client;
using System.Text;
//队列名称
const string QueueName = "job";
//连接通道构建
var factory = new ConnectionFactory { HostName = "localhost", UserName = "admin", Password = "123456" };
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();
//申明队列
channel.QueueDeclare(QueueName,
durable: false,
exclusive: false,
autoDelete: false,
arguments: null);
//等待消息
Console.WriteLine(" [*] Waiting for messages.");
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) => {
var body = ea.Body.ToArray();
var message = Encoding.UTF8.GetString(body);
Console.WriteLine($" [x] Received {message}");
};
channel.BasicConsume(QueueName, true, consumer);
Console.ReadKey();
2 多消费者模式(竞争消费者模式)
第一小节中,我们有一个生产者和一个消费者,如果我们有一系列耗时的任务,仅使用一个线程处理会比较慢,比如爬虫爬取视频网站的视频、比如批量下载文件、比如批量生产PDF等等,于是我们考虑多个消费者模式。
我们将 代码1 中的代码独立为一个生产者程序,新建一个控制台程序模拟。并且我们可以使用Thread.Sleep() 模拟耗时的任务。
我们将 代码2 中的代码独立为一个消费者程序,新建另一个控制台程序模拟(假设有多个消费者,多个消费者的方式下文将介绍)。
当生产者在短时间内 “生产” 了多个任务,按照官网的意思,RabbitMQ 会按顺序发送给下一个消费者,按照我的理解应该是下一个空闲的消费者,比如 A、B、C 三个消费者,当有 1、2、3、4 个任务出现,会依此发布给 A1、B2、C3,此时,假设 1、3 任务耗时较多,2任务先完成,那么 RabbitMQ 消息调度程序应该会自动将 4 任务发布给 B 处理。(我们将使用代码实际证明这一点)
2.1 模拟耗时任务
添加一个类,用于存储任务数据,其中 ConsumingSecond 定义一个耗时毫秒,模拟任务耗时。
namespace CommonLib;
/// <summary>
/// 任务数据
/// </summary>
public class JobData
{
/// <summary>
/// 任务ID
/// </summary>
public Guid JobId { get; set; } = Guid.NewGuid();
/// <summary>
/// 耗时秒
/// </summary>
public int ConsumingSecond => new Random().Next(1, 10);
/// <summary>
/// 任务描述
/// </summary>
public string JobDescribe => $"耗时 {ConsumingSecond} 秒的任务";
}
我们将 代码1 作一定的调整,如下: 代码3: 随机耗时的任务
using CommonLib;
using Newtonsoft.Json;
using RabbitMQ.Client;
using System.Text;
//队列名称
const string QueueName = "job";
while (true)
{
Console.Write("添加几个耗时任务到消息队列:");
var inputLine = Console.ReadLine();
if (string.IsNullOrEmpty(inputLine))
{
Console.WriteLine("请输入一个整数!");
}
else if(int.TryParse(inputLine, out int jobCount))
{
if (jobCount <= 0)
{
Console.WriteLine("请输入一个有效的整数!");
}
else
{
AddJob(jobCount);
Console.WriteLine($"自动添加 {jobCount} 个耗时任务到消息队列!");
}
}
else
{
Console.WriteLine("请输入一个有效的整数!");
}
}
/// <summary>
/// 添加任务
/// </summary>
static void AddJob(int jobCount)
{
//连接通道构建
var factory = new ConnectionFactory { HostName = "localhost", UserName = "admin", Password = "123456" };
var connection = factory.CreateConnection();
var channel = connection.CreateModel();
//申明队列
channel.QueueDeclare(QueueName,
durable: false,
exclusive: false,
autoDelete: false,
arguments: null);
//循环添加
for (var index = 0; index < jobCount; index ++)
{
//消息
var message = JsonConvert.SerializeObject(new JobData(index + 1));
var body = Encoding.UTF8.GetBytes(message);
channel.BasicPublish(string.Empty, QueueName, mandatory: false, basicProperties: null, body: body);
Console.WriteLine($" [x] Sent {message}");
}
}
2.2 使用多个控制台程序模拟多个消费者
代码2 调整后形成 代码3
using RabbitMQ.Client.Events;
using RabbitMQ.Client;
using System.Text;
using CommonLib;
using Newtonsoft.Json;
using System.Diagnostics;
//队列名称
const string QueueName = "job";
//连接通道构建
var factory = new ConnectionFactory { HostName = "localhost", UserName = "admin", Password = "123456" };
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();
//申明队列
channel.QueueDeclare(QueueName,
durable: false,
exclusive: false,
autoDelete: false,
arguments: null);
//进程ID
var processId = Process.GetCurrentProcess().Id;
//等待消息
Console.WriteLine($" [{processId}] Waiting for messages.");
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) => {
var body = ea.Body.ToArray();
var message = Encoding.UTF8.GetString(body);
var jobData = JsonConvert.DeserializeObject<JobData>(message);
if (jobData == null)
{
Console.WriteLine($" [{processId}] Received No Content.");
}
else
{
Console.Write($" [{processId}] {jobData.JobId}.处理任务一个{jobData.JobDescribe}...");
Thread.Sleep(jobData.ConsumingSecond * 1000);
Console.WriteLine("完成。");
}
};
channel.BasicConsume(QueueName, true, consumer);
Console.ReadKey();
同时运行一个生产者和多个消费者,我们通过生产者发布任务到 RabbitMQ 服务器,会发现消费者会逐个收到任务,一个窗体进程完成了任务(空闲)就会进行下一个任务,直到完成所有任务。
但是任务完成我们发现一个很神奇的现象,任务的分配是按照窗口(消费者)的创建顺序发布的,并不是按照先完成先获得任务。
为了进一步验证我们任务的发布规则,我们特意先运行了两个消费者,一个生产者,当生产者发布任务后再运行一个消费者,我们发现任务都分配给了两个消费者,于是得到一个这样的结论:任务发布给消费者的过程是瞬时的,发布时存在一个消费者则任务均发给这个消费者,存在多个消费者则任务按顺序逐次分给每个消费者,而并不等待前一个任务完成,也就是说发布任务的过程是异步的。
官方是这样描述的:
By default, RabbitMQ will send each message to the next consumer, in sequence. On average every consumer will get the same number of messages. This way of distributing messages is called round-robin. Try this out with three or more workers.
2.3 消息确认
这时候聪明如你自然会发现问题:
- 被分配的任务如果没有完成或者某个消费者直接 “死亡”,对应的任务将都会丢失
- RabbitMQ 服务本身宕机会不会导致任务丢失
- 消息发布给消费者是按发布时间同时在线的消费者按顺序逐个发放的,这样在任务的执行效率(耗时总长)上来说,不是平均的,并且发布消息的时候不会考虑当前消费者本身具有的未反馈确认的消息个数
- 如果目前有两个消费者,但是任务过多,我们要临时添加新的消费者是不能帮忙分担任务量的
本小节就说明一下第一个疑问。RabbitMQ 采用的方式是,每个任务生产者发布给消费者,RabbitMQ 不会立即将其标记为结束,而是等待消费者给与反馈(即:消息确认),如果消费者给与反馈一个确认(acknowledgement),RabbitMQ才会自由删除这个消息(任务)。
这里有两个特殊情况:
- 消费者 “死亡”(通道关闭、连接关闭、TCP连接丢失)RabbitMQ 将理解消息没有被完全处理并将重新排队。
- 消费者在一定的时间段内(默认 30 分钟,可修改)没有给出处理完成反馈,则 RabbitMQ 会自动认为消费者 “死亡” 从而强制执行 Consumer Delivery Acknowledgement Timeout,并将这个消费者所有没有进行完成反馈的任务转交给其他消费者处理。
默认情况为什么会导致任务丢失呢?因为 RabbitMQ 默认采用 automatic acknowledgement mode,也就是说在这种模式下,一旦 Broker 将消息分发给消费者,消息就被视为已经被消费者处理,RabbitMQ 立即将其从队列中删除,并向生产者发送确认。然而其实,我们的任务并没有实际处理完成。
那么怎么处理呢?我们只需要在消费者代码中添加 autoAck:false,不自动进行确认即可。此时我们在消费者完成任务时,通过代码人工反馈确认。
using RabbitMQ.Client.Events;
using RabbitMQ.Client;
using System.Text;
using CommonLib;
using Newtonsoft.Json;
using System.Diagnostics;
//队列名称
const string QueueName = "job";
//连接通道构建
var factory = new ConnectionFactory { HostName = "localhost", UserName = "admin", Password = "123456" };
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();
//申明队列
channel.QueueDeclare(QueueName,
durable: false,
exclusive: false,
autoDelete: false,
arguments: null);
//进程ID
var processId = Process.GetCurrentProcess().Id;
//等待消息
Console.WriteLine($" [{processId}] Waiting for messages.");
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) => {
var body = ea.Body.ToArray();
var message = Encoding.UTF8.GetString(body);
var jobData = JsonConvert.DeserializeObject<JobData>(message);
if (jobData == null)
{
Console.WriteLine($" [{processId}] Received No Content.");
}
else
{
Console.Write($" [{processId}] {jobData.JobId}.处理任务一个{jobData.JobDescribe}...");
Thread.Sleep(jobData.ConsumingSecond * 1000);
Console.WriteLine("完成。");
channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
}
};
channel.BasicConsume(QueueName, false, consumer);
Console.ReadKey();
注:人工确认方式,开发人员容易忘记写人工确认代码,从而导致任务被多次重复进行,RabbitMQ 提供了下列命令帮忙排查这种情况的任务
rabbitmqctl list_queues name messages_ready messages_unacknowledged
这个命令会自动列出执行的消息及确认的消息,队列名称是什么
/ # rabbitmqctl list_queues name messages_ready messages_unacknowledged
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
name messages_ready messages_unacknowledged
job 0 0
/ #
2.4 消息持久化
如果 RabbitMQ 服务宕机,导致消费者无法反馈确认。而且如果同时消费者 “死亡” 会导致未完成任务丢失。
When RabbitMQ quits or crashes it will forget the queues and messages unless you tell it not to. Two things are required to make sure that messages aren't lost: we need to mark both the queue and messages as durable.
我们有必要使用队列和消息变成持久的:
- 队列持久 修改代码
channel.QueueDeclare(queue: "job" ,
durable: true ,
exclusive: false ,
autoDelete: false ,
arguments: null );
- 任务(消息)持久 我们在添加任务(消息)之前,先给通道声明消息持久的
//消息持久化
var properties = channel.CreateBasicProperties();
properties.Persistent = true;
我们仅运行生产者,不运行消费者,添加 10 条任务后,使用命令测试一下:
/ # rabbitmqctl list_queues name messages_ready messages_unacknowledged
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
name messages_ready messages_unacknowledged
job 10 0
/ #
此时我们关闭生产者,再命令测试一下,发现 messages_ready 依旧是 10,消息没有丢失。
测试我们再运行消费者,会发现任务会逐个被执行,这里注意,因为启动一个消费者就会一次性收到 10 个任务,按顺序执行,没法将任务分配给多个消费者,但是由于我们开始了消息人工反馈确认,再所有任务没有被执行完成之前关闭消费者,没有完成的任务依旧存留在服务端,我们通过命令跟踪一下:
/ # rabbitmqctl list_queues name messages_ready messages_unacknowledged
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
name messages_ready messages_unacknowledged
job 3 0
/ #
注意,官方有这样一段说明 Note on Message Persistence Marking messages as persistent doesn't fully guarantee that a message won't be lost. Although it tells RabbitMQ to save the message to disk, there is still a short time window when RabbitMQ has accepted a message and hasn't saved it yet. Also, RabbitMQ doesn't do fsync(2) for every message -- it may be just saved to cache and not really written to the disk. The persistence guarantees aren't strong, but it's more than enough for our simple task queue. If you need a stronger guarantee then you can use publisher confirms.
简单的说,就是虽然有了消息持久化,我们依旧不能完全保证消息不会丢失,因为消息发布到服务端首先保存到缓存中,而从缓存保存到磁盘文件中是有一个很短的间隔的,如果这个期间服务宕机了会丢失,但是简单任务队列这个强度已经足够了,如果需要更高的强度,可以查阅发布确认 发布确认.
2.5 公平调度
在 2.3 消息确认中提到的 3、4 两个问题
- 消息发布给消费者是按发布时间同时在线的消费者按顺序逐个发放的,这样在任务的执行效率(耗时总长)上来说,不是平均的,并且发布消息的时候不会考虑当前消费者本身具有的未反馈确认的消息个数
- 如果目前有两个消费者,但是任务过多,我们要临时添加新的消费者是不能帮忙分担任务量的
从根本原因上来说,还是因为任务调度不均衡导致的,也可以说是 “不公平”,那么 RabbitMQ 如何解决这个问题呢?
//一次预取的消息数量
channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);
通过添加分配消息的数据来限制每次只能分配一个任务,这顺利的解决了问题,在发布任务的时候,所有在线消费者会收到一个任务,直到完成才能收到下一个
证明方式是:
- 任务将不再是按照顺序分配给消费者,而是谁先空闲先分配给谁
- 发布多个任务后,先运行一个消费者,再运行另外的消费者,会发现另外的消费者也会 “收到” (这里应该用预取)任务
这里需要注意的是,这段代码是加载消费者代码中,我们从单词
prefetch中可以看出,任务是消费者主动获取的,而不是由 RabbitMQ 主动发布的。
这一点,ChatAI(ChatGPT的一个发布版本)获取能更好的回答我们
就是说消费者主动获取了消息(任务)后,RabbitMQ 会将任务标记为已传递,在消费者没有断连并且没有超时的情况,这个任务不会被其他消费者获取,直到这个消费者主动反馈已处理(反馈确认)或者这个消费者断连或者处理超时。
3 发布订阅模式
在前两节中,我们都是生成和消费,一个消息(任务)由一个消费者进行。而我们本小节会讲一个不一样的模式,发布和订阅,也就是说当消息发布,将由多个订阅者订阅。
同样,在前两个小节中,我们知道当我们要发送消息,并由消费者获取,是需要进过一个中转的消息队列的,那么如果我们的生产者不明确指出我们消息发给哪个队列,而是假定有一个交换区(exchange),生产者产出消息后发布给交换区,再由交换区预定的方式把消息如何给到哪些队列,RabbitMQ 有几种交换类型可用:direct、topic、headers 和 fanout,本节就扇出(fanout ExchangeType.Fanout)进行介绍。
Fanout 将收到的所有消息广播到它知道的所有队列。官方举了一个例子,我们生产开发中经常有记录日志的功能,并且我们经常会遇到需要将日志同时存储到多个介质上的情况,比如输出到控制台、输出到文本文件,输出到数据库。我们就可以考虑采用扇出的方式广播给不同的输出方式。
首先给出一个 RabbitMQ 命令行脚本,用于查询服务端所有存在的 exchange 列表,默认会存在这些交换。
/ # rabbitmqctl list_exchanges
Listing exchanges for vhost / ...
name type
amq.topic topic
amq.rabbitmq.trace topic
amq.headers headers
amq.fanout fanout
direct
amq.direct direct
amq.match headers
/ #
这里我们需要注意一个问题,在前两个小节,我们没有明确指出我们需要使用的交换的情况,其实有默认使用交换功能,只不过默认交换名称为 "",
var message = GetMessage(args);
var body = Encoding.UTF8.GetBytes(message);
channel.BasicPublish(exchange: string.Empty,
routingKey: "hello",
basicProperties: null,
body: body);
按照官方的说法,空字符串表示默认或无名称的交换:消息将路由到由routingKey指定名称的队列(如果存在)。
The empty string denotes the default or nameless exchange: messages are routed to the queue with the name specified by routingKey, if it exists.
下面我们就讲具名交换,下列的代码定义了一个名称为 logs 的交换:
channel.ExchangeDeclare( "logs" , ExchangeType.Fanout);
3.1 临时队列
在讲临时队列之前,先讲一下临时队列的概念,当我们处理:
- 临时的应答队列,用于处理请求/应答消息;
- 订阅临时主题,用于接收特定的消息;
- 一次性的临时队列,用于异步处理某些任务;
- 日志收集、实时分析、广播通信、分布式系统等。
下列语句会创建一个具有生成名称的非持久、排他、自动删除队列,这个名字可能会是这样(amq.gen-JzTY20BRgKO-HjmUJj0wLg):
var queueName = channel.QueueDeclare().QueueName;