7.【RabbitMQ实战】- 延迟队列
概念
延时队列,队列内部是有序的,最重要的特性就体现在它的延时属性上,延时队列中的元素是希望在指定时间到了以后或之前取出和处理,简单来说,延时队列就是用来存放需要在指定时间被处理的元素的队列
场景
- 订单在十分钟之内未支付则自动取消
 - 新创建的店铺,如果在十天内都没有上传过商品,则自动发送消息提醒。
 - 用户注册成功后,如果三天内没有登陆则进行短信提醒。
 - 用户发起退款,如果三天内没有得到处理则通知相关运营人员。
 - 预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议
 
设置消息TTL
生产者代码针对每条消息设置TLL
var props = channel.CreateBasicProperties();
props.Expiration = "1000";
channel.BasicPublish(exchange: RabbitmqUntils.test_exchange, RabbitmqUntils.test_routingkey, false, props, body); //开始传递

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

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方法 




第一条消息在 10S 后变成了死信消息,然后被消费者消费掉,第二条消息在 40S 之后变成了死信消息,然后被消费掉,这样一个延时队列就打造完成了。
不过,如果这样使用的话,岂不是每增加一个新的时间需求,就要新增一个队列,这里只有 10S 和 40S两个时间选项,如果需要一个小时后处理,那么就需要增加TTL 为一个小时的队列,如果是预定会议室然后提前通知这样的场景,岂不是要增加无数个队列才能满足需求?
延迟队列优化
代码架构图
在这里新增了一个队列 QC,绑定关系如下,该队列不设置TTL 时间
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交换机

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();
}

测试效果
- 调用
SenExpirationMsg先发送 msg="111",expiration=20000ms的消息 

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

- 调用
ReciveMsg等待控制台日志 

可以看到消息已经正常接受到并且接受时间都是20s,按照我们预期应该先接受消息“222”因为它的延时为2s,在接受消息“111”,因为它的延时为20s,因为这是在同一个队列,必须前一个消费,第二个才能消费,所以就出现了时序问题。
如果使用在消息属性上设置 TTL 的方式,消息可能并不会按时“死亡“,因为 RabbitMQ 只会检查第一个消息是否过期,如果过期则丢到死信队列,如果第一个消息的延时时长很长,而第二个消息的延时时长很短,第二个消息并不会优先得到执行。此时我们可以使用社区提供的延时队列插件解决上述问题
Rabbitmq 插件实现延时队列
实现原理
这里将使用的是一个 RabbitMQ 延迟消息插件 rabbitmq-delayed-message-exchange,目前维护在 RabbitMQ 插件社区,我们可以声明 x-delayed-message 类型的 Exchange,消息发送时指定消息头 x-delay 以毫秒为单位将消息进行延迟投递。
上面使用 DLX + TTL 的模式,消息首先会路由到一个正常的队列,根据设置的 TTL 进入死信队列,与之不同的是通过 x-delayed-message 声明的交换机,它的消息在发布之后不会立即进入队列,先将消息保存至 Mnesia(一个分布式数据库管理系统,适合于电信和其它需要持续运行和具备软实时特性的 Erlang 应用。目前资料介绍的不是很多)
这个插件将会尝试确认消息是否过期,首先要确保消息的延迟范围是 Delay > 0, Delay =< ?ERL_MAX_T(在 Erlang 中可以被设置的范围为 (2^32)-1 毫秒),如果消息过期通过 x-delayed-type 类型标记的交换机投递至目标队列,整个消息的投递过程也就完成了。
Windows环境Rabbitmq 安装延时队列插件
- 官网 https://www.rabbitmq.com/community-plugins.html 下载
rabbitmq_delayed_message_exchange 

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

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

- 使用cmd命令进到对应的
/sbin目录下找到对应插件 
插件安装命令详见官网 https://www.rabbitmq.com/plugins.html

- 安装成功  
rabbitmq-plugins enable rabbitmq_delayed_message_exchange 


代码架构图
在这里新增了一个队列delayed.queue,一个自定义交换机delayed.exchange,绑定关系如下:
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;
        }

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();
        }


测试效果
- 可看见
x-delayed-message类型的交换机delayed.exchange 

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

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

- 调用
ReciveDelayedMsg等待控制台日志 

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


                
            
        
浙公网安备 33010602011771号