萬字長文:從 C# 入門學會 RabbitMQ 消息隊列編程

RabbitMQ 教程

本文已推送到 github :https://github.com/whuanle/learnrabbitmq
如果文章排版不方便閱讀,可以到倉庫下載原版 markdown 文件閱讀。

RabbitMQ 簡介

RabbitMQ 是一個實現了 AMQP 協議的消息隊列,AMQP 被定義爲作爲消息傳遞中間件的開放標準的應用層協議。它代表高級消息隊列協議,具有消息定位、路由、隊列、安全性和可靠性等特點。

目前社區上比較流行的消息隊列有 kafka、ActiveMQ、Pulsar、RabbitMQ、RocketMQ 等。

筆者也編寫了 一系列的 Kafka 教程,歡迎閱讀:https://kafka.whuanle.cn/

RabbitMQ 的優點、用途等,大概是可靠性高、靈活的路由規則配置、支持分佈式部署、遵守 AMQP 協議等。可以用於異步通訊、日誌收集(日誌收集還是 Kafka 比較好)、事件驅動架構系統、應用通訊解耦等。

RabbitMQ 社區版本的特點如下:

  • 支持多種消息傳遞協議、消息隊列、傳遞確認、靈活的隊列路由、多種交換類型(交換器)。

  • 支持 Kubernetes 等分佈式部署,提供多種語言的 SDK,如 Java、Go、C#。

  • 可插入的身份驗證、授權,支持 TLS 和 LDAP。

  • 支持持續集成、操作度量和與其他企業系統集成的各種工具和插件。

  • 提供一套用於管理和監視 RabbitMQ 的 HTTP-API、命令行工具和 UI。

RabbitMQ 的基本對象有以下幾點,但是讀者現在並不需要記住,在後面的章節中,筆者將會逐個介紹。

  • 生產者(Producer):推送消息到 RabbitMQ 的程序。
  • 消費者(Consumer):從 RabbitMQ 消費消息的程序。
  • 隊列(Queue):RabbitMQ 存儲消息的地方,消費者可以從隊列中獲取消息。
  • 交換器(Exchange):接收來自生產者的消息,並將消息路由到一個或多個隊列中。
  • 綁定(Binding):將隊列和交換器關聯起來,當生產者推送消息時,交換器將消息路由到隊列中。
  • 路由鍵(Routing Key):用於交換器將消息路由到特定隊列的匹配規則。

RabbitMQ 的技術知識點大概分爲:

  • 用戶和權限:配置用戶、角色和其對應的權限。
  • Virtual Hosts:配置虛擬主機,用於分隔不同的消息隊列環境。
  • Exchange 和 Queue 的屬性:配置交換器和隊列的屬性,比如持久化、自動刪除等。
  • Policies:定義策略來自動設置隊列、交換器和鏈接的參數。
  • 連接和通道:配置連接和通道的屬性,如心跳間隔、最大幀大小等。
  • 插件:啓用和配置各種插件,如管理插件、STOMP 插件等。
  • 集羣和高可用性:配置集羣和鏡像隊列,以提供高可用性。
  • 日誌和監控:配置日誌級別、目標和監控插件。
  • 安全性:配置 SSL/TLS 選項、認證後端等安全相關的設置。

由於筆者技術有限以及篇幅限制,本文只講解與 C# 編程相關的技術細節,從中瞭解 RabbitMQ 的編碼技巧和運作機制。

安裝與配置

安裝 RabbitMQ

讀者可以在 RabbitMQ 官方文檔中找到完整的安裝教程:https://www.rabbitmq.com/download.html

本文使用 Docker 的方式部署。

RabbitMQ 社區鏡像列表:https://hub.docker.com/_/rabbitmq

創建目錄用於映射存儲卷:

mkdir -p /opt/lib/rabbitmq

部署容器:

docker run -itd --name rabbitmq -p 5672:5672 -p 15672:15672 \
-v /opt/lib/rabbitmq:/var/lib/rabbitmq \
rabbitmq:3.12.8-management

部署時佔用兩個端口。5672 是 MQ 通訊端口,15672 是 Management UI 工具端口。

打開 15672 端口,會進入 Web 登錄頁面,默認賬號密碼都是 guest。

image-20231114142145244

image-20231114142240075

關於 RabbitMQ Management UI 的使用方法,後續再介紹。

打開管理界面後會,在 Exchanges 菜單中,可以看到如下圖表格。這些是默認的交換器。現在可以不需要了解這些東西,後面會有介紹。

Virtual host Name Type Features
/ (AMQP default) direct D
/ amq.direct direct D
/ amq.fanout fanout D
/ amq.headers headers D
/ amq.match headers D
/ amq.rabbitmq.trace topic D I
/ amq.topic topic D

image-20231114142616280

發佈與訂閱模型

使用 C# 開發 RabbitMQ,需要使用 nuget 引入 RabbitMQ.Client,官網文檔地址:.NET/C# RabbitMQ Client Library — RabbitMQ

在繼續閱讀文章之前,請先創建一個控制檯程序。

生產者、消費者、交換器、隊列

爲了便於理解,本文製作了幾十張圖片,約定一些圖形表示的含義:

對應生產者,使用如下圖表示:

p

對於消費者,使用如下圖表示:

C

對於消息隊列,使用如下圖表示:

Q

對於交換器,使用如下圖表示:

X

在 RabbitMQ 中,生產者發佈的消息是不會直接進入到隊列中,而是經過交換器(Exchange) 分發到各個隊列中。前面提到,部署 RabbitMQ 後,默認有 七個交換器,如 (AMQP default)amq.direct 等。

當然,對於現在來說,我們不需要了解交換器,所以,在本節的教程中,會使用默認交換器完成實驗。

忽略交換器存在的情況下,我們可以將生產和消費的流程簡化如下圖所示:

s1

請一定要注意,圖中省略了交換器的存在,因爲使用的是默認的交換器。但是生產者推送消息必須是推送到交換器,而不是隊列這一句一定要弄清楚

對於消費者來說,要使用隊列,必須確保隊列已經存在。

使用 C# 聲明(創建)一個隊列的代碼和參數如下所示:

// 聲明一個隊列
channel.QueueDeclare(
	// 隊列名稱
	queue: "myqueue",

	// 持久化配置,隊列是否能夠在 broker 重啓後存活
	durable: false,

	// 連接關閉時被刪除該隊列
	exclusive: false,

	// 當最後一個消費者(如果有的話)退訂時,是否應該自動刪除這個隊列
	autoDelete: false,

	// 額外的參數配置
	arguments: null
	);

完整代碼示例:


ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

// 連接
using IConnection connection = factory.CreateConnection();

// 通道
using IModel channel = connection.CreateModel();

channel.QueueDeclare(
	// 隊列名稱
	queue: "myqueue",

	// 持久化配置,隊列是否能夠在 broker 重啓後存活
	durable: false,

	// 連接關閉時被刪除該隊列
	exclusive: false,

	// 當最後一個消費者(如果有的話)退訂時,是否應該自動刪除這個隊列
	autoDelete: false,

	// 額外的參數配置
	arguments: null
	);
  • queue:隊列的名稱。

  • durable:設置是否持久化。持久化的隊列會存盤,在服務器重啓的時候可以保證不丟失相關信息。

  • exclusive 設置是否排他。如果一個隊列被聲明爲排他隊列,該隊列僅對首次聲明它的連接可見,並在連接斷開時自動刪除。

  • 該配置是基於 IConnection 的,同一個 IConnection 創建的不同通道 (IModel) ,也會遵守此規則。

  • autoDelete:設置是否自動刪除。自動刪除的前提是至少有一個消費者連接到這個隊列,之後所有與這個隊列連接的消費者都斷開時,纔會自動刪除。

  • argurnents: 設置隊列的其他一些參數,如隊列的消息過期時間等。

如果隊列已經存在,不需要再執行 QueueDeclare()。重複調用 QueueDeclare(),如果參數相同,不會出現副作用,已經推送的消息也不會出問題。

但是,如果 QueueDeclare() 參數如果跟已存在的隊列配置有差異,則可能會報錯。

1700013681316

一般情況下,爲了合理架構和可靠性,會由架構師等在消息隊列中提前創建好交換器、隊列,然後客戶端直接使用即可。一般不讓程序啓動時設置,這樣會帶來很大的不確定性和副作用。

生產者發送消息時的代碼也很簡單,指定要發送到哪個交換器或路由中即可。

請一定要注意,RabbitMQ 生產者發送消息,推送到的是交換器,而不是直接推送到隊列!

channel.BasicPublish(

	// 使用默認交換器
	exchange: string.Empty,

	// 推送到哪個隊列中
	routingKey: "myqueue",

	// 隊列屬性
	basicProperties: null,

	// 要發送的消息需要先轉換爲 byte[]
	body: Encoding.UTF8.GetBytes("測試")
	);

BasicPublish 有三個重載:

BasicPublish(
    PublicationAddress addr, 
    IBasicProperties basicProperties, 
    ReadOnlyMemory<byte> body)
BasicPublish(string exchange, 
             string routingKey, 
             IBasicProperties basicProperties, 
             ReadOnlyMemory<byte> body)
BasicPublish(string exchange, 
             string routingKey, 
             bool mandatory = false, 
             IBasicProperties basicProperties = null, 
             ReadOnlyMemory<byte> body = default)
  • exchange: 交換器的名稱,如果留空則會推送到默認交換器。
  • routingKey: 路由鍵,交換器根據路由鍵將消息存儲到相應的隊列之中。
  • basicProperties:消息屬性,如過期時間等。
  • mandatory:值爲 false 時,如果交換器沒有綁定合適的隊列,則該消息會丟失。值爲 true 時,如果交換器沒有綁定合適的隊列,則會觸發IModel.BasicReturn 事件。

IBasicProperties basicProperties 參數是接口,我們可以使用 IModel.CreateBasicProperties() 創建一個接口對象。

IBasicProperties 接口中封裝了很多屬性,使得我們不需要使用字符串的顯示傳遞配置。

IBasicProperties 其完整屬性如下:

// 標識應用程序的 ID
public String AppId { set; get; }

// 標識集羣的 ID
public String ClusterId { set; get; }

// 指定消息內容的編碼方式,例如 "utf-8"
public String ContentEncoding { set; get; }

// 指定消息內容的 MIME 類型,例如 "application/json"
public String ContentType { set; get; }

// 用於關聯消息之間的關係,通常用於 RPC(遠程過程調用)場景
public String CorrelationId { set; get; }

// 指定消息的持久化方式,值 1:不持久化,值 2:持久化
public Byte DeliveryMode { set; get; }

// 單位毫秒,指定該消息的過期時間
public String Expiration { set; get; }

// 自定義消息的頭部信息
public IDictionary`2 Headers { set; get; }

// 指定消息的唯一標識符
public String MessageId { set; get; }

// 是否持久化
public Boolean Persistent { set; get; }

// 指定消息的優先級,範圍從 0 到 9
public Byte Priority { set; get; }

// 指定用於回覆消息的隊列名稱
public String ReplyTo { set; get; }

// 指定用於回覆消息的地址信息
public PublicationAddress ReplyToAddress { set; get; }

// 指定消息的時間戳
public AmqpTimestamp Timestamp { set; get; }

// 消息的類型
public String Type { set; get; }

// 標識用戶的 ID
public String UserId { set; get; }

推送消息時,可以對單個消息細粒度地設置 IBasicProperties :

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

// 創建兩個隊列
channel.QueueDeclare(queue: "q1", durable: false, exclusive: false, autoDelete: false);

var properties = channel.CreateBasicProperties();
// 示例 1:
properties.Persistent = true;
properties.ContentType = "application/json";
properties.ContentEncoding = "UTF-8";

// 示例 2:
//properties.Persistent = true;
//properties.ContentEncoding = "gzip";
//properties.Headers = new Dictionary<string, object>();

channel.BasicPublish(
	exchange: string.Empty,
	routingKey: "q1",
	basicProperties: properties,
	body: Encoding.UTF8.GetBytes($"測試{i}")
);

對於 IBasicProperties 的使用,文章後面會有更加詳細的介紹。

現在,我們推送了 10 條消息到隊列中,然後在 Management UI 中觀察。

int i = 0;
while (i < 10)
{
	channel.BasicPublish(
	exchange: string.Empty,
	routingKey: "myqueue",
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);
	i++;
}

我們可以在 UI 的 Queues and Streams 中看到當前所有的隊列。

image-20231114150349632

可以看到當前隊列中的 Ready 狀態 Unacked 狀態的消息數,分別對應上文中的等待投遞給消費者的消息數和己經投遞給消費者但是未收到確認信號的消息數

點擊該隊列後,會打開如下圖所示的界面。

image-20231114150916157

首先看 Overview。

image-20231114150948347

Ready 指還沒有被消費的消息數量。

Unacked 指消費但是沒有 ack 的消息數量。

另一個 Message rates 圖表,指的是發佈、消費消息的速度,因爲不重要,因此這裏不說明。

image-20231114151826264

在 Bindings 中,可以看到該隊列綁定了默認的交換器。

image-20231114151116813

然後編寫一個消費者,消費該隊列中的消息,其完整代碼如下:

using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System.Text;

ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

channel.QueueDeclare(
	// 隊列名稱
	queue: "myqueue",

	// 持久化配置,隊列是否能夠在 broker 重啓後存活
	durable: false,

	// 連接關閉時被刪除該隊列
	exclusive: false,

	// 當最後一個消費者(如果有的話)退訂時,是否應該自動刪除這個隊列
	autoDelete: false,

	// 額外的參數配置
	arguments: null
	);

// 定義消費者
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
	var message = Encoding.UTF8.GetString(ea.Body.Span);
	Console.WriteLine($" [x] Received {message}");
};

// 開始消費
channel.BasicConsume(queue: "myqueue",
					 autoAck: true,
					 consumer: consumer);

Console.ReadLine();

image-20231114151412342

注意,如果填寫了一個不存在的隊列,那麼程序會報異常。

image-20231114151356189

在消費者程序未退出前,即 IConnection 未被 Dispose() 之前,可以在 Consumers 中看到消費者客戶端程序信息。

image-20231114151842412

那麼,如果我們只消費,不設置自動 ack 呢?

將消費者代碼改成:

channel.BasicConsume(queue: "myqueue",
					 autoAck: false,
					 consumer: consumer);

完整代碼如下:

using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System.Text;

ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

channel.QueueDeclare(
	queue: "myqueue",
	durable: false,
	exclusive: false,
	autoDelete: false,
	arguments: null
	);

int i = 0;
while (i < 10)
{
	channel.BasicPublish(
	exchange: string.Empty,
	routingKey: "myqueue",
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);
	i++;
}

// 定義消費者
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
	var message = Encoding.UTF8.GetString(ea.Body.Span);
	Console.WriteLine($" [x] Received {message}");
};

// 開始消費
channel.BasicConsume(queue: "myqueue",
					 autoAck: false,
					 consumer: consumer);

Console.ReadLine();

此時會發現,所有的消息都已經讀了,但是 Unacked 爲 10。

image-20231114152049850

如下圖所示,autoAck: false 之後,如果重新啓動程序(只消費,不推送消息),那麼程序會繼續重新消費一遍。

對於未 ack 的消息,消費者重新連接後,RabbitMQ 會再次推送。

image-20231114151412342

與 Kafka 不同的是,Kafka 如果沒有 ack 當前消息,則服務器會自動重新發送該條消息給消費者,如果該條消息未完成,則會一直堵塞在這裏。而對於 RabbitMQ,未被 ack 的消息會被暫時忽略,自動消費下一條。所以基於這一點,默認情況下,RabbitMQ 是不能保證消息順序性

當然, RabbitMQ 是很靈活的,我們可以選擇性地消費部分消息,避免當前消息阻塞導致程序不能往下消費:

	// 定義消費者
	int i = 0;
	var consumer = new EventingBasicConsumer(channel);
	consumer.Received += (model, ea) =>
	{
		var message = Encoding.UTF8.GetString(ea.Body.Span);
		Console.WriteLine($" [x] Received {message}");
		i++;
        // 確認該消息被正確消費
		if (i % 2 == 0)
			channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
	};

	// 開始消費
	channel.BasicConsume(queue: "myqueue",
						 autoAck: false,
						 consumer: consumer);

在某些場景下,這個特性很有用,我們可以將多次執行失敗的消息先放一放,轉而消費下一條消息,從而避免消息堆積。

多工作隊列

如果同一個隊列的不同客戶端綁定到交換器中,多個消費者一起工作的話,那麼會發生什麼情況?

對於第一種情況,RabbitMQ 會將消息平均分發給每個客戶端。

該條件成立的基礎是,兩個消費者是不同的消費者,如果在同一個程序裏面參加不同的實例去消費,但是因爲其被識別爲同一個消費者,則規則無效。

s2

但是,RabbitMQ 並不會看未確認的消息數量,它只是盲目地將第 n 個消息發送給第 n 個消費者

另外在指定交換器名稱的情況下,我們可以將 routingKey 設置爲空,這樣發佈的消息會由交換器轉發到對應的隊列中。

	channel.BasicPublish(
	exchange: "logs",
	routingKey: string.Empty,
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);

而多隊列對應一個交換器的情況比較複雜,後面的章節會提到。

生產者和消費者都能夠使用 QueueDeclare() 來聲明一個隊列。所謂的聲明,實際上是對 RabbitMQ Broker 請求創建一個隊列,因此誰來創建都是一樣的。

跟聲明隊列相關的,還有兩個函數:

// 無論創建失敗與否,都不理會
channel.QueueDeclareNoWait();
// 判斷隊列是否存在,如果不存在則彈出異常,存在則什麼也不會發生
channel.QueueDeclarePassive();

此外,我們還可以刪除隊列:

// ifUnused: 隊列沒有被使用時
// ifEmpty: 隊列中沒有堆積的消息時
channel.QueueDelete(queue: "aaa", ifUnused: true, ifEmpty: true);

交換器類型

生產者只能向交換器推送消息,而不能向隊列推送消息。

推送消息時,可以指定交換器名稱和路由鍵。

如下面代碼所示:

	channel.BasicPublish(
	exchange: string.Empty,
	routingKey: "myqueue",
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);

s3

ExchangeType 中定義了幾種交換器類型的名稱。

    public static class ExchangeType
    {
        public const string Direct = "direct";
        public const string Fanout = "fanout";
        public const string Headers = "headers";
        public const string Topic = "topic";
        private static readonly string[] s_all = {Fanout, Direct, Topic, Headers};
    }

在使用一個交換器之前,需要先聲明一個交換器:

channel.ExchangeDeclare("logs", ExchangeType.Fanout);

如果交換器已存在,重複執行聲明代碼,只要配置跟現存的交換器配置區配,則 RabbitMQ 啥也不幹,不會出現副作用。

但是,不能出現不一樣的配置,例如已存在的交換器是 Fanout 類型,但是重新執行代碼聲明隊列爲 Direct 類型。

image-20231115100630422

ExchangeDeclare 函數的定義如下:

ExchangeDeclare(string exchange, 
                string type, 
                bool durable = false, 
                bool autoDelete = false,
                IDictionary<string, object> arguments = null)
  • exchange: 交換器的名稱。
  • type 交換器的類型,如 fanout、direct、topic。
  • durable: 設置是否持久 durab ,如果值爲 true,則服務器重啓後也不會丟失。
  • autoDelete:設置是否自動刪除。
  • argument:其他一些結構化參數。

當然,交換器也可以被刪除。

// ifUnused 只有在隊列未被使用的情況下,纔會刪除
channel.ExchangeDelete(exchange: "log", ifUnused: true);

還有一個 NotWait 方法。

channel.ExchangeDeclareNoWait("logs", ExchangeType.Direct);
//channel.ExchangeDeclareNoWait(...);

即使重新聲明交換器和刪除時有問題,由於其返回 void,因此操作失敗也不會報異常。

也有個判斷交換器是否存在的方法。如果交換器不存在,則會拋出異常,如果交換器存在,則什麼也不會發生。

channel.ExchangeDeclarePassive("logs")

創建多個隊列後,還需要將隊列和交換器綁定起來。

s4

如下代碼所示,其交換器綁定了兩個隊列,生產者推送消息到交換器時,兩個隊列都會收到相同的消息。

ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

// 創建交換器
channel.ExchangeDeclare("logs", ExchangeType.Fanout);

// 創建兩個隊列
channel.QueueDeclare(
	queue: "myqueue1",
	durable: false,
	exclusive: false,
	autoDelete: false,
	arguments: null
	);
channel.QueueDeclare(
	queue: "myqueue2",
	durable: false,
	exclusive: false,
	autoDelete: false,
	arguments: null
	);

channel.QueueBind(queue: "myqueue1", exchange: "logs", routingKey: string.Empty);
channel.QueueBind(queue: "myqueue2", exchange: "logs", routingKey: string.Empty);

int i = 0;
while (i < 10)
{
	channel.BasicPublish(
	exchange: "logs",
	routingKey: string.Empty,
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);
	i++;
}

推送消息後,每個綁定了 logs 交換器的隊列都會收到相同的消息。

image-20231114162421261

注意,由於交換器不會存儲消息,因此,再創建一個 myqueue3 的消息隊列綁定 logs 交換器時,myqueue3 只會接收到綁定之後推送的消息,不能得到更早之前的消息。

交換器有以下類型:

  • direct:根據 routingKey 將消息傳遞到隊列。
  • topic:有點複雜。根據消息路由鍵與用於將隊列綁定到交換器的模式之間的匹配將消息路由到一個或多個隊列。
  • headers:本文不講,所以不做解釋。
  • fanout:只要綁定即可,不需要理會路由。

Direct

direct 是根據 routingKey 將消息推送到不同的隊列中。

首先,創建多個隊列。

// 創建兩個隊列
channel.QueueDeclare(queue: "direct1");
channel.QueueDeclare(queue: "direct2");

然後將隊列綁定交換器時,綁定關係需要設置 routingKey。

// 使用 routingKey 綁定交換器
channel.QueueBind(exchange: "logs", queue: "direct1", routingKey: "debug");
channel.QueueBind(exchange: "logs", queue: "direct2", routingKey: "info");

最後,推送消息時,需要指定交換器名稱,以及 routingKey。

// 發送消息時,需要指定 routingKey
channel.BasicPublish(
exchange: "logs",
routingKey: "debug",
basicProperties: null,
body: Encoding.UTF8.GetBytes($"測試")
);

當消息推送到 logs 交換器時,交換器會根據 routingKey 將消息轉發到對應的隊列中。

完整的代碼示例如下:

// 創建交換器
channel.ExchangeDeclare("logs", ExchangeType.Direct);

// 創建兩個隊列
channel.QueueDeclare(queue: "direct1");
channel.QueueDeclare(queue: "direct2");

// 使用 routingKey 綁定交換器
channel.QueueBind(exchange: "logs", queue: "direct1", routingKey: "debug");
channel.QueueBind(exchange: "logs", queue: "direct2", routingKey: "info");

// 發送消息時,需要指定 routingKey
channel.BasicPublish(
exchange: "logs",
routingKey: "debug",
basicProperties: null,
body: Encoding.UTF8.GetBytes($"測試")
);

啓動後,發現只有 direct1 隊列可以收到消息,因爲這是根據綁定時使用的 routingKey=debug 決定的。

s7

image-20231114164634559

Fanout

只要隊列綁定了交換器,則每個交換器都會收到一樣的消息,Fanout 會忽略 routingKey。

如下代碼所示:

// 創建交換器
channel.ExchangeDeclare("logs1", ExchangeType.Fanout);

// 創建兩個隊列
channel.QueueDeclare(queue: "fanout1");
channel.QueueDeclare(queue: "fanout2");

// 使用 routingKey 綁定交換器
channel.QueueBind(exchange: "logs1", queue: "fanout1", routingKey: "debug");
channel.QueueBind(exchange: "logs1", queue: "fanout2", routingKey: "info");

// 發送消息時,需要指定 routingKey
channel.BasicPublish(
exchange: "logs1",
routingKey: "debug",
basicProperties: null,
body: Encoding.UTF8.GetBytes($"測試")
);

image-20231114164857740

Topic

Topic 會根據 routingKey 查找符合條件的隊列,隊列可以使用 .#* 三種符號進行區配,Topic 的區配規則比較靈活,

在創建隊列之後,綁定交換器時,routingKey 使用表達式。

// 使用 routingKey 綁定交換器
channel.QueueBind(exchange: "logs3", queue: "topic1", routingKey: "red.#");
channel.QueueBind(exchange: "logs3", queue: "topic2", routingKey: "red.yellow.#");

推送消息時,routingKey 需要設置完整的名稱。

// 發送消息
channel.BasicPublish(
	exchange: "logs3",
	routingKey: "red.green",
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試")
);

首先,routingKey 會根據 . 符號進行劃分。

比如 red.yellow.green 會被拆成 [red,yellow,green] 三個部分。

如果想模糊區配一個部分,則可以使用 *。比如 red.*.green ,可以區配到 red.aaa.greenred.666.green

* 可以在任何一部分使用,比如 *.yellow.**.*.green

# 可以區配多個部分,比如 red.# 可以區配到 red.ared.a.ared.a.a.a

完整的代碼示例如下:

// 創建交換器
channel.ExchangeDeclare("logs3", ExchangeType.Topic);

// 創建兩個隊列
channel.QueueDeclare(queue: "topic1");
channel.QueueDeclare(queue: "topic2");

// 使用 routingKey 綁定交換器
channel.QueueBind(exchange: "logs3", queue: "topic1", routingKey: "red.#");
channel.QueueBind(exchange: "logs3", queue: "topic2", routingKey: "red.yellow.#");

// 發送消息
channel.BasicPublish(
	exchange: "logs3",
	routingKey: "red.green",
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試")
);

channel.BasicPublish(
	exchange: "logs3",
	routingKey: "red.yellow.green",
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試")
);

上面推送了兩條消息到 logs 交換器中,其中 routingKey=red.green 的消息,被 red.# 區配到,因此會被轉發到 topic1 隊列中。

routingKey=red.yellow.green 的消息,可以被兩個隊列區配,因此 topic1 和 topic 2 都可以接收到。

image-20231114170206509

交換器綁定交換器

交換器除了可以綁定隊列,也可以綁定交換器。

示例:

將 b2 綁定到 b1 中,b2 可以得到 b1 的消息。

channel.ExchangeBind(destination: "b2", source: "b1", routingKey: string.Empty);

綁定之後,推送到 b1 交換器的消息,會被轉發到 b2 交換器。

s5

完整示例代碼如下:

ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();
channel.ExchangeDeclare(exchange: "b1", ExchangeType.Fanout);
channel.ExchangeDeclare(exchange: "b2", ExchangeType.Fanout);

// 因爲兩者都是 ExchangeType.Fanout,
// 所以 routingKey 使用 string.Empty
channel.ExchangeBind(destination: "b2", source: "b1", routingKey: string.Empty);


// 創建隊列
channel.QueueDeclare(queue: "q1", durable: false, exclusive: false, autoDelete: false);
channel.QueueBind(queue: "q1", exchange: "b2", routingKey: string.Empty);

int i = 0;
while (i < 10)
{
	channel.BasicPublish(
	exchange: "b1",
	routingKey: string.Empty,
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);
	i++;
}

當然,可以將交換器、隊列同時綁定到 b1 交換器中。

s8

另外,兩個交換器的類型可以不同。不過這樣會導致區配規則有點複雜。

channel.ExchangeDeclare(exchange: "b1", ExchangeType.Direct);
channel.ExchangeDeclare(exchange: "b2", ExchangeType.Fanout);

我們可以理解成在交換器綁定時,b2 相對於一個隊列。當 b1 設置成 Direct 交換器時,綁定交換器時還需要指定 routingKey。

channel.ExchangeBind(destination: "b2", source: "b1", routingKey: "demo");

而 b2 交換器和 q2 隊列,依然是 Fanout 關係,不受影響。

意思是說,b1、b2 是一個關係,它們的映射關係不會影響到別人,也不會影響到下一層。

s6

完整代碼示例如下:


using RabbitMQ.Client;
using System.Text;

ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();
channel.ExchangeDeclare(exchange: "b1", ExchangeType.Direct);
channel.ExchangeDeclare(exchange: "b2", ExchangeType.Fanout);

// 因爲兩者都是 ExchangeType.Fanout,
// 所以 routingKey 使用 string.Empty
channel.ExchangeBind(destination: "b2", source: "b1", routingKey: "demo");


// 創建兩個隊列
channel.QueueDeclare(queue: "q1", durable: false, exclusive: false, autoDelete: false);
channel.QueueBind(queue: "q1", exchange: "b2", routingKey: string.Empty);

int i = 0;
while (i < 10)
{
	channel.BasicPublish(
	exchange: "b1",
	routingKey: "demo",
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);
	i++;
}

消費者、消息屬性

消費者 BasicConsume 函數定義如下:

BasicConsume(string queue,
            bool autoAck,
            string consumerTag,
            IDictionary<string, object> arguments,
            IBasicConsumer consumer)

不同的消費訂閱採用不同消費者標籤 (consumerTag) 來區分彼 ,在同一個通道(IModel)中的消費者 需要通過消費者標籤作區分,默認情況下不需要設置。

  • queue:隊列的名稱。
  • autoAck:設置是否自動確認。
  • consumerTag: 消費者標籤,用來區分多個消費者。
  • arguments:設置消費者的其他參數。

前面,我們使用了 EventingBasicConsumer 創建 IBasicConsumer 接口的消費者程序,其中,EventingBasicConsumer 包含了以下事件:

public event EventHandler<BasicDeliverEventArgs> Received;
public event EventHandler<ConsumerEventArgs> Registered;
public event EventHandler<ShutdownEventArgs> Shutdown;
public event EventHandler<ConsumerEventArgs> Unregistered;

這些事件會在消息處理的不同階段被觸發。

消費者程序有推、拉兩種消費模式,前面所提到的代碼都是推模式,即出現新的消息時,RabbitMQ 會自動推送到消費者程序中。

var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
	var message = Encoding.UTF8.GetString(ea.Body.Span);
	Console.WriteLine($" [x] Received {message}");
	channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
};

// 開始消費
channel.BasicConsume(queue: "myqueue5",
					 autoAck: false,
					 consumer: consumer,
					 consumerTag: "demo");

如果使用拉模式(BasicGet() 函數),那麼在 RabbitMQ Broker 的隊列中沒有消息時,會返回 null。

// 開始消費
while (true)
{
	var result = channel.BasicGet(queue: "q1", autoAck: false);

	// 如果沒有拉到消息時
	if (result == null) 
    {
      // 沒有消息時,避免無限拉取
      Thread.Sleep(100);
      continue;   
    }

	Console.WriteLine(Encoding.UTF8.GetString(result.Body.Span));
	channel.BasicAck(deliveryTag: result.DeliveryTag, multiple: false);
}

當使用 BasicGet() 手動拉取消息時,該程序不會作爲消費者程序存在,也就是 RabbitMQ 的 Consumer 中看不到。

image-20231115170727764

兩種推拉模式之下,ack 消息時,均有一個 multiple 參數。

  • 如果將 multiple 設爲 false,則只確認指定 deliveryTag 的一條消息。
  • 如果將 multiple 設爲 true,則會確認所有比指定 deliveryTag 小的並且未被確認的消息。

消息的 deliveryTag 屬性是 ulong 類型,表示消息的偏移量,從 1.... 開始算起。

在大批量接收消息並進行處理時,可以使用 multiple 來確認一組消息,而不必逐條確認,這樣可以提高效率。

Qos 、拒絕接收

消費者程序可以設置 Qos。

channel.BasicQos(prefetchSize: 10, prefetchCount: 10, global: false);

prefetchSize:這個參數表示消費者所能接收未確認消息的總體大小的上限,設置爲 0 則表示沒有上限。

prefetchCount: 的方法來設置消費者客戶端最大能接收的未確認的消息數。這個配置跟滑動窗口數量意思差不多。

global 則有些特殊。

當 global 爲 false 時,只有新的消費者需要遵守規則。

如果是 global 爲 true 時,同一個 IConnection 中的消費者均會被修改配置。

// 不受影響
// 	var result = channel.BasicConsume(queue: "q1", autoAck: false,... ...);

channel.BasicQos(prefetchSize: 0, prefetchCount: 10, global: false);

// 新的消費者受影響
// 	var result = channel.BasicConsume(queue: "q1", autoAck: false,... ...);

當收到消息時,如果需要明確拒絕該消息,可以使用 BasicReject,RabbitMQ 會將該消息從隊列中移除。

BasicReject() 會觸發消息死信。

while (true)
{
	var result = channel.BasicGet(queue: "q1", autoAck: false);
	if (result == null) continue;

	Console.WriteLine(Encoding.UTF8.GetString(result.Body.Span));
	channel.BasicReject(deliveryTag: result.DeliveryTag, requeue: true);
}

如果 requeue 參數設置爲 true ,則 RabbitMQ 會重新將這條消息存入隊列,以便可以發送給下個訂閱的消費者,或者說該程序重啓後可以重新接收。

如果 requeue 參數設置爲 false ,則 RabbitMQ立即會把消息從隊列中移除,而不會把它發送給新的消費者。

如果想批量拒絕消息。

channel.BasicNack(deliveryTag: result.DeliveryTag, multiple: true, requeue: true);

multiple 爲 true 時,則表示拒絕 deliveryTag 編號之前所有未被當前消費者確認的消息。

BasicRecover() 方法用來從 RabbitMQ 重新獲取還未被確認的消息

requeue=true 時,未被確認的消息會被重新加入到隊列中,對於同一條消息來說,其會被分配給給其它消費者。

requeue=false,同條消息會被分配給與之前相同的消費者。

channel.BasicRecover(requeue: true);
// 異步
channel.BasicRecoverAsync(requeue: true);

消息確認模式

前面提到,當 autoAck=false 時,消息雖然沒有 ack,但是 RabbitMQ 還是會跳到下一個消息。

爲了保證消息的順序性,在未將當前消息消費完成的情況下,不允許自動消費下一個消息。

只需要使用 BasicQos 配置即可:

channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);
ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

// 創建交換器
channel.ExchangeDeclare("acktest", ExchangeType.Fanout);

// 創建兩個隊列
channel.QueueDeclare(queue: "myqueue5");

// 使用 routingKey 綁定交換器
channel.QueueBind(exchange: "acktest", queue: "myqueue5", routingKey: string.Empty);

int i = 0;
while (i < 10)
{
	// 發送消息
	channel.BasicPublish(
	exchange: "acktest",
	routingKey: string.Empty,
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試")
	);
	i++;
}

// 未 ack 之前,不能消費下一個
channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);

var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
	var message = Encoding.UTF8.GetString(ea.Body.Span);
	Console.WriteLine($" [x] Received {message}");
	// channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
};

// 開始消費
channel.BasicConsume(queue: "myqueue5",
					 autoAck: false,
					 consumer: consumer);

之前這段代碼後,你會發現,第一條消息未被 ack 時,程序不會自動讀取下一條消息,也不會重新拉取未被 ack 的消息。

如果我們想重新讀取未被 ack 的消息,可以重新啓動程序,或使用 BasicRecover() 讓服務器重新推送。

消息持久化

前面提到了 BasicPublish 函數的定義:

BasicPublish(string exchange, 
             string routingKey, 
             bool mandatory = false, 
             IBasicProperties basicProperties = null, 
             ReadOnlyMemory<byte> body = default)

當設置 mandatory = true 時,如果交換器無法根據自身的類型和路由鍵找到一個符合條件的隊列,那麼 RabbitMQ 觸發客戶端的 IModel.BasicReturn 事件, 將消息返回給生產者 。

從設計上看,一個 IConnection 雖然可以創建多個 IModel(通道),但是隻建議編寫一個消費者程序或生產者程序,不建議混合多用。

因爲各類事件和隊列配置,是針對一個 IModel(通道) 來設置的。

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();
channel.BasicReturn += (object sender, BasicReturnEventArgs e) =>
{

};

當設置了 mandatory = true 時,如果該消息找不到隊列存儲消息,那麼就會觸發客戶端的 BasicReturn 事件接收 BasicPublish 失敗的消息。

完整示例代碼如下:

using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System.Runtime;
using System.Text;

ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

channel.ExchangeDeclare(exchange: "e2", type: ExchangeType.Fanout, durable: false, autoDelete: false);


channel.BasicReturn += (object? s, BasicReturnEventArgs e) =>
{
	Console.WriteLine($"無效消息:{Encoding.UTF8.GetString(e.Body.Span)}");
};


int i = 0;
while (i < 10)
{
	channel.BasicPublish(
	exchange: "e2",
	routingKey: string.Empty,

	// mandatory=true,當沒有隊列接收消息時,會觸發 BasicReturn 事件
	mandatory: true,
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);
	i++;
}


Console.ReadLine();

在實際開發中,當 mandatory=false 時,如果一條消息推送到交換器,但是卻沒有綁定隊列,那麼該條消息就會丟失,可能會導致嚴重的後果。

而在 RabbitMQ 中,提供了一種被稱爲備胎交換器的方案,這是通過在定義交換器時添加 alternate-exchange 參數來實現。其作用是當 A 交換器無法找到隊列轉發消息時,就會將消息轉發到 B 隊列中。

完整代碼示例如下:

首先創建 e3_bak 隊列,接着創建 e3 隊列時設置其備胎交換器爲 e3_bak。

然後,e3_bak 需要綁定一個隊列消費消息。

ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

channel.ExchangeDeclare(
	exchange: "e3_bak",
	type: ExchangeType.Fanout,
	durable: false,
	autoDelete: false
	);

// 聲明 e3 交換器,當 e3 交換器沒有綁定隊列時,消息將會被轉發到 e3_bak 交換器
channel.ExchangeDeclare(
	exchange: "e3",
	type: ExchangeType.Fanout,
	durable: false,
	autoDelete: false,
	arguments: new Dictionary<string, object> {
		{ "alternate-exchange", "e3_bak" }
	}
	);

channel.QueueDeclare(queue: "q3", durable: false, exclusive: false, autoDelete: false);
channel.QueueBind(queue: "q3", "e3_bak", routingKey: string.Empty);

// 因爲已經設置了 e3 的備用交換器,所以不會觸發 BasicReturn
channel.BasicReturn += (object? s, BasicReturnEventArgs e) =>
{
	Console.WriteLine($"無效消息:{Encoding.UTF8.GetString(e.Body.Span)}");
};


int i = 0;
while (i < 10)
{
	channel.BasicPublish(
	exchange: "e3",
	routingKey: string.Empty,
	// 因爲已經設置了 e3 的備用交換器,所以開啓這個不會觸發 BasicReturn
	mandatory: true,
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);
	i++;
}

Console.ReadLine();

注意,如果備胎交換器有沒有綁定合適隊列的話,那麼該消息就會丟失。

如果 e3 是 Direct,e3_bak 也是 Direct,那麼需要兩者具有相同的 routingKey,如果 e3 中有個 routingKey = cat,但是 e3_bak 中不存在對應的 routingKey,那麼該消息還是會丟失的。還有其它一些情況,這裏不再贅述。

推送消息時,有一個 IBasicProperties basicProperties 屬性,前面的小節中已經介紹過該接口的屬性,當 IBasicProperties.DeliveryMode=2 時,消息將被標記爲持久化,即使 RabbitMQ 服務器重啓,消息也不會丟失。

相對來說,通過前面的實驗,你可以觀察到客戶端把隊列的消息都消費完畢後,隊列中的消息都會消失。而對應 Kafka 來說,一個 topic 中的消息被消費,其依然會被保留。這一點要注意,使用 RabbitMQ 時,需要提前設置好隊列消息的持久化,避免消費或未成功消費時,消息丟失。

生產者在推送消息時,可以使用 IBasicProperties.DeliveryMode=2 將該消息設置爲持久化。

	var ps = channel.CreateBasicProperties();
	ps.DeliveryMode = 2;

	channel.BasicPublish(
	exchange: "e3",
	routingKey: string.Empty,
	mandatory: false,
	basicProperties: ps,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);

消息 TTL 時間

設置消息 TTL 時間後,該消息如果在一定時間內沒有被消費,那麼該消息就成爲了死信消息。對於這種消息,會有大概這麼兩個處理情況。

第一種,如果隊列設置了 "x-dead-letter-exchange" ,那麼該消息會被從隊列轉發到另一個交換器中。這種方法在死信交換器一節中會介紹。

第二種,消息被丟棄。

目前有兩種方法可以設置消息的 TTL 。

第一種方法是通過隊列屬性設置,這樣一來隊列中所有消息都有相同的過期時間。

第二種方法是對單條消息進行單獨設置,每條消息的 TTL 可以不同。

如果兩種設置一起使用,則消息的 TTL 以兩者之間較小的那個數值爲準。消息在隊列中的生存時一旦超過設置 TTL 值時,消費者將無法再收到該消息,所以最好設置死信交換器。

第一種,對隊列設置:

channel.QueueDeclare(queue: "q4",
	durable: false,
	exclusive: false,
	autoDelete: false,
	arguments: new Dictionary<string, object>() { { "x-message-ttl", 6000 } });

第二種通過設置屬性配置消息過期時間。

var ps = channel.CreateBasicProperties();
// 單位毫秒
ps.Expiration = "6000";

對於第一種設置隊列屬性的方法,一旦消息過期就會從隊列中抹去(如果設置了死信交換器,會被轉發到死信交換器中)。而在第二種方法中,即使消息過期,也不會馬上從隊列中抹去,因爲該條消息在即將投遞到消費者之前,纔會檢查消息是否過期。對於第二種情況,當隊列進行任何一次輪詢操作時,纔會被真正移除。

對於第二種情況,雖然是在被輪詢時,過期了纔會被真正移除,但是一旦過期,就會被轉發到死信隊列中,只是不會立即移除。

隊列 TTL 時間

當對一個隊列設置 TTL 時,如果該隊列在規定時間內沒被使用,那麼該隊列就會被刪除。這個約束包括一段時間內沒有被消費消息(包括 BasicGet() 方式消費的)、沒有被重新聲明、沒有消費者連接,否則被刪除的倒計時間會被重置。

channel.QueueDeclare(queue: "q6",
	durable: false,
	exclusive: false,
	autoDelete: false,
	arguments: new Dictionary<string, object>
	{
		// 單位是毫秒,設置 隊列過期時間是 1 小時
		{"x-expires",1*3600*1000}
	});

image-20231115171330662

DLX 死信交換器

DLX(Dead-Letter-Exchange) 死信交換器,消息在一個隊列 A 中變成死信之後,它能被重新被髮送到另一個 B 交換器中。其中 A 隊列綁定了死信交換器,那麼在Management UI 界面會看到 DLX 標識,而 B 交換器就是一個普通的交換器,無需配置。

1700096700627

消息變成死信 般是由於以下幾種情況:

  • 消息被消費者拒絕,BasicReject()BasicNack() 兩個函數可以拒絕消息。
  • 消息過期。
  • 隊列達到最大長度。

當這個隊列 A 中存在死信消息時,RabbitMQ 就會自動地將這個消息重新發布到設置的交換器 B 中。一般會專門給重要的隊列設置死信交換器 B,而交換器 B 也需要綁定一個隊列 C 纔行,不然消息也會丟失。

設置隊列出現死信消息時,將消息轉發到哪個交換器中:

channel.QueueDeclare(queue: "q7", durable: false, exclusive: false, autoDelete: false,
		arguments: new Dictionary<string, object> {
		{ "x-dead-letter-exchange", "e7_bak" } });

完整示例代碼如下所示:


using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System.Text;

ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

channel.ExchangeDeclare(
	exchange: "e7_bak",
	type: ExchangeType.Fanout,
	durable: false,
	autoDelete: false
	);

channel.QueueDeclare(queue: "q7_bak", durable: false, exclusive: false, autoDelete: false);
channel.QueueBind(queue: "q7_bak", "e7_bak", routingKey: string.Empty);

channel.ExchangeDeclare(
	exchange: "e7",
	type: ExchangeType.Fanout,
	durable: false,
	autoDelete: false
	);

channel.QueueDeclare(queue: "q7", durable: false, exclusive: false, autoDelete: false,
		arguments: new Dictionary<string, object> {
		{ "x-dead-letter-exchange", "e7_bak" } });

channel.QueueBind(queue: "q7", "e7", routingKey: string.Empty);

int i = 0;
while (i < 10)
{
	channel.BasicPublish(
	exchange: "e7",
	routingKey: string.Empty,
	mandatory: false,
	basicProperties: null,
	body: Encoding.UTF8.GetBytes($"測試{i}"));
	i++;
}

Thread.Sleep(1000);

int y = 0;
// 定義消費者
channel.BasicQos(0, prefetchCount: 1, true);
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
	var message = Encoding.UTF8.GetString(ea.Body.Span);
	Console.WriteLine($" [x] Received {message}");

	if (y % 2 == 0)
		channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);

	// requeue 要設置爲 false 纔行,
	// 否則此消息被拒絕後還會被放回隊列。
	else
		channel.BasicReject(deliveryTag: ea.DeliveryTag, requeue: false);
	Interlocked.Add(ref y, 1);
};

// 開始消費
channel.BasicConsume(queue: "q7",
					 autoAck: false,
					 consumer: consumer);

Console.ReadLine();

image-20231115180908233

延遲隊列

RabbitMQ 本身沒有直接支持延遲隊列的功能。

那麼爲什麼會出現延遲隊列這種東西呢?

主要是因爲消息推送後,不想立即被消費。比如說,用戶下單後,如果 10 分鐘內沒有支付,那麼該訂單會被自動取消。所以需要做一個消息被延遲消費的功能。

所以說,實際需求是,該消息在一定時間之後才能被消費者消費

在 RabbitMQ 中做這個功能,需要使用兩個交換器,以及至少兩個隊列。

思路是定義兩個交換器 e8、e9 和兩個隊列 q8、q9,交換器 e8 和隊列 q8 綁定、交換器 e9 和 q9 綁定。

最重要的一點來了,q9 設置了死信隊列,當消息 TTL 時間到時,轉發到 e9 交換器中。所以,e9 交換器 - q9 隊列 接收到的都是到期(或者說過期)的消息。

在發送消息到 e8 交換器時,設置 TTL 時間。當 q8 隊列中的消息過期時,消息會被轉發到 e9 交換器,然後存入 q9 隊列。

消費者只需要訂閱 q9 隊列,即可消費到期後的消息。

全部完整代碼示例如下:

using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System.Text;

ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

channel.ExchangeDeclare(
	exchange: "e8",
	type: ExchangeType.Fanout,
	durable: false,
	autoDelete: false
	);

channel.ExchangeDeclare(
	exchange: "e9",
	type: ExchangeType.Fanout,
	durable: false,
	autoDelete: false
	);

channel.QueueDeclare(queue: "q9", durable: false, exclusive: false, autoDelete: false);
channel.QueueBind(queue: "q9", "e9", routingKey: string.Empty);

channel.QueueDeclare(queue: "q8", durable: false, exclusive: false, autoDelete: false,
		arguments: new Dictionary<string, object> {
		{ "x-dead-letter-exchange", "e9" } });

channel.QueueBind(queue: "q8", "e8", routingKey: string.Empty);

int i = 0;
while (i < 10)
{
	var ps = channel.CreateBasicProperties();
	ps.Expiration = "6000";

	channel.BasicPublish(
	exchange: "e8",
	routingKey: string.Empty,
	mandatory: false,
	basicProperties: ps,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);
	i++;
}


var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
	var message = Encoding.UTF8.GetString(ea.Body.Span);
	Console.WriteLine($" [x] 已到期消息 {message}");
};

// 開始消費
channel.BasicConsume(queue: "q9",
					 autoAck: true,
					 consumer: consumer);

Console.ReadLine();

消息優先級

消息優先級越高,就會越快被消費者消費。

代碼示例如下:

var ps = channel.CreateBasicProperties();
// 優先級 0-9 
ps.Priority = 9;

	channel.BasicPublish(
	exchange: "e8",
	routingKey: string.Empty,
	mandatory: false,
	basicProperties: ps,
	body: Encoding.UTF8.GetBytes($"測試{i}")
	);

所以說,RabbitMQ 不一定可以保證消息的順序性,這一點跟 Kafka 是有區別的。

事務機制

事務機制是,發佈者確定消息一定推送到 RabbitMQ Broker 中,往往會跟業務代碼一起使用。

比如說,用戶成功支付之後,推送一個通知到 RabbitMQ 隊列中。

數據庫當然要做事務,這樣在支付失敗後修改的數據會被回滾。但是問題來了,如果消息已經推送了,但是數據庫卻回滾了。

這個時候會涉及到一致性,可以使用 RabbitMQ 的事務機制來處理,其思路跟數據庫事務過程差不多,也是有提交和回滾操作。

其目的是確保消息成功推送到 RabbitMQ Broker 以及跟客戶端其它代碼保持數據一致,推送消息跟代碼操作同時成功或同時回滾。

但是,RabbitMQ 事務和數據庫事務是兩種東西。當其中一個 RabbitMQ 事務完成時,但是程序就已經掛了,那麼另一個數據庫事務回滾了。此時數據又會不一致。
因此,還需要保證兩個階段一定可以同時完成。此時,我們可能又需要引入二階段提交、支持補償機制等。這回到了分佈式事務領域。

其完整的代碼示例如下:

ConnectionFactory factory = new ConnectionFactory
{
	HostName = "localhost"
};

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

// 客戶端發送 Tx.Select.將信道置爲事務模式;
channel.TxSelect();

try
{
	// 發送消息
	channel.QueueDeclare(queue: "transaction_queue",
						 durable: false,
						 exclusive: false,
						 autoDelete: false,
						 arguments: null);

	string message = "Hello, RabbitMQ!";
	var body = Encoding.UTF8.GetBytes(message);

	channel.BasicPublish(exchange: "",
						 routingKey: "transaction_queue",
						 basicProperties: null,
						 body: body);


	// 執行一系列操作

	// 提交事務
	channel.TxCommit();
	Console.WriteLine(" [x] Sent '{0}'", message);
}
catch (Exception e)
{
	// 回滾事務
	channel.TxRollback();
	Console.WriteLine("An error occurred: " + e.Message);
}

Console.ReadLine();

發送方確認機制

發送方確認機制,是保證消息一定推送到 RabbitMQ 的方案。

而事務機制,一般是爲了保證一致性,推送消息和其它操作同時成功或同時失敗,不能出現兩者不一致的情況。

其完整代碼示例如下:

using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

// 開啓發送方確認模式
channel.ConfirmSelect();

string exchangeName = "exchange_name";
string routingKey = "routing_key";

// 定義交換器
channel.ExchangeDeclare(exchange: exchangeName, type: ExchangeType.Direct);

// 發送消息
string message = "Hello, RabbitMQ!";
var body = Encoding.UTF8.GetBytes(message);

// 發佈消息
channel.BasicPublish(exchange: exchangeName,
					 routingKey: routingKey,
					 basicProperties: null,
					 body: body);

// 等待確認已推送到 RabbitMQ
if (channel.WaitForConfirms())
{
	Console.WriteLine(" [x] Sent '{0}'", message);
}
else
{
	Console.WriteLine("Message delivery failed.");
}

Console.ReadLine();

文章寫到這裏,恰好一萬詞。

對於 RabbitMQ 集羣、運維等技術,本文不再贅述。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章