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

日常开发中,我们经常遇到这种业务场景,如:外卖订单超 30 分钟未支付,则自动取订单;用户注册成功 15 分钟后,发短信息通知用户等等。这就是延时任务处理场景。

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

定时任务(数据库轮询)

通过定时任务关闭订单,是一种成本很低,实现也很容易的方案。通过简单的几行代码,写一个定时任务,定期扫描数据库中的订单,如果时间过期,就将其状态更新为关闭即可。

java 复制代码
@Scheduled(cron = "0 0 22 * * ?")
public void pmNotify() {
    this.pmService.todoNotify();
}

优点:实现容易,成本低,基本不依赖其他组件。

缺点:

  • 时间可能不够精确。由于定时任务扫描的间隔是固定的,所以可能造成一些订单已经过期了一段时间才被扫描到,订单关闭的时间比正常时间晚一些。
  • 增加了数据库的压力。随着订单的数量越来越多,扫描的成本也会越来越大,执行时间也会被拉长,可能导致某些应该被关闭的订单迟迟没有被关闭。

总结:采用定时任务的方案比较适合对时间要求不是很敏感,并且数据量不太多的业务场景。

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 就可以监听过期的数据量。

java 复制代码
public abstract class KeyspaceEventMessageListener implements MessageListener, InitializingBean, DisposableBean {

    private static final Topic TOPIC_ALL_KEYEVENTS = new PatternTopic("__keyevent@*");

    //...省略部分代码
    public void init() {
        if (StringUtils.hasText(keyspaceNotificationsConfigParameter)) {
            RedisConnection connection =
                listenerContainer.getConnectionFactory().getConnection();
            try {
                Properties config = connection.getConfig("notify-keyspace-events");
                if (!StringUtils.hasText(config.getProperty("notify-keyspace-events"))) {
                    connection.setConfig("notify-keyspace-events",
                        keyspaceNotificationsConfigParameter);
                }
            } finally {
                connection.close();
            }
        }
        doRegister(listenerContainer);
    }

    protected void doRegister(RedisMessageListenerContainer container) {
        listenerContainer.addMessageListener(this, TOPIC_ALL_KEYEVENTS);
    }
    
    //...省略部分代码
    @Override
    public void afterPropertiesSet() throws Exception {
        init();
    }
}

通过以上源码,我们可以发现,其本质也是注册一个listener,利用redis 的发布订阅,当key 过期时,发布过期消息(key)到Channel :keyevent@*:expired 中。

在实际的业务中,我们可以将订单的过期时间设置比如30 分钟,然后放入到redis。30 分钟之后,就可以消费这个key,然后做一些业务上的后置动作,比如检查用户是否支付。

优点: 由于redis 的高性能,所以我们在设置key,或者消费key 时,速度上是可以保证的。

缺点:致命缺陷,不宜使用

在 Redis 官方手册的keyspace-notifications: timing-of-expired-events中明确指出:

tex 复制代码
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实现订单到期关闭

  1. 时间精度和可靠性问题
  • 延迟队列的不可预测性:MQ通常为异步通信设计,会受到网络延迟、队列长度等多种因素影响,可能无法在确切的时间执行消息消费,导致订单关闭时间不准确。这对于需要严格时间控制的订单业务来说是一个重要问题。
  • 消息可靠性:尽管MQ能提供相对可靠的消息投递,但在极端情况下,消息丢失或重复消费依然可能发生。这会给订单的准确关闭带来风险,如订单未关闭或多次错误关闭。
  1. 系统复杂性增加
  • 为了实现这一功能,系统需要引入并维护消息队列,例如ActiveMQ、RabbitMQ或Kafka,这增加了系统的复杂性和运维负担。
  • 性能与负载问题:需要处理大量与订单相关的延迟消息,可能会导致MQ系统负载增加,尤其是在高并发环境下,这会影响系统整体性能。
  1. 灵活性和扩展性问题
  • 动态性不足:如果需要调整订单关闭时间,已经在队列中的消息很难修改。这会限制系统灵活适应业务需求变化的能力。
  • 复杂的业务逻辑:实现简单的定时关闭功能需要复杂的处理逻辑,包括消息的发送、接收、消费、异常处理等,这会增加业务流程的复杂性。

并发口诀:一锁二判三更新

不管我们使用定时任务还是延迟消息时,不可避免的会遇到并发执行任务的情况 (比如重复消费、调度重试等)。

当我们执行任务时,我们可以按照一锁二判三更新这个口诀来处理。

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

兜底意识 + 配置监控

虽然我们提到了很多的实现策略,现实实战时依然容易出现问题,比如不合理的操作导致消息丢失。

因此,我们应该具备兜底意识

假如少量消息丢失,我们可以通过每天凌晨跑一次任务,批量将这些未处理的订单批量取消。这种兜底行为工程实现简单,同时对系统影响很小。

还有一点,就是配置监控

笔者曾经自研过任务调度系统,应用 A 接入后,从控制台发现每隔 2 个小时调度应用 A 的任务时,经常发生超时,通过分析,发现应用 A 线程出现了死锁。

这种问题出现的几率非常高,因此配置监控特别要必要。

对业务系统来讲,监控分为两个层面:系统监控业务监控

  • 系统监控

在条件允许的情况下,建议关注性能监控,方法可用性监控,方法调用次数监控这三大类。

性能监控

上图是性能监控的示例图,性能监控不同时间段性能分布,实时统计 TP99、TP999 、AVG 、MAX 等维度指标,这也是性能调优的重点关注对象。

  • 业务监控

业务监控功能是从业务角度出发,各个应用系统需要从业务层面进行哪些监控,以及提供怎样的业务层面的监控功能支持业务相关的应用系统。

具体就是对业务数据,业务功能进行监控,实时收集业务流程的数据,并根据设置的策略对业务流程中不符合预期的部分进行预警和报警,并对收集到业务监控数据进行集中统一的存储和各种方式进行展示。

比如订单系统中有一个定时结算的服务,每两分钟执行一次。我们可以在定时任务 JOB 中添加埋点,并配置业务监控,假如十分钟该定时任务没有执行,则发送邮件,短信给相关负责人。

扩展

一笔订单,在取消的那一刻用户刚好付款了,怎么办?

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

  1. 用户支付成功,支付回调的那一刻支付单刚好还没取消,而等回调结束,取消支付单的事务提交,支付单取消。此时用户扣款了,但是对应的权益或资产没了。
  1. 用户支付成功,支付回调的那一刻支付单已经被取消。但此时用户已经扣款,东西却没了

可以看到,不论是哪种情况,其实都需要做一定的处理,不然用户肯定会来投诉!

这种场景无非就是支付单支付成功和取消两种状态的"争夺",正常情况下,订单或者支付单都会有状态机的存在,在当前场景简单来说有以下两条路径:

  1. 待支付->支付中->支付成功
  2. 待支付->支付中->已取消

针对情况1,如果是支付回调取胜,此时的状态应该已从 支付中->支付成功

针对情况2,如果是取消支付单取胜,此时的状态应该已从 支付中->已取消

所以我们在修改支付单状态的时候,基于原始状态的判断,就可以做正常的处理,来看下 SOL应该就很清晰了:

sql 复制代码
# 支付成功
update pay_info set status = 'paySuccess' where orderNo = '1' and status = 'paying';

# 取消
update pay_info set status = 'cancel' where orderNo = '1' and status = 'paying';

重点就是我们加了 status='paying'这个条件,这就能保证情况只有一个能成功,另一个一定失败。这种其实就是乐观锁的方式

  1. 假设情况1成功了,此时用户已经成功付款,那么状态已经变为paySucces,取消的SQL必定执行失败,此时就让它失败,不需要做任何别的处理。
  1. 假设情况2成功了,此时订单已被取消,status已经变为 cancel,支付成功的SOL必定执行失败,这种情况下我们就需要做逆向处理,即给用户退款。订单被取消,用户的钱也被原路退回,这种处理也没任何问题

业务优化

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

Redis 分布式锁实现

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

订单取消流程:

  1. 超时触发取消订单
  2. 取消订单方法中先获取该订单的分布式锁。如果锁被其他操作持有(如付款),等待或抛出异常
  3. 若成功获取锁,检查订单状态是否已付款:
    • 若订单未付款,将订单状态更新为"已取消"
    • 若订单已付款,直接跳过这笔订单的处理。。
    • 释放分布式锁,完成取消流程。

订单付款流程:

  1. 三方支付成功回调。
  2. 后端系统接收回调后,先获取该订单的分布式锁,如果锁被其他提作持有(如取消),等待或抛出异常(没有给三方响应成功,三方会重新发起回调)
  3. 若成功获取锁,检查订单状态是否为"待支付":
  4. 若订单状态为"待支付",继续执行扣款,并将订单状态更新为"已付款"。
    • 若订单状态为"已取消",则发起退款,并提示用户订单已取消,无法支付。
    • 释放分布式锁,完成流程。

往期推荐

相关推荐
BillKu35 分钟前
Java + Spring Boot + Mybatis 插入数据后,获取自增 id 的方法
java·tomcat·mybatis
全栈凯哥36 分钟前
Java详解LeetCode 热题 100(26):LeetCode 142. 环形链表 II(Linked List Cycle II)详解
java·算法·leetcode·链表
chxii37 分钟前
12.7Swing控件6 JList
java
全栈凯哥39 分钟前
Java详解LeetCode 热题 100(27):LeetCode 21. 合并两个有序链表(Merge Two Sorted Lists)详解
java·算法·leetcode·链表
YuTaoShao39 分钟前
Java八股文——集合「List篇」
java·开发语言·list
PypYCCcccCc44 分钟前
支付系统架构图
java·网络·金融·系统架构
华科云商xiao徐1 小时前
Java HttpClient实现简单网络爬虫
java·爬虫
扎瓦1 小时前
ThreadLocal 线程变量
java·后端
BillKu2 小时前
Java后端检查空条件查询
java·开发语言
jackson凌2 小时前
【Java学习笔记】String类(重点)
java·笔记·学习