为什么要关闭订单?
使用过第三方支付工具的都知道:一笔订单一段时间后没有支付,会被自动关闭,首先我们先说说为什么要关闭它们吧!
保障交易安全
防止信息泄露
未支付订单长时间留存可能导致用户的支付信息、交易数据等在系统中处于未完成状态,增加了信息被窃取或泄露的风险。关闭订单可以减少这种风险,保护用户的隐私和资金安全。
避免恶意利用
防止有人利用未支付订单进行恶意操作,如篡改订单信息、进行钓鱼攻击等。关闭订单后,这些潜在的风险操作将无法进行,维护了支付系统的安全性和稳定性。
提高运营效率
释放系统资源
支付系统需要为每一个订单保留一定的系统资源,包括存储订单数据、监控订单状态等。大量未支付订单会占用大量系统资源,影响系统的运行效率和响应速度。关闭这些订单可以释放资源,使系统能够更高效地处理其他正常交易。
优化业务流程
有助于保持业务流程的顺畅和清晰。对于商家和支付平台来说,及时关闭未支付订单可以使订单管理更加有序,减少因大量未支付订单堆积而导致的管理混乱,提高运营效率和服务质量。
提升用户体验
避免误操作
长时间未支付的订单可能会让用户遗忘,导致后续产生不必要的麻烦,如误操作再次支付或因订单过期无法享受优惠等。关闭订单可以避免这种情况发生,让用户的购物或支付过程更加顺畅。
促进交易完成
设定订单关闭时间可以给用户一定的支付压力,促使他们尽快完成支付,提高交易的成功率。对于一些有时间限制的优惠活动或特价商品,关闭未支付订单可以让其他用户有机会购买,提高商品的销售效率,也能让用户感受到公平的购物环境。
符合业务规则和法律法规
遵循行业规范
在支付行业中,通常有一定的规范和标准来处理未支付订单。关闭未支付订单是符合行业惯例的做法,有助于维护整个支付行业的正常秩序。
满足法律要求
从法律角度来看,长时间保留未支付订单可能涉及到一些法律风险,如消费者权益保护、数据存储和使用等方面的问题。关闭订单可以确保支付平台和商家在法律框架内运营,避免潜在的法律纠纷。
如何优雅的关闭订单?
说到这里,我们能想到几种关闭订单的方法呢?每个人都能说出几种,但是无怪乎就下面几种方法,接下来我们一一讲述一下!
定时任务
是我们最容易想到的办法,没有之一!! 也是被很多同学描述为稳如老狗的一种方法,不可否认,大道至简往往是最好的办法,但是这种办法也是有很多局限的。
局限性
时间不精确
定时任务执行图
如上图所示,假如定时任务从0点0秒开始,5分钟执行一次,在第一次执行时将符合关闭条件的的订单从数据库load出来,进行关闭操作,5分钟后进行第二次关闭操作,再过5分钟进行第三次关闭操作,第三次关闭时会将第二次未关闭的订单4进行再次关闭。这时候关闭时间可能会有分钟级的差距。
大订单量处理较慢
如果订单量比较大的话,把本来比较分散的关闭时间集中到任务调度的那一段时间,那么就可能导致任务执行时间很长,整个任务的时间越长,处理的时间点也不是很及时。
数据库压力大
定时任务集中扫描数据库,使得数据库IO在短时间内被大量占用和消耗,如果没有数据库防护且业务量比较大的话,就可能会影响到线上的正常业务,极端情况可能造成数据库不可用,造成严重的生产事故。
因此这种方式比较适合订单量不大并且对时间精度要求不高的场景,反之则不适合该种方式。
延迟队列
延迟队列,也是可以实现关闭订单的,首先,在用户创建订单的时候,把订单加入到队列中,然后,还需要一个常驻任务不断的从队列中取出那些到了超时时间的订单,然后在符合条件的订单关闭掉,之后把关闭的订单从队列中移除。
JDK自带的延迟队列
优势
使用DelayQueue实现超时关单的方案,实现起来简单,不须要依赖第三方的框架和类库,JDK原生就支持了。
劣势
DelayQueue是基于JVM内存的,需要把订单放进去,那如果订单量太大的话,可能会导致OOM的问题;一旦机器重启了,里面的数据就都没有了。虽然我们可以配合数据库等方式持久化,但目前绝大部分应用都是集群部署的,每一台机器维护一个延迟队列,那每台机器的协同也是一个很大的问题。
RocketMQ延迟消息队列
延迟消息队列,当消息写入到Broker后,不会立刻被消费者消费,需要等待指定的时长后才可被消费处理的消息,称为延时消息。
如我们创建一个订单,发送一个延迟消息,比如60分钟取消订单,那就发一个延迟60分钟的延迟消息,然后在60分钟之后,消息就会被消费者消费,消费者在接收到消息之后,去关单就行了。
但是,RocketMQ的延迟消息5.0之前并不支持支持任意时长的延迟的,5.0之前它只支持:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h这18个时长。
优势
有了RocketMQ延迟(定时)消息之后,我们处理上就简单很多,只需要发消息,和接收消息就行了,系统之间完全解耦了。那使用这种方式比较合适。
劣势
如果我们整套系统没有使用到RocketMQ,为了实现关单这一功能特地引入RocketMQ不是特别合适,另外需要使用RocketMQ5.0版本才行,其它版本暂不支持该特性。
RabbitMQ延迟消息队列
RabbitMQ也能实现延迟消息,基于rabbitmq_delayed_message_exchange插件,这种方案能够解决通过死信队列实现延迟消息出现的消息阻塞问题。但是该插件从RabbitMQ的3.6.12开始支持的,所以对版本有要求。
优势
插件是官方出的,可以放心使用,基于RabbitMQ实现,所以在可用性、性能方便都很不错。并且不存在消息阻塞的问题。
劣势
这个插件支持的最大延长时间是(2^32)-1 毫秒,大约49天,超过这个时间就会被立即消费,有一定的局限性。RabbitMQ的3.6.12之后才支持的。
Redisson延迟队列
Redisson是一个在Redis的基础上实现的框架,它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。
Redisson 延迟队列的工作原理依赖于 Redis 的 Sorted Set 数据结构。Sorted Set 是一个有序的集合,其中每个元素都有一个分数(score),这些元素会按照分数自动排序。Redisson 利用这一特性来管理延迟任务,在 Redisson 中,延迟队列是通过 RDelayedQueue 接口实现的
优势
高效:Redisson 延迟队列基于 Redis 的 Sorted Set 数据结构,因此任务的插入、查询、删除操作非常高效,能够在大规模分布式系统中稳定运行。
分布式支持:Redisson 的延迟队列支持分布式部署,多个节点可以共同处理延迟任务,保证任务的高可用性。
可靠性:通过 Redis 的持久化机制,延迟队列中的任务可以在 Redis 重启时恢复,避免任务丢失。
劣势
当量很大的时候,如果设置的 partition ,poll 在10 左右,由于极致压榨了cpu性能,保证延时队列不延时,CPU能打到 50% 。所以尽量将延时队列部署到单独机器, 或者降低 partition ,poll 的大小,最好设置在5以下。
RedissonFastDelayQueueClient的回调处理耗时不能太长, 如果量很大,可能会导致 poll 线程池堆积。
死信队列
在RabbitMQ中没有延迟或者定时消息队列的概念,但是也是可以实现类似功能的,只不过其底层是基于死信队列实现的。
当RabbitMQ中的一条正常的消息,过了存活时间(TTL过期)、队列长度超限、被消费者拒绝等原因无法被消费时,就会变成Dead Message,即死信队列。
优势
那么基于这样的机制,我们给一个创建订单后,发一个消息并设定TTL(可以是任意时长),并不消费这个消息等他过期,过期后就会进入到死信队列,然后我们再监听死信队列的消息消费就行了。
借助RabbitMQ的集群扩展性,可以实现高可用,以及处理大并发量。
劣势
队列是先进先出的,而且每次只会判断队头的消息是否过期,那么,如果队头的消息时间很长,一直都不过期,那么就会阻塞整个队列,这时候即使排在他后面的消息过期了,那么也会被一直阻塞。
比较复杂,不仅要依赖RabbitMQ,而且还需要声明很多队列(exchange)出来,增加系统的复杂度。
Redis过期监听
在redis.conf 中,加入一条配置notify-keyspace-events Ex开启过期监听,然后再代码中实现一个KeyExpirationEventMessageListener,就可以监听key的过期消息了。
这样就可以在接收到过期消息的时候,进行订单的关单操作。
这个方案不建议大家使用,是因为Redis官网上明确的说过,Redis并不保证Key在过期的时候就能被立即删除,更不保证这个消息能被立即发出。所以,消息延迟是必然存在的,随着数据量越大延迟越长,延迟个几分钟都是常事儿。
而且,在Redis 5.0之前,这个消息是通过PUB/SUB模式发出的,他不会做持久化,至于你有没有接到,有没有消费成功,他不管。也就是说,如果发消息的时候,你的客户端挂了,之后再恢复的话,这个消息你就彻底丢失了。
Redis的zset
借助Redis中的有序集合——zset来实现这个功能。
redis的zset是一个自动根据元素score排序的有序集合,和普通集合set非常相似,是一个没有重复元素的字符串集合。
我们将订单超时时间的时间戳(下单时间+超时时长)与订单号分别设置为 score 和 member。这样redis会对zset按照score延时时间进行排序。然后我们再开启redis扫描任务,获取”当前时间 > score”的延时任务,扫描到之后取出订单号,然后查询到订单进行关单操作即可。
优势
redis zset来实现订单关闭的功能的优点是可以借助redis的持久化、高可用机制。避免数据丢失。
劣势
在高并发场景中,有可能有多个消费者同时获取到同一个订单号,一般采用加分布式锁解决,但是这样做也会降低吞吐型,不做加锁也可以,一般情况下业务系统需要自行做幂等处理。
需要根据zset的数据结构,自行编写代码进行处理逻辑,不够友好。
时间轮
时间轮可以理解为一种环形结构,像钟表一样被分为多个 slot。每个 slot 代表一个时间段,每个 slot 中可以存放多个任务,使用的是链表结构保存该时间段到期的所有任务。时间轮通过一个时针随着时间一个个 slot 转动,并执行 slot 中的所有到期任务。
时间轮算法
Netty的时间轮
基于Netty的HashedWheelTimer可以帮助我们快速的实现一个时间轮,这种方式和DelayQueue类似,缺点都是基于内存、集群扩展麻烦、内存有限制等等
优势
基于Netty的时间轮方案比基于JDK的DelayQueue效率更高,实现起来更简单
劣势
只适合在单机场景、并且数据量不大的场景中使用,如果涉及到分布式场景,那还是不建议使用
Kafka的时间轮
Kafka内部有很多延时性的操作,如延时生产,延时拉取,延时数据删除等,这些延时功能由内部的延时操作管理器来做专门的处理,其底层是采用时间轮实现的。
Kafka 中的时间轮的实现是 TimingWheel 类,位于 kafka.utils.timer 包中。基于Kafka的时间轮同样可以得到O(1)时间复杂度,性能上还是不错的。
优势
稳定性和性能都要更高一些,而且适合用在分布式场景中。
劣势
基于Kafka的时间轮的实现方式,在实现方式上有点复杂,需要依赖kafka。
总结
上面我们介绍了不同的关闭订单的方式,不同的场景中也适合不同的方案:
单体应用,业务量不大:Netty的时间轮、JDK自带的DelayQueue、定时任务
分布式应用,业务量不大: Redis过期监听、RabbitMQ死信队列、Redis的zset、定时任务
分布式应用,业务量大、并发高: Redission、RabbitMQ插件、kafka时间轮、RocketMQ延迟消息。
上面的建议也不是绝对的,往往需要各自的场景,来选择其中一种或者几种配合使用,才能达到更好的效果。