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 的时间轮,这些方式各有特点,看需要适用的场景