一、需求描述
在电商、支付等领域,往往会有这样的场景,用户下单后放弃支付了,那这笔订单会在指定的时间段后进行关闭操作,而且时间很准确,误差在1s内。
二、实现方案
- 定时任务关闭订单(不推荐)
- RocketMQ延迟队列(不够灵活)
- RabbitMQ死信队列(不推荐)
- RabbitMQ的delay插件rabbitmq_delayed_message_exchange实现延时消息(推荐)
- 时间轮算法
- Redis过期监听
三、方案详解
3.1 定时任务关闭订单
一般情况下,最不推荐的方式就是关单方式就是定时任务方式。我们假设,关单时间为下单后10分钟,定时任务间隔也是10分钟,如果在第1分钟下单,在第20分钟的时候才能被扫描到执行关单操作,这样误差达到10分钟,这在很多场景下是不可接受的,另外需要频繁扫描主订单号造成网络IO和磁盘IO的消耗,对实时交易造成一定的冲击,所以PASS。
3.2 RocketMQ延迟队列方式
延迟消息 生产者把消息发送到消息服务器后,并不希望被立即消费,而是等待指定时间后才可以被消费者消费,这类消息通常被称为延迟消息。 在RocketMQ开源版本中,支持延迟消息,但是不支持任意时间精度的延迟消息,只支持特定级别的延迟消息。 消息延迟级别分别为1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,共18个级别。
这种方式相比定时任务好了很多,但是有一个致命的缺点,就是延迟等级只有18种(商业版本支持自定义时间),如果我们想把关闭订单时间设置在15分钟该如何处理呢?显然不够灵活。
3.3 RabbitMQ死信队列
Rabbitmq本身是没有延迟队列的,只能通过Rabbitmq本身队列的特性来实现,想要Rabbitmq实现延迟队列,需要使用Rabbitmq的死信交换机(Exchange)和消息的存活时间TTL(Time To Live)。
一个消息在满足如下条件下,会进死信交换机,记住这里是交换机而不是队列,一个交换机可以对应很多队列。
一个消息被Consumer拒收了,并且reject方法的参数里requeue是false。也就是说不会被再次放在队列里,被其他消费者使用。 上面的消息的TTL到了,消息过期了。
队列的长度限制满了,排在前面的消息会被丢弃或者扔到死信路由上。 死信交换机就是普通的交换机,只是因为我们把过期的消息扔进去,所以叫死信交换机,并不是说死信交换机是某种特定的交换机。
消息TTL(消息存活时间) 消息的TTL就是消息的存活时间。RabbitMQ可以对队列和消息分别设置TTL。对队列设置就是队列没有消费者连着的保留时间,也可以对每一个单独的消息做单独的设置。超过了这个时间,我们认为这个消息就死了,称之为死信。如果队列设置了,消息也设置了,那么会取值较小的。所以一个消息如果被路由到不同的队列中,这个消息死亡的时间有可能不一样(不同的队列设置)。这里单讲单个消息的TTL,因为它才是实现延迟任务的关键。
可以通过设置消息的expiration字段或者x-message-ttl属性来设置时间,两者是一样的效果。只是expiration字段是字符串参数,所以要写个int类型的字符串,当上面的消息扔到队列中后,过了60秒,如果没有被消费,它就死了。不会被消费者消费到。这个消息后面的,没有“死掉”的消息对顶上来,被消费者消费。死信在队列中并不会被删除和释放,它会被统计到队列的消息数中去。
3.4 RabbitMQ的delay插件rabbitmq_delayed_message_exchange实现延时消息
3.4.1 安装插件
去RabbitMQ的官网下载插件,插件地址:https://www.rabbitmq.com/community-plugins.html
,直接搜索rabbitmq_delayed_message_exchange
即可找到我们需要下载的插件,下载和RabbitMQ配套的版本。
3.4.2 与RabbitMQ死信队列实现方式对比
- 死信队列:死信队列是这样一个队列,如果消息发送到该队列并超过了设置的时间,就会被转发到设置好的处理超时消息的队列当中去,利用该特性可以实现延迟消息。
- 延迟插件:通过安装插件,自定义交换机,让交换机拥有延迟发送消息的能力,从而实现延迟消息。
- 总结:由于死信队列方式需要创建两个交换机(死信队列交换机+处理队列交换机)、两个队列(死信队列+处理队列),而延迟插件方式只需创建一个交换机和一个队列,所以后者使用起来更简单。
3.5 时间轮算法
1、创建环形队列,例如可以创建一个包含3600个slot的环形队列(本质是个数组)
2、任务集合,环上每一个slot是一个Set,同时启动一个timer,这个timer每隔1s,在上述环形队列中移动一格,有一个Current Index指针来标识正在检测的slot。
Task结构中有两个很重要的属性:
1、Cycle-Num:当Current Index第几圈扫描到这个Slot时,执行任务。
2、订单号:要关闭的订单号(也可以是其他信息,比如:是一个基于某个订单号的任务)
假设当前Current Index指向第0格,例如在3610秒之后,有一个订单需要关闭,只需:
1、计算这个订单应该放在哪一个slot,当我们计算的时候现在指向1,3610秒之后,应该是第10格,所以这个Task应该放在第10个slot的Set中。
2、计算这个Task的Cycle-Num,由于环形队列是3600格(每秒移动一格,正好1小时),这个任务是3610秒后执行,所以应该绕3610/3600=1圈之后再执行,于是Cycle-Num=1
Current Index不停的移动,每秒移动到一个新slot,这个slot中对应的Set,每个Task看Cycle-Num是不是0。如果不是0,说明还需要多移动几圈,将Cycle-Num减1。如果是0,说明马上要执行这个关单Task了,取出订单号执行关单(可以用单独的线程来执行Task),并把这个订单信息从Set中删除即可。
1、无需再轮询全部订单,效率高
2、一个订单,任务只执行一次
3、时效性好,精确到秒(控制timer移动频率可以控制精度)。
3.6 Redis过期监听
1、修改redis.windows.conf
配置文件中notify-keyspace-events
的值,默认配置notify-keyspace-events
的值为 "" 修改为 notify-keyspace-events Ex
这样便开启了过期事件
2、 创建配置类RedisListenerConfig
(配置RedisMessageListenerContainer
这个bean)。
3、继承KeyExpirationEventMessageListener
创建Redis过期事件的监听类。