在电商、支付等领域,往往会有这样的场景,用户下单后放弃支付了,那这笔订单会在指定的时间段后进行关闭操作,细心的你一定发现了像某宝、某东都有这样的逻辑,而且时间很准确,误差在1s内;那他们是怎么实现的呢?
文章转载:乐字节
一般的做法有如下几种
定时任务关闭订单
rocketmq延迟队列
rabbitmq死信队列
时间轮算法
redis过期监听
一、定时任务关闭订单(最low)
一般情况下,最不推荐的方式就是关单方式就是定时任务方式,原因我们可以看下面的图来说明
我们假设,关单时间为下单后10分钟,定时任务间隔也是10分钟;通过上图我们看出,如果在第1分钟下单,在第20分钟的时候才能被扫描到执行关单操作,这样误差达到10分钟,这在很多场景下是不可接受的,另外需要频繁扫描主订单号造成网络io和磁盘io的消耗,对实时交易造成一定的冲击,所以pass
二、rocketmq延迟队列方式
延迟消息 生产者把消息发送到消息服务器后,并不希望被立即消费,而是等待指定时间后才可以被消费者消费,这类消息通常被称为延迟消息。 在rocketmq开源版本中,支持延迟消息,但是不支持任意时间精度的延迟消息,只支持特定级别的延迟消息。 消息延迟级别分别为1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,共18个级别。
发送延迟消息(生产者)
消费延迟消息(消费者)
实现监听类,处理具体逻辑
这种方式相比定时任务好了很多,但是有一个致命的缺点,就是延迟等级只有18种(商业版本支持自定义时间),如果我们想把关闭订单时间设置在15分钟该如何处理呢?显然不够灵活。
三、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秒,如果没有被消费,它就死了。不会被消费者消费到。这个消息后面的,没有“死掉”的消息对顶上来,被消费者消费。死信在队列中并不会被删除和释放,它会被统计到队列的消息数中去
处理流程图
创建交换机(exchanges)和队列(queues)
创建死信交换机
如图所示,就是创建一个普通的交换机,这里为了方便区分,把交换机的名字取为:delay
创建自动过期消息队列 这个队列的主要作用是让消息定时过期的,比如我们需要2小时候关闭订单,我们就需要把消息放进这个队列里面,把消息过期时间设置为2小时
创建一个一个名为delay_queue1的自动过期的队列,当然图片上面的参数并不会让消息自动过期,因为我们并没有设置x-message-ttl参数,如果整个队列的消息有消息都是相同的,可以设置,这里为了灵活,所以并没有设置,另外两个参数x-dead-letter-exchange代表消息过期后,消息要进入的交换机,这里配置的是delay,也就是死信交换机,x-dead-letter-routing-key是配置消息过期后,进入死信交换机的routing-key,跟发送消息的routing-key一个道理,根据这个key将消息放入不同的队列
创建消息处理队列 这个队列才是真正处理消息的队列,所有进入这个队列的消息都会被处理
消息队列的名字为delay_queue2 消息队列绑定到交换机 进入交换机详情页面,将创建的2个队列(delayqueue1和delayqueue2)绑定到交换机上面
自动过期消息队列的routing key 设置为delay 绑定delayqueue2
delayqueue2 的key要设置为创建自动过期的队列的x-dead-letter-routing-key参数,这样当消息过期的时候就可以自动把消息放入delay_queue2这个队列中了 绑定后的管理页面如下图:
当然这个绑定也可以使用代码来实现,只是为了直观表现,所以本文使用的管理平台来操作 发送消息
设置了让消息6秒后过期 注意:因为要让消息自动过期,所以一定不能设置delay_queue1的监听,不能让这个队列里面的消息被接受到,否则消息一旦被消费,就不存在过期了
接收消息 接收消息配置好delay_queue2的监听就好了
这种方式可以自定义进入死信队列的时间;是不是很完美,但是有的小伙伴的情况是消息中间件就是rocketmq,公司也不可能会用商业版,怎么办?那就进入下一节
四、时间轮算法
(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: (1)如果不是0,说明还需要多移动几圈,将cycle-num减1 (2)如果是0,说明马上要执行这个关单task了,取出订单号执行关单(可以用单独的线程来执行task),并把这个订单信息从set中删除即可。 (1)无需再轮询全部订单,效率高 (2)一个订单,任务只执行一次 (3)时效性好,精确到秒(控制timer移动频率可以控制精度)
五、redis过期监听
1.修改redis.windows.conf配置文件中notify-keyspace-events的值 默认配置notify-keyspace-events的值为 "" 修改为 notify-keyspace-events ex 这样便开启了过期事件
2. 创建配置类redislistenerconfig(配置redismessagelistenercontainer这个bean)
3.继承keyexpirationeventmessagelistener创建redis过期事件的监听类
4:测试 通过redis客户端存一个有效时间为3s的订单:
结果:
总结: 以上方法只是个人对于关单的一些想法,可能有些地方有疏漏,请在公众号直接留言进行指出,当然如果你有更好的关单方式也可以随时沟通交流