首页
学习
活动
专区
工具
TVP
发布

七月在线深度学习集训营第三期2022一片孤城万仞山

如何关闭到期订单?

七月在线深度学习集训营第三期2022

download:https://www.zxit666.com/5616/

在电子商务、支付等系统中,一般先创建一个订单(付款单),然后给用户一定的时间进行支付。如果没有按时付款,之前的订单(付款单)需要取消。类似的场景还有很多,到期自动收货,超时自动退款,下单后自动发短信等等,都是类似的业务问题。

从这样的业务问题出发,讨论有哪些技术解决方案,这些解决方案的实现细节,以及相关的优缺点。

由于本文要讲的内容很多,涉及11个具体方案,由于篇幅所限,本文主要讲方案,不会涉及具体的代码实现。只要方案明确,代码实现并不难。

1.被动关闭

解决这类问题,有一个比较简单的办法,就是通过业务的被动方式来关闭账单。

简单来说就是订单创建之后。我们不会主动关闭我们系统上的订单。当用户访问这个订单时,我们会判断时间是否超过了过期时间。如果有,我们将关闭订单,然后提示用户。

这种方法最简单,基本不需要开发定时关闭的功能。但是它的缺点也很明显,就是如果用户不一直检查这个订单,数据库里会有很多脏数据冗余无法关闭。

还有一个缺点,就是需要在用户查询过程中进行写操作。通常,写操作比读操作花费的时间长,并且可能会失败。一旦平仓单失败,系统处理起来会更加复杂。

所以这个方案只适合自学,不建议在任何商业网站实现订单关闭的功能。

二、计时任务

很容易想到定期平仓的方案。

具体的实现细节是我们通过一些调度平台来实现预定的任务。任务是扫描所有过期订单,然后执行关闭操作。

这种方案的优点是简单易行。它可以基于Timer、ScheduledThreadPoolExecutor或类似xxl-job的调度框架来实现,但它有以下问题:

1.时间不准确。一般预定任务都是按照固定的频率和时间执行的,所以很多订单可能已经到了超时时间,但是预定任务的预定时间还没有到,就会导致这些订单的实际关闭时间比应该的时间要晚。

2.不能处理大订单。任务调度的方式是将原本分散的关门时间集中到任务调度的时间段。如果订单量大,可能会导致任务执行时间长。整个任务花费的时间越长,订单扫描的时间就越晚,导致关门时间也越晚。

3.对数据库的压力。当任务集中扫描表时,数据库IO会在短时间内被占用和消耗。如果隔离做得不好,业务量比较大,可能会影响正常的网上业务。

4.子数据库和子表的问题。订单系统,一旦订单量大,可能会考虑分仓分表,分仓分表扫描全表,这是非常推荐的方案。

因此,计划任务的方案适用于对时间精度要求不高、业务量不大的场景。如果时间精度要求高,业务量大,此方案不适用。

第三,JDK自己的延迟队列

有这样一个方案,可以直接基于应用本身实现,不需要任何外部资源,也就是基于JDK自己的DelayQueue实现。

DelayQueue是一个无界的BlockingQueue,用于放置实现延迟接口的对象,对象只有在过期时才能从队列中取出。

基于延迟队列,可以延迟订单的关闭。首先,当用户创建订单时,订单被添加到延迟队列中。然后,需要一个常驻任务不断地从队列中取出那些已经超过时间限制的订单,然后关闭它们并从队列中删除它们。

这个方案需要有一个线程,不断从队列中取出需要关闭的订单。一般需要在这个线程中增加一个while(true)循环,以保证任务的连续执行,及时取出加班单。

使用DelayQueue实现超时关闭的方案实现简单,不需要依赖第三方的框架和类库。JDK天生支持它。

当然,这个方案也不是没有缺点。首先,基于DelayQueue,你需要把订单放进去,订单量太大可能会导致OOM问题;另外,DelayQueue是基于JVM的内存,一旦重启机器,里面的数据就全没了。虽然我们可以将它与数据库的持久性结合使用。而现在很多应用都部署在集群中,如何在一个集群中的多个实例上与多个DelayQueue协同工作是一个很大的问题。

因此,基于JDK的延迟队列方案只适用于单机场景和数据量较小的场景。如果涉及分布式场景,还是不推荐。

第四,内蒂的时间轮

还有另一种方式,类似于上面提到的JDK附带的延迟队列,基于时间轮实现。

为什么会有时间轮?主要原因是DelayQueue-O (nlog (n))中插入和删除操作的平均时间复杂度相当不错,但是时间轮方案可以将插入和删除操作的时间复杂度降低到O(1)。

时间轮可以理解为环形结构,像时钟一样分成多个槽。每个槽代表一个时间段,每个槽可以存储多个任务,并且使用链表结构来存储在该时间段到期的所有任务。时间轮通过时针随着时间一个一个槽的旋转,执行槽内所有到期的任务。

基于Netty的HashedWheelTimer可以帮助我们快速实现一个时间轮,类似于DelayQueue。它的缺点是基于内存,集群扩展麻烦,内存有限等等。

但与DelayQueue相比,它的效率更高,任务触发的延迟更低。代码实现也更加精简。

因此,基于Netty的时间轮方案比基于JDK的DelayQueue方案更高效,更容易实现,但同样,它只能用于数据量较小的单机场景。如果涉及分布式场景,还是不推荐。

动词 (verb的缩写)卡夫卡的时间之轮

既然基于Netty的时间轮存在一些问题,那么还有其他的时间轮实现吗?

有,就是卡夫卡的时间之轮。卡夫卡中有很多延迟操作,比如延迟生产、延迟拉取、延迟数据删除等。这些延迟功能由内部的延迟操作管理器专门处理,其底层由时间轮实现。

而且为了解决一些时间跨度较大的延迟任务,Kafka还引入了分层时间轮,可以更好地控制时间粒度,应对更复杂的定时任务处理场景;

Kafka中时间轮的实现是TimingWheel类,它位于kafka.utils.timer包中。基于卡夫卡的时间轮也能得到O(1)时间复杂度,性能不错。

基于kafka的时间轮实现有点复杂,需要依赖Kafka,但是稳定性和性能更高,适合分布式场景。

六。RocketMQ延迟消息

与Kafka相比,RocketMQ有一个强大的功能,就是支持延迟消息。

延迟消息在写入Broker时,不会立即被消费者消费,只能在等待指定的一段时间后才能被消费和处理,这就是所谓的延迟消息。

有了延迟消息,我们可以在订单创建后发送延迟消息,比如20分钟后取消订单,再发送延迟20分钟的延迟消息,然后20分钟后,消息就被消费者消费了。收到消息后,消费者可以关闭订单。

但是RocketMQ的延迟消息不支持任何长度的延迟,只支持1s 50s 30s 1m 2m 4m 6m 8m 9m 10m 30m 1h 2h的长度。(商业版支持任意时长)

正如您所看到的,有了RocketMQ延迟消息,我们处理它就容易多了。我们只需要发送和接收消息,系统是完全解耦的。但是,由于延迟时间有限,它不是很灵活。

在我们的业务中,如果关闭时间刚好匹配RocketMQ延迟消息支持的时间,那么可以基于RocketMQ延迟消息实现。否则,这种方法不是最佳的。(不过在RocketMQ 5.0中新增了基于时间轮的定时消息,可以解决这个问题!)

七。RabbitMQ死信队列

消息延迟不仅在RocketMQ中支持,在RabbitMQ中也有实现,但其底层是基于死信队列的。

当RabbitMQ中的一条正常消息由于寿命到期(TTL过期)、队列长度超过限制、被消费者拒绝而无法消费时,就会变成死消息,也就是一封死信。

当消息成为死信时,它可以被重新发送到死信队列(实际上是交换机)。

然后基于这个机制,可以实现延迟消息。也就是说,我们为一条消息设置TTL,但不消费它。当它过期时,它将进入死信队列,然后我们可以监控死信队列的消息消耗。

而且RabbitMQ中的TTL可以设置为任意时长,解决了RocketMQ不灵活的问题。

但是死信队列的实现有一个问题,就是可能会造成队列头被阻塞,因为队列是先进先出的,每次只会判断队列头的消息是否过期。然后,如果队列头的消息持续时间长,就会阻塞整个队列,即使他后面的消息逾期了,也会一直被阻塞。

基于RabbitMQ的死信队列可以实现消息的延迟和关闭账单的灵活定时,并借助RabbitMQ的集群可扩展性,实现高可用性和处理大并发。他的缺点是可能存在消息阻塞,方案复杂,不仅依赖RabbitMQ,还需要声明很多队列(交换),增加了系统的复杂度。

八。RabbitMQ插件

其实基于RabbitMQ,不需要死信队列就可以实现延迟消息,也就是基于rabbit MQ _ delayed _ message _ exchange插件,这个方案可以解决延迟消息通过死信队列造成的消息阻塞问题。但是从RabbitMQ的3.6.12开始就支持插件了,所以对版本有要求。

这个插件是官方的,可以放心使用。安装并启用该插件后,您可以创建一个x延迟消息队列。

前面提到的基于私有消息队列的方法是,消息会先投递到一个正常队列,TTL过期后再进入死消息队列。但是基于插件,消息并不会立即进入队列,而是保存在Erlang开发的一个Mnesia数据库中,然后通过定时器查询需要传递的消息,再传递到x-delayed-message队列中。

基于RabbitMQ插件的方式可以延迟消息,不存在消息阻塞的问题。但是因为是基于插件的,所以这个插件支持的最大延长时间是(2 ^ 32)-1 ms,大概49天左右,之后就会立刻消耗掉。但它是基于RabbitMQ的,所以在易用性和便捷性上表现非常好。

九、Redis过期监控

很多用过Redis的人都知道Redis有一个过期的监控功能。

在redis.conf中添加一个notify-keyspace-events Ex的配置来启动过期监控,然后在代码中实现一个keyexpirationeventmessagelistener,就可以监控key的过期消息了。

这样,当收到过期消息时,可以关闭订单。

这个方案不建议大家使用,因为Redis官网明确说明,Redis不保证密钥过期后立即删除,更不保证这个消息可以立即发送。因此,消息延迟是不可避免的。随着数据量的增加,延迟变长,延迟几分钟是常有的事。

而且在Redis 5.0之前,这个消息是通过PUB/SUB模式发送的,他不会持久化。至于你有没有收到,他不在乎是不是消费成功了。也就是说,如果你的客户端在发送消息的时候挂机,然后恢复,你就完全失去了消息。(Redis 5.0以后,因为Stream的引入,可以作为延迟消息队列。)

X.雷迪斯的zset

虽然基于Redis过期监控的方案并不完善,但并不是Redis实现通关功能不完善。还有其他方案。

我们可以使用Redis-ZSet中的有序集来实现这个功能。

Zset是一个有序集合,每个元素(成员)都与一个分数相关联,集合中的值可以按分数排序。

我们将订单超时的时间戳(下单时间+超时时长)和订单号分别设置为score和member。这样redis会根据分数的延迟时间对zset进行排序。然后我们打开redis扫描任务,得到“当前时间>比分”的延迟任务,扫描后取出订单号,然后查询订单关闭订单。

使用redisSet关闭订单的好处是可以利用Redis的持久性和高可用性机制。避免数据丢失。不过这种方案也有一个缺点,就是在高并发场景下,有可能多个消费者同时获得同一个订单号,一般通过添加分布式锁来解决,但这样也会降低吞吐量。

但在大多数业务场景中,如果幂等做得好,多个消费者得到同一个订单号也没关系。

XI。Redis+Redis

上面的方案看起来不错,但是我们需要基于数据结构zset编写自己的代码。有没有更友好的方式?

是的,那是基于雷迪森。

Redisson是一个基于Redis的框架,它不仅提供了一系列分布式Java公共对象,还提供了许多分布式服务。

分布式延迟队列RDelayedQueue是在Redission中定义的,它是基于我们前面介绍的zset结构的延迟队列。它允许元素以指定的延迟持续时间被放置在目标队列中。

实际上是在zset的基础上增加了一个基于内存的延迟队列。当我们要向延迟队列中添加一个数据时,redission会将data+timeout放入zset,并启动一个延迟任务。当任务到期后,我们会去zset取出数据,返回给客户端。

这是总的思路。感兴趣的可以看看RDelayedQueue的具体实现。

基于Redisson的实现方法可以解决zset方案中的并发和重复问题,实现方法相对简单,具有较高的稳定性和性能。

摘要

我们介绍了11种实现订单及时关闭的方案,其中不同的方案各有优缺点,也适用于不同的场景。让我们试着总结一下:

关于实现的复杂性(包括所用框架的依赖和部署):

Redission > RabbitMQ插件> RabbitMQ死信队列> RocketMQ延迟消息≈Redis的Zset > Redis过期监控≈ kafka时间轮>计划任务> Netty的时间轮> JDK自己的DelayQueue >被动关机

方案的完整性:

红刊≈ RabbitMQ插件> kafka时间轮> zset≈rocket MQ Redis的延迟消息≈ Rabbit MQ死信队列> Redis过期监控>定时任务> Netty的时间轮> JDK自己的DelayQueue >被动关机

不同的场景也适用于不同的方案:

自己玩:被动关机

单一应用程序,小业务量:Netty的时间轮,JDK自己的延迟队列,预定任务。

分布式应用,业务量小:Redis过期监控,RabbitMQ死信队列,Redis zset,调度任务。

大业务量高并发的分布式应用:Redission、RabbitMQ插件、kafka时间轮、RocketMQ延时消息

总体来说,考虑到第三方框架的成本、完备性、复杂性、普及性,个人比较建议优先考虑redision+Redis、RabbitMQ插件、Redis的zset、RocketMQ延迟消息等方案。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20221110A03UTN00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券