萬字長文:從 C# 入門學會 RabbitMQ 訊息佇列程式設計

痴者工良發表於2023-11-17

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 叢集、運維等技術,本文不再贅述。

相關文章