挑挑刺儿~高并发下订单延时关闭功能技术方案怎么考虑?

前言

现如今,高并发下的订单未支付延时失效功能已经成为商家必备的一种应对措施。在这篇文章中,将会介绍三种常用的技术方案实现原理和其不足之处,希望给大家技术选型的时候带来多一点思路嘞~

Redis 过期监听

过期机制简介

Redis的过期机制允许开发者为某个Key设置一个时间。 (但是,Redis并不会即时地删除过期的键,而是通过一种叫做「惰性删除」的方式来处理过期键。这意味着,Redis只有在访问某个键时才会检查该键是否过期,如果过期就会被删除) 。这个时间主要通过Redis命令EXPIRE或PEXPIRE来设置,单位可以是秒或毫秒。当时间到期后被访问,Redis将会将这个Key加入到一个特殊的队列中,通过消息事件监听器,持续监听该队列中的 ****Redis 的过期事件。监听器使用 Redis 的 PSUBSCRIBE 命令 ****订阅过期事件,并在监听到过期事件时,消息监听器捕获到该事件,并获取到过期的订单号。接着,监听器执行订单关闭的逻辑,如更新订单状态为关闭状态,释放相关资源等,实现订单的十分钟延时关闭功能。

当一个键过期时,并不会立即通知应用程序。相反,对于过期键的处理通过一种异步方式进行。Redis会在后台定期检查过期键并删除它们,但这实际上是由Redis自己管理,而不是应用程序主动获取通知。不是说客户端去查了才进行判断是否过期

不足之处

  • 由于Redis key过期删除是定时+惰性,当key过多时,删除会有延迟,回调通知同样会有延迟。在极端情况下,可能会有一小段时间的延迟,在高并发下,偏差更大,如果对业务的准确性要求非常严格,需要考虑其他方案。
  • 通知是一次性的,没有ack机制,若收到通知后处理失败,将不再收到通知。需自行保证收到通知后处理成功。需要手动在订单关闭逻辑中添加容错机制,以应对 Redis 可能发生故障或重启的情况
  • 使用Redis实现延时队列时,需要自己处理并发冲突的情况。例如,多个客户端同时进行操作时,可能存在竞争条件,需要使用Redis的乐观锁或其他并发控制机制来解决。
  • 消息堆积,缓冲区溢出,消费者会被强制踢下线,数据也会丢失
  • 使用Redis实现延时队列,意味着你需要依赖于Redis服务。如果Redis服务出现故障或不可用,可能会影响业务的正常运行。

Stream优化

Redis 5.0 之前是不保证延迟消息持久化的,如果客户端消费过程中宕机或者重启,这个消息不会重复投递。5.0 之后推出了 Stream 功能,有了持久化和主备复制功能等比较完善的延迟消息功能。

通过使用Redis Stream,可以创建一个持久化的消息队列,即使在消费者宕机或重启的情况下也能保证消息的传递和消费进度的恢复。我们可以使用XADD命令将消息添加到Stream中,为每个消息指定唯一标识符和延迟时间,一旦消息的延迟时间过期,Redis会将该消息发送给订阅者进行消费。要注意的是,在Redis Stream中,延迟消息的处理方式与惰性删除是不同的。Redis Stream使用一个内部的任务调度器来管理延迟消息。当消息被添加到Stream时,Redis会将其放置在适当的位置,并设置一个计划任务来在消息的延迟时间到期后触发。一旦延迟时间到期,Redis会立即发送该消息给订阅者进行消费,而不需要等待被访问或检查键的过期时间。

有关Redis Stream的详细详细内容可以参考下述文章:Redis消息队列------Redis Stream


调度平台定时任务

通过定时任务是一种常见的订单延迟关闭解决方案。可以通过调度平台来实现定时任务的执行,具体任务是根据订单创建时间扫描所有到期的订单,并执行关闭订单的操作。

大家常用的定时任务调度平台有以下这些:

不足之处

  • 延迟时间不精确:使用定时任务执行订单关闭逻辑,无法保证订单在十分钟后准确地关闭。如果任务执行器在关闭订单的具体时间点出现问题,可能导致订单关闭的时间延后。
  • 不适合高并发场景:定时任务执行的频率通常是固定的,无法根据实际订单的情况来灵活调整。在高并发场景下,可能导致大量的定时任务同时执行,造成系统负载过大。
  • 分库分表问题:订单表按照用户标识和订单号进行了分库分表,那这样的话,和上面说的根据订单创建时间去扫描一批订单进行关闭,自然就行不通。因为根据创建时间查询没有携带分片键,存在读扩散问题。

通常最不推荐的方式是使用定时任务来实现订单关闭。


MQ 延时队列消费(推荐)

这里主要围绕两大主流MQ:RabbitMQ 和 RocketMQ 展开讨论

RabbitMQ

RabbitMQ 是一个功能强大的消息中间件,通过使用 RabbitMQ 的延时消息特性,我们可以轻松实现订单十分钟延时关闭功能。首先,我们需要在 RabbitMQ 服务器上启用延时特性,通常通过安装 rabbitmq_delayed_message_exchange 插件来支持延时消息功能。

实现逻辑

我们创建两个队列:订单队列和死信队列。订单队列用于存储需要延时关闭的订单消息,而死信队列则用于存储延时时间到达后的订单消息。在创建订单队列时,我们要为队列配置延时特性,指定订单消息的延时时间,比如十分钟。这样,当有新的订单需要延时关闭时,我们只需要将订单消息发送到订单队列,并设置消息的延时时间。

在订单队列中设置死信交换机和死信队列,当订单消息的延时时间到达后,消息会自动转发到死信队列,从而触发关闭订单的操作。在死信队列中,我们可以监听消息,并执行关闭订单的逻辑。为了确保消息的可靠性,可以在关闭订单操作前添加适当的幂等性措施,这样即使消息重复处理,也不会对系统产生影响。

通过以上步骤,我们就成功实现了订单的十分钟延时关闭功能。当有新的订单需要延时关闭时,将订单消息发送到订单队列,并设置延时时间。在延时时间到达后,订单消息会自动进入死信队列,从而触发关闭订单的操作。这种方式既简单又可靠,保证了系统的稳定性和可用性。

从整体来说 RabbitMQ 实现延时关闭订单功能是比较合适的,但也存在几个问题:

底层实现带来的问题

  • 高并发阻塞:如果系统中有大量的订单需要延时关闭,而订单关闭操作非常复杂耗时,可能会导致消息队列中的消息堆积。

这里可以参考一下死信消息判定的流程,首先我们要知道,RabbitMQ中,哪怕生产者在发送消息时保证了顺序性,消息在传输过程中依然可能会被重新排序,所以消息就不具备有序性。此外,检测一个消息是否有效需要它在队列头进行检测才知道,不是说你在队列中过期了就立马飞到死信队列中了的,因此在高并发下,MQ会接收到海量数据,指不定你的延时消息挤在队列中哪个小角落呢,等你到了队列头说不定黄花菜都凉了

  • 重复消息问题:由于网络原因或其他不可预知的因素,可能会导致消息重复发送到订单队列。可能会导致订单重复关闭的问题,从而造成数据不一致或其他异常情况。

消息中间件是一个可靠的组件。这里的可靠性指的是,只要消息被成功投递到了消息中间件,它就不会丢失,至少能够被消费者成功消费一次。这是消息中间件最基本的特性之一,也就是我们通常所说的 "AT LEAST ONCE",即消息至少会被成功消费一遍。

这种可靠性特性也会导致消息被多次投递的情况。举个例子,仍然以之前的例子为例,如果消费程序A接收并完成消息M的消费逻辑后,正准备通知消息中间件"我已经消费成功了",但在此之前程序A又重启了,那么对于消息中间件来说,这个消息M并没有被成功消费过,因此消息中间件会继续投递这个消息。而对于消费程序A来说,尽管它已经成功消费了这个消息,但由于程序重启导致消息中间件继续投递,看起来就好像这个消息还没有被成功消费过一样。

  • 延时精度:RabbitMQ 的延时消息特性是基于消息的 TTL(Time-To-Live)来实现的,因此消息的延时时间并不是完全准确的,可能会有一定的误差。

这是因为RabbitMQ并不是通过主动检查消息的TTL来触发延迟消息的发送,而是依赖于系统的调度器来实现。因此,即使消息的TTL已经过期,RabbitMQ仍然需要等待系统的调度器将消息从队列中删除并发送到死信队列中。

此外,RabbitMQ的延迟消息还可能受到其他因素的影响,如队列的负载、网络延迟、服务器的运行状态等。这些因素都可能会导致延迟消息的发送时间略有延迟,从而导致订单关闭时间略晚于预期时间。

  • 可靠性问题:RabbitMQ 是一个消息中间件,它是一个独立的系统。如果 RabbitMQ 本身出现故障或宕机,可能会导致订单延时关闭功能失效。因此,在使用 RabbitMQ 实现延时关闭功能时,需要考虑如何保证 RabbitMQ 的高可用性和稳定性。

延时精度和高并发属于一类问题,取决于客户端的消费能力。重复消费问题是所有消息中间件都需要解决,需要通过消息表等幂等解决方案解决。比较难搞定的是可用性问题,RabbitMQ 在可用性方面较弱,部分场景下会存在单点故障问题。

RocketMQ(推荐)

延时消息原理

RocketMQ 的延时消息并非是在消息发送时设置一个固定的时间,而是在消息发送后再根据消息在队列中的位置计算。RocketMQ 采用了定时任务的方式,只要到了指定时间就会将消息重新放入到队列中,等待消费者再次消费。

延时消息的实现原理可以通过消费者端的源码来了解。RocketMQ 的消息消费过程是由 PullConsumer 实现的,该类是 Consumer 接口的实现,同时也是 RocketMQ 中用于消费消息的最基本的消费者类型。PullConsumer 类中实现了消费者拉取消息的逻辑,其中涉及到了定时任务和延时消息的处理。

延时消息实现方式

在 RocketMQ 中,延时消息的实现方式主要有两种,一种是通过设置消息的延时级别来实现,另一种是通过自定义消息存储器来实现。

具体咋整这里不再赘述嘞

上面每一种方案都指出了不足之处,RocketMQ也不能幸免

底层实现带来的问题

  • 延时级别只有 18 个,并不能满足所有场景;
  • 如果通过修改 messageDelayLevel 配置来自定义延时级别,并不灵活,比如一个在大规模的平台上,延时级别成百上千,而且随时可能增加新的延时时间;
  • 延时时间不准确,后台的定时线程可能会因为处理消息量大导致延时误差大。

时间轮算法

为了解决定时任务队列遍历任务导致的性能开销,RocketMQ 定时消息引入了秒级的时间轮算法。如下图:

图中是一个 60s 的时间轮,时间轮上会有一个指向当前时间的指针定时地移动到下一个时间(秒级)。

时间轮算法的优势是不用去遍历所有的任务,每一个时间节点上的任务用链表串起来,当时间轮上的指针移动到当前的时间时,这个时间节点上的全部任务都执行。

虽然上面只是一个 60s 的时间轮,但是对于所有的时间延时,都是支持的。可以在每个时间节点增加一个 round 字段,记录时间轮转动的圈数,比如对于延时 130s 的任务,round 就是 2,放在第 10 个时间刻度的链表中。这样当时间轮转到一个节点,执行节点上的任务时,首先判断 round 是否等于 0,如果等于 0,则把这个任务从任务链表中移出交给异步线程执行,否则将 round 减 1 继续检查后面的任务。

基于时间轮算法的思想,RocketMQ 实现了精准的定时消息

关于 RocketMQ 4.x 版本和 5.x 版本的延时消息实现原理参考文章:

弥补延时消息的不足,RocketMQ 基于时间轮算法实现了定时消息!

文末总结

通过以上对比,相信大家也对于这几种技术方案有了自己的一些评估,大概总结一下,如果说你的项目延时关闭功能对可靠性和实时精确性要求不是很高,并发量也不多,那么咱可以上Redis或者RabbitMQ这些比较轻量级的解决方案;反之,可以考虑一下采用RocketMQ的解决方案,相信更能满足你的需求。

最后希望通过以上的讲解,能给大家对订单延时关闭功能技术方案设计带来新的思路,最后再给大家引用一位大佬的话:

技术设计中不存在"银弹"。选择技术选型往往会有得失,多方面权衡后选择出一个适合项目的使用即可。

相关推荐
言之。4 分钟前
【Java】面试题 并发安全 (1)
java·开发语言
m0_748234524 分钟前
2025最新版Java面试八股文大全
java·开发语言·面试
van叶~11 分钟前
仓颉语言实战——2.名字、作用域、变量、修饰符
android·java·javascript·仓颉
张声录115 分钟前
【ETCD】【实操篇(十九)】ETCD基准测试实战
java·数据库·etcd
xiaosannihaiyl2425 分钟前
Scala语言的函数实现
开发语言·后端·golang
鱼香鱼香rose40 分钟前
面经hwl
java·服务器·数据库
邂逅岁月42 分钟前
滑不动窗口的秘密—— “滑动窗口“算法 (Java版)
算法·面试·求职招聘·创业创新·刷题
新手小袁_J42 分钟前
java.lang.IllegalStateException: Error processing condition on org.springframework.boot.autoconfigur
java·开发语言·spring·spring cloud·bootstrap·maven·mybatis
墨鸦_Cormorant43 分钟前
Java 创建图形用户界面(GUI)组件详解之下拉式菜单(JMenu、JMenuItem)、弹出式菜单(JPopupMenu)等
java·开发语言·gui
cccccc语言我来了43 分钟前
c++-----------------多态
java·开发语言·c++