開源一款功能強大的 .NET 訊息佇列通訊模型框架 Maomi.MQ

痴者工良發表於2024-06-13

目錄
  • 文件說明
    • 導讀
    • 快速開始
    • 訊息釋出者
      • IMessagePublisher
      • 連線池
      • 訊息過期
      • 事務
      • 傳送方確認模式
      • 獨佔模式
    • 消費者
      • 消費者模式
      • 事件模式
      • 分組
    • 消費者模式
      • 消費、重試和補償
      • 消費失敗
      • 自動建立佇列
      • Qos
      • 延遲佇列
      • 空消費者
      • 分組
    • 事件匯流排模式
      • 中介軟體
      • 分組消費
    • 配置
      • 消費者配置
      • 環境隔離
      • 雪花 id 配置
    • Qos 併發和順序
      • Qos 場景
      • 併發和異常處理
    • 重試
      • 重試時間
      • 重試機制
      • 持久化剩餘重試次數
    • 死信佇列
      • 死信佇列
      • 延遲佇列
    • 可觀測性

文件說明

作者:痴者工良

文件地址:https://mmq.whuanle.cn

倉庫地址:https://github.com/whuanle/Maomi.MQ

作者部落格:

  • https://www.whuanle.cn

  • https://www.cnblogs.com/whuanle

導讀

Maomi.MQ 是一個訊息通訊模型專案,目前只支援了 RabbitMQ。

Maomi.MQ.RabbitMQ 是一個用於專為 RabbitMQ 設計的釋出者和消費者通訊模型,大大簡化了釋出和訊息的程式碼,並提供一系列簡便和實用的功能,開發者可以透過框架提供的消費模型實現高效能消費、事件編排,框架還支援釋出者確認機制、自定義重試機制、補償機制、死信佇列、延遲佇列、連線通道複用等一系列的便利功能。開發者可以把更多的精力放到業務邏輯中,透過 Maomi.MQ.RabbitMQ 框架簡化跨程序訊息通訊模式,使得跨程序訊息傳遞更加簡單和可靠。

此外,框架透過 runtime 內建的 api 支援了分散式可觀測性,可以透過進一步使用 OpenTelemetry 等框架進一步收集可觀測性資訊,推送到基礎設施平臺中。

快速開始

本文將快速介紹 Maomi.MQ.RabbitMQ 的使用方法。

引入 Maomi.MQ.RabbitMQ 包,在 Web 配置中注入服務:

builder.Services.AddSwaggerGen();
builder.Services.AddLogging();

builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
	options.WorkId = 1;
	options.AppName = "myapp";
	options.Rabbit = (ConnectionFactory options) =>
	{
		options.HostName = "192.168.3.248";
		options.ClientProvidedName = Assembly.GetExecutingAssembly().GetName().Name;
	};
}, [typeof(Program).Assembly]);

var app = builder.Build();
  • WorkId: 指定用於生成分散式雪花 id 的節點 id,預設為 0。
  • AppName:用於標識訊息的生產者,以及在日誌和鏈路追蹤中標識訊息的生產者或消費者。
  • Rabbit:RabbitMQ 客戶端配置,請參考 ConnectionFactory

如果是控制檯專案,則需要引入 Microsoft.Extensions.Hosting 包。

var host = new HostBuilder()
	.ConfigureLogging(options =>
	{
		options.AddConsole();
		options.AddDebug();
	})
	.ConfigureServices(services =>
	{
		services.AddMaomiMQ(options =>
		{
			options.WorkId = 1;
			options.AppName = "myapp";
			options.Rabbit = (ConnectionFactory options) =>
			{
				options.HostName = "192.168.3.248";
				options.ClientProvidedName = Assembly.GetExecutingAssembly().GetName().Name;
			};
		}, new System.Reflection.Assembly[] { typeof(Program).Assembly });
		
		// Your services.
		services.AddHostedService<MyPublishAsync>();
	}).Build();

await host.RunAsync();

定義訊息模型類,該模型類將會被序列化為二進位制內容傳遞到 RabbitMQ 伺服器中。

public class TestEvent
{
    public int Id { get; set; }

    public override string ToString()
    {
        return Id.ToString();
    }
}

定義消費者,消費者需要實現 IConsumer<TEvent> 介面,以及使用 [Consumer] 特性註解配置消費者屬性。

[Consumer("test", Qos = 1, RetryFaildRequeue = true)]
public class MyConsumer : IConsumer<TestEvent>
{
    private static int _retryCount = 0;

    // 消費
    public async Task ExecuteAsync(EventBody<TestEvent> message)
    {
        Console.WriteLine($"事件 id: {message.Id} {DateTime.Now}");
        await Task.CompletedTask;
    }
    
    // 每次消費失敗時執行
    public Task FaildAsync(Exception ex, int retryCount, EventBody<TestEvent>? message) => Task.CompletedTask;
    
    // 補償
    public Task<bool> FallbackAsync(EventBody<TestEvent>? message) => Task.FromResult(true);
}

然後注入 IMessagePublisher 服務釋出訊息:

[ApiController]
[Route("[controller]")]
public class IndexController : ControllerBase
{
    private readonly IMessagePublisher _messagePublisher;

    public IndexController(IMessagePublisher messagePublisher)
    {
        _messagePublisher = messagePublisher;
    }

    [HttpGet("publish")]
    public async Task<string> Publisher()
    {
        // 釋出訊息
        await _messagePublisher.PublishAsync(queue: "test", message: new TestEvent
		{
        	Id = i
        });
        return "ok";
    }
}

訊息釋出者

訊息釋出者用於推送訊息到 RabbitMQ 伺服器中。

透過注入 IMessagePublisher 介面即可向 RabbitMQ 推送訊息,示例專案請參考 PublisherWeb

定義一個事件模型類:

public class TestEvent
{
	public int Id { get; set; }

	public override string ToString()
	{
		return Id.ToString();
	}
}

注入 IMessagePublisher 服務後釋出訊息:

[ApiController]
[Route("[controller]")]
public class IndexController : ControllerBase
{
	private readonly IMessagePublisher _messagePublisher;

	public IndexController(IMessagePublisher messagePublisher)
	{
		_messagePublisher = messagePublisher;
	}

	[HttpGet("publish")]
	public async Task<string> Publisher()
	{
		for (var i = 0; i < 100; i++)
		{
			await _messagePublisher.PublishAsync(queue: "PublisherWeb", message: new TestEvent
			{
				Id = i
			});
		}

		return "ok";
	}
}

IMessagePublisher

IMessagePublisher 定義比較簡單,只有三個方法和一個屬性:

public ConnectionPool ConnectionPool { get; }

Task PublishAsync<TEvent>(string queue, TEvent message, Action<IBasicProperties>? properties = null)
where TEvent : class;

Task PublishAsync<TEvent>(string queue, TEvent message, IBasicProperties properties);

//  不建議直接使用該介面。
Task CustomPublishAsync<TEvent>(string queue, EventBody<TEvent> message, BasicProperties properties);

三個 PublishAsync 方法用於釋出事件,ConnectionPool 屬性用於獲取 RabbitMQ.Client.IConnection 物件。

由於直接公開了 BasicProperties ,因此開發者完全自由配置 RabbitMQ 原生的訊息屬性,所以 Maomi.MQ.RabbitMQ 沒必要過度設計,只提供了簡單的功能介面。

例如,可以透過 BasicProperties 配置單條訊息的過期時間:

await _messagePublisher.PublishAsync(queue: "RetryWeb", message: new TestEvent
{
	Id = i
}, (BasicProperties p) =>
{
	p.Expiration = "1000";
});

當釋出一條訊息時,實際上框架傳遞的是 EventBody<T> 型別,EventBody<T> 中包含了一些重要的附加訊息屬性,這些屬性會給訊息處理和故障診斷帶來很大的方便。

public class EventBody<TEvent>
{
	// 事件唯一 id.
	public long Id { get; init; }

	// Queue.
	public string Queue { get; init; } = null!;

	// App name.
	public string Publisher { get; init; } = null!;

	// 事件建立時間.
	public DateTimeOffset CreationTime { get; init; }

	// 事件體.
	public TEvent Body { get; init; } = default!;
}

Maomi.MQ 透過 DefaultMessagePublisher 型別實現了 IMessagePublisher,DefaultMessagePublisher 預設生命週期是 Singleton:

services.AddSingleton<IMessagePublisher, DefaultMessagePublisher>();

生命週期不重要,如果需要修改預設的生命週期,可以手動修改替換。

services.AddScoped<IMessagePublisher, DefaultMessagePublisher>();

開發者也可以自行實現 IMessagePublisher 介面,具體示例請參考 DefaultMessagePublisher 型別。

連線池

為了複用 RabbitMQ.Client.IConnection ,Maomi.MQ.RabbitMQ 內部實現了 ConnectionPool 型別,透過物件池維護複用的 RabbitMQ.Client.IConnection 物件。

預設物件池中的 RabbitMQ.Client.IConnection 數量為 0,只有當連線被真正使用時才會從物件池委託中建立,連線物件會隨著程式併發量而自動增加,但是,預設最大連線物件數量為 Environment.ProcessorCount * 2

除了 IMessagePublisher 介面提供的 PublishAsync 方法可以釋出事件,開發者還可以從 ConnectionPool 獲取連線物件,請務必在使用完畢後透過 ConnectionPool.Return() 方法將其歸還到連線物件池。

透過連線池直接使用 IConnection 物件釋出訊息:

[HttpGet("publish")]
public async Task<string> Publisher()
{
	for (var i = 0; i < 100; i++)
	{
		var connectionPool = _messagePublisher.ConnectionPool;
		var connection = connectionPool.Get();

		try
		{
			connection.Channel.BasicPublishAsync(
			exchange: string.Empty,
			routingKey: "queue",
			basicProperties: properties,
			body: _jsonSerializer.Serializer(message),
			mandatory: true);
		}
		finally
		{
			connectionPool.Return(connection);
		}
	}

	return "ok";
}

你也可以繞開 IMessagePublisher ,直接注入 ConnectionPool 使用 RabbitMQ 連線物件,但是不建議這樣使用。

private readonly ConnectionPool _connectionPool;

public DefaultMessagePublisher(ConnectionPool connectionPool)
{
	_connectionPool = connectionPool;
}

public async Task MyPublshAsync()
{
	var connection = _connectionPool.Get();
	try
	{
		await connection.Channel.BasicPublishAsync(...);
	}
	finally
	{
		_connectionPool.Return(connection);
	}
}

為了更加簡便地管理連線物件,可以使用 CreateAutoReturn() 函式建立連線管理物件,該物件被釋放時會自動將 IConnection 返還給連線池。

using var poolObject = _messagePublisher.ConnectionPool.CreateAutoReturn();
poolObject.Channel.BasicPublishAsync(
	exchange: string.Empty,
	routingKey: "queue",
	basicProperties: properties,
	body: _jsonSerializer.Serializer(message),
	mandatory: true);

如果你自行使用 ConnectionPool 推送訊息到 RabbitMQ,請務必透過序列化 EventBody<TEvent> 事件物件,這樣 Maomi.MQ.RabbitMQ 消費者才能正常工作。同時,Moami.MQ 對可觀測性做了支援,如果自行使用 ConnectionPool 獲取連線物件推送訊息,可能會導致可觀測性資訊缺失。

正常情況下,RabbitMQ.Client 中包含了可觀測性的功能,但是 Maomi.MQ.RabbitMQ 附加的可觀測性資訊有助於診斷故障問題。

請注意:

  • Maomi.MQ.RqbbitMQ 透過 EventBody<TEvent> 泛型物件釋出和接收事件。

  • DefaultMessagePublisher 包含了鏈路追蹤等可觀測性程式碼。

訊息過期

IMessagePublisher 對外開放了 BasicProperties 或 BasicProperties,可以自由配置訊息屬性。

例如為訊息配置過期時間:

[HttpGet("publish")]
public async Task<string> Publisher()
{
	for (var i = 0; i < 1; i++)
	{
		await _messagePublisher.PublishAsync(queue: "test", message: new TestEvent
		{
			Id = i
		}, properties =>
		{
			properties.Expiration = "6000";
		});
	}

	return "ok";
}

如果此時為 test 繫結死信佇列,那麼該訊息長時間沒有被消費時,會被移動到另一個佇列,請參考 死信佇列

還可以透過配置訊息屬性實現更多的功能,請參考 IBasicProperties

事務

RabbitMQ 支援事務,不過據 RabbitMQ 官方文件顯示,事務會使吞吐量減少 250 倍。

RabbitMQ 事務使用上比較簡單,可以保證釋出的訊息已經被推送到 RabbitMQ 伺服器,只有當提交事務時,提交的訊息才會被 RabbitMQ 儲存並推送給消費者。

使用示例:

[HttpGet("publish_tran")]
public async Task<string> Publisher_Tran()
{
	using var tranPublisher = await _messagePublisher.TxSelectAsync();

	try
	{
		await tranPublisher.PublishAsync(queue: "publish_tran", message: new TestEvent
		{
			Id = 666
		});
		await tranPublisher.TxCommitAsync();
	}
	catch
	{
		await tranPublisher.TxRollbackAsync();
		throw;
	}

	return "ok";
}

或者手動開啟事務:

[HttpGet("publish_tran")]
public async Task<string> Publisher_Tran()
{
	using var tranPublisher = _messagePublisher.CreateTransaction();

	try
	{
		await tranPublisher.TxSelectAsync();
		await tranPublisher.PublishAsync(queue: "publish_tran", message: new TestEvent
		{
			Id = 666
		});
		await tranPublisher.TxCommitAsync();
	}
	catch
	{
		await tranPublisher.TxRollbackAsync();
		throw;
	}

	return "ok";
}

注意,在該種模式之下,建立 TransactionPublisher 物件時,會從物件池中取出一個連線物件,因為開啟事務模式可能會汙染當前連線通道,因此 TransactionPublisher 不會向連線池歸還連線物件,而是直接釋放

傳送方確認模式

雖然事務模式可以保證訊息會被推送到 RabbitMQ 伺服器中,但是由於事務模式會導致吞吐量降低 250 倍,因此不是一個好的選擇。為了解決這個問題, RabbitMQ 引入了一種確認機制,這種機制就像滑動視窗,能夠保證訊息推送到伺服器中,並且具備高效能的特性。

請參考 https://www.rabbitmq.com/docs/confirms

使用示例:

[HttpGet("publish_confirm")]
public async Task<string> Publisher_Confirm()
{
	using var confirmPublisher = await _messagePublisher.ConfirmSelectAsync();

	for (var i = 0; i < 5; i++)
	{
		await confirmPublisher.PublishAsync(queue: "publish_confirm1", message: new TestEvent
		{
			Id = 666
		});

		var result = await confirmPublisher.WaitForConfirmsAsync();

		// 如果在超時內沒有接收到 nacks,則為 True,否則為 false。
		Console.WriteLine($"釋出 {i},{result}");
	}

	return "ok";
}

WaitForConfirmsAsync 方法會返回一個值,如果正常被伺服器確認了訊息已經傳達,則結果為 true,如果超時沒有被伺服器確認,則返回 false。

此外,還有一個 WaitForConfirmsOrDieAsync 方法,它會一直等待該頻道上的所有已釋出訊息都得到確認,使用示例:

using var confirmPublisher = await _messagePublisher.ConfirmSelectAsync();

for (var i = 0; i < 5; i++)
{
	await confirmPublisher.PublishAsync(queue: "publish_confirm1", message: new TestEvent
	{
		Id = 666
	});

	Console.WriteLine($"釋出 {i}");
}

await confirmPublisher.WaitForConfirmsOrDieAsync();

注意,在該種模式之下,建立 ConfirmPublisher 物件時,會從物件池中取出一個連線物件,因為開啟事務模式可能會汙染當前連線通道,因此 ConfirmPublisher 不會向連線池歸還連線物件,而是直接釋放

注意,同一個通道不能同時使用事務和傳送方確認模式。

獨佔模式

預設情況下,每次使用 IMessagePublisher.PublishAsync() 釋出訊息時,都會從連線池中取出連線物件,然後使用該連線通道釋出訊息,釋出完畢後就會歸還連線物件給連線池。

如果需要在短時間內大批次釋出訊息,則需要每次都要重複獲取和返還連線物件。

使用獨佔模式時可以在一段時間內獨佔一個連線物件,超出作用域後,連線物件會自動放回連線池。這種模式對於需要大量釋出訊息的場景提高吞吐量非常有幫助。為了能夠將連線通道歸還連線池,請務必使用 using 關鍵字修飾變數,或者手動呼叫 Dispose 函式。

使用示例:

// 建立獨佔模式
using var singlePublisher = _messagePublisher.CreateSingle();

for (var i = 0; i < 500; i++)
{
	await singlePublisher.PublishAsync(queue: "publish_single", message: new TestEvent
	{
		Id = 666
	});
}

消費者

Maomi.MQ.RabbitMQ 中,有兩種消費模式,一種是消費者模式,一種是事件模式(事件匯流排模式)。

下面簡單瞭解這兩種模式的使用方法。

消費者模式

消費者服務需要實現 IConsumer<TEvent> 介面,並且配置 [Consumer("queue")] 特性繫結佇列名稱,透過消費者物件來控制消費行為。

消費者模式有具有失敗通知和補償能力,使用上也比較簡單。

public class TestEvent
{
    public int Id { get; set; }
}

[Consumer("PublisherWeb", Qos = 1, RetryFaildRequeue = true)]
public class MyConsumer : IConsumer<TestEvent>
{
    private static int _retryCount = 0;

    // 消費或重試
    public async Task ExecuteAsync(EventBody<TestEvent> message)
    {
        _retryCount++;
        Console.WriteLine($"執行次數:{_retryCount} 事件 id: {message.Id} {DateTime.Now}");
        await Task.CompletedTask;
    }
    
    // 失敗
    public Task FaildAsync(Exception ex, int retryCount, EventBody<TestEvent>? message) => Task.CompletedTask;
    
    // 補償
    public Task<bool> FallbackAsync(EventBody<TestEvent>? message) => Task.FromResult(true);
}

事件模式

事件模式是透過事件匯流排的方式實現的,以事件模型為中心,透過事件來控制消費行為。

[EventTopic("web2", Qos = 1, RetryFaildRequeue = true)]
public class TestEvent
{
	public string Message { get; set; }
}

然後使用 [EventOrder] 特性編排事件執行順序。

// 編排事件消費順序
[EventOrder(0)]
public class My1EventEventHandler : IEventHandler<TestEvent>
{
	public async Task CancelAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
	{
	}

	public async Task ExecuteAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
	{
		Console.WriteLine($"{@event.Id},事件 1 已被執行");
	}
}

[EventOrder(1)]
public class My2EventEventHandler : IEventHandler<TestEvent>
{
	public async Task CancelAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
	{
	}

	public async Task ExecuteAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
	{
		Console.WriteLine($"{@event.Id},事件 2 已被執行");
	}
}

當然,事件模式也可以透過建立中介軟體增加補償功能,透過中介軟體還可以將所有排序事件放到同一個事務中,一起成功或失敗,避免事件執行時出現程式退出導致的一致性問題。

public class TestEventMiddleware : IEventMiddleware<TestEvent>
{
    private readonly BloggingContext _bloggingContext;

    public TestEventMiddleware(BloggingContext bloggingContext)
    {
        _bloggingContext = bloggingContext;
    }

    public async Task ExecuteAsync(EventBody<TestEvent> @event, EventHandlerDelegate<TestEvent> next)
    {
        using (var transaction = _bloggingContext.Database.BeginTransaction())
        {
            await next(@event, CancellationToken.None);
            await transaction.CommitAsync();
        }
    }

    public Task FaildAsync(Exception ex, int retryCount, EventBody<TestEvent>? message)
    {
        return Task.CompletedTask;
    }

    public Task<bool> FallbackAsync(EventBody<TestEvent>? message)
    {
        return Task.FromResult(true);
    }
}

分組

消費者模式和事件模式都可以設定分組,在特性上設定了 Group 屬性,具有同一個分組的事件會被放到一個連線通道(RabbitMQ.Client.IConnection)中,對於消費頻率不高的事件,複用連線通道可以有效較低資源消耗。

消費者模式分組示例:

[Consumer("ConsumerWeb_group_1", Qos = 1, Group = "group")]
public class Group_1_Consumer : IConsumer<GroupEvent>
{
    public Task ExecuteAsync(EventBody<GroupEvent> message) => Task.CompletedTask;

    public Task FaildAsync(Exception ex, int retryCount, EventBody<GroupEvent>? message) => Task.CompletedTask;

    public Task<bool> FallbackAsync(EventBody<GroupEvent>? message) => Task.FromResult(true);
}

[Consumer("ConsumerWeb_group_2", Qos = 1, Group = "group")]
public class Group_2_Consumer : IConsumer<GroupEvent>
{
    public Task ExecuteAsync(EventBody<GroupEvent> message) => Task.CompletedTask;

    public Task FaildAsync(Exception ex, int retryCount, EventBody<GroupEvent>? message) => Task.CompletedTask;

    public Task<bool> FallbackAsync(EventBody<GroupEvent>? message) => Task.FromResult(true);
}

事件匯流排模式分組示例:

[EventTopic("web1", Qos = 1, RetryFaildRequeue = true, Group = "group")]
public class Test1Event
{
	public string Message { get; set; }
}
[EventTopic("web2", Qos = 1, RetryFaildRequeue = true, Group = "group")]
public class Test2Event
{
	public string Message { get; set; }
}

消費者模式

消費者模式要求服務實現 IConsumer<TEvent> 介面,並新增 [Connsumer] 特性。

IConsumer<TEvent> 介面比較簡單,其定義如下:

public interface IConsumer<TEvent>
    where TEvent : class
{
    // 訊息處理.
    public Task ExecuteAsync(EventBody<TEvent> message);

    // ExecuteAsync 異常後立即執行此程式碼.
    public Task FaildAsync(Exception ex, int retryCount, EventBody<TEvent>? message);

    // 最後一次重試失敗時執行,用於補償.
    public Task<bool> FallbackAsync(EventBody<TEvent>? message);
}

使用消費者模式時,需要先定義一個模型類,用於釋出者和消費者之間傳遞訊息,事件模型類只要是類即可,能夠正常序列化和反序列化,沒有其它要求。

public class TestEvent
{
	public int Id { get; set; }

	public override string ToString()
	{
		return Id.ToString();
	}
}

然後繼承 IConsumer<TEvent> 介面實現消費者功能:

[Consumer("web1", Qos = 1)]
public class MyConsumer : IConsumer<TestEvent>
{
	// 消費
	public async Task ExecuteAsync(EventBody<TestEvent> message)
	{
		Console.WriteLine(message.Body.Id);
	}

	// 每次失敗時被執行
	public async Task FaildAsync(Exception ex, int retryCount, EventBody<TestEvent>? message)
	{
		Console.WriteLine($"重試 {message.Body.Id},次數 {retryCount}");
		await Task.CompletedTask;
	}

	// 最後一次失敗時執行
	public async Task<bool> FallbackAsync(EventBody<TestEvent>? message)
	{
		Console.WriteLine($"最後一次 {message.Body.Id}");
        // 如果返回 true,說明補償成功。
		return true;
	}
}

特性配置的說明請參考 消費者配置

消費、重試和補償

消費者收到伺服器推送的訊息時,ExecuteAsync 方法會被自動執行。當 ExecuteAsync 執行異常時,FaildAsync 方法會馬上被觸發,開發者可以利用 FaildAsync 記錄相關日誌資訊。

// 每次失敗時被執行
public async Task FaildAsync(Exception ex, int retryCount, EventBody<TestEvent>? message)
{
	// 當 retryCount == -1 時,錯誤並非是 ExecuteAsync 方法導致的
	if (retryCount == -1)
	{
		_logger.LogError(ex, "Consumer error,event id: {Id}", message?.Id);

		// 可以在此處新增告警通知程式碼
		await Task.Delay(1000);
	}
	else
	{
		_logger.LogError(ex, "Consumer exception,event id: {Id},retry count: {retryCount}", message!.Id, retryCount);
	}
}

如果 FaildAsync 方法也出現異常時,不會影響整體流程,框架會等待到達間隔時間後繼續重試 ExecuteAsync 方法。

建議 FaildAsync 使用 try{}cathc{} 套住程式碼,不要對外丟擲異常,FaildAsync 的邏輯不要包含太多邏輯,並且 FaildAsync 只應記錄日誌或進行告警使用。

FaildAsync 被執行有一個額外情況,就是在消費訊息之前就已經發生錯誤,例如一個事件模型類有建構函式導致不能被反序列化,這個時候 FaildAsync 會被立即執行,且 retryCount = -1

ExecuteAsync 方法執行異常時,框架會自動重試,預設會重試五次,如果五次都失敗,則會執行 FallbackAsync 方法進行補償。

重試間隔時間會逐漸增大,請參考 重試

當重試五次之後,就會立即啟動補償機制。

// 最後一次失敗時執行
public async Task<bool> FallbackAsync(EventBody<TestEvent>? message)
{
	return true;
}

FallbackAsync 方法需要返回 bool,如果返回 true ,表示雖然 ExecuteAsync 出現異常,但是 FallbackAsync 補償後已經正常,該訊息會被正常消費掉。如果返回 false,則說補償失敗,該訊息按照消費失敗處理。

只有 ExecuteAsync 異常時,才會觸發 FaildAsyncFallbackAsync ,如果是在處理訊息之前的異常,會直接失敗。

retry

消費失敗

ExecuteAsync 失敗次數達到閾值時,並且 FallbackAsync 返回 false,則該條訊息消費失敗,或者由於序列化等錯誤時直接失敗。

[Consumer] 特性中有三個很重要的配置:

public class ConsumerAttribute : Attribute
{
    // 消費失敗次數達到條件時,是否放回佇列.
    public bool RetryFaildRequeue { get; set; }

    // 現異常時是否放回佇列,例如序列化錯誤等原因導致的,而不是消費時發生異常導致的.
    public bool ExecptionRequeue { get; set; }  = true;
    
    // 繫結死信佇列.
    public string? DeadQueue { get; set; }
}

ExecuteAsync 失敗次數達到閾值時,並且 FallbackAsync 返回 false,則該條訊息消費失敗。

如果 RetryFaildRequeue == false,那麼該條訊息會被 RabbitMQ 丟棄。

如果繫結了死信佇列,則會先推送到死信佇列,接著再丟棄。

如果 RetryFaildRequeue == true,那麼該條訊息會被返回 RabbbitMQ 佇列中,等待下一次消費。

由於訊息失敗後會被放回佇列,因此繫結的死信佇列不會收到該訊息。

當序列化異常或者其它問題導致錯誤而不能進入 ExecuteAsync 方法時,FaildAsync 方法會首先被觸發一次,此時 retryCount 引數值為 -1

出現此種問題時,一般是開發者 bug 導致的,不會進行補償等操作,開發者可以在 FaildAsync 中處理該事件,記錄相關日誌資訊。

// 每次失敗時被執行,或者出現無法進入 ExecuteAsync 的異常
public async Task FaildAsync(Exception ex, int retryCount, EventBody<TestEvent>? message)
{
	// 當 retryCount == -1 時,錯誤並非是 ExecuteAsync 方法導致的
	if (retryCount == -1)
	{
		_logger.LogError(ex, "Consumer error,event id: {Id}", message?.Id);

		// 可以在此處新增告警通知程式碼
		await Task.Delay(1000);
	}
	else
	{
		_logger.LogError(ex, "Consumer exception,event id: {Id},retry count: {retryCount}", message!.Id, retryCount);
	}
}

由於這種情況不妥善處理,會導致訊息丟失,因此框架預設將 ExecptionRequeue 設定為 true,也就是說出現這種異常時,訊息會被放回佇列。如果問題一致沒有得到解決,則會出現迴圈:呼叫 FaildAsync 、放回佇列、呼叫 FaildAsync 、放回佇列... ...

所以應該在 FaildAsync 中新增程式碼通知開發者相關資訊,並且設定間隔時間,避免重試太頻繁。

自動建立佇列

框架預設會自動建立佇列,如果需要關閉自動建立功能,把 AutoQueueDeclare 設定為 false 即可。

builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
	options.WorkId = 1;
	options.AppName = "myapp";
	options.AutoQueueDeclare = false;
	options.Rabbit = (ConnectionFactory options) =>
	{
		options.HostName = "192.168.3.248";
		options.ClientProvidedName = Assembly.GetExecutingAssembly().GetName().Name;
	};
}, [typeof(Program).Assembly]);

當然還可以單獨為消費者配置是否自動建立佇列:

[Consumer("ConsumerWeb_create", AutoQueueDeclare = AutoQueueDeclare.Enable)]

預設情況下,關閉了全域性自動建立,則不會自動建立佇列。

如果關閉全域性自動建立,但是消費者配置了 AutoQueueDeclare = AutoQueueDeclare.Enable,則還是會自動建立佇列。

如果消費者配置了 AutoQueueDeclare = AutoQueueDeclare.Disable ,則會忽略全域性配置,不會建立佇列。

Qos

讓程式需要嚴格根據順序消費時,可以使用 Qos = 1,框架會嚴格保證逐條消費,如果程式不需要順序消費,希望可以快速處理所有訊息,則可以將 Qos 設定大一些。由於 Qos 和重試、補償機制組合使用會有多種情況,因此請參考 重試

Qos 是透過特性來配置的:

[Consumer("ConsumerWeb", Qos = 1)]

可以透過調高 Qos 值,讓程式在可以併發訊息,提高併發量。

延遲佇列

延遲佇列有兩種,一種設定訊息過期時間,一種是設定佇列過期時間。

設定訊息過期時間,那麼該訊息在一定時間沒有被消費時,會被丟棄或移動到死信佇列中,該配置只對單個訊息有效,請參考 訊息過期

佇列設定過期後,當訊息在一定時間內沒有被消費時,會被丟棄或移動到死信佇列中,該配置只對所有訊息有效。基於這一點,我們可以實現延遲佇列。

首先建立消費者,繼承 EmptyConsumer,那麼該佇列會在程式啟動時被建立,但是不會建立 IConnection 進行消費。然後設定佇列訊息過期時間以及繫結死信佇列,繫結的死信佇列既可以使用消費者模式實現,也可以使用事件模式實現。

[Consumer("ConsumerWeb_dead_2", Expiration = 6000, DeadQueue = "ConsumerWeb_dead_queue_2")]
public class EmptyDeadConsumer : EmptyConsumer<DeadEvent>
{
}

// ConsumerWeb_dead 消費失敗的訊息會被此消費者消費。
[Consumer("ConsumerWeb_dead_queue_2", Qos = 1)]
public class Dead_2_QueueConsumer : IConsumer<DeadQueueEvent>
{
    // 消費
    public Task ExecuteAsync(EventBody<DeadQueueEvent> message)
    {
        Console.WriteLine($"死信佇列,事件 id:{message.Id}");
        return Task.CompletedTask;
    }

    // 每次失敗時被執行
    public Task FaildAsync(Exception ex, int retryCount, EventBody<DeadQueueEvent>? message) => Task.CompletedTask;

    // 最後一次失敗時執行
    public Task<bool> FallbackAsync(EventBody<DeadQueueEvent>? message) => Task.FromResult(false);
}

空消費者

當識別到空消費者時,框架只會建立佇列,而不會啟動消費者消費訊息。

可以結合延遲佇列一起使用,該佇列不會有任何消費者,當該佇列的訊息過期時,都由死信佇列直接消費,示例如下:

[Consumer("ConsumerWeb_empty", Expiration = 6000, DeadQueue = "ConsumerWeb_empty_dead")]
public class MyEmptyConsumer : EmptyConsumer<TestEvent> { }

[Consumer("ConsumerWeb_empty_dead", Qos = 10)]
public class MyDeadConsumer : IConsumer<TestEvent>
{
	public Task ExecuteAsync(EventBody<TestEvent> message) => Task.CompletedTask;

	public Task FaildAsync(Exception ex, int retryCount, EventBody<TestEvent>? message) => Task.CompletedTask;

	public Task<bool> FallbackAsync(EventBody<TestEvent>? message) => Task.FromResult(true);
}

對於跨程序的佇列,A 服務不消費只發布,B 服務負責消費,A 服務中可以加一個空消費者,保證 A 服務啟動時該佇列一定存在,另一方面,消費者服務不應該關注佇列的定義,也不太應該建立佇列。

分組

透過配置 Group 屬性將多個消費者放到同一個連線通道中執行,對於那些併發量不高的佇列,複用連線通道可以降低資源消耗。

示例:

[Consumer("ConsumerWeb_group_1", Qos = 1, Group = "group")]
public class Group_1_Consumer : IConsumer<GroupEvent>
{
}

[Consumer("ConsumerWeb_group_2", Qos = 1, Group = "group")]
public class Group_2_Consumer : IConsumer<GroupEvent>
{
}

事件匯流排模式

Maomi.MQ 內部設計了一個事件匯流排,可以幫助開發者實現事件編排、實現本地事務、正向執行和補償。

首先定義一個事件型別,該事件繫結一個 topic 或佇列,事件需要使用 [EventTopic] 標識,並設定該事件對於的佇列名稱。

[EventTopic] 特性擁有與 [Consumer] 相同的特性,可參考 [Consumer] 的使用配置事件,請參考 消費者配置

[EventTopic("EventWeb")]
public class TestEvent
{
	public string Message { get; set; }

	public override string ToString()
	{
		return Message;
	}
}

然後編排事件執行器,每個執行器都需要繼承 IEventHandler<T> 介面,然後使用 [EventOrder] 特性標記執行順序。

[EventOrder(0)]
public class My1EventEventHandler : IEventHandler<TestEvent>
{
	public async Task CancelAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
	{
	}

	public async Task ExecuteAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
	{
		Console.WriteLine($"{@event.Id},事件 1 已被執行");
	}
}

[EventOrder(1)]
public class My2EventEventHandler : IEventHandler<TestEvent>
{
	public async Task CancelAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
	{
	}

	public async Task ExecuteAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
	{
		Console.WriteLine($"{@event.Id},事件 2 已被執行");
	}
}

每個事件執行器都必須實現 IEventHandler<T> 介面,並且設定 [EventOrder] 特性以便確認事件的執行順序,框架會按順序執行 IEventHandler<T>ExecuteAsync 方法,當 ExecuteAsync 出現異常時,則反向按順序呼叫 CancelAsync

由於程式可能隨時掛掉,因此透過 CancelAsync 實現補償是不太可能的,CancelAsync 主要作為記錄相關資訊而使用。

中介軟體

中介軟體的作用是便於開發者攔截事件、記錄資訊、實現本地事務等,如果開發者不配置,則框架會自動建立 DefaultEventMiddleware<TEvent> 型別作為該事件的中介軟體服務。

自定義事件中介軟體示例程式碼:

public class TestEventMiddleware : IEventMiddleware<TestEvent>
{
	public async Task HandleAsync(EventBody<TestEvent> @event, EventHandlerDelegate<TestEvent> next)
	{
		await next(@event, CancellationToken.None);
	}
}

next 委託是框架構建的事件執行鏈路,在中介軟體中可以攔截事件、決定是否執行事件鏈路。

在中介軟體中呼叫 next() 委託時,框架開始按順序執行事件,即前面提到的 My1EventEventHandlerMy2EventEventHandler

當一個事件有多個執行器時,由於程式可能會在任何時刻掛掉,因此本地事務必不可少。

例如,在中介軟體中注入資料庫上下文,然後啟動事務執行資料庫操作,當其中一個 EventHandler 執行失敗時,執行鏈路會回滾,同時不會提交事務。

可以參考 消費者模式 實現中介軟體的重試和補償方法。

示例如下:

public class TestEventMiddleware : IEventMiddleware<TestEvent>
{
    private readonly BloggingContext _bloggingContext;

    public TestEventMiddleware(BloggingContext bloggingContext)
    {
        _bloggingContext = bloggingContext;
    }

    public async Task HandleAsync(EventBody<TestEvent> @event, EventHandlerDelegate<TestEvent> next)
    {
        using (var transaction = _bloggingContext.Database.BeginTransaction())
        {
            await next(@event, CancellationToken.None);
            await transaction.CommitAsync();
        }
    }

    public Task FaildAsync(Exception ex, int retryCount, EventBody<TestEvent>? message)
    {
        return Task.CompletedTask;
    }

    public Task<bool> FallbackAsync(EventBody<TestEvent>? message)
    {
        return Task.FromResult(true);
    }
}

[EventOrder(0)]
public class My1EventEventHandler : IEventHandler<TestEvent>
{
    private readonly BloggingContext _bloggingContext;

    public My1EventEventHandler(BloggingContext bloggingContext)
    {
        _bloggingContext = bloggingContext;
    }

    public async Task CancelAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
    {
        Console.WriteLine($"{@event.Id} 被補償,[1]");
    }

    public async Task HandlerAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
    {
        await _bloggingContext.Posts.AddAsync(new Post
        {
            Title = "魯濱遜漂流記",
            Content = "隨便寫寫就對了"
        });
        await _bloggingContext.SaveChangesAsync();
    }
}

[EventOrder(1)]
public class My2EventEventHandler : IEventHandler<TestEvent>
{
    private readonly BloggingContext _bloggingContext;

    public My2EventEventHandler(BloggingContext bloggingContext)
    {
        _bloggingContext = bloggingContext;
    }
    public async Task CancelAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
    {
        Console.WriteLine($"{@event.Id} 被補償,[2]");
    }

    public async Task HandlerAsync(EventBody<TestEvent> @event, CancellationToken cancellationToken)
    {
        await _bloggingContext.Posts.AddAsync(new Post
        {
            Title = "紅樓夢",
            Content = "賈寶玉初試雲雨情"
        });
        await _bloggingContext.SaveChangesAsync();

        throw new OperationCanceledException("故意報錯");
    }
}

image-20240525155639461

事件執行時,如果出現異常,也是會被重試的,中介軟體 TestEventMiddleware 的 FaildAsync、FallbackAsync 會被依次執行。

你可以參考 消費者模式 或者 重試

分組消費

事件分組消費主要是利用同一個 IConnection 同時處理多個訊息佇列,提高通道利用率。

示例:

[EventTopic("EventGroup_1", Group = "aaa")]
public class Test1Event
{
	public string Message { get; set; }

	public override string ToString()
	{
		return Message;
	}
}

[EventTopic("EventGroup_2", Group = "aaa")]
public class Test2Event
{
	public string Message { get; set; }

	public override string ToString()
	{
		return Message;
	}
}

Maomi.MQ 的 IConsumer<T> 是一個消費者(一個佇列)使用一個 IConnection,預設情況下事件匯流排也是。

對於哪些併發量不大或利用率較低的佇列,可以透過事件分組將其合併到同一個 IConnection 中進行處理。

使用方法很簡單,只需要在定義事件時,配置 [EventTopic] 特性的 Group 方法即可。

由於不同佇列被放到一個 IConnection 中消費,如果事件都設定了 Qos,那麼框架會預設計算平均值,例如:

[EventTopic("web3_1", Group = "aaa", Qos = 10)]
public class Test1Event

[EventTopic("web3_2", Group = "aaa", Qos = 6)]
public class Test2Event

此時框架會設定 Qos 為 8

配置

在引入 Maomi.MQ 框架時,可以配置相關屬性,示例和說明如下:

// this.
builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
    // 當前程式節點,用於配置分散式雪花 id
	options.WorkId = 1;
    
    // 是否自動建立佇列
	options.AutoQueueDeclare = true;
    
    // 當前應用名稱,用於標識訊息的釋出者和消費者程式
	options.AppName = "myapp";
    
    // RabbitMQ 配置
	options.Rabbit = (ConnectionFactory options) =>
	{
		options.HostName = "192.168.3.248";
		options.ClientProvidedName = Assembly.GetExecutingAssembly().GetName().Name;
	};
}, [typeof(Program).Assembly]);  // 要被掃描的程式集

消費者配置

消費者模式 [Consumer] 和事件匯流排模式 [EventTopic] 具有相同的屬性配置,其配置說明如下:

名稱 型別 預設值 說明
Queue string 佇列名稱
DeadQueue string? 繫結死信佇列名稱
ExecptionRequeue bool true 出現異常時是否放回佇列,例如序列化錯誤等原因導致的,而不是消費時發生異常導致的
Expiration int 0 佇列訊息過期時間,單位毫秒
Qos ushort 1 Qos
RetryFaildRequeue bool false 消費失敗次數達到條件時,是否放回佇列
Group string? 分組名稱
AutoQueueDeclare AutoQueueDeclare AutoQueueDeclare.None 是否自動建立佇列

環境隔離

目前還在考慮要不要支援多租戶模式。

在開發中,往往需要在本地除錯,本地程式啟動後會連線到開發伺服器上,一個佇列收到訊息時,會向其中一個消費者推送訊息。那麼我本地除錯時,釋出一個訊息後,可能本地程式收不到該訊息,而是被開發環境中的程式消費掉了。

這個時候,我們希望可以將本地除錯環境跟開發環境隔離開來,可以使用 RabbitMQ 提供的 VirtualHost 功能。

首先透過 put 請求建立一個新的 VirtualHost,請參考文件:https://www.rabbitmq.com/docs/vhosts#using-http-api

image-20240612193415867

然後在程式碼中配置 VirtualHost:

builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
	options.WorkId = 1;
	options.AutoQueueDeclare = true;
	options.AppName = "myapp";
	options.Rabbit = (ConnectionFactory options) =>
	{
		options.HostName = "192.168.3.248";
#if DEBUG
		options.VirtualHost = "debug";
#endif
		options.ClientProvidedName = Assembly.GetExecutingAssembly().GetName().Name;
	};
}, [typeof(Program).Assembly]);

雪花 id 配置

Maomi.MQ.RabbitMQ 使用了 IdGenerator 生成雪花 id,使得每個事件在叢集中都有一個唯一 id。

框架透過 IIdFactory 介面建立雪花 id,你可以透過替換 IIdFactory 介面配置雪花 id 生成規則。

services.AddSingleton<IIdFactory>(new DefaultIdFactory((ushort)optionsBuilder.WorkId));

示例:

public class DefaultIdFactory : IIdFactory
{
    /// <summary>
    /// Initializes a new instance of the <see cref="DefaultIdFactory"/> class.
    /// </summary>
    /// <param name="workId"></param>
    public DefaultIdFactory(ushort workId)
    {
        var options = new IdGeneratorOptions(workId) { SeqBitLength = 10 };
        YitIdHelper.SetIdGenerator(options);
    }

    /// <inheritdoc />
    public long NextId() => YitIdHelper.NextId();
}

IdGenerator 框架生成雪花 id 配置請參考:

https://github.com/yitter/IdGenerator/tree/master/C%23

Qos 併發和順序

基於消費者模式和基於事件模式都是透過特性來配置消費屬性,Qos 是其中一個重要的屬性。

Qos 場景

對於消費者模式和事件匯流排模式,在沒有使用 Group 屬性配置消費行為時,每個佇列都會獨佔一個 IConnection 以及 Host service。

對於消費頻率很高但是不能併發的佇列,最好不要設定 Group 屬性,以及務必設定 Qos = 1。這樣依賴,該消費者會獨佔資源進行消費,在保證順序的情況下,獨佔資源有助於提高消費能力。

[Consumer("web1", Qos = 1)]
public class MyConsumer : IConsumer<TestEvent>
{
}

當需要需要提高消費吞吐量,而且不需要順序消費時,可以將 Qos 設定高一些,RabbitMQ Client 框架會透過預取等方式提高吞吐量,並且多條訊息可以併發消費。

如果判斷一些消費者的消費頻率不會很高時,可以將這些消費者放到一個分組中。

當多個消費者或事件配置共用一個分組時,那麼這些事件的 Qos 應當一致,否則按照平均值來算。

示例:

[Consumer("web1", Qos = 10, Group = "group")]
public class My1Consumer : IConsumer<TestEvent>
{
}

[Consumer("web2", Qos = 6, Group = "group")]
public class My2Consumer : IConsumer<TestEvent>
{
}

由於兩個消費者使用相同的分組,因此複用通道的 Qos 會被設定為 8。

如果消費頻率不高,但是需要順序消費時,可以將這些消費者放到同一個分組中,並且 Qos 設定為 1。

[Consumer("web1", Qos = 1, Group = "group1")]
public class My1Consumer : IConsumer<TestEvent>
{
}

[Consumer("web2", Qos = 1, Group = "group1")]
public class My2Consumer : IConsumer<TestEvent>
{
}

併發和異常處理

第一次情況,Qos 為 1 時,不設定 ExecptionRequeue 、RetryFaildRequeue。

第二種情況,Qos 為 1 時,設定 ExecptionRequeue 、RetryFaildRequeue。

Qos 為 1 時,會保證嚴格順序消費,ExecptionRequeue 、RetryFaildRequeue 會影響失敗的訊息是否會被放回佇列,如果放回佇列,下一次消費會繼續消費之前失敗的訊息。如果錯誤(如 bug)得不到解決,則會出現消費、失敗、放回佇列、重新消費這樣的迴圈。

第三次情況,Qos > 1 時,不設定 ExecptionRequeue 、RetryFaildRequeue。

第四種情況,Qos > 1 時,設定 ExecptionRequeue 、RetryFaildRequeue。

當 Qos 大於 1 時,如果設定了 RetryFaildRequeue = true,那麼消費失敗的訊息會被放回佇列中,但是不一定下一次會立即重新消費該條訊息。

重試

重試時間

當消費者 ExecuteAsync 方法異常時,框架會進行重試,預設會重試五次,按照 2 作為指數設定重試時間間隔。

第一次失敗後,間隔 2 秒重試,第二次失敗後,間隔 4 秒,接著分別是 8、16、32 秒。

Maomi.MQ.RabbitMQ 使用了 Polly 框架做重試策略管理器,預設透過 DefaultRetryPolicyFactory 服務生成重試間隔策略。

DefaultRetryPolicyFactory 程式碼示例如下:

/// <summary>
/// Default retry policy.<br />
/// 預設的策略提供器.
/// </summary>
public class DefaultRetryPolicyFactory : IRetryPolicyFactory
{
    /// <inheritdoc/>
    public virtual Task<AsyncRetryPolicy> CreatePolicy(string queue, long id)
    {
        // Create a retry policy.
        // 建立重試策略.
        var retryPolicy = Policy
            .Handle<Exception>()
            .WaitAndRetryAsync(
                retryCount: 5,
                sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
                onRetry: async (exception, timeSpan, retryCount, context) =>
                {
                    _logger.LogDebug("Retry execution event,queue [{Queue}],retry count [{RetryCount}],timespan [{TimeSpan}]", queue, retryCount, timeSpan);
                    await FaildAsync(queue, exception, timeSpan, retryCount, context);
                });

        return Task.FromResult(retryPolicy);
    }

    
    public virtual Task FaildAsync(string queue, Exception ex, TimeSpan timeSpan, int retryCount, Context context)
    {
        return Task.CompletedTask;
    }
}

你可以透過實現 IRetryPolicyFactory 介面,替換預設的重試策略服務服務。

services.AddSingleton<IRetryPolicyFactory, DefaultRetryPolicyFactory>();

重試機制

設定消費者程式碼如下:

    [Consumer("web1", Qos = 1 , RetryFaildRequeue = true)]
    public class MyConsumer : IConsumer<TestEvent>
    {
        private  int _retryCount = 0;
        // 消費
        public async Task ExecuteAsync(EventBody<TestEvent> message)
        {
            Console.WriteLine($"執行 {message.Body.Id} 第幾次:{_retryCount} {DateTime.Now}");
            _retryCount++;
            throw new Exception("1");
        }

        // 每次失敗時被執行
        public async Task FaildAsync(Exception ex, int retryCount, EventBody<TestEvent>? message)
        {
            Console.WriteLine($"重試 {message.Body.Id} 第幾次:{retryCount} {DateTime.Now}");
            await Task.CompletedTask;
        }


        // 最後一次失敗時執行
        public async Task<bool> FallbackAsync(EventBody<TestEvent>? message)
        {
            Console.WriteLine($"執行 {message.Body.Id} 補償 {DateTime.Now}");
            return true;
        }
    }
}

retry

首先會執行 IConsumer<TEvent>.ExecuteAsync()IEventMiddleware<TEvent>.ExecuteAsync() 消費訊息,此時 ExecuteAsync() 執行失敗,立即觸發 FaildAsync() 函式。

然後等待一段時間間隔後,接著會重新執行 ExecuteAsync() 方法。

比如預設重試機制是重試五次,那麼最終 IConsumer<TEvent>.ExecuteAsync()IEventMiddleware<TEvent>.ExecuteAsync() 都會被執行 6次,一次正常消費和五次重試消費。

FallbackAsync() 方法會在最後一次重試失敗後被呼叫,該函式要返回一個 bool 型別。

當多次重試失敗後,框架會呼叫 FallbackAsync 方法,如果該方法放回 true,那麼框架會認為雖然 ExecuteAsync() 執行失敗,但是透過 FallbackAsync() 已經補償好了,該訊息會被當做正常完成消費,框架會向 RabbitMQ 伺服器傳送 ACK,接著消費下一條訊息。

如果 FallbackAsync() 返回 true,框架會認為該訊息徹底失敗,如果設定了 RetryFaildRequeue = true,那麼該條訊息會被放回訊息佇列,等待下一次消費。否則該條訊息會被直接丟棄。

持久化剩餘重試次數

當消費者處理訊息失敗時,預設消費者會重試 5 次,如果已經重試了 3 次,此時程式重啟,那麼下一次消費該訊息時,依然是繼續重試五次。

需要記憶重試次數,在程式重啟時,能夠按照剩餘次數進行重試。

引入 Maomi.MQ.RedisRetry 包。

配置示例:

builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
	options.WorkId = 1;
	options.AutoQueueDeclare = true;
	options.AppName = "myapp";
	options.Rabbit = (ConnectionFactory options) =>
	{
		options.HostName = "192.168.3.248";
		options.ClientProvidedName = Assembly.GetExecutingAssembly().GetName().Name;
	};
}, [typeof(Program).Assembly]);

builder.Services.AddMaomiMQRedisRetry((s) =>
{
	ConnectionMultiplexer redis = ConnectionMultiplexer.Connect("192.168.3.248");
	IDatabase db = redis.GetDatabase();
	return db;
});

預設 key 只會保留 5 分鐘。也就是說,如果五分鐘之後程式才重新消費該訊息,那麼就會剩餘重試次數就會重置。

死信佇列

死信佇列

可以給一個消費者或事件繫結死信佇列,當該佇列的訊息失敗後並且不會放回佇列時,該訊息會被推送到死信佇列中,示例:

[Consumer("ConsumerWeb_dead", Qos = 1, DeadQueue = "ConsumerWeb_dead_queue", RetryFaildRequeue = false)]
public class DeadConsumer : IConsumer<DeadEvent>
{
	// 消費
	public Task ExecuteAsync(EventBody<DeadEvent> message)
	{
		Console.WriteLine($"事件 id:{message.Id}");
		throw new OperationCanceledException();
	}

	// 每次失敗時被執行
	public Task FaildAsync(Exception ex, int retryCount, EventBody<DeadEvent>? message) => Task.CompletedTask;

	// 最後一次失敗時執行
	public Task<bool> FallbackAsync(EventBody<DeadEvent>? message) => Task.FromResult(false);
}

// ConsumerWeb_dead 消費失敗的訊息會被此消費者消費。
[Consumer("ConsumerWeb_dead_queue", Qos = 1)]
public class DeadQueueConsumer : IConsumer<DeadQueueEvent>
{
	// 消費
	public Task ExecuteAsync(EventBody<DeadQueueEvent> message)
	{
		Console.WriteLine($"死信佇列,事件 id:{message.Id}");
		return Task.CompletedTask;
	}

	// 每次失敗時被執行
	public Task FaildAsync(Exception ex, int retryCount, EventBody<DeadQueueEvent>? message) => Task.CompletedTask;

	// 最後一次失敗時執行
	public Task<bool> FallbackAsync(EventBody<DeadQueueEvent>? message) => Task.FromResult(false);
}

image-20240601012127169

如果使用死信佇列,則務必將 RetryFaildRequeue 設定為 false,那麼消費者會在重試多次失敗後,向 RabbitMQ 傳送 nack 訊號,RabbitMQ 就會將該訊息轉發到繫結的死信佇列中。

延遲佇列

建立一個消費者,繼承 EmptyConsumer,那麼該佇列會在程式啟動時被建立,但是不會建立 IConnection 進行消費。然後設定佇列訊息過期時間以及繫結死信佇列,繫結的死信佇列既可以使用消費者模式實現,也可以使用事件模式實現。

[Consumer("ConsumerWeb_dead_2", Expiration = 6000, DeadQueue = "ConsumerWeb_dead_queue_2")]
public class EmptyDeadConsumer : EmptyConsumer<DeadEvent>
{
}

// ConsumerWeb_dead 消費失敗的訊息會被此消費者消費。
[Consumer("ConsumerWeb_dead_queue_2", Qos = 1)]
public class Dead_2_QueueConsumer : IConsumer<DeadQueueEvent>
{
    // 消費
    public Task ExecuteAsync(EventBody<DeadQueueEvent> message)
    {
        Console.WriteLine($"事件 id:{message.Id} 已到期");
        return Task.CompletedTask;
    }

    // 每次失敗時被執行
    public Task FaildAsync(Exception ex, int retryCount, EventBody<DeadQueueEvent>? message) => Task.CompletedTask;

    // 最後一次失敗時執行
    public Task<bool> FallbackAsync(EventBody<DeadQueueEvent>? message) => Task.FromResult(false);
}

例如,使用者下單之後,如果 15 分鐘之內沒有付款,那麼訊息到期時,自動取消訂單。

可觀測性

功能還在繼續完善中。請參考 ActivitySourceApi 示例。

為了快速部署可觀測性平臺,可以使用 OpenTelemetry 官方提供的示例包快速部署相關的服務。

下載示例倉庫原始碼:

git clone https://github.com/open-telemetry/opentelemetry-demo.git

由於示例中會包含大量的 demo 微服務,因此我們需要開啟 docker-compose.yml 檔案,將 services 節點的 Core Demo ServicesDependent Services 服務直接刪除,只保留可觀測性元件。或者直接點選下載筆者已經修改好的版本: docker-compose.yml

image-20240612200711787

執行命令部署可觀測性服務:

docker-compose up -d

image-20240612201100976

opentelemetry-collector-contrib 用於收集鏈路追蹤的可觀測性資訊,有 grpc 和 http 兩種,監聽埠如下:

Port Protocol Endpoint Function
4317 gRPC n/a Accepts traces in OpenTelemetry OTLP format  (Protobuf).
4318 HTTP /v1/traces Accepts traces in OpenTelemetry OTLP format  (Protobuf and JSON).

經過容器埠對映後,對外埠可能不是 4317、4318 了。

1718196602032.png

引入 Maomi.MQ.Instrumentation 包,以及其它相關 OpenTelemetry 包。

<PackageReference Include="Maomi.MQ.Instrumentation " Version="1.1.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.8.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.8.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.8.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.8.1" />

然後注入服務:

const string serviceName = "myapp";

builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
	options.WorkId = 1;
	options.AutoQueueDeclare = true;
	options.AppName = serviceName;
	options.Rabbit = (ConnectionFactory options) =>
	{
		options.HostName = "192.168.3.248";
		options.ClientProvidedName = Assembly.GetExecutingAssembly().GetName().Name;
	};
}, [typeof(Program).Assembly]);

builder.Services.AddOpenTelemetry()
	  .ConfigureResource(resource => resource.AddService(serviceName))
	  .WithTracing(tracing =>
	  {
		  tracing.AddMaomiMQInstrumentation(options =>
		  {
              options.Sources.AddRange(MaomiMQDiagnostic.Sources);
			  options.RecordException = true;
		  })
		  .AddAspNetCoreInstrumentation()
		  .AddOtlpExporter(options =>
		  {
			  options.Endpoint = new Uri("http://127.0.0.1:32772/v1/traces");
			  options.Protocol = OtlpExportProtocol.HttpProtobuf;
		  });
	  });

啟動服務後,進行釋出、消費,鏈路追蹤資訊會被自動推送到 OpenTelemetry Collector 中,透過 Jaeger 、Skywalking 等元件可以讀取出來。

image-20240612205140595

由於 publish、consumer 屬於兄弟 trace 而不是同一個 trace,因此需要透過 Tags 查詢相關聯的 trace,格式 event.id=xxx

1718196773292

3662d0c35aaac72c77046a430988e87

相關文章