电商平台中订单未支付过期如何实现主动关单?

打印 上一主题 下一主题

主题 1853|帖子 1853|积分 5569

日常开辟中,我们经常遇到这种业务场景,如:外卖订单超 30 分钟未支付,则主动取订单;用户注册乐成 15 分钟后,发短信息通知用户等等。这就是延时任务处理场景。
在电商,支付等系统中,一设都是先创建订单(支付单),再给用户一定的时间进行支付,如果没有按时支付的话,就需要把之前的订单(支付单)取消掉。这种类以的场景有很多,另有比如到期主动收货,超时主动退款,下单后主动发送短信等等都是类似的业务问题。
定时任务(数据库轮询)

通过定时任务关闭订单,是一种成本很低,实现也很容易的方案。通过简朴的几行代码,写一个定时任务,定期扫描数据库中的订单,如果时间过期,就将其状态更新为关闭即可。
  1. @Scheduled(cron = "0 0 22 * * ?")
  2. public void pmNotify() {
  3.     this.pmService.todoNotify();
  4. }
复制代码
优点:实现容易,成本低,基本不依靠其他组件。
缺点:

  • 时间可能不敷正确。由于定时任务扫描的间隔是固定的,所以可能造成一些订单已经过期了一段时间才被扫描到,订单关闭的时间比正常时间晚一些。
  • 增加了数据库的压力。随着订单的数量越来越多,扫描的成本也会越来越大,实行时间也会被拉长,可能导致某些应该被关闭的订单迟迟没有被关闭。
总结:采取定时任务的方案比较适合对时间要求不是很敏感,而且数据量不太多的业务场景。
JDK 延迟队列 DelayQueue

DelayQueue是JDK提供的一个无界队列,DelayQueue队列中的元素需要实现Delayed,它只提供了一个方法,就是获取过期时间。

用户的订单天生以后,设置过期时间比如30分钟,放入定义好的DelayQueue,然后创建一个线程,在线程中通过while(true)不断的从DelayQueue中获取过期的数据。
优点:不依靠任何第三方组件,连数据库也不需要了,实现起来也方便。
缺点:

  • 因为DelayQueue是一个无界队列,如果放入的订单过多,会造成JVMOOM。
  • DelayQueue基于JVM内存,如果JVM重启了,那全部数据就丢失了。
  • 创建了一个不断while(true)的线程,占用了cpu资源
总结:DelayQueue适用于数据量较小,且丢失也不影响主业务的场景,比如内部系统的一些非紧张通知,就算丢失,也不会有太大影响。
redis过期监听

redis 是一个高性能的KV 数据库,除了用作缓存以外,其实还提供了过期监听的功能。在redis.conf 中,配置notify-keyspace-events Ex 即可开启此功能。然后在代码中继承KeyspaceEventMessageListener,实现onMessage 就可以监听过期的数据量。
  1. public abstract class KeyspaceEventMessageListener implements MessageListener, InitializingBean, DisposableBean {
  2.     private static final Topic TOPIC_ALL_KEYEVENTS = new PatternTopic("__keyevent@*");
  3.     //...省略部分代码
  4.     public void init() {
  5.         if (StringUtils.hasText(keyspaceNotificationsConfigParameter)) {
  6.             RedisConnection connection =
  7.                 listenerContainer.getConnectionFactory().getConnection();
  8.             try {
  9.                 Properties config = connection.getConfig("notify-keyspace-events");
  10.                 if (!StringUtils.hasText(config.getProperty("notify-keyspace-events"))) {
  11.                     connection.setConfig("notify-keyspace-events",
  12.                         keyspaceNotificationsConfigParameter);
  13.                 }
  14.             } finally {
  15.                 connection.close();
  16.             }
  17.         }
  18.         doRegister(listenerContainer);
  19.     }
  20.     protected void doRegister(RedisMessageListenerContainer container) {
  21.         listenerContainer.addMessageListener(this, TOPIC_ALL_KEYEVENTS);
  22.     }
  23.    
  24.     //...省略部分代码
  25.     @Override
  26.     public void afterPropertiesSet() throws Exception {
  27.         init();
  28.     }
  29. }
复制代码
通过以上源码,我们可以发现,其本质也是注册一个listener,利用redis 的发布订阅,当key 过期时,发布过期消息(key)到Channel :keyevent@*:expired 中。
在实际的业务中,我们可以将订单的过期时间设置比如30 分钟,然后放入到redis。30 分钟之后,就可以消费这个key,然后做一些业务上的后置动作,比如查抄用户是否支付。
优点: 由于redis 的高性能,所以我们在设置key,或者消费key 时,速度上是可以包管的。
缺点:致命缺陷,不宜利用
在 Redis 官方手册的keyspace-notifications: timing-of-expired-events中明确指出:
  1. Basically expired events are generated when the Redis server deletes the key and not when the time to live theoretically reaches the value of zero
复制代码
redis 主动过期的实现方式是:定时任务离线扫描并删除部分过期键;在访问键时惰性查抄是否过期并删除过期键。也就是说,由于redis 的key 过期计谋缘故起因,当一个key 过期时,redis 从未包管会在设定的过期时间立刻删除并发送过期通知,自然我们的监听事件也无法第一时间消费到这个key,所以会存在一定的延迟。实际上,过期通知晚于设定的过期时间数分钟的情况也比较常见。
另外,在redis5.0 之前,订阅发布中的消息并没有被持久化,自然也没有所谓的确认机制。所以一旦消费消息的过程中我们的客户端发生了宕机,这条消息就彻底丢失了。
总结:redis 的过期订阅相比于其他方案没有太大的上风,在实际生产环境中,用得相对较少。
Redisson分布式延迟队列

Redisson是一个基于redis实现的Java驻内存数据网格,它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。
Redisson除了提供我们常用的分布式锁外,还提供了一个分布式延迟队列RDelayedQueue,他是一种基于zset布局实现的延迟队列,其实现类是RedissonDelayedQueue。delayqueue 中有一个名为 timeoutSetName 的有序集合,其中元素的 score 为投递时间戳。delayqueue 会定时利用 zrangebyscore 扫描已到投递时间的消息,然后把它们移动到就绪消息列表中。
delayqueue 包管 redis 不崩溃的情况下不会丢失消息,在没有更好的解决方案时不妨一试。

优点:利用简朴,而且其实现类中大量利用lua 脚本包管其原子性,不会有并发重复问题。
缺点:需要依靠redis(如果这算一种缺点的话)。
总结:Redisson 是redis 官方推荐的JAVA 客户端,提供了很多常用的功能,利用简朴、高效,推荐利用。
RocketMQ 延迟消息

延迟消息:当消息写入到Broker 后,不会立刻被消费者消费,需要等待指定的时长后才可被消费处理的消息,称为延时消息。
在订单创建之后,我们就可以把订单作为一条消息投递到rocketmq,并将延迟时间设置为30 分钟,这样,30 分钟后我们定义的consumer 就可以消费到这条消息,然后查抄用户是否支付了这个订单。
通过延迟消息,我们就可以将业务解耦,极大地简化我们的代码逻辑。
优点:可以使代码逻辑清晰,系统之间完全解耦,只需关注生产及消费消息即可。另外其吞吐量极高,最多可以支持万亿级的数据量。
缺点:相对来说,mq 是重量级的组件,引入mq 之后,随之而来的消息丢失、幂等性问题等都加深了系统的复杂度。
总结:通过mq 进行系统业务解耦,以及对系统性能削峰填谷已经是当前高性能系统的标配。
RabbitMQ 死信队列

除了RocketMQ 的延迟队列,RabbitMQ 的死信队列也可以实现消息延迟功能。
死信(Dead Letter) 是 rabbitmq 提供的一种机制。当一条消息满意下列条件之一那么它会成为死信:

  • 消息被否定确认(如channel.basicNack) 而且此时requeue 属性被设置为false。
  • 消息在队列的存活时间凌驾设置的TTL时间
  • 消息队列的消息数量已经凌驾最大队列长度
基于这样的机制,我们可以给消息设置一个ttl,然后故意不消费消息,等消息过期就会进入死信队列,我们再消费死信队列即可。通过这样的方式,就可以达到同RocketMQ 延迟消息一样的结果。
在 rabbitmq 中创建死信队列的操作流程大概是:

  • 创建一个交换机作为死信交换机
  • 在业务队列中配置 x-dead-letter-exchange 和 x-dead-letter-routing-key,将第一步的交换机设为业务队列的死信交换机
  • 在死信交换机上创建队列,并监听此队列
死信队列的计划目的是为了存储没有被正常消费的消息,便于排查和重新投递。死信队列同样也没有对投递时间做出包管,在第一条消息成为死信之前,后面的消息纵然过期也不会投递为死信。
为了解决这个问题,rabbit 官方推出了延迟投递插件 rabbitmq-delayed-message-exchange ,推荐利用官方插件来做延时消息。
优点:同RocketMQ 一样,RabbitMQ 同样可以使业务解耦,基于其集群的扩展性,也可以实现高可用、高性能的目的。
缺点:死信队列本质还是一个队列,队列都是先进先出,如果队头的消息过期时间比较长,就会导致后面过期的消息无法得到实时消费,造成消息阻塞。
总结:除了增加系统复杂度之外,死信队列的阻塞问题也是需要我们重点关注的。
这里说点题外话,利用 redis 过期监听或者 rabbitmq 死信队列做延时任务都是以计划者预想之外的方式利用中间件,这种出其不意必自毙的举动通常会存在某些隐患,比如缺乏一致性和可靠性包管,吞吐量较低、资源泄漏等。比较出名的一个事例是很多人利用 redis 的 list 作为消息队列,以致于最后作者看不下去写了 disque 并最后演变为 redis stream。工作中还是尽量不要滥用中间件,用专业的组件做专业的事
最佳实践

实际上,在数据库索引计划良好的情况下,定时扫描数据库中未完成的订单产生的开销并没有想象中那么大。在利用 redisson delayqueue 等定时任务中间件时可以同时利用扫描数据库的方法作为补偿机制,避免中间件故障造成任务丢失。
为什么不建议用MQ实现订单到期关闭


  • 时间精度和可靠性问题


  • 延迟队列的不可预测性:MQ通常为异步通讯计划,会受到网络延迟、队列长度等多种因素影响,可能无法在确切的时间实行消息消费,导致订单关闭时间不正确。这对于需要严格时间控制的订单业务来说是一个紧张问题。
  • 消息可靠性:只管MQ能提供相对可靠的消息投递,但在极端情况下,消息丢失或重复消费依然可能发生。这会给订单的正确关闭带来风险,如订单未关闭或多次错误关闭。

  • 系统复杂性增加


  • 为了实现这一功能,系统需要引入并维护消息队列,比方ActiveMQ、RabbitMQ或Kafka,这增加了系统的复杂性和运维负担。
  • 性能与负载问题:需要处理大量与订单相关的延迟消息,可能会导致MQ系统负载增加,尤其是在高并发环境下,这会影响系统整体性能。

  • 灵活性和扩展性问题


  • 动态性不足:如果需要调解订单关闭时间,已经在队列中的消息很难修改。这会限制系统灵活适应业务需求变化的能力。
  • 复杂的业务逻辑:实现简朴的定时关闭功能需要复杂的处理逻辑,包括消息的发送、吸收、消费、非常处理等,这会增加业务流程的复杂性。
并发口诀:一锁二判三更新

不管我们利用定时任务还是延迟消息时,不可避免的会遇到并发实行任务的情况 (比如重复消费、调理重试等)。
当我们实行任务时,我们可以按照一锁二判三更新这个口诀来处理。

  • 锁定当前需要处理的订单。
  • 判断订单是否已经更新过对应状态了
  • 如果订单之前没有更新过状态了,可以更新并完成相关业务逻辑,否则本次不能更新,也不能完成业务逻辑。
  • 释放当前订单的锁。


兜底意识 + 配置监控

虽然我们提到了很多的实现计谋,现实实战时依然容易出现问题,比如不合理的操作导致消息丢失。
因此,我们应该具备兜底意识
如果少量消息丢失,我们可以通过每天凌晨跑一次任务,批量将这些未处理的订单批量取消。这种兜底举动工程实现简朴,同时对系统影响很小。
另有一点,就是配置监控
笔者曾经自研过任务调理系统,应用 A 接入后,从控制台发现每隔 2 个小时调理应用 A 的任务时,经常发生超时,通过分析,发现应用 A 线程出现了死锁。
这种问题出现的几率非常高,因此配置监控特殊要须要。
对业务系统来讲,监控分为两个层面:系统监控业务监控

  • 系统监控
在条件允许的情况下,建议关注性能监控,方法可用性监控,方法调用次数监控这三大类。
性能监控
上图是性能监控的示例图,性能监控差异时间段性能分布,实时统计 TP99、TP999 、AVG 、MAX 等维度指标,这也是性能调优的重点关注对象。

  • 业务监控
业务监控功能是从业务角度出发,各个应用系统需要从业务层面进行哪些监控,以及提供怎样的业务层面的监控功能支持业务相关的应用系统。
详细就是对业务数据,业务功能进行监控,实时收集业务流程的数据,并根据设置的计谋对业务流程中不符合预期的部分进行预警和报警,并对收集到业务监控数据进行集中统一的存储和各种方式进行展示。
比如订单系统中有一个定时结算的服务,每两分钟实行一次。我们可以在定时任务 JOB 中添加埋点,并配置业务监控,如果十分钟该定时任务没有实行,则发送邮件,短信给相关负责人。
扩展

一笔订单,在取消的那一刻用户刚好付款了,怎么办?
这种情况在正常的业务场景中是有可能出现的,因为订单都会有定时取消的逻辑,比如10 分钟或者 15分钟,而用户刚好卡在这个时间点进行付款,此时就会出现两种情况:

  • 用户支付乐成,支付回调的那一刻支付单刚好还没取消,而等回调结束,取消支付单的事务提交,支付单取消。此时用户扣款了,但是对应的权益或资产没了。


  • 用户支付乐成,支付回调的那一刻支付单已经被取消。但此时用户已经扣款,东西却没了

可以看到,不论是哪种情况,其实都需要做一定的处理,不然用户肯定会来投诉!
这种场景无非就是支付单支付乐成和取消两种状态的“争夺”,正常情况下,订单或者支付单都会有状态机的存在,在当前场景简朴来说有以下两条路径:

  • 待支付->支付中->支付乐成
  • 待支付->支付中->已取消
针对情况1,如果是支付回调取胜,此时的状态应该已从 支付中->支付乐成
针对情况2,如果是取消支付单取胜,此时的状态应该已从 支付中->已取消
所以我们在修改支付单状态的时间,基于原始状态的判断,就可以做正常的处理,来看下 SOL应该就很清晰了:
  1. # 支付成功
  2. update pay_info set status = 'paySuccess' where orderNo = '1' and status = 'paying';
  3. # 取消
  4. update pay_info set status = 'cancel' where orderNo = '1' and status = 'paying';
复制代码
重点就是我们加了 status='paying’这个条件,这就能包管情况只有一个能乐成,另一个一定失败。这种其实就是乐观锁的方式

  • 假设情况1乐成了,此时用户已经乐成付款,那么状态已经变为paySucces,取消的SQL必定实行失败,此时就让它失败,不需要做任何别的处理。


  • 假设情况2乐成了,此时订单已被取消,status已经变为 cancel,支付乐成的SOL必定实行失败,这种情况下我们就需要做逆向处理,即给用户退款。订单被取消,用户的钱也被原路退回,这种处理也没任何问题

业务优化

针对订单超时业务,这里在业务上可以做一个小优化,你想想,用户付款前可能有点挣扎,然后在最后一刻终于下定决心进行付款,这时间却告知被退款了,用户很可能就不会再下单了。因此我们在页面上可以限时订单取消设置计时为 10分钟,但实际后端是延迟 11 分钟取消订单,这样就能避免这种情况的发生啦。
Redis 分布式锁实现

最后除了利用数据库处理,还可以利用分布式锁,对一笔订单加锁也能包管这笔订单正常的业务流转。每次进行取消订单或付款操作时,起首尝试获取订单的分布式锁,确保只有一个操作能修改订单状态。在分布式系统中,订单在取消的同时用户付款的竞态问题可以通过分布式锁来解决。以下是一个详细的、落地的方案,确保订单状态的可靠性,避免因并发导致状态冲突
订单取消流程:

  • 超时触发取消订单
  • 取消订单方法中先获取该订单的分布式锁。如果锁被其他操作持有(如付款),等待或抛出非常
  • 若乐成获取锁,查抄订单状态是否已付款:

    • 若订单未付款,将订单状态更新为“已取消”
    • 若订单已付款,直接跳过这笔订单的处理。。
    • 释放分布式锁,完成取消流程。

订单付款流程:

  • 三方支付乐成回调。
  • 后端系统吸收回调后,先获取该订单的分布式锁,如果锁被其他提作持有(如取消),等待或抛出非常(没有给三方相应乐成,三方会重新发起回调)
  • 若乐成获取锁,查抄订单状态是否为“待支付”:
  • 若订单状态为“待支付”,继续实行扣款,并将订单状态更新为“已付款”。

    • 若订单状态为“已取消”,则发起退款,并提示用户订单已取消,无法支付。
    • 释放分布式锁,完成流程。

往期推荐


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

篮之新喜

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表