前言

当消费者端接收消息处理业务时,如果出现异常或是拒收消息将消息又变更为等待投递再次推送给消费者,这样一来,则形成循环的条件。

循环场景

生产者发送100条消息到RabbitMQ中,消费者设定读取到第50条消息时,设置拒收,同时设定是否还留存在当前队列中(当requeue为false时,设置了死信队列则进入死信队列,否则移除消息)。

consumer.Received += (model, ea) =>
{
    var message = ea.Body;
    Console.WriteLine("接收到信息为:" + Encoding.UTF8.GetString(message.ToArray()));     if (Encoding.UTF8.GetString(message.ToArray()).Contains("50"))
    {
        Console.WriteLine("拒收");
        ((EventingBasicConsumer)model).Model.BasicReject(ea.DeliveryTag, requeue: true);
        return;
    }     ((EventingBasicConsumer)model).Model.BasicAck(ea.DeliveryTag, multiple: false);
};

当第50条消息拒收,则仍在队列中且处在队列头部,重新推送给消费者,再次拒收,再次推送,反反复复。

最终其他消息全部消费完毕,仅剩第50条消息往复间不断消费,拒收,消费,这将可能导致RabbitMQ出现内存泄漏问题。

解决方案

RabbitMQ及AMQP协议本身没有提供这类重试功能,但可以利用一些已有的功能来间接实现重试限定(以下只考虑基于手动确认模式情况)。此处只想到或是只查到了如下几种方案解决消息循环消费问题。

  • 一次消费

    • 无论成功与否,消费者都对外返回ack,将拒收原因或是异常信息catch存入本地或是新队列中另作重试。
    • 消费者拒绝消息或是出现异常,返回Nack或Reject,消息进入死信队列或丢弃(requeue设定为false)。
  • 限定重试次数
    • 在消息的头中添加重试次数,并将消息重新发送出去,再每次重新消费时从头中判断重试次数,递增或递减该值,直到达到限制,requeue改为false,最终进入死信队列或丢弃。
    • 可以在Redis、Memcache或其他存储中存储消息唯一键(例如Guid、雪花Id等,但必须在发布消息时手动设置它),甚至在mysql中连同重试次数一起存储,然后在每次重新消费时递增/递减该值,直到达到限制,requeue改为false,最终进入死信队列或丢弃。
    • 队列使用Quorum类型,限制投递次数,超过次数消息被删除。
  • 队列消息过期
    • 设置过期时间,给队列或是消息设置TTL,重试一定次数消息达到过期时间后进入死信队列或丢弃(requeue设定为true)。
  • 也许还有更多好的方案...

一次消费

对外总是Ack

消息到达了消费端,可因某些原因消费失败了,对外可以发送Ack,而在内部走额外的方式去执行补偿操作,比如将消息转发到内部的RabbitMQ或是其他处理方式,终归是只消费一次。

var queueName = "alwaysack_queue";
channel.QueueDeclare(queue: queueName, durable: false, exclusive: false, autoDelete: false, arguments: null);
channel.BasicQos(0, 5, false); var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
    try
    {
        var message = ea.Body;
        Console.WriteLine("接收到信息为:" + Encoding.UTF8.GetString(message.ToArray()));         if (Encoding.UTF8.GetString(message.ToArray()).Contains("50"))
        {
            throw new Exception("模拟异常");
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
    }
    finally
    {
        ((EventingBasicConsumer)model).Model.BasicAck(ea.DeliveryTag, multiple: false);
    }
}; channel.BasicConsume(queue: queueName, autoAck: false, consumer: consumer);

当消费端收到消息,处理时出现异常,可以另想办法去处理,而对外保持着ack的返回,以避免消息的循环消费。

消息不重入队列

在消费者端,因异常或是拒收消息时,对requeue设置为false时,如果设置了死信队列,则符合“消息被拒绝且不重入队列”这一进入死信队列的情况,从而避免消息反复重试。如未设置死信队列,则消息被丢失。

此处假定接收100条消息,在接收到第50条消息时设置拒收,并且设置了requeue为false。

var dlxExchangeName = "dlx_exchange";
channel.ExchangeDeclare(exchange: dlxExchangeName, type: "fanout", durable: false, autoDelete: false, arguments: null);
var dlxQueueName = "dlx_queue";
channel.QueueDeclare(queue: dlxQueueName, durable: false, exclusive: false, autoDelete: false, arguments: null);
channel.QueueBind(queue: dlxQueueName, exchange: dlxExchangeName, routingKey: ""); var queueName = "nackorreject_queue";
var arguments = new Dictionary<string, object>
{
    { "x-dead-letter-exchange", dlxExchangeName }
};
channel.QueueDeclare(queue: queueName, durable: false, exclusive: false, autoDelete: false, arguments: arguments);
channel.BasicQos(0, 5, false); var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
    var message = ea.Body;
    Console.WriteLine("接收到信息为:" + Encoding.UTF8.GetString(message.ToArray()));     if (Encoding.UTF8.GetString(message.ToArray()).Contains("50"))
    {
        Console.WriteLine("拒收");
        ((EventingBasicConsumer)model).Model.BasicReject(ea.DeliveryTag, requeue: false);//关键在于requeue=false
        return;
    }     ((EventingBasicConsumer)model).Model.BasicAck(ea.DeliveryTag, multiple: false);
}; channel.BasicConsume(queue: queueName, autoAck: false, consumer: consumer);

如此一来,拒收消息不会重入队列,并且现有队列绑定了死信交换机,因此,消息进入到死信队列中,如不绑定,则消息丢失。

限定重试次数

设置重试次数,限定循环消费的次数,允许短暂的循环,但最终打破循环。

消息头设定次数

在消息头中设置次数记录作为标记,但是,消费端无法对接收到的消息修改消息头然后将原消息送回MQ,因此,需要将原消息内容重新发送消息到MQ,具体步骤如下

  1. 原消息设置不重入队列。
  2. 再发送新的消息其内容与原消息一致,可设置新消息的消息头来携带重试次数。
  3. 消费端再次消费时,便可从消息头中查看消息被消费的次数。

此处假定接收10条消息,在接收到第5条消息时设置拒收, 当消息头中重试次数未超过设定的3次时,消息可以重入队列,再次被消费。

var queueName = "messageheaderretrycount_queue";
channel.QueueDeclare(queue: queueName, durable: false, exclusive: false, autoDelete: false, arguments: null);
channel.BasicQos(0, 5, false); var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
    var message = ea.Body;
    Console.WriteLine("接收到信息为:" + Encoding.UTF8.GetString(message.ToArray()));     if (Encoding.UTF8.GetString(message.ToArray()).Contains("5"))
    {
        var maxRetryCount = 3;         Console.WriteLine($"拒收 {DateTime.Now}");         //初次消费
        if (ea.BasicProperties.Headers == null)
        {
            //原消息设置为不重入队列
            ((EventingBasicConsumer)model).Model.BasicReject(ea.DeliveryTag, requeue: false);             //发送新消息到队列中
            RetryPublishMessage(channel, queueName, message.ToArray(), 1);
            return;
        }         //获取重试次数
        var retryCount = ParseRetryCount(ea);
        if (retryCount < maxRetryCount)
        {
            //原消息设置为不重入队列
            ((EventingBasicConsumer)model).Model.BasicReject(ea.DeliveryTag, requeue: false);             //发送新消息到队列中
            RetryPublishMessage(channel, queueName, message.ToArray(), retryCount + 1);
            return;
        }         //到达最大次数,不再重试消息
        ((EventingBasicConsumer)model).Model.BasicReject(ea.DeliveryTag, requeue: false);
        return;
    }     ((EventingBasicConsumer)model).Model.BasicAck(ea.DeliveryTag, multiple: false);
}; channel.BasicConsume(queue: queueName, autoAck: false, consumer: consumer); static void RetryPublishMessage(IModel channel, string queueName, byte[] body, int retryCount)
{
    var basicProperties = channel.CreateBasicProperties();
    basicProperties.Headers = new Dictionary<string, object>();
    basicProperties.Headers.Add("retryCount", retryCount);
    channel.BasicPublish(exchange: "", routingKey: queueName, basicProperties: basicProperties, body: body);
} static int ParseRetryCount(BasicDeliverEventArgs ea)
{
    var existRetryRecord = ea.BasicProperties.Headers.TryGetValue("retryCount", out object retryCount);
    if (!existRetryRecord)
    {
        throw new Exception("没有设置重试次数");
    }     return (int)retryCount;
}

消息被拒收后,再重新发送消息到原有交换机或是队列下中,以使得消息像是消费失败回到了队列中,如此来控制消费次数,但是这种场景下,新消息排在了队列的尾部,而不是原消息排在队列头部。

存储重试次数

在存储服务中存储消息的唯一标识与对应重试次数,消费消息前对消息进行判断是否存在。

与消息头判断一致,只是消息重试次数的存储从消息本身挪入存储服务中了。需要注意的是,消息发送端需要设置消息的唯一标识(MessageId属性)

//模拟外部存储服务
var MessageRetryCounts = new Dictionary<ulong, int>(); var queueName = "storageretrycount_queue";
channel.QueueDeclare(queue: queueName, durable: false, exclusive: false, autoDelete: false, arguments: null);
channel.BasicQos(0, 5, false); var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
    var message = ea.Body;
    Console.WriteLine("接收到信息为:" + Encoding.UTF8.GetString(message.ToArray())); if (Encoding.UTF8.GetString(message.ToArray()).Contains("50"))
{
    var maxRetryCount = 3;
    Console.WriteLine("拒收");     //重试次数判断
    var existRetryRecord = MessageRetryCounts.ContainsKey(ea.BasicProperties.MessageId);
    if (!existRetryRecord)
    {
        //重入队列,继续重试
        MessageRetryCounts.Add(ea.BasicProperties.MessageId, 1);
        ((EventingBasicConsumer)model).Model.BasicReject(ea.DeliveryTag, requeue: true);
        return;
    }     if (MessageRetryCounts[ea.BasicProperties.MessageId] < maxRetryCount)
    {
        //重入队列,继续重试
        MessageRetryCounts[ea.BasicProperties.MessageId] = MessageRetryCounts[ea.BasicProperties.MessageId] + 1;
        ((EventingBasicConsumer)model).Model.BasicReject(ea.DeliveryTag, requeue: true);
        return;
    }     //到达最大次数,不再重试消息
    ((EventingBasicConsumer)model).Model.BasicReject(ea.DeliveryTag, requeue: false);
    return;
}     ((EventingBasicConsumer)model).Model.BasicAck(ea.DeliveryTag, multiple: false);
}; channel.BasicConsume(queue: queueName, autoAck: false, consumer: consumer);

除第一次拒收外,允许三次重试机会,三次重试完毕后,设置requeue为false,消息丢失或进入死信队列(如有设置的话)。

队列使用Quorum类型

第一种和第二种分别是消息自身、外部存储服务来管理消息重试次数,使用Quorum,由MQ来限定消息的投递次数,也就控制了重试次数。

设置队列类型为quorum,设置投递最大次数,当超过投递次数后,消息被丢弃。

var queueName = "quorumtype_queue";
var arguments = new Dictionary<string, object>()
{
    { "x-queue-type", "quorum"},
    { "x-delivery-limit", 3 }
};
channel.QueueDeclare(queue: queueName, durable: true, exclusive: false, autoDelete: false, arguments: arguments);
channel.BasicQos(0, 5, false); var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
    var message = ea.Body;
    Console.WriteLine("接收到信息为:" + Encoding.UTF8.GetString(message.ToArray()));     if (Encoding.UTF8.GetString(message.ToArray()).Contains("50"))
    {
        Console.WriteLine($"拒收 {DateTime.Now}");
        ((EventingBasicConsumer)model).Model.BasicReject(ea.DeliveryTag, requeue: true);
        return;
    }     ((EventingBasicConsumer)model).Model.BasicAck(ea.DeliveryTag, multiple: false);
}; channel.BasicConsume(queue: queueName, autoAck: false, consumer: consumer);

第一次消费被拒收重入队列后,经最大三次投递后,消费端不再收到消息,如此一来也限制了消息的循环消费。

队列消息过期

当为消息设置了过期时间时,当消息没有受到Ack,且还在队列中,受到过期时间的限制,反复消费但未能成功时,消息将走向过期,进入死信队列或是被丢弃。

聚焦于过期时间的限制,因此在消费者端,因异常或是拒收消息时,需要对requeue设置为true,将消息再次重入到原队列中。

设定消费者端第五十条消息会被拒收,且队列的TTL设置为5秒。

//死信交换机和死信队列
var dlxExchangeName = "dlx_exchange";
channel.ExchangeDeclare(exchange: dlxExchangeName, type: "fanout", durable: false, autoDelete: false, arguments: null);
var dlxQueueName = "dlx_queue";
channel.QueueDeclare(queue: dlxQueueName, durable: false, exclusive: false, autoDelete: false, arguments: null);
channel.QueueBind(queue: dlxQueueName, exchange: dlxExchangeName, routingKey: ""); //常规队列
var queueName = "normalmessage_queue";
var arguments = new Dictionary<string, object>
{
    { "x-message-ttl", 5000},
    { "x-dead-letter-exchange", dlxExchangeName }
};
channel.QueueDeclare(queue: queueName, durable: false, exclusive: false, autoDelete: false, arguments: arguments);
channel.BasicQos(0, 5, false); var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
    var message = ea.Body;
    Console.WriteLine("接收到信息为:" + Encoding.UTF8.GetString(message.ToArray()));     if (Encoding.UTF8.GetString(message.ToArray()).Contains("50"))
    {
        Console.WriteLine($"拒收 {DateTime.Now}");         ((EventingBasicConsumer)model).Model.BasicReject(ea.DeliveryTag, requeue: true);
        return;
    }     ((EventingBasicConsumer)model).Model.BasicAck(ea.DeliveryTag, multiple: false);
}; channel.BasicConsume(queue: queueName, autoAck: false, consumer: consumer);

当消费者端拒收消息后消息重入队列,再次消费,反复进行超过5秒后,消息在队列中达到了过期时间,则被挪入到死信队列中。

从Web管理中死信队列中可查看该条过期的消息。

参考资料

  1. https://www.jianshu.com/p/f77a0b10c140
  2. https://www.jianshu.com/p/4904c609632f
  3. https://stackoverflow.com/questions/23158310/how-do-i-set-a-number-of-retry-attempts-in-rabbitmq

2022-10-29,望技术有成后能回来看见自己的脚步

.Net Core&RabbitMQ限制循环消费的更多相关文章

  1. 依赖注入[8]: .NET Core DI框架[服务消费]

    包含服务注册信息的IServiceCollection对象最终被用来创建作为DI容器的IServiceProvider对象.当需要消费某个服务实例的时候,我们只需要指定服务类型调用IServicePr ...

  2. IdentityServer4 + SignalR Core +RabbitMQ 构建web即时通讯(三)

    IdentityServer4 + SignalR Core +RabbitMQ 构建web即时通讯(三) 后台服务用户与认证 新建一个空的.net core web项目Demo.Chat,端口配置为 ...

  3. IdentityServer4 + SignalR Core +RabbitMQ 构建web即时通讯(二)

    IdentityServer4 + SignalR Core +RabbitMQ 构建web即时通讯(二) IdentityServer4 用户中心生成数据库 上文已经创建了所有的数据库上下文迁移代码 ...

  4. IdentityServer4 + SignalR Core +RabbitMQ 构建web即时通讯(一)

    IdentityServer4 + SignalR Core +RabbitMQ 构建web即时通讯 前言 .net core 2.1已经正式发布了,signalr core1.0随之发布,是时候写个 ...

  5. 面试官:RabbitMQ怎么实现消费端限流

    哈喽!大家好,我是小奇,一位不靠谱的程序员 小奇打算以轻松幽默的对话方式来分享一些技术,如果你觉得通过小奇的文章学到了东西,那就给小奇一个赞吧 文章持续更新 一.前言 RabbitMQ有很多高级特性, ...

  6. .net core RabbitMQ 消息队列

    上篇我们说到erlang的安装,现在有了基础前提,就可以继续安装RabbitMQ了! 这里我选用的RabbitMQ版本是: PS:这个RabbitMQ版本是要对应前面erlang版本,所以前面我们安装 ...

  7. 压测应用服务对RabbitMQ消息的消费能力--实践脚本

    最近运维跟我反馈我负责的应用服务线上监控到消费RabbitMQ消息队列过慢,目前只有20左右,监控平台会有消息积压的告警. 开发修改了一版应用服务的版本,提交给我做压测验证. 之前没有做过消息中间件的 ...

  8. .NET Core RabbitMQ探索(1)

    RabbitMQ可以被比作一个邮局,当你向邮局寄一封信时,邮局会保证将这封信送达你写的收件人,而RabbitMQ与邮局最主要的区别是,RabbitMQ并不真的处理信件,它处理的是二进制的数据块,它除了 ...

  9. 在.NET Core中遭遇循环依赖问题"A circular dependency was detected"

    今天在将一个项目迁移至ASP.NET Core的过程中遭遇一个循环依赖问题,错误信息如下: A circular dependency was detected for the service of ...

随机推荐

  1. MySQL 数据查询语句

    一般查询 字段取别名 别名不用加单引号,as 可省略. select t.id ID, t.name 名称 from grade t; 拼接字符串 concat(a, b) select concat ...

  2. java学习第七天注解.day19

    注解 可以使用注解来修饰类中的成员信息 "注解,可以看作是对 一个 类/方法 的一个扩展的模版 元注解 注解:用来贴在类/方法/变量等之上的一个标记,第三方程序可以通过这个标记赋予一定功能 ...

  3. Web 前端模块出现的原因,以及 Node.js 中的模块

    模块出现原因 简单概述 随着 Web 2.0 时代的到来,JavaScript 不再是以前的小脚本程序了,它在前端担任了更多的职责,也逐渐地被广泛运用在了更加复杂的应用开发的级别上. 但是 JavaS ...

  4. linux centos7开启防火墙端口

    firewall-cmd --zone=public --add-port=3306/tcp --permanent firewall-cmd --reload

  5. LOJ6029「雅礼集训 2017 Day1」市场 (线段树)

    题面 从前有一个学校,在 O n e I n D a r k \rm OneInDark OneInDark 到任之前风气都是非常良好的,自从他来了之后,发布了一系列奇怪的要求,挟制了整个学校,导致风 ...

  6. Springboot连接数据库 (解决报错)

    好家伙,来解决报错 1.新建项目时, 将SQL的" Spring Date 'jdbc' "点上 2.使用idea快速创建springboot项目时会出现连接不到服务器的情况 这里 ...

  7. 轻量级RTSP服务和内置RTSP网关有什么不同?

    好多开发者疑惑,什么是内置RTSP网关,和轻量级RTSP服务又有什么区别和联系?本文就以上问题,做个简单的介绍: 轻量级RTSP服务 为满足内网无纸化/电子教室等内网超低延迟需求,避免让用户配置单独的 ...

  8. java的数据类型分为两大类

    java的数据类型分为两大类 基本类型(primitive type) 数据类型 整数类型 byte占一个字节范围:-128-127 short占两个字节范围:-32768-32767 int占四个字 ...

  9. C/C++内存泄漏检测方法

    1. 内存泄漏 内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果. 2. 检测代码 使用链 ...

  10. 天天写SQL,这些神奇的特性你知道吗?

    摘要:不要歪了,我这里说特性它不是 bug,而是故意设计的机制或语法,你有可能天天写语句或许还没发现原来还能这样用,没关系我们一起学下涨姿势. 本文分享自华为云社区<[云驻共创]天天写 SQL, ...