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

日常开发中,我们经常遇到这种业务场景,如:外卖订单超 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. 若订单状态为"待支付",继续执行扣款,并将订单状态更新为"已付款"。
    • 若订单状态为"已取消",则发起退款,并提示用户订单已取消,无法支付。
    • 释放分布式锁,完成流程。

往期推荐

相关推荐
小萌新上大分20 分钟前
SpringCloudGateWay
java·开发语言·后端·springcloud·springgateway·cloudalibaba·gateway网关
直视太阳1 小时前
springboot+easyexcel实现下载excels模板下拉选择
java·spring boot·后端
Code成立2 小时前
《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》第2章 Java内存区域与内存溢出异常
java·jvm·jvm内存模型·jvm内存区域
一 乐2 小时前
实验室预约|实验室预约小程序|基于Java+vue微信小程序的实验室预约管理系统设计与实现(源码+数据库+文档)
java·数据库·微信小程序·小程序·毕业设计·论文·实验室预约小程序
程序媛学姐2 小时前
SpringRabbitMQ消息模型:交换机类型与绑定关系
java·开发语言·spring
努力努力再努力wz2 小时前
【c++深入系列】:类与对象详解(中)
java·c语言·开发语言·c++·redis
兰亭序咖啡2 小时前
学透Spring Boot — 009. Spring Boot的四种 Http 客户端
java·spring boot·后端
独行soc2 小时前
2025年渗透测试面试题总结- 某四字大厂面试复盘扩展 一面(题目+回答)
java·数据库·python·安全·面试·职场和发展·汽车
早上好啊! 树哥2 小时前
常见的文件加密方式之【异或加密】,代入原理看例子,帮助更好的理解。
android·java·junit
兰亭序咖啡3 小时前
学透Spring Boot — 018. 优雅支持多种响应格式
java·spring boot·后端