7.【RabbitMQ实战】- 延迟队列

概念

延时队列,队列内部是有序的,最重要的特性就体现在它的延时属性上,延时队列中的元素是希望在指定时间到了以后或之前取出和处理,简单来说,延时队列就是用来存放需要在指定时间被处理的元素的队列

死信队列的一种,设置死信队列 TTL即为延迟队列

场景

  1. 订单在十分钟之内未支付则自动取消
  2. 新创建的店铺,如果在十天内都没有上传过商品,则自动发送消息提醒。
  3. 用户注册成功后,如果三天内没有登陆则进行短信提醒。
  4. 用户发起退款,如果三天内没有得到处理则通知相关运营人员。
  5. 预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议

设置消息TTL

生产者代码针对每条消息设置TLL

var props = channel.CreateBasicProperties();
props.Expiration = "1000";
channel.BasicPublish(exchange: RabbitmqUntils.test_exchange, RabbitmqUntils.test_routingkey, false, props, body); //开始传递

image.png

设置队列TTL

设置队列的x-message-ttl 属性
image.png

代码架构图

创建两个队列 QA 和 QB,两者队列 TTL 分别设置为 10S 和 40S,然后在创建一个交换机 X 和死信交换机 Y,它们的类型都是direct,创建一个死信队列 QD,它们的绑定关系如下

image.png

RabbitmqUntils配置代码如下

using RabbitMQ.Client;
using RabbitMQ.Client.Events;

using System.Data.SqlTypes;
using System.Runtime.CompilerServices;
using System.Threading.Channels;

namespace rabbitmq.common
{
    /// <summary>
    /// 工具类
    /// </summary>
    public class RabbitmqUntils
    {
        /// <summary>
        /// 对列名称
        /// </summary>
        public static string QueueName { get; set; } = "test_hello";
        public static string WorkQueueName { get; set; } = "test_WorkQueue";
        public static string AckQueueName { get; set; } = "test_AckQueueName";
        public static string FanoutExchangeName { get; set; } = "test_FanoutExchangeName";
        public static string DirectExchangeName { get; set; } = "test_DirectExchangeName";
        public static string DirectQueueOneName { get; set; } = "test_DirectQueueOneName";
        public static string DirectQueueTwoName { get; set; } = "test_DirectQueueTwoName";
        public static string DirectRoutingkeyOrange { get; set; } = "test_DirectRoutingkeyOrange";
        public static string DirectRoutingkeyBlack { get; set; } = "test_DirectRoutingkeyBlack";
        public static string DirectRoutingkeyGreen { get; set; } = "test_DirectRoutingkeyGreen";
        public static string TopicExchangeName { get; set; } = "test_TopicExchangeName";

        public static string test_exchange { get; set; } = "test_exchange";
        public static string test_queue { get; set; } = "test_queue";
        public static string test_routingkey { get; set; } = "test";
        public static string dead_exchange { get; set; } = "dead_exchange";
        public static string dead_queue { get; set; } = "dead_queue";
        public static string dead_routingkey { get; set; } = "dead";

        public static string X_Exchange { get; set; } = "X";
        public static string Y_Exchange { get; set; } = "Y";
        public static string QA_Queue { get; set; } = "QA";
        public static string QB_Queue { get; set; } = "QB";
        public static string QD_Queue { get; set; } = "QD";

        /// <summary>
        /// 得到一个Channel  作为轻量级的Connection极大减少了操作系统建立TCPconnection的开销
        /// </summary>
        /// <returns></returns>
        public static IModel GetChannel()
        {
            //创建一个连接工厂
            ConnectionFactory connectionFactory = new ConnectionFactory();
            connectionFactory.HostName = "localhost";
            connectionFactory.UserName = "guest";
            connectionFactory.Password = "guest";
            var connection = connectionFactory.CreateConnection();
            var channel = connection.CreateModel();
            return channel;
        }

        /// <summary>
        /// 创建一个延迟队列及其相关配置
        /// </summary>
        public static IModel GetTTLQueue()
        {
            var channel = RabbitmqUntils.GetChannel();

            // 申明X交换机
            // 申明Y交换机
            // 申明QA队列 ttl 为 10s 并绑定到对应的死信交换机
            // 申明QB队列 ttl 为 40s 并绑定到对应的死信交换机
            // 申明QD队列

            channel.ExchangeDeclare(RabbitmqUntils.X_Exchange, "direct");
            channel.ExchangeDeclare(RabbitmqUntils.Y_Exchange, "direct");

            var argumentsA = new Dictionary<string, object> { };
            argumentsA.Add("x-dead-letter-exchange", RabbitmqUntils.Y_Exchange);//声明当前队列绑定的死信交换机
            argumentsA.Add("x-dead-letter-routing-key", "YD");//声明当前队列的死信路由 key
            argumentsA.Add("x-message-ttl", 10000);//声明队列的 TTL
            channel.QueueDeclare(RabbitmqUntils.QA_Queue, false,false,false, argumentsA);
            channel.QueueBind(RabbitmqUntils.QA_Queue, RabbitmqUntils.X_Exchange, "XA");

            var argumentsB = new Dictionary<string, object> { };
            argumentsB.Add("x-dead-letter-exchange", RabbitmqUntils.Y_Exchange);//声明当前队列绑定的死信交换机
            argumentsB.Add("x-dead-letter-routing-key", "YD");//声明当前队列的死信路由 key
            argumentsB.Add("x-message-ttl", 40000);//声明队列的 TTL
            channel.QueueDeclare(RabbitmqUntils.QB_Queue, false, false, false, argumentsB);
            channel.QueueBind(RabbitmqUntils.QB_Queue, RabbitmqUntils.X_Exchange, "XB");

            channel.QueueDeclare(RabbitmqUntils.QD_Queue,false, false, false, null);
            channel.QueueBind(RabbitmqUntils.QD_Queue, RabbitmqUntils.Y_Exchange, "YD");

            return channel;
        }

    }

}

TestContorller代码(webapi)

using Microsoft.AspNetCore.Mvc;

using rabbitmq.common;

using RabbitMQ.Client;
using RabbitMQ.Client.Events;

using System.ComponentModel.DataAnnotations;
using System.Text;

namespace TTLExchange
{

    [Route("api/[controller]")]
    [ApiController]
    public class TestContorller:ControllerBase
    {
        private ILogger<TestContorller> logger;

        public TestContorller(ILogger<TestContorller> logger)
        {
            this.logger = logger;
        }

        [HttpGet]
        [Route("api/test/SendMsg")]
        public IActionResult SendMsg([Required]string msg)
        {
            logger.LogInformation($"开始发送消息");
            using var channel = RabbitmqUntils.GetTTLQueue();
            var qaMsg = $"消息来自ttl 为 10s QA队列{msg}";
            var qbMsg = $"消息来自ttl 为 40s QB队列{msg}";
            var qaBody = Encoding.UTF8.GetBytes(qaMsg);
            var qbBody = Encoding.UTF8.GetBytes(qbMsg);
            channel.BasicPublish(RabbitmqUntils.X_Exchange,"XA",false,null, qaBody);
            channel.BasicPublish(RabbitmqUntils.X_Exchange,"XB",false,null, qbBody);

            logger.LogInformation($"{DateTime.Now} 发送消息:{qaMsg}");
            logger.LogInformation($"{DateTime.Now} 发送消息:{qbMsg}");
            logger.LogInformation($"发送完成");

            return Ok($"{DateTime.Now} 发送消息:{msg}");
        }


        [HttpGet]
        [Route("api/test/ReciveMsg")]
        public IActionResult ReciveMsg()
        {
            logger.LogInformation($"开始接受消息");
            using var channel = RabbitmqUntils.GetTTLQueue();
            //事件对象
            var consumer = new EventingBasicConsumer(channel);           
            // 接收消息回调
            consumer.Received += (sender, e) =>
            {
                var body = e.Body.ToArray();
                var message = Encoding.UTF8.GetString(body);
                logger.LogInformation($"{DateTime.Now} 接收消息:{message}");
                channel.BasicAck(e.DeliveryTag, false);
            };
            // autoAck:false 手动应答
            channel.BasicConsume(queue: RabbitmqUntils.QD_Queue, false, consumer);

            Thread.Sleep(60000); // 注意测试需要:此处需要手动休眠等待 演示消息正常消费,否则return之后线程结束,消费者即不在线无法正常消费消息
            return Ok();
        }
    }
}

测试效果

  • 手动调用生产者代码 SendMsg 方法
  • 手动调用消费者代码 ReciveMsg方法

image.png
image.png
image.png

image.png

第一条消息在 10S 后变成了死信消息,然后被消费者消费掉,第二条消息在 40S 之后变成了死信消息,然后被消费掉,这样一个延时队列就打造完成了。
不过,如果这样使用的话,岂不是每增加一个新的时间需求,就要新增一个队列,这里只有 10S 和 40S两个时间选项,如果需要一个小时后处理,那么就需要增加TTL 为一个小时的队列,如果是预定会议室然后提前通知这样的场景,岂不是要增加无数个队列才能满足需求?

延迟队列优化

代码架构图

在这里新增了一个队列 QC,绑定关系如下,该队列不设置TTL 时间
image.png

RabbitmqUntils配置代码调整如下

    // 申明QC队列并配置转发到死信队列QD
    // X交换机绑定QC
    var argumentsC = new Dictionary<string, object>();
    argumentsC.Add("x-dead-letter-exchange", Y_Exchange);
    argumentsC.Add("x-dead-letter-routing-key", "YD");
    channel.QueueDeclare(QC_Queue, false, false, false, argumentsC);
    channel.QueueBind(QC_Queue, X_Exchange, "XC"); //队列QC绑定X交换机

image.png

TestContorller代码(webapi)调整如下

[HttpGet]
[Route("api/test/SenExpirationMsg")]
public IActionResult SenExpirationMsg([Required] string msg, [Required] string expiration)
{
    logger.LogInformation($"开始发送消息");
    using var channel = RabbitmqUntils.GetTTLQueue();          
    var message = $"当前时间:{DateTime.Now} 发送消息{msg} 时长{expiration} ms";
    var body = Encoding.UTF8.GetBytes(message);

    // 设置消息TTL
    var props = channel.CreateBasicProperties();
    props.Expiration = expiration;
    channel.BasicPublish(RabbitmqUntils.X_Exchange, "XC", false, props, body);

    logger.LogInformation($"{DateTime.Now} 发送消息:{message}");
    return Ok();
}

image.png

测试效果

  1. 调用SenExpirationMsg先发送 msg="111",expiration=20000ms的消息

image.png

  1. 调用SenExpirationMsg在发送 msg="222",expiration=2000ms的消息

image.png

  1. 调用ReciveMsg等待控制台日志

image.png

可以看到消息已经正常接受到并且接受时间都是20s,按照我们预期应该先接受消息“222”因为它的延时为2s,在接受消息“111”,因为它的延时为20s,因为这是在同一个队列,必须前一个消费,第二个才能消费,所以就出现了时序问题

如果使用在消息属性上设置 TTL 的方式,消息可能并不会按时“死亡“,因为 RabbitMQ 只会检查第一个消息是否过期,如果过期则丢到死信队列,如果第一个消息的延时时长很长,而第二个消息的延时时长很短,第二个消息并不会优先得到执行。此时我们可以使用社区提供的延时队列插件解决上述问题

Rabbitmq 插件实现延时队列

实现原理

这里将使用的是一个 RabbitMQ 延迟消息插件 rabbitmq-delayed-message-exchange,目前维护在 RabbitMQ 插件社区,我们可以声明 x-delayed-message 类型的 Exchange,消息发送时指定消息头 x-delay 以毫秒为单位将消息进行延迟投递。
image.png

上面使用 DLX + TTL 的模式,消息首先会路由到一个正常的队列,根据设置的 TTL 进入死信队列,与之不同的是通过 x-delayed-message 声明的交换机,它的消息在发布之后不会立即进入队列,先将消息保存至 Mnesia(一个分布式数据库管理系统,适合于电信和其它需要持续运行和具备软实时特性的 Erlang 应用。目前资料介绍的不是很多)
这个插件将会尝试确认消息是否过期,首先要确保消息的延迟范围是 Delay > 0, Delay =< ?ERL_MAX_T(在 Erlang 中可以被设置的范围为 (2^32)-1 毫秒),如果消息过期通过 x-delayed-type 类型标记的交换机投递至目标队列,整个消息的投递过程也就完成了。

Windows环境Rabbitmq 安装延时队列插件

  1. 官网 https://www.rabbitmq.com/community-plugins.html 下载rabbitmq_delayed_message_exchange

image.png

  1. 下载对应版本的插件(服务安装的为3.8版本)

image.png

  1. 将下载的插件放到对应的/plugins目录并重启服务

image.png

  1. 使用cmd命令进到对应的/sbin目录下找到对应插件

插件安装命令详见官网 https://www.rabbitmq.com/plugins.html

image.png

  1. 安装成功 rabbitmq-plugins enable rabbitmq_delayed_message_exchange

image.png
image.png

代码架构图

在这里新增了一个队列delayed.queue,一个自定义交换机delayed.exchange,绑定关系如下:
image.png

RabbitmqUntils配置代码调整如下

新增 GetDelayedQueue() 方法

        public static IModel GetDelayedQueue()
        {
            var channel = RabbitmqUntils.GetChannel();
            var arguments = new Dictionary<string, object>();
            arguments.Add("x-delayed-type", "direct");// 指定x-delayed-message 类型的交换机,并且添加x-delayed-type属性
            channel.ExchangeDeclare(Delayed_Exchange, "x-delayed-message", true,false, arguments);
            channel.QueueDeclare(Delayed_Queue,false,false,false,null);
            // 队列delayed.queue 绑定自定义交换机delayed.exchange
            channel.QueueBind(Delayed_Queue, Delayed_Exchange, Delayed_Routingkey);
            return channel;
        }

image.png

TestContorller代码(webapi)调整如下

        [HttpGet]
        [Route("api/test/SendDelayedMsg")]
        public IActionResult SendDelayedMsg([Required] string msg, [Required] string delayedTime)
        {
            logger.LogInformation($"开始发送消息");
            using var channel = RabbitmqUntils.GetDelayedQueue();
            var body = Encoding.UTF8.GetBytes(msg);
            
            var props = channel.CreateBasicProperties();
            props.Headers = new Dictionary<string, object> 
            {
                { "x-delay", delayedTime }   // 一定要设置,否则无效
            };
            channel.BasicPublish(RabbitmqUntils.Delayed_Exchange, RabbitmqUntils.Delayed_Routingkey, false, props, body);

            logger.LogInformation($"{DateTime.Now} 发送消息:{msg} delayedTime={delayedTime}ms");
            return Ok();
        }


		[HttpGet]
        [Route("api/test/ReciveDelayedMsg")]
        public IActionResult ReciveDelayedMsg()
        {
            logger.LogInformation($"开始接受消息");
            using var channel = RabbitmqUntils.GetDelayedQueue();
            //事件对象
            var consumer = new EventingBasicConsumer(channel);
            // 接收消息回调
            consumer.Received += (sender, e) =>
            {
                var body = e.Body.ToArray();
                var message = Encoding.UTF8.GetString(body);
                logger.LogInformation($"{DateTime.Now} 接收消息:{message}");
                channel.BasicAck(e.DeliveryTag, false);
            };
            // autoAck:false 手动应答
            channel.BasicConsume(queue: RabbitmqUntils.Delayed_Queue, false, consumer);

            Thread.Sleep(60000); // 注意测试需要:此处需要手动休眠等待 演示消息正常消费,否则return之后线程结束,消费者即不在线无法正常消费消息
            return Ok();
        }

image.png
image.png

测试效果

  1. 可看见x-delayed-message类型的交换机 delayed.exchange

image.png

  1. 调用SendDelayedMsg先发送 msg="111",expiration=20000ms的消息

image.png

  1. 调用SendDelayedMsg在发送 msg="222",expiration=2000ms的消息

image.png

  1. 调用ReciveDelayedMsg等待控制台日志

image.png

总结

延时队列在需要延时处理的场景下非常有用,使用 RabbitMQ 来实现延时队列可以很好的利用
RabbitMQ 的特性,如:消息可靠发送、消息可靠投递、死信队列来保障消息至少被消费一次以及未被正
确处理的消息不会被丢弃。另外,通过 RabbitMQ 集群的特性,可以很好的解决单点故障问题,不会因为
单个节点挂掉导致延时队列不可用或者消息丢失。
当然,延时队列还有很多其它选择,比如利用 Java 的 DelayQueue,利用 Redis 的 zset,利用 Quartz 或者利用 kafka 的时间轮,这些方式各有特点,看需要适用的场景

posted @ 2023-04-12 22:45  无敌土豆  阅读(75)  评论(0编辑  收藏  举报