Maomi.MQ 2.0 | 功能强大的 .NET 消息队列通讯模型框架

说明

作者:痴者工良

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

仓库地址:https://github.com/whuanle/Maomi.MQ

作者博客:

导读

Maomi.MQ 是一个简化了消息队列使用方式的通讯框架,目前支持了 RabbitMQ。

Maomi.MQ.RabbitMQ 是一个用于专为 RabbitMQ 设计的发布者和消费者通讯模型,大大简化了发布和消息的代码,并提供一系列简便和实用的功能,开发者可以通过框架提供的消费模型实现高性能消费、事件编排,框架还支持发布者确认机制、自定义重试机制、补偿机制、死信队列、延迟队列、连接通道复用等一系列的便利功能。开发者可以把更多的精力放到业务逻辑中,通过 Maomi.MQ.RabbitMQ 框架简化跨进程消息通讯模式,使得跨进程消息传递更加简单和可靠。

此外,框架通过 runtime 内置的 api 支持了分布式可观测性,可以通过进一步使用 OpenTelemetry 等框架进一步收集可观测性信息,推送到基础设施平台中。

目录

快速开始

在本篇教程中,将介绍 Maomi.MQ.RabbitMQ 的使用方法,以便读者能够快速了解该框架的使用方式和特点。

创建一个 Web 项目(可参考 WebDemo 项目),引入 Maomi.MQ.RabbitMQ 包,在 Web 配置中注入服务:

csharp 复制代码
// using Maomi.MQ;
// using RabbitMQ.Client;

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

var app = builder.Build();
  • WorkId: 指定用于生成分布式雪花 id 的节点 id,默认为 0。

    每条消息生成一个唯一的 id,便于追踪。如果不设置雪花id,在分布式服务中,多实例并行工作时,可能会产生相同的 id。

  • AppName:用于标识消息的生产者,以及在日志和链路追踪中标识消息的生产者或消费者。

  • Rabbit:RabbitMQ 客户端配置,请参考 ConnectionFactory

定义消息模型类,模型类是 MQ 通讯的消息基础,该模型类将会被序列化为二进制内容传递到 RabbitMQ 服务器中。

csharp 复制代码
public class TestEvent
{
    public int Id { get; set; }

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

定义消费者,消费者需要实现 IConsumer<TEvent> 接口,以及使用 [Consumer] 特性注解配置消费者属性,如下所示,[Consumer("test")] 表示该消费者订阅的队列名称是 test

IConsumer<TEvent> 接口有三个方法,ExecuteAsync 方法用于处理消息,FaildAsync 会在 ExecuteAsync 异常时立即执行,如果代码一直异常,最终会调用 FallbackAsync 方法,Maomi.MQ 框架会根据 ConsumerState 值确定是否将消息放回队列重新消费,或者做其它处理动作。

csharp 复制代码
[Consumer("test")]
public class MyConsumer : IConsumer<TestEvent>
{
    // 消费
    public async Task ExecuteAsync(MessageHeader messageHeader, TestEvent message)
    {
        Console.WriteLine($"事件 id: {message.Id} {DateTime.Now}");
        await Task.CompletedTask;
    }

    // 每次消费失败时执行
    public Task FaildAsync(MessageHeader messageHeader, Exception ex, int retryCount, TestEvent message) 
        => Task.CompletedTask;

    // 补偿
    public Task<ConsumerState> FallbackAsync(MessageHeader messageHeader, TestEvent? message, Exception? ex) 
        => Task.FromResult( ConsumerState.Ack);
}

Maomi.MQ 还具有多种消费者模式,代码写法不一样,后续会详细讲解不同的消费者模式。

如果要发布消息,只需要注入 IMessagePublisher 服务即可。

csharp 复制代码
[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(exchange: string.Empty, routingKey: "test", message: new TestEvent
        {
            Id = 123
        });
        return "ok";
    }
}

启动 Web 服务,在 swagger 页面上请求 API 接口,MyConsumer 服务会立即接收到发布的消息。

如果是控制台项目,则需要引入 Microsoft.Extensions.Hosting 包,以便让消费者在后台订阅队列消费消息。

参考 ConsoleDemo 项目。

csharp 复制代码
using Maomi.MQ;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using RabbitMQ.Client;
using System.Reflection;

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 = Environment.GetEnvironmentVariable("RABBITMQ")!;
                options.Port = 5672;
                options.ClientProvidedName = Assembly.GetExecutingAssembly().GetName().Name;
            };
        }, new System.Reflection.Assembly[] { typeof(Program).Assembly });

    }).Build();

// 后台运行
var task =  host.RunAsync();

Console.ReadLine();

消息发布者

消息发布者用于推送消息到 RabbitMQ 服务器中,Maomi.MQ 支持多种消息发布者模式,支持 RabbitMQ 事务模式等,示例项目请参考 PublisherWeb

Maomi.MQ 通过 IMessagePublisher 向开发者提供消息推送服务。

在发布消息之前,需要定义一个事件模型类,用于传递消息。

csharp 复制代码
public class TestEvent
{
	public int Id { get; set; }

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

然后注入 IMessagePublisher 服务,发布消息:

csharp 复制代码
[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(exchange: string.Empty, routingKey: "publish", message: new TestEvent
            {
                Id = i
            });
        }

        return "ok";
    }
}

一般情况下,一个模型类只应该被一个消费者所使用,那么通过事件可以找到唯一的消费者,也就是通过事件类型找到消费者的 IConsumerOptions,此时框架可以使用对应的配置发送消息。

TestMessageEvent 模型只有一个消费者:

csharp 复制代码
[Consumer("publish", Qos = 1, RetryFaildRequeue = true)]
public class TestEventConsumer : IConsumer<TestMessageEvent>
{
	// ... ...
}

可以直接发送事件,不需要填写交换器(Exchange)和路由键(RoutingKey)。

csharp 复制代码
[HttpGet("publish_message")]
public async Task<string> PublisherMessage()
{
	// 如果在本项目中 TestMessageEvent 只指定了一个消费者,那么通过 TestMessageEvent 自动寻找对应的配置
	for (var i = 0; i < 100; i++)
	{
		await _messagePublisher.PublishAsync(model: new TestMessageEvent
		{
			Id = i
		});
	}

	return "ok";
}

IMessagePublisher

IMessagePublisher 是 Maomi.MQ 的基础消息发布接口,有以下方法:

csharp 复制代码
// 消息发布者.
public interface IMessagePublisher
{
    Task PublishAsync<TMessage>(string exchange,    // 交换器名称.
                                string routingKey,  // 队列/路由键名称.
                                TMessage message,   // 事件对象.
                                Action<BasicProperties> properties, 
                                CancellationToken cancellationToken = default)
        where TMessage : class;

    Task PublishAsync<TMessage>(string exchange, 
                                string routingKey, 
                                TMessage message, 
                                BasicProperties? properties = default, 
                                CancellationToken cancellationToken = default);

    Task PublishAsync<TMessage>(TMessage message, 
                                Action<BasicProperties>? properties = null, 
                                CancellationToken cancellationToken = default)
        where TMessage : class;

    Task PublishAsync<TMessage>(TMessage model, 
                                BasicProperties? properties = default, 
                                CancellationToken cancellationToken = default);
    
    Task CustomPublishAsync<TMessage>(string exchange, 
                                      string routingKey, 
                                      TMessage message, 
                                      BasicProperties? properties = default, 
                                      CancellationToken cancellationToken = default);
}

Maomi.MQ 的消息发布接口就这么几个,由于直接公开了 BasicProperties ,因此开发者完全自由配置 RabbitMQ 原生的消息属性,所以接口比较简单,开发者使用接口时可以灵活一些,使用难度也不大。

BasicProperties 是 RabbitMQ 中的消息基础属性对象,直接面向开发者,可以消息的发布和消费变得灵活和丰富功能,例如,可以通过 BasicProperties 配置单条消息的过期时间:

csharp 复制代码
await _messagePublisher.PublishAsync(exchange: string.Empty, routingKey: "publish", message: new TestEvent
{
	Id = i
}, (BasicProperties p) =>
{
	p.Expiration = "1000";
});

Maomi.MQ 通过 DefaultMessagePublisher 类型实现了 IMessagePublisher,DefaultMessagePublisher 默认生命周期是 Scoped:

csharp 复制代码
services.AddScoped<IMessagePublisher, DefaultMessagePublisher>();

开发者也可以自行实现 IMessagePublisher 接口,实现自己的消息发布模型,具体示例请参考 DefaultMessagePublisher 类型。

原生通道

开发者可以通过 ConnectionPool 服务获取原生连接对象,直接在 IConnection 上使用 RabbitMQ 的接口发布消息:

csharp 复制代码
private readonly ConnectionPool _connectionPool;

var connectionObject = _connectionPool.Get();
connectionObject.DefaultChannel.BasicPublishAsync(... ...);

常驻内存连接对象

Maomi.MQ 通过 ConnectionPool 管理 RabbitMQ 连接对象,注入 ConnectionPool 服务后,通过 .Get() 接口获取全局默认连接实例。

如果开发者有自己的需求,也可以通过 .Create() 接口创建新的连接对象。

csharp 复制代码
using var newConnectionObject = _connectionPool.Create();
using var newConnection = newConnectionObject.Connection;
using var newChannel = newConnection.CreateChannelAsync();

请务必妥善使用连接对象,不要频繁创建和释放,也不要忘记了管理生命周期,否则容易导致内存泄漏。

单个 IConnectionn 即可满足大多数场景下的使用,吞吐量足够用了,笔者经过了多次长时间的测试,发现一个 IConnection 即可满足需求,多个 IConnection 并不会带来任何优势,因此去掉了旧版本的连接池,现在默认全局只会存在一个 IConnection,但是不同的消费者使用 IChannel 来隔离。

程序只维持一个 IConnection 时,四个发布者同时发布消息,每秒速度如下:

如果消息内容非常大时,单个 IConnection 也足够应付,取决于带宽。

每条消息 478 KiB。

消息过期

IMessagePublisher 对外开放了 BasicProperties,开发者可以自由配置消息属性。

例如为消息配置过期时间:

csharp 复制代码
[HttpGet("publish")]
public async Task<string> Publisher()
{
	for (var i = 0; i < 1; i++)
	{
		await _messagePublisher.PublishAsync(exchange: string.Empty, routingKey: "publish", message: new TestEvent
		{
			Id = i
		}, properties =>
		{
			properties.Expiration = "6000";
		});
	}

	return "ok";
}

为该消息设置过期时间后,如果队列绑定了死信队列,那么该消息长时间没有被消费时,会被移动到另一个队列,请参考 死信队列

还可以通过配置消息属性实现更多的功能,请参考 IBasicProperties 文档。

事务

RabbitMQ 原生支持事务模型,RabbitMQ 的事务通讯协议可以参考 https://www.rabbitmq.com/docs/semantics

据 RabbitMQ 官方文档显示,事务模式会使吞吐量减少 250 倍,这个主要跟事务机制有关,事务模式不仅仅要保证消息已经推送到 Rabbit broker,还要保证 Rabbit broker 多节点分区同步,在 Rabbit broker 挂掉的情况下消息已被完整同步。不过一般可能用不上这么严格的模式,所以也可以使用下一小节提到的发送方确认机制。

Maomi.MQ 的事务接口使用上比较简单,可以使用扩展方法直接开启一个 ITransactionPublisher,事务接口使用上也比较简洁,示例如下:

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

	try
	{
		await tranPublisher.PublishAsync(exchange: string.Empty, routingKey: "publish", message: new TestEvent
		{
			Id = 666
		});
		await Task.Delay(5000);
		await tranPublisher.TxCommitAsync();
	}
	catch
	{
		await tranPublisher.TxRollbackAsync();
		throw;
	}

	return "ok";
}

发送方确认模式

事务模式可以保证消息会被推送到 RabbitMQ 服务器中,并在个节点中已完成同步,但是由于事务模式会导致吞吐量降低 250 倍,因此 RabbitMQ 引入了一种确认机制,这种机制就像滑动窗口,能够保证消息推送到服务器中,并且具备高性能的特性,其吞吐量是事务模式 100 倍,参考资料:

https://www.rabbitmq.com/blog/2011/02/10/introducing-publisher-confirms

https://www.rabbitmq.com/docs/confirms

不过 .NET RabbitMQClient 库的新版本已经去掉了一些 API 接口,改动信息详细参考:Issue #1682RabbitMQ tutorial - Reliable Publishing with Publisher Confirms

Maomi.MQ 根据新版本做了简化调整,具体用法是通过创建使用独立通道的消息发布者,然后在参数中指定 IChannel 属性。

csharp 复制代码
using var confirmPublisher = _messagePublisher.CreateSingle(
	new CreateChannelOptions(publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true));

for (var i = 0; i < 5; i++)
{
	await confirmPublisher.PublishAsync(exchange: string.Empty, routingKey: "publish", message: new TestEvent
	{
		Id = 666
	});
}

事务模式和确认机制模式发布者是相互隔离的,创建这两者对象时都会默认自动使用新的 IChannel,因此不需要担心冲突。

如果开发者有自己的需求,也可以 CreateSingle 创建独立的 IChannel,然后自定义 CreateChannelOptions 属性,关于 CreateChannelOptions 的描述,请参考:

https://rabbitmq.github.io/rabbitmq-dotnet-client/api/RabbitMQ.Client.CreateChannelOptions.html

广播模式

广播模式是用于将一条消息推送到交换器,然后绑定的多个队列都可以收到相同的消息,简单来说该模式是向交换器推送消息,然后交换器将消息转发到各个绑定的队列中,这样一来不同队列的消费者可以同时收到消息。

RabbitMQ 中,交换器大约有四种模式,在 RabbitMQ 中有常量定义:

csharp 复制代码
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 };
}

但是不同的交换器模式使用上不一样,下面笔者 fanout 为例,当队列绑定到 fanout 类型的交换器后,Rabbit broker 会忽略 RoutingKey,将消息推送到所有绑定的队列中。

所以我们定义两个消费者,绑定到一个相同的 fanout 类型的交换器:

csharp 复制代码
[Consumer("fanout_1", BindExchange = "fanouttest", ExchangeType = "fanout")]
public class FanoutEvent_1_Consumer : IConsumer<FanoutEvent>
{
    // 消费
    public virtual async Task ExecuteAsync(MessageHeader messageHeader, FanoutEvent message)
    {
        Console.WriteLine($"【fanout_1】,事件 id: {message.Id} {DateTime.Now}");
        await Task.CompletedTask;
    }
    
    // ... ...
}

[Consumer("fanout_2", BindExchange = "fanouttest", ExchangeType = "fanout")]
public class FanoutEvent_2_Consumer : IConsumer<FanoutEvent>
{
    // 消费
    public virtual async Task ExecuteAsync(MessageHeader messageHeader, FanoutEvent message)
    {
        Console.WriteLine($"【fanout_2】,事件 id: {message.Id} {DateTime.Now}");
        await Task.CompletedTask;
    }
    
    // ... ...
}

发布消息时,只需要配置交换器名称即可,两个消费者服务都会同时收到消息:

csharp 复制代码
[HttpGet("publish_fanout")]
public async Task<string> Publisher_Fanout()
{
	for (var i = 0; i < 5; i++)
	{
		await _messagePublisher.PublishAsync(exchange: "fanouttest", routingKey: string.Empty, message: new FanoutEvent
		{
			Id = 666
		});
	}

	return "ok";
}

对于 Topic 类型的交换器和队列,使用方式也是一致的,定义两个消费者:

csharp 复制代码
[Consumer("red.yellow.#", BindExchange = "topictest", ExchangeType = "topic")]
public class TopicEvent_1_Consumer : IConsumer<TopicEvent>
{
    // 消费
    public virtual async Task ExecuteAsync(MessageHeader messageHeader, TopicEvent message)
    {
        Console.WriteLine($"【red.yellow.#】,事件 id: {message.Id} {DateTime.Now}");
        await Task.CompletedTask;
    }
    
    // ... ...
}

[Consumer("red.#", BindExchange = "topictest", ExchangeType = "topic")]
public class TopicEvent_2_Consumer : IConsumer<TopicEvent>
{
    // 消费
    public virtual async Task ExecuteAsync(MessageHeader messageHeader, TopicEvent message)
    {
        Console.WriteLine($"【red.#】,事件 id: {message.Id} {DateTime.Now}");
        await Task.CompletedTask;
    }
    
    // ... ...
}

发布消息:

csharp 复制代码
[HttpGet("publish_topic")]
public async Task<string> Publisher_Topic()
{
	for (var i = 0; i < 5; i++)
	{
		await _messagePublisher.PublishAsync(exchange: "topictest", routingKey: "red.a", message: new TopicEvent
		{
			Id = 666
		});
		await _messagePublisher.PublishAsync(exchange: "topictest", routingKey: "red.yellow.a", message: new TopicEvent
		{
			Id = 666
		});
	}

	return "ok";
}

不可路由消息

当发布消息时,如果该消息不可路由,即找不对应的队列等情况,那么将会触发 IBreakdown.BasicReturnAsync 接口,BasicReturnEventArgs 属性有详细的错误原因。

对于网络故障、RabbitMQ 服务挂了、没有对应交换器名称等失败等情况,则会在当前线程上出现异常,并且 TCP 连接会自动重新连接。

需要注意 RabbitMQ 的机制,推送消息并不是同步发生的,因此即使推送失败,也不会在当前线程中出现异常,所以不能判断当前消息是否成功推送。

对于不可路由的消息,Maomi.MQ 只提供了简单的接口通知,没有其它处理机制,所以开发者需要自行处理,社区中有一款 MQ 通讯框架叫 EasyNetQ,它的默认机制是自动创建新的队列,将当前不可路由的队列推送到新的队列中,以便持久化保存。

开发者可以实现该接口,然后注册为到容器:

csharp 复制代码
services.AddScoped<IBreakdown, MyDefaultBreakdown>();

例如将不可路由的消息推送到新的队列中:

csharp 复制代码
public class MyDefaultBreakdown : IBreakdown
{
    private readonly ConnectionPool _connectionPool;

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

    /// <inheritdoc />
    public async Task BasicReturnAsync(object sender, BasicReturnEventArgs @event)
    {
        var connectionObject = _connectionPool.Get();
        await connectionObject.DefaultChannel.BasicPublishAsync<BasicProperties>(
            @event.Exchange, 
            @event.RoutingKey + ".faild", 
            true, 
            new BasicProperties(@event.BasicProperties), 
            @event.Body);
    }

    /// <inheritdoc />
    public Task NotFoundConsumerAsync(string queue, Type messageType, Type consumerType)
    {
        return Task.CompletedTask;
    }
}

其实对于这种不可路由消息的情况,不单单只是转发存储,要检查是否误删队列、发布消息时队列名称是否一致等。

消费者

Maomi.MQ.RabbitMQ 中,有三种消费模式,分别是消费者模式、事件模式(事件总线模式)、动态消费者模式,其中动态消费者模式也支持了多种消费模式。

下面简单介绍这三种模式的使用方法,后面会更加详细地介绍。

消费者模式

消费者服务需要实现 IConsumer<TEvent> 接口,并且配置 [Consumer("queue")] 特性绑定队列名称,通过消费者对象来控制消费行为,消费者模式有具有失败通知和补偿能力,使用上也比较简单。

在运行时可以修改配置 [ConsumerAttribute]

csharp 复制代码
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(MessageHeader messageHeader, TestEvent message)
    {
        _retryCount++;
        Console.WriteLine($"执行次数:{_retryCount} 事件 id: {message.Id} {DateTime.Now}");
        await Task.CompletedTask;
    }

    // 每次消费失败时执行
    public Task FaildAsync(MessageHeader messageHeader, Exception ex, int retryCount, TestEvent message)
        => Task.CompletedTask;

    // 补偿
    public Task<ConsumerState> FallbackAsync(MessageHeader messageHeader, TestEvent? message, Exception? ex)
        => Task.FromResult(ConsumerState.Ack);
}

事件模式

事件模式是通过事件总线的方式实现的,以事件模型为中心,通过事件来控制消费行为。

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

然后使用 [EventOrder] 特性编排事件执行顺序。

csharp 复制代码
// 编排事件消费顺序
[EventOrder(0)]
public class My1EventEventHandler : IEventHandler<TestEvent>
{
	public async Task CancelAsync(TestEvent @event, CancellationToken cancellationToken)
	{
	}

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

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

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

当然,事件模式也可以通过创建中间件增加补偿功能,通过中间件还可以将所有排序事件放到同一个事务中,一起成功或失败,避免事件执行时出现程序退出导致的一致性问题。

csharp 复制代码
public class TestEventMiddleware : IEventMiddleware<TestEvent>
{
    private readonly BloggingContext _bloggingContext;

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

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

    public Task FaildAsync(MessageHeader messageHeader, Exception ex, int retryCount, TMessage? message)
    {
        return Task.CompletedTask;
    }

    public Task<ConsumerState> FallbackAsync(MessageHeader messageHeader, TMessage? message, Exception? ex)
    {
        return Task.FromResult(true);
    }
}

消费者模式和事件总线模式都可以应对大容量的消息,如下图所示,每个消息接近 500kb,多个队列并发拉取消费。

如果消息内容不大,则可以达到很高的消费速度。

动态消费者

动态消费者可以在运行期间动态订阅队列,并且支持消费者类型、事件总线类型、函数绑定三种方式

注入 IDynamicConsumer 即可使用动态消费者服务。

csharp 复制代码
await _dynamicConsumer.ConsumerAsync<MyConsumer, TestEvent>(new ConsumerOptions("myqueue")
{
	Qos = 10
});
csharp 复制代码
// 自动事件模型对应消费者
await _dynamicConsumer.ConsumerAsync<TestEvent>(new ConsumerOptions("myqueue")
{
	Qos = 10
});
csharp 复制代码
// 函数方式消费
_dynamicConsumer.ConsumerAsync<TestEvent>(new ConsumerOptions("myqueue")
{
	Qos = 10
}, async (header, message) =>
{
	Console.WriteLine($"事件 id: {message.Id} {DateTime.Now}");
	await Task.CompletedTask;
});

消费者注册模式

Maomi.MQ 提供了 ITypeFilter 接口,开发者可以使用该接口实现自定义消费者注册模式。

Maomi.MQ 内置三个 ITypeFilter,分别是:

  • 消费者模式 ConsumerTypeFilter
  • 事件总线模式 EventBusTypeFilter
  • 自定义消费者模式 ConsumerTypeFilter

框架默认注册 ConsumerTypeFilter、EventBusTypeFilter 两种模式,开发者可以自行调整决定使用哪种模式。

csharp 复制代码
var consumerTypeFilter = new ConsumerTypeFilter();
// ...
builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
    // ... ...
}, 
[typeof(Program).Assembly], 	// 要自动扫描的程序集
[new ConsumerTypeFilter(), new EventBusTypeFilter(), consumerTypeFilter]); 	// 配置要使用的消费者注册模式

消费者模式

消费者模式要求服务实现 IConsumer<TEvent> 接口,消费者服务的注册方式有三种。

  • 添加 [Connsumer] 特性,程序启动时自动扫描注入,可以动态修改 [Connsumer]
  • 不设置 [Connsumer] ,使用 CustomConsumerTypeFilter 手动设置消费者服务和配置。
  • 在运行时使用 IDynamicConsumer 动态绑定消费者。

本篇示例可参考 ConsumerWeb 项目。

IConsumer<TEvent> 接口比较简单,其定义如下:

csharp 复制代码
public interface IConsumer<TMessage>
    where TMessage : class
{
    public Task ExecuteAsync(MessageHeader messageHeader, TMessage message);

    public Task FaildAsync(MessageHeader messageHeader, Exception ex, int retryCount, TMessage message);

    public Task<ConsumerState> FallbackAsync(MessageHeader messageHeader, TMessage? message, Exception? ex);
}

使用消费者模式时,需要先定义一个模型类,用于发布者和消费者之间传递消息,事件模型类只要是类即可,能够正常序列化和反序列化,没有其它要求。

csharp 复制代码
public class TestEvent
{
	public int Id { get; set; }

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

然后继承 IConsumer<TEvent> 接口实现消费者功能:

csharp 复制代码
[Consumer("ConsumerWeb", Qos = 1)]
public class MyConsumer : IConsumer<TestEvent>
{
    private readonly ILogger<MyConsumer> _logger;

    public MyConsumer(ILogger<MyConsumer> logger)
    {
        _logger = logger;
    }

    // 消费
    public async Task ExecuteAsync(MessageHeader messageHeader, TestEvent message)
    {
        Console.WriteLine($"事件 id:{message.Id}");
    }

    // 每次失败时被执行
    public async Task FaildAsync(MessageHeader messageHeader, Exception ex, int retryCount, TestEvent message)
    {
        _logger.LogError(ex, "Consumer exception,event id: {Id},retry count: {retryCount}", message!.Id, retryCount);
    }

    // 最后一次失败时执行
    public async Task<ConsumerState> FallbackAsync(MessageHeader messageHeader, TestEvent? message, Exception? ex)
    {
        return ConsumerState.Ack;
    }
}

特性配置的说明请参考 消费者配置

手动注入消费者

开发者也可以通过 CustomConsumerTypeFilter 手动注册消费者服务,只需要手动配置 ConsumerOptions 即可。

csharp 复制代码
var consumerOptions = new ConsumerOptions("test-queue_2")
{
	DeadExchange = "test-dead-exchange_2",
	DeadRoutingKey = "test-dead-routing-key_2",
	Expiration = 60000,
	Qos = 10,
	RetryFaildRequeue = true,
	AutoQueueDeclare = AutoQueueDeclare.Enable,
	BindExchange = "test-bind-exchange_2",
	ExchangeType = "direct",
	RoutingKey = "test-routing_2"
};

// 创建自定义的消费者模式
var consumerTypeFilter = new CustomConsumerTypeFilter();
var consumerType = typeof(TestConsumer);
consumerTypeFilter.AddConsumer(consumerType, consumerOptions);

在注册 MQ 服务时,添加自定义消费者模式:

csharp 复制代码
builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
    // ... ...
}, 
[typeof(Program).Assembly], 
[new ConsumerTypeFilter(), new EventBusTypeFilter(),consumerTypeFilter]);	// 添加自定义消费者模式

动态消费者

注入 IDynamicConsumer 即可使用动态消费者服务,添加的消费者会在后台自动运行。

csharp 复制代码
var consumerTag = await _dynamicConsumer.ConsumerAsync<MyConsumer, TestEvent>(new ConsumerOptions("myqueue")
{
	Qos = 10
});

如果需要需求订阅,可以通过 consumerTag 或队列名称进行取消。

csharp 复制代码
await _dynamicConsumer.StopConsumerTagAsync(consumerTag);
await _dynamicConsumer.StopConsumerAsync(queueName);

消费、重试和补偿

消费者收到服务器推送的消息时,ExecuteAsync 方法会被自动执行。当 ExecuteAsync 执行异常时,FaildAsync 方法会马上被触发,开发者可以利用 FaildAsync 记录相关日志信息。

csharp 复制代码
// 每次失败时被执行
public async Task FaildAsync(MessageHeader messageHeader, Exception ex, int retryCount, TestEvent message)
{
	_logger.LogError(ex, "Consumer exception,event id: {Id},retry count: {retryCount}", message!.Id, retryCount);
}

默认情况下,框架会最多重试三次,也就是总共最多执行四次 ExecuteAsync 方法。

如果 FaildAsync 方法也出现异常时,不会影响整体流程,框架会等待到达间隔时间后继续重试 ExecuteAsync 方法。

建议 FaildAsync 使用 try{}cathc{} 套住代码,不要对外抛出异常,FaildAsync 的逻辑不要包含太多逻辑,并且 FaildAsync 只应记录日志或进行告警使用。

ExecuteAsync 方法执行异常时,框架会自动重试,默认会重试三次,如果三次都失败,则会执行 FallbackAsync 方法进行补偿。

重试间隔时间会逐渐增大,请参考 重试

当重试三次之后,就会立即启动补偿机制。

csharp 复制代码
// 最后一次失败时执行
public async Task<ConsumerState> FallbackAsync(MessageHeader messageHeader, TestEvent? message, Exception? ex)
{
	return ConsumerState.Ack;
}

FallbackAsync 方法需要返回 ConsumerState 表示虽然 ExecuteAsync 出现异常,但是 FallbackAsync 补偿后已经正常,该消息会被正常消费掉。如果返回 false,则说补偿失败,该消息按照消费失败处理。

只有 ExecuteAsync 异常时,才会触发 FaildAsyncFallbackAsync

消费失败

ExecuteAsync 失败次数达到阈值时,则该条消息消费失败,或者由于序列化等错误时直接失败,最后会触发 FallbackAsync

在 IConsumerOptions 中有三个很重要的配置:

csharp 复制代码
public class IConsumerOptions : Attribute
{
    // 消费失败次数达到条件时,是否放回队列.
    public bool RetryFaildRequeue { get; set; }

    /// 绑定死信交换器
    public string? DeadExchange { get; set; }

    /// 绑定死信队列
    public string? DeadRoutingKey { get; set; }

}

FallbackAsync 返回值是 ConsumerState 枚举,其定义如下:

csharp 复制代码
/// 接受 RabbitMQ 消息后,通过状态枚举确定进行 ACK、NACK 以及放回队列等.
public enum ConsumerState
{
    /// ACK.
    Ack = 1,

    /// 立即 NACK,并使用默认配置设置是否将消息放回队列.
    Nack = 1 << 1,

    /// 立即 NACK,并将消息放回队列.
    NackAndRequeue = 1 << 2,

    /// 立即 NACK,消息将会从服务器队列中移除.
    NackAndNoRequeue = 1 << 3,

    /// 出现异常情况.
    Exception = 1 << 4
}

消费失败的情况有多种,下面列出具体逻辑:

  • 如果反序列化异常或者 FallbackAsync 执行异常等,会直接触发 ConsumerState.Exception,最后根据 IConsumerOptions.RetryFaildRequeue 确定是否要将消息放回队列中,下次重新消费。
  • 如果 FallbackAsync 返回 ConsumerState.ACK,表示虽然消费消息一直失败,但是依然 ACK 该条消息。
  • 如果 FallbackAsync 返回 ConsumerState.Nack,表示消费失败,但是是否要返回队列,由 IConsumerOptions.RetryFaildRequeue 决定。
  • 如果 FallbackAsync 返回 ConsumerState.NackAndRequeue,表示立即消费失败,并将消息放回队列。
  • 如果 FallbackAsync 返回 ConsumerState.NackAndNoRequeue,表示立即消费失败,并且该消息不再放回队列。

自动创建队列

框架默认会自动创建队列,如果需要关闭自动创建功能,把 AutoQueueDeclare 设置为 false 即可。

csharp 复制代码
builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
	options.WorkId = 1;
	options.AppName = "myapp";
	options.AutoQueueDeclare = false;
	options.Rabbit = (ConnectionFactory options) =>
	{
        // ... ...
	};
}, [typeof(Program).Assembly]);

当然还可以单独为消费者配置是否自动创建队列:

csharp 复制代码
[Consumer("ConsumerWeb_create", AutoQueueDeclare = AutoQueueDeclare.Enable)]

默认情况下,关闭了全局自动创建,则不会自动创建队列。

如果关闭全局自动创建,但是消费者配置了 AutoQueueDeclare = AutoQueueDeclare.Enable,则还是会自动创建队列。

如果消费者配置了 AutoQueueDeclare = AutoQueueDeclare.Disable ,则会忽略全局配置,不会创建队列。

Qos

默认 Qos = 100

让程序需要严格根据顺序消费时,可以使用 Qos = 1,框架会严格保证逐条消费,如果程序不需要顺序消费,希望可以快速处理所有消息,则可以将 Qos 设置大一些。由于 Qos 和重试、补偿机制组合使用会有多种情况,因此请参考 重试

Qos 是通过特性来配置的:

csharp 复制代码
[Consumer("ConsumerWeb", Qos = 1)]

可以通过调高 Qos 值,让程序在可以并发消息,提高并发量。

根据网络环境、服务器性能和实例数量等设置 Qos 值可以有效提高消息处理速度,请参考 Qos.

延迟队列

延迟队列有两种,一种设置消息过期时间,一种是设置队列过期时间。

设置消息过期时间,那么该消息在一定时间没有被消费时,会被丢弃或移动到死信队列中,该配置只对单个消息有效,请参考 消息过期

队列设置过期后,当消息在一定时间内没有被消费时,会被丢弃或移动到死信队列中,该配置只对所有消息有效。基于这一点,我们可以实现延迟队列。

首先创建消费者,继承 EmptyConsumer,那么该队列会在程序启动时被创建,但是不会创建 IConnection 进行消费。然后设置队列消息过期时间以及绑定死信队列,绑定的死信队列既可以使用消费者模式实现,也可以使用事件模式实现。

csharp 复制代码
[Consumer("consumerWeb_dead", Expiration = 6000, DeadRoutingKey = "consumerWeb_dead_queue")]
public class EmptyDeadConsumer : EmptyConsumer<DeadEvent>
{
}

// ConsumerWeb_dead 消费失败的消息会被此消费者消费。
[Consumer("consumerWeb_dead_queue", Qos = 1)]
public class Dead_QueueConsumer : IConsumer<DeadQueueEvent>
{
    // 消费
    public Task ExecuteAsync(MessageHeader messageHeader, DeadQueueEvent message)
    {
        Console.WriteLine($"死信队列,事件 id:{message.Id}");
        return Task.CompletedTask;
    }

    // 每次失败时被执行
    public Task FaildAsync(MessageHeader messageHeader, Exception ex, int retryCount, DeadQueueEvent message) => Task.CompletedTask;

    // 最后一次失败时执行
    public Task<ConsumerState> FallbackAsync(MessageHeader messageHeader, DeadQueueEvent? message, Exception? ex)
        => Task.FromResult(ConsumerState.Ack);
}

空消费者

当识别到空消费者时,框架只会创建队列,而不会启动消费者消费消息。

可以结合延迟队列一起使用,该队列不会有任何消费者,当该队列的消息过期时,都由死信队列直接消费,示例如下:

csharp 复制代码
[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>
{
    // ... ...
}

对于跨进程的队列,A 服务不消费只发布,B 服务负责消费,A 服务中可以加一个空消费者,保证 A 服务启动时该队列一定存在,另一方面,消费者服务不应该关注队列的定义,也不太应该创建队列。

广播模式

在 RabbitMQ 中,设置一个 Fanout 或 Topic 交换器之后,多个队列绑定到该交换器时,每个队列都会收到一模一样的消息,在微服务场景下,比如用户中心,员工离职后,需要发布一个消息,所有订阅了这个消息的系统都要处理员工离职后的相关数据。

创建两个消费者队列,队列的名称不能相同,然后绑定到同一个交换器,名称可以随意,例如 exchange

csharp 复制代码
[Consumer("ConsumerWeb_exchange_1", BindExchange = "exchange")]
public class Exchange_1_Consumer : IConsumer<TestEvent>
{
    /// ... ...
}

[Consumer("ConsumerWeb_exchange_2", BindExchange = "exchange")]
public class Exchange_2_Consumer : IConsumer<TestEvent>
{
    // ... ... 
}

发布者发布消息时,需要使用广播发布者模式发布,请参考:广播模式

当然,Maomi.MQ 可以自定义交换器类型和交换器名字。

基于事件

Maomi.MQ 内部设计了一个事件总线,可以帮助开发者实现事件编排、实现本地事务、正向执行和补偿。

Maomi.MQ 没有设计本地消息表等分布式事务保障机制,主要基于以下几点考虑:

  • Maomi.MQ 是基于消息队列的通讯模型,不是专门为分布式事务设计的,对于分布式事务没有什么协调能力,要使用到分布式事务编排,需要使用类似 DTM 、Seata 等类型的分布式事务管理平台,分布式事务需要一个事务中心协调平台。
  • Maomi.MQ 本身设计了重试策略和补偿策略机制,可以一定程度上解决异常的情况。
  • Maomi.MQ 本身不能保证幂等性、空补偿等问题,但是也不是什么情况都需要严格保证消费的。
  • 通过事件模式的中间件功能,开发者也可以很简单地处理幂等性、空补偿、悬挂等问题。

使用事件模式

首先定义一个事件类型,该事件绑定一个 topic 或队列,事件需要使用 [EventTopic] 标识,并设置该事件对于的队列名称。

[EventTopic] 特性拥有与 [Consumer] 相同的特性,可参考 [Consumer] 的使用配置事件,请参考 消费者配置

csharp 复制代码
[EventTopic("EventWeb")]
public class TestEvent
{
	public string Message { get; set; }

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

然后编排事件执行器,每个执行器都需要继承 IEventHandler<T> 接口,然后使用 [EventOrder] 特性标记执行顺序。

csharp 复制代码
[EventOrder(0)]
public class My1EventEventHandler : IEventHandler<TestEvent>
{
    public Task CancelAsync(TestEvent message, CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }

    public Task ExecuteAsync(TestEvent message, CancellationToken cancellationToken)
    {
        Console.WriteLine($"{message.Message},事件 1 已被执行");
        return Task.CompletedTask;
    }
}

[EventOrder(1)]
public class My2EventEventHandler : IEventHandler<TestEvent>
{
    public Task CancelAsync(TestEvent message, CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }

    public Task ExecuteAsync(TestEvent message, CancellationToken cancellationToken)
    {
        Console.WriteLine($"{message.Message},事件 2 已被执行");
        return Task.CompletedTask;
    }
}

每个事件执行器都必须实现 IEventHandler<T> 接口,并且设置 [EventOrder] 特性以便确认事件的执行顺序,框架会按顺序执行 IEventHandler<T>ExecuteAsync 方法,当 ExecuteAsync 出现异常时,则反向按顺序调用 CancelAsync

由于程序可能随时挂掉,因此通过 CancelAsync 实现补偿是不太可能的,CancelAsync 主要作为记录相关信息而使用。

中间件

中间件的作用是便于开发者拦截事件、记录信息、实现本地事务等,如果开发者不配置,则框架会自动创建 DefaultEventMiddleware<TEvent> 类型作为该事件的中间件服务。

自定义事件中间件示例代码:

csharp 复制代码
public class TestEventMiddleware : IEventMiddleware<TestEvent>
{
    public async Task ExecuteAsync(MessageHeader messageHeader,TestEvent message, EventHandlerDelegate<TestEvent> next)
    {
        await next(messageHeader, message, CancellationToken.None);
    }
    public Task FaildAsync(MessageHeader messageHeader, Exception ex, int retryCount, TestEvent? message) => Task.CompletedTask;
    public Task<ConsumerState> FallbackAsync(MessageHeader messageHeader, TestEvent? message, Exception? ex) => Task.FromResult(ConsumerState.Ack);
}

next 委托是框架构建的事件执行链路,在中间件中可以拦截事件、决定是否执行事件链路。

在中间件中调用 next() 委托时,框架开始按顺序执行事件,即前面提到的 My1EventEventHandlerMy2EventEventHandler

当一个事件有多个执行器时,由于程序可能会在任何时刻挂掉,因此本地事务必不可少。

例如,在中间件中注入数据库上下文,然后启动事务执行数据库操作,当其中一个 EventHandler 执行失败时,执行链路会回滚,同时不会提交事务。

可以参考 消费者模式 实现中间件的重试和补偿方法。

示例如下:

csharp 复制代码
public class TestEventMiddleware : IEventMiddleware<TestEvent>
{
    private readonly BloggingContext _bloggingContext;

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

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

    public Task FaildAsync(MessageHeader messageHeader, Exception ex, int retryCount, TestEvent? message)
    {
        return Task.CompletedTask;
    }

    public Task<ConsumerState> FallbackAsync(MessageHeader messageHeader, TestEvent? message, Exception? ex)
    {
        return Task.FromResult(ConsumerState.Ack);
    }
}
csharp 复制代码
[EventOrder(0)]
public class My1EventEventHandler : IEventHandler<TestEvent>
{
    private readonly BloggingContext _bloggingContext;

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

    public async Task CancelAsync(TestEvent message, CancellationToken cancellationToken)
    {
        Console.WriteLine($"{message.Message} 被补偿,[1]");
    }

    public async Task ExecuteAsync(TestEvent message, 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(TestEvent message, CancellationToken cancellationToken)
    {
        Console.WriteLine($"{message.Id} 被补偿,[2]");
    }

    public async Task ExecuteAsync(TestEvent message, CancellationToken cancellationToken)
    {
        await _bloggingContext.Posts.AddAsync(new Post
        {
            Title = "红楼梦",
            Content = "贾宝玉初试云雨情"
        });
        await _bloggingContext.SaveChangesAsync();

        throw new OperationCanceledException("故意报错");
    }
}

事件执行时,如果出现异常,也是会被重试的,中间件 TestEventMiddleware 的 FaildAsync、FallbackAsync 会被依次执行。

你可以参考 消费者模式 或者 重试

幂等性、空补偿、悬挂

在微服务中,一个服务可能会在任何一个时间挂掉重启,由此会出现幂等性、空补偿、悬挂等问题。

幂等性

比如,A 消费者消费消息 01 时,将结果写入数据库,然后 Maomi.MQ 还没有向 RabbitMQ 推送 ack 时,程序就重启了。程序重启后,由于 01 还没有被 ack,因此程序会重复消费该条消息,如果此时继续写入数据库,就会导致重复。因此,开发者需要保证即使重复消费了该消息,也不会导致数据库的数据不一致或重复操作。

当然,并不是所有情况都不能重复消费,我们这里只围绕那些只能消费一次的情况,例如插入订单信息到数据库。

这就要求每个消息都有一个特定的业务 id 或分布式雪花 id,在消费时,需要判断数据库是否已经存在该 id,这样可以判断程序是否重复消费。

例如:

csharp 复制代码
public class TestEventMiddleware : IEventMiddleware<TestEvent>
{
    private readonly BloggingContext _bloggingContext;

    public async Task ExecuteAsync(MessageHeader messageHeader, TestEvent message, EventHandlerDelegate<TestEvent> next)
    {
        var existId = await _bloggingContext.Posts.AnyAsync(x=>x.PostId == @event.Id);
        if (existId)
        {
            return;
        }

        using (var transaction = _bloggingContext.Database.BeginTransaction())
        {
            await next(@event, CancellationToken.None);
            await transaction.CommitAsync();
        }
    }
}

空补偿

在分布式事务中,当编排 A => B => C 三个服务的接口时,如果 C 出现了异常,则分布式事务管理器会先调用 C 的补偿接口,然后调用 B、A。

这里每次调用都是通过接口调用,因此无法在一个数据库事务中处理。

这里两种情况。

一种是,C 已经完成了插入数据库的操作,给用户的余额+100 ,但是接着程序重启了或者超时了等各种情况,导致事务管理器认为失败了,需要调用补偿接口。此时补偿接口撤销之前修改的数据。这里没问题。

第二种情况,C 数据库还没有完成数据库操作就异常了,此时事务管理器调用了补偿接口,如果补偿接口给用户余额 -100 元,那就不对了。

因此,服务必须保证之前的操作到底有没有成功,如果有,则开始撤销流程,如果没有,那就立即返回补偿成功的结果。

一般情况下 Maomi.MQ 不会出现空补偿问题,因为 Maomi.MQ 压根不是分布式事务框架,哈哈哈。

Maomi.MQ 虽然提供了 CancelAsync() 方法用于执行撤销流程,但是这个主要是用于给开发者记录日志等,不是用于执行补偿的。而且事件编排的所有流程都在本地,完全不会涉及分布式事务的空补偿问题,因此只需要保证本地数据库事务即可,即保证幂等性即可。

悬挂

在分布式事务中,会有一个正向执行请求和一个撤销请求,如果执行失败,就会调用撤销接口。但是由于分布式网络的复杂性,事务管理器并不能很确定 C 服务的情况,C 服务相对于一个小黑盒,当请求失败时,事务管理器就会调用补偿接口。补偿接口被调用之后,由于各种原因,正向执行接口被调用了,可能是因为网关的自动重试,也可能由于服务太卡了,结果补偿接口先进入代码,然后正向执行接口才进入代码。此时,这个分布式事务是失败的,事务管理器已经调用了补偿流程,那么这个事务已经结束了,但是由于 C 在后面执行了一次正向接口,用户余额 +100,就会导致看起来都正常,实际上不正常。这就是悬挂。

由于 Maomi.MQ 不涉及多服务事务编排,因此只需要关心幂等性即可,不需要关心空补偿和悬挂问题,而幂等性是否需要保证,则需要开发者依据业务来定,因此 Maomi.MQ 没有设计本地消息表的分布式事务工作模式。

事件模式下的配置与消费者模式一致,因此这里不再赘述,可以参考 消费者模式.

自定义消费者和动态订阅

主要实现了两部分的功能。

  • 在程序启动时,可以自定义消费者配置和消费者模型,不需要使用特性注解配置。
  • 在程序启动后,可以随时启动一个消费者或者停止一个消费者。

参考示例项目:https://github.com/whuanle/Maomi.MQ/tree/main/example/consumer/DynamicConsumerWeb

自定义消费者

消费者可以不使用特性注解,只需要实现 IConsumer<TEvent> 即可,扫描程序集时会忽略掉没有添加特性注解的消费者。

定义消费者模型:

csharp 复制代码
public class DynamicCustomConsumer : IConsumer<TestEvent>
{
    public Task ExecuteAsync(MessageHeader messageHeader, TestEvent message)
    {
        throw new NotImplementedException();
    }

    public Task FaildAsync(MessageHeader messageHeader, Exception ex, int retryCount, TestEvent message)
    {
        throw new NotImplementedException();
    }

    public Task<ConsumerState> FallbackAsync(MessageHeader messageHeader, TestEvent? message, Exception? ex)
    {
        throw new NotImplementedException();
    }
}

然后通过 DynamicConsumerTypeFilter 手动配置消费者和属性。

csharp 复制代码
DynamicConsumerTypeFilter dynamicConsumerTypeFilter = new();

dynamicConsumerTypeFilter.AddConsumer(typeof(DynamicCustomConsumer), new ConsumerOptions
{
	Queue = "test1"
});
dynamicConsumerTypeFilter.AddConsumer(typeof(DynamicCustomConsumer), new ConsumerOptions
{
	Queue = "test2"
});

然后注入服务时,手动添加类型过滤器。

csharp 复制代码
builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
	options.WorkId = 1;
	options.AutoQueueDeclare = true;
	options.AppName = "myapp";
	options.Rabbit = (ConnectionFactory options) =>
	{
        // ... ...
	};
}, [typeof(Program).Assembly], [
    new ConsumerTypeFilter(),  // 消费者类型过滤器
    new EventBusTypeFilter(),  // 事件总线类型过滤器
    dynamicConsumerTypeFilter  // 动态消费者过滤器
]);

动态订阅

在程序启动后,通过 IDynamicConsumer 服务可以动态启动或停止一个消费者。对于在程序启动时就已经运行的消费者,不会受到动态订阅控制,不能在程序运行时停止。

动态启动消费者:

csharp 复制代码
private readonly IMessagePublisher _messagePublisher;
private readonly IDynamicConsumer _dynamicConsumer;

[HttpPost("create")]
public async Task<string> CreateConsumer([FromBody] ConsumerDto consumer)
{
	foreach (var item in consumer.Queues)
	{
		await _dynamicConsumer.ConsumerAsync<MyConsumer, TestEvent>(new ConsumerOptions(item));
	}

	return "ok";
}

如果不想定义模型类,也可以直接使用函数方式:

csharp 复制代码
foreach (var item in consumer.Queues)
{
	var consumerTag = await _dynamicConsumer.ConsumerAsync<TestEvent>(
		consumerOptions: new ConsumerOptions(item),
		execute: async (header, message) =>
		{
			await Task.CompletedTask;
		},
		faild: async (header, ex, retryCount, message) => { },
		fallback: async (header, message, ex) => ConsumerState.Ack
		);
}

return "ok";

使用队列名称可以动态停止消费者:

csharp 复制代码
[HttpPost("stop")]
public async Task<string> StopConsumer([FromBody] ConsumerDto consumer)
{
	foreach (string queueName in consumer.Queues)
	{
		await _dynamicConsumer.StopConsumerAsync(queueName);
	}

	return "ok";
}

也可以使用消费者标识:

csharp 复制代码
var consumerTag = await _dynamicConsumer.ConsumerAsync<MyConsumer, TestEvent>(new ConsumerOptions(item));
await _dynamicConsumer.StopConsumerTagAsync(consumerTag);

配置

在引入 Maomi.MQ 框架时,可以配置相关属性,示例和说明如下:

csharp 复制代码
// this.
builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
    // 必填,当前程序节点,用于配置分布式雪花 id,
    // 配置 WorkId 可以避免高并发情况下同一个消息的 id 重复。
	options.WorkId = 1;
    
    // 是否自动创建队列
	options.AutoQueueDeclare = true;
    
    // 当前应用名称,用于标识消息的发布者和消费者程序
	options.AppName = "myapp";
    
    // 必填,RabbitMQ 配置
	options.Rabbit = (ConnectionFactory options) =>
	{
        options.HostName = Environment.GetEnvironmentVariable("RABBITMQ")!;
        options.Port = 5672;
		options.ClientProvidedName = Assembly.GetExecutingAssembly().GetName().Name;
	};
}, [typeof(Program).Assembly]);  // 要被扫描的程序集

开发者可以通过 ConnectionFactory 手动管理 RabbitMQ 连接,例如故障恢复、自定义连接参数等。

类型过滤器

类型过滤器的接口是 ITypeFilter,作用是扫描识别类型,并将其添加为消费者,默认启用 ConsumerTypeFilter、EventBusTypeFilter 两个类型过滤器,它们会识别并使用消费者模型和事件总线消费者模式,这两种模型都要求配置对于的特性注解。

此外还有一个动态消费者过滤器 DynamicConsumerTypeFilter,可以自定义消费者模型和配置。

如果开发者需要自定义消费者模型或者接入内存事件总线例如 MediatR ,只需要实现 ITypeFilter 即可。

拦截器

Maomi.MQ 默认启用消费者模式和事件总线模式,开发者可以自由配置是否启用。

csharp 复制代码
builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
	options.WorkId = 1;
	options.AutoQueueDeclare = true;
	options.AppName = "myapp";
	options.Rabbit = (ConnectionFactory options) =>
	{
        // ... ...
	};
},
[typeof(Program).Assembly], 
[new ConsumerTypeFilter(), new EventBusTypeFilter()]); // 注入消费者模式和事件总线模式

另外框架还提供了动态配置拦截,可以实现在程序启动时修改消费者特性的配置。

csharp 复制代码
builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
	options.WorkId = 1;
	options.AutoQueueDeclare = true;
	options.AppName = "myapp";
	options.Rabbit = (ConnectionFactory options) =>
	{
        // ... ...
	};
},
[typeof(Program).Assembly],
[new ConsumerTypeFilter(ConsumerInterceptor), new EventBusTypeFilter(EventInterceptor)]);

实现拦截器函数:

csharp 复制代码
private static RegisterQueue ConsumerInterceptor(IConsumerOptions consumerOptions, Type consumerType)
{
	var newConsumerOptions = new ConsumerOptions(consumerOptions.Queue);
	consumerOptions.CopyFrom(newConsumerOptions);

	// 修改 newConsumerOptions 中的配置

	return new RegisterQueue(true, consumerOptions);
}

private static RegisterQueue EventInterceptor(IConsumerOptions consumerOptions, Type eventType)
{
	if (eventType == typeof(TestEvent))
	{
		var newConsumerOptions = new ConsumerOptions(consumerOptions.Queue);
		consumerOptions.CopyFrom(newConsumerOptions);
		newConsumerOptions.Queue = newConsumerOptions.Queue + "_1";

		return new RegisterQueue(true, newConsumerOptions);
	}
	return new RegisterQueue(true, consumerOptions);
}

开发者可以在拦截器中修改配置值。

拦截器有返回值,当返回 false 时,框架会忽略注册该消费者或事件,也就是该队列不会启动消费者。

消费者配置

Maomi.MQ 中对于消费者的逻辑处理,是通过 IConsumerOptions 接口的属性来流转的,无论是自定义消费者还是事件总线等消费模式,本身都是向框架注册 IConsumerOptions 。

其配置说明如下:

名称 类型 必填 默认值 说明
Queue string 必填 队列名称
DeadExchange string? 可选 绑定死信交换器名称
DeadRoutingKey string? 可选 绑定死信路由键
Expiration int 可选 队列消息过期时间,单位毫秒
Qos ushort 可选 100 每次拉取消息时可以拉取的消息的数量,有助于提高消费能力
RetryFaildRequeue bool 可选 false 消费失败次数达到条件时,是否放回队列
AutoQueueDeclare AutoQueueDeclare 可选 None 是否自动创建队列
BindExchange string? 可选 绑定交换器名称
ExchangeType string? 可选 BindExchange 的交换器类型
RoutingKey string? 可选 BindExchange 的路由键名称

前面提到,框架会扫描消费者和事件总线的消费者特性,然后生成 IConsumerOptions 绑定该消费者,可以通过拦截函数的方式修改配置属性。

csharp 复制代码
new ConsumerTypeFilter((consumerOptions, type) =>
{
	var newConsumerOptions = new ConsumerOptions(consumerOptions.Queue);
	consumerOptions.CopyFrom(newConsumerOptions);

	newConsumerOptions.Queue = "app1_" + newConsumerOptions.Queue;

	return new RegisterQueue(true, consumerOptions);
});

此外,还有一个 IRoutingProvider 接口可以动态映射新的配置,在程序启动后,Maomi.MQ 会自动创建交换器、队列,会调用 IRoutingProvider 映射新的配置,在发布消息时,如果使用模型类发布,也会通过 IRoutingProvider 映射配置,所以开发者可以通过实现此接口动态修改配置的属性。

csharp 复制代码
services.AddSingleton<IRoutingProvider, MyRoutingProvider>();

环境隔离

目前还在考虑要不要支持多租户模式。

在开发中,往往需要在本地调试,本地程序启动后会连接到开发服务器上,一个队列收到消息时,会向其中一个消费者推送消息。那么我本地调试时,发布一个消息后,可能本地程序收不到该消息,而是被开发环境中的程序消费掉了。

这个时候,我们希望可以将本地调试环境跟开发环境隔离开来,可以使用 RabbitMQ 提供的 VirtualHost 功能。

首先通过 put 请求 RabbitMQ 创建一个新的 VirtualHost,请参考文档:https://www.rabbitmq.com/docs/vhosts#using-http-api

然后在代码中配置 VirtualHost 名称:

csharp 复制代码
builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
	options.WorkId = 1;
	options.AutoQueueDeclare = true;
	options.AppName = "myapp";
	options.Rabbit = (ConnectionFactory options) =>
	{
        options.HostName = Environment.GetEnvironmentVariable("RABBITMQ")!;
        options.Port = 5672;
#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 生成规则。

csharp 复制代码
services.AddSingleton<IIdFactory>(new DefaultIdFactory((ushort)optionsBuilder.WorkId));

示例:

csharp 复制代码
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

调试

Maomi.MQ 框架在 nuget.org 中有符号包,需要调试 Maomi.MQ 框架时会非常方便。

第一次使用时建议加载所有模块,并启动程序。

后面可以手动选择只加载那些模块。

F12 到要调试的位置,启动程序后即可进入断点。

如果需要调试 Maomi.MQ.RabbtiMQ,可以在程序中加一个断点(不是在 Maomi.MQ 中),然后等待程序启动到达这个断点后,配置符号,点击加载所有符号。

然后在 Maomi.MQ.RabbitMQ 中设置断点即可进入调试。

Qos 并发和顺序

基于消费者模式和基于事件模式都是通过特性来配置消费属性,Qos 是其中一个重要的属性,Qos 默认值为 100,Qos 配置指的是一次允许消费者接收多少条未确认的消息。

Qos 场景

全局所有消费者共用一个 IConnection 对象,每个消费者独占一个 IChannel。

对于消费频率很高但是不能并发的队列,请务必设置 Qos = 1,这样 RabbitMQ 会逐个推送消息,在保证顺序的情况下,保证消费严格顺序。

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

当需要需要提高消费吞吐量,而且不需要顺序消费时,可以将 Qos 设置高一些,RabbitMQ Client 框架会通过预取等方式提高吞吐量,并且多条消息可以并发消费。

并发和异常处理

主要根据 Qos 和 RetryFaildRequeue 来处理,RetryFaildRequeue 默认是 true。

Qos = 1 的情况下,结合 IConsumerOptions.RetryFaildRequeueFallbackAsync ,当该消息放回队列时,下一次还是继续消费该条消息。

Qos > 1 的情况下,由于并发性,那么消费失败的消息会被放回队列中,但是不一定下一次会立即重新消费该条消息。

Qos 为 1 时,会保证严格顺序消费,ExecptionRequeue 、RetryFaildRequeue 会影响失败的消息是否会被放回队列,如果放回队列,下一次消费会继续消费之前失败的消息。如果错误(如 bug)得不到解决,则会出现消费、失败、放回队列、重新消费这样的循环。

如何设置 Qos

注意,在 RabbitMQClient 7.0 版本中,新增了很多东西,其中一个是消费者并发线程数 ConsumerDispatchConcurrency ,默认为 1,如果不修改该配置,会导致消费速度非常低下,每个 IChannel 都可以单独设置该属性,也可以在 ConnectionFactory 设置默认全局属性。

csharp 复制代码
services.AddMaomiMQ(options =>
{
	options.WorkId = 1;
	options.AppName = "myapp-consumer";
	options.Rabbit = (options) =>
	{
		options.HostName = Environment.GetEnvironmentVariable("RABBITMQ")!;
		options.Port = 5672;
		options.ClientProvidedName = Assembly.GetExecutingAssembly().GetName().Name;
		options.ConsumerDispatchConcurrency = 100;		// 设置这里
	};
}, new System.Reflection.Assembly[] { typeof(Program).Assembly });

Maomi.MQ.RabbitMQ 中的 Qos 指 prefetch_count,取值范围是 0-65535,为 0 时指不限制,一般默认设置为 100 即可,Qos 设置再大不一定可以提高消费效率。

Qos 不等于消费者并发线程数量,而是指一次可以接收的未经处理的消息数量,消费者可以一次性拉取 N 条,然后逐个消费。

根据官方 Finding bottlenecks with RabbitMQ 3.3 | RabbitMQ 文档显示,预取数量对象会影响消费者的队列利用率。

Prefetch limit预取限制 Consumer utilisation消费者使用率
1 14%
3 25%
10 46%
30 70%
1000 74%

一般情况下需要开发者中综合各类因素去配置 Qos,应当综合考虑机器网络带宽、每条消息的大小、发布消息的频率、估算程序整体占用的资源、服务实例等情况。

当程序需要严格顺序消费时,可以设置为 1。

如果在内网连接 RabbitMQ 可以无视网络带宽限制,消息的内容十分大、需要极高的并发量时,可以设置 Qos = 0。当 Qos = 0 时,RabbitMQ.Client 会尽可能吃掉机器的性能,请谨慎使用。

Qos 和消费性能测试

为了说明不同 Qos 对消费者程序的性能影响,下面设置不同 Qos 消费 100w 条消息的代码进行测试,在启动消费者之前, 先向 RabbitMQ 服务器推送 100w 条数据。

定义事件:

csharp 复制代码
public class TestEvent
{
    public int Id { get; set; }
    public string Message { get; set; }
    public int[] Data { get; set; }

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

QosPublisher 项目的消息发布者代码如下,用于向服务器推送 100w 条消息,每条的消息内容约 800 byte,小于 1k。

csharp 复制代码
[HttpGet("publish")]
public async Task<string> Publisher()
{
	int totalCount = 0;
	List<Task> tasks = new();
	var message = string.Join(",", Enumerable.Range(0, 100));
	var data = Enumerable.Range(0, 100).ToArray();
	for (var i = 0; i < 100; i++)
	{
		var task = Task.Factory.StartNew(async () =>
		{
			using var singlePublisher = _messagePublisher.CreateSingle();

			for (int k = 0; k < 10000; k++)
			{
				var count = Interlocked.Increment(ref totalCount);
				await singlePublisher.PublishAsync(exchange: string.Empty, routingKey: "qos", message: new TestEvent
				{
					Id = count,
					Message = message,
					Data = data
				});
			}
		});
		tasks.Add(task);
	}

	await Task.WhenAll(tasks);
	return "ok";
}

等待一段时间后,服务器已经有 100w 条消息了。

创建消费者项目 QosConsole,人为给消费者增加 50ms 的耗时,运行程序。

csharp 复制代码
class Program
{
    static async Task Main()
    {
        var host = new HostBuilder()
            .ConfigureLogging(options =>
            {
                options.AddConsole();
                options.AddDebug();
            })
            .ConfigureServices(services =>
            {
                services.AddMaomiMQ(options =>
                {
                    options.WorkId = 1;
                    options.AppName = "myapp-consumer";
                    options.Rabbit = (options) =>
                    {
                        options.HostName = Environment.GetEnvironmentVariable("RABBITMQ")!;
                        options.Port = 5672;
                        options.ClientProvidedName = Assembly.GetExecutingAssembly().GetName().Name;
                        options.ConsumerDispatchConcurrency = 1000;
                    };
                }, new System.Reflection.Assembly[] { typeof(Program).Assembly });

            }).Build();

        Console.WriteLine($"start time:{DateTime.Now}");
        await host.RunAsync();
    }
}


[Consumer("qos", Qos = 30)]
public class QosConsumer : IConsumer<TestEvent>
{
    private static int Count = 0;

    public async Task ExecuteAsync(MessageHeader messageHeader, TestEvent message)
    {
        Interlocked.Increment(ref Count);
        Console.WriteLine($"date time:{DateTime.Now},id:{message.Id}, count:{Count}");
        await Task.Delay(50);
    }

    public Task FaildAsync(MessageHeader messageHeader, Exception ex, int retryCount, TestEvent message)
    {
        return Task.CompletedTask;
    }

    public Task<ConsumerState> FallbackAsync(MessageHeader messageHeader, TestEvent? message, Exception? ex)
    {
        return Task.FromResult(ConsumerState.Ack);
    }
}

为了有直观的对比,这里也直接使用 RabbitMQ.Client 编写原生消费者项目 RabbitMQConsole。

csharp 复制代码
static async Task Main()
{
	ConnectionFactory connectionFactory = new ConnectionFactory
	{
		HostName = Environment.GetEnvironmentVariable("RABBITMQ")!,
		Port = 5672,
		ConsumerDispatchConcurrency = 1000
	};

	var connection = await connectionFactory.CreateConnectionAsync();
	var channel = await connection.CreateChannelAsync(new CreateChannelOptions(
		publisherConfirmationsEnabled: false,
		publisherConfirmationTrackingEnabled: false,
		consumerDispatchConcurrency: 1000));
	var messageSerializer = new DefaultMessageSerializer();

	var consumer = new AsyncEventingBasicConsumer(channel);
	await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1000, global: true);

	consumer.ReceivedAsync += async (sender, eventArgs) =>
	{
		var testEvent = messageSerializer.Deserialize<TestEvent>(eventArgs.Body.Span);
		Console.WriteLine($"start time:{DateTime.Now} {testEvent.Id}");
		await Task.Delay(50);
		await channel.BasicAckAsync(eventArgs.DeliveryTag, false);
	};

	await channel.BasicConsumeAsync(
		queue: "qos",
		autoAck: false,
		consumer: consumer);

	while (true)
	{
		await Task.Delay(10000);
	}
}

Maomi.MQ.RabbitMQ 是基于 RabbitMQ.Client 进行封装的,Maomi.MQ.RabbitMQ 消费时需要记录日志、增加可观测性信息、构建新的依赖注入容器 等,因此耗时和资源消耗肯定会比 RabbitMQ.Client 多一些,因此需要将两者对比一下。

以 Release 模式在 VS 中启动程序,以单进程方式,分开启动 QosConsole、RabbitMQConsole 进行测试,并测试不同 Qos 情况下的消费速度。

稳定性测试

可以参考 可观测性 搭建监控环境,参考 OpenTelemetryConsole 中的代码,一个程序中一个有三个消费者,在该程序中发布消息和消费。

每秒发布或消费约 560 条消息,三个小时内发布约 900w 条消息已经消费 900w 条消息。

内存稳定,机器 CPU 性能不高,并且不定期的 GC 等情况都需要消耗 CPU,其波动如下:

重试

重试时间

当消费者 ExecuteAsync 方法异常时,框架会进行重试,默认会重试三次,按照 2 作为指数设置重试时间间隔。

第一次失败后,立即重试,然后间隔 2 秒重试,第二次失败后,间隔 4 秒,接着分别是 8、16 秒。

Maomi.MQ.RabbitMQ 使用了 Polly 框架做重试策略管理器,默认通过 DefaultRetryPolicyFactory 服务生成重试间隔策略。

DefaultRetryPolicyFactory 代码示例如下:

csharp 复制代码
/// <summary>
/// Default retry policy.<br />
/// 默认的策略提供器.
/// </summary>
public class DefaultRetryPolicyFactory : IRetryPolicyFactory
{
    protected readonly int RetryCount = 3;
    protected readonly int RetryBaseDelaySeconds = 2;

    protected readonly ILogger<DefaultRetryPolicyFactory> _logger;

    /// <summary>
    /// Initializes a new instance of the <see cref="DefaultRetryPolicyFactory"/> class.
    /// </summary>
    /// <param name="logger"></param>
    public DefaultRetryPolicyFactory(ILogger<DefaultRetryPolicyFactory> logger)
    {
        _logger = logger;

        RetryCount = 3;
        RetryBaseDelaySeconds = 2;
    }

    /// <inheritdoc/>
    public virtual Task<AsyncRetryPolicy> CreatePolicy(string queue, string id)
    {
        // Create a retry policy.
        // 创建重试策略.
        var retryPolicy = Policy
            .Handle<Exception>()
            .WaitAndRetryAsync(
                retryCount: RetryCount,
                sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(RetryBaseDelaySeconds, 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 接口,替换默认的重试策略服务服务。

csharp 复制代码
services.AddSingleton<IRetryPolicyFactory, DefaultRetryPolicyFactory>();

持久化剩余重试次数

当消费者处理消息失败时,默认消费者会重试 3 次,如果已经重试了 2 次,此时程序重启,那么下一次消费该消息时,最后重试一次。

需要记忆重试次数,在程序重启时,能够按照剩余次数进行重试。

引入 Maomi.MQ.RedisRetry 包。

配置示例:

csharp 复制代码
builder.Services.AddMaomiMQ((MqOptionsBuilder options) =>
{
	options.WorkId = 1;
	options.AutoQueueDeclare = true;
	options.AppName = "myapp";
	options.Rabbit = (ConnectionFactory options) =>
	{
        // ... ... 
	};
}, [typeof(Program).Assembly]);

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

默认 key 只会保留 5 分钟。也就是说,如果五分钟之后程序才重新消费该消息,那么就会剩余重试次数就会重置。

死信队列

可以给一个消费者或事件绑定死信队列,当该队列的消息失败后并且不会放回队列时,该消息会被推送到死信队列中,示例:

csharp 复制代码
[Consumer("ConsumerWeb_dead", Qos = 1, DeadQueue = "ConsumerWeb_dead_queue", RetryFaildRequeue = false)]
public class DeadConsumer : IConsumer<DeadEvent>
{
	// 消费
	public Task ExecuteAsync(MessageHeader messageHeader, TestEvent message)
	{
		Console.WriteLine($"事件 id:{message.Id}");
		throw new OperationCanceledException();
	}
}

// ConsumerWeb_dead 消费失败的消息会被此消费者消费。
[Consumer("ConsumerWeb_dead_queue", Qos = 1)]
public class DeadQueueConsumer : IConsumer<DeadQueueEvent>
{
	// 消费
	public Task ExecuteAsync(MessageHeader messageHeader, TestEvent message)
	{
		Console.WriteLine($"死信队列,事件 id:{message.Id}");
		return Task.CompletedTask;
	}
}

如果使用死信队列,则务必将 RetryFaildRequeue 设置为 false,那么消费者会在重试多次失败后,向 RabbitMQ 发送 nack 信号,RabbitMQ 就会将该消息转发到绑定的死信队列中。

延迟队列

创建一个消费者,继承 EmptyConsumer,那么该队列会在程序启动时被创建,但是不会创建 IConnection 进行消费。然后设置队列消息过期时间以及绑定死信队列,绑定的死信队列既可以使用消费者模式实现,也可以使用事件模式实现。

csharp 复制代码
[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(MessageHeader messageHeader, TestEvent message)
    {
        Console.WriteLine($"事件 id:{message.Id} 已到期");
        return Task.CompletedTask;
    }
}

例如,用户下单之后,如果 15 分钟之内没有付款,那么消息到期时,自动取消订单。

可观测性

请参考 ActivitySourceApi 、OpenTelemetryConsole 示例。

部署环境

为了快速部署可观测性平台,可以使用 OpenTelemetry 官方提供的示例包快速部署相关的服务,里面包含了 Prometheus、Grafana、Jaeger 等中间件。

open-telemetry 官方集成项目地址:https://github.com/open-telemetry/opentelemetry-demo

下载示例仓库源码:

csharp 复制代码
git clone -b 1.12.0 https://github.com/open-telemetry/opentelemetry-demo.git

请注意,不要下载 main 分支,因为有可能带有 bug。

可以把版本号设置为最新的版本。

由于 docker-compose.yml 示例中会包含大量的 demo 微服务,我们只需要基础设施即可因此我们需要打开 docker-compose.yml 文件,将 services 节点的 Core Demo ServicesDependent Services 只保留 valkey-cart,其它直接删除。或者直接点击下载笔者已经修改好的版本替换到项目中: docker-compose.yml

注意,不同版本可能不一样。

执行命令部署可观测性服务:

bash 复制代码
docker-compose up -d

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 了。

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

csharp 复制代码
<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" />

引入命名空间:

csharp 复制代码
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Maomi.MQ;
using OpenTelemetry.Exporter;
using RabbitMQ.Client;
using System.Reflection;
using OpenTelemetry;

注入链路追踪和监控,自动上报到 Opentelemetry。

csharp 复制代码
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(Environment.GetEnvironmentVariable("OTLPEndpoint")! + "/v1/traces");
			  options.Protocol = OtlpExportProtocol.HttpProtobuf;
		  });
	  })
	  .WithMetrics(metrices =>
	  {
		  metrices.AddAspNetCoreInstrumentation()
		  .AddMaomiMQInstrumentation()
		  .AddOtlpExporter(options =>
		  {
			  options.Endpoint = new Uri(Environment.GetEnvironmentVariable("OTLPEndpoint")! + "/v1/metrics");
			  options.Protocol = OtlpExportProtocol.HttpProtobuf;
		  });
	  });

链路追踪

启动 ActivitySourceApi 服务后,进行发布、消费,链路追踪信息会被自动推送到 OpenTelemetry Collector 中,通过 Jaeger 、Skywalking 等组件可以读取出来。

打开映射了 16686 端口的 Jaejer ui 面板:

由于 publish、consumer 属于兄弟 trace 而不是同一个 trace,因此需要通过 Tags 查询相关联的 trace,格式 event.id=xxx

监控

Maomi.MQ 内置了以下指标:

名称 说明
maomimq_consumer_message_pull_count_total 已拉取的消息条数
maomimq_consumer_message_faild_count_total 消费失败的消息数量
maomimq_consumer_message_received_Byte_bucket
maomimq_consumer_message_received_Byte_count
maomimq_consumer_message_received_Byte_sum 接收到的消息总字节数
maomimq_publisher_message_count_total 发送的消息数量
maomimq_publisher_message_faild_count_total 发送失败的消息数量
maomimq_publisher_message_sent_Byte_bucket
maomimq_publisher_message_sent_Byte_count
maomimq_publisher_message_sent_Byte_sum 发送的消息的总字节数

接着,要将数据显示到 Grafana 中。

下载模板文件: maomi.json

然后在 Grafana 面板的 Dashboards 中导入文件,可以在面板中查看当前所有服务的消息队列监控。

开源项目代码引用

OpenTelemetry.Instrumentation.MaomiMQ 项目的 Includes 代码来源于 https://github.com/open-telemetry/opentelemetry-dotnet-contrib/tree/main/src/Shared