别再找了,创建订单30分钟未支付,这里有10种方案

大家好,我是飘渺。今天继续更新DDD&微服务专栏,本篇主要与大家探讨一下在Dailymart中如何定时关闭未支付的订单。

概述

之前的文章提及过,在DailyMart项目中,我们采用了预扣模式进行库存扣减。预扣模式的核心思想是在用户下单时提前扣减库存,在规定时间内完成支付,否则系统将释放预扣的库存。

这种模式的应用需要确保及时关闭未支付订单并释放库存,以避免商家出现库存不足导致少卖的问题。在系统开发中,类似的场景也有很多,例如到期自动收货、超时自动退款、下单后自动发送短信等。

本文旨在从这类业务问题出发,深入探讨可行的技术方案、实现细节,以及相关方案的优缺点。最后,将回顾DailyMart是如何解决这一问题的。由于篇幅有限,本文将主要聚焦于方案的阐述,而不涉及具体的代码实现。

一、定时任务

定时任务关闭订单是一个较为直观的方案,很多小型项目均是基于此方案实现。

具体实现是通过调度平台执行定时任务,扫描所有即将到期的订单并执行关单动作。这种方案的优势在于简单易实现,可基于Timer、ScheduledThreadPoolExecutor,或像xxl-job这类调度框架来实现。然而,此方案会存在以下几个问题:

1、时间不精准 :

定时任务基于固定的频率或时间执行,可能导致一些订单已经超时,但定时任务尚未触发,使得实际关闭时间延迟。

2、无法处理大订单量

定时任务将关闭操作集中在一段时间内,当订单量较大时,任务执行时间可能较长,延迟订单扫描和关闭时间。

3、对数据库造成压力

定时任务集中扫描数据库表,可能在短时间内占用大量数据库IO,若未进行良好隔离,可能影响线上正常业务。

4、分库分表问题:

在订单系统中,一旦订单量大就可能会考虑分库分表,而在分库分表中执行全表扫描,这是一个极不推荐的做法。

综上,**定时任务的方案,适合于对时间精确度要求不高、并且业务量不是很大的场景中。如果对时间精度要求比较高,并且业务量很大的话,这种方案不适用。 **

二、JDK自带的延迟队列

利用JDK自带的DelayQueue可直接实现延时队列。

DelayQueue是一个无界的BlockingQueue,用于存放实现了Delayed接口的对象,这些对象只能在到期时才能被取出。通过延迟队列,可以实现订单的延迟关闭。

具体实现步骤为:用户创建订单时,将订单加入DelayQueue;随后,需要一个常驻任务从队列中取出超时的订单并执行关单操作,然后将其从队列中移除。

这一方案需要一个线程来循环从队列中取出订单,通常使用while(true)确保任务持续执行并及时处理超时订单。该方案的优势在于简单易实现,无需依赖第三方框架和类库,原生支持于JDK。不过,这一方案的缺点也很明显:

首先,基于DelayQueue需要将订单存放其中,当订单量过大时,可能引发OOM问题。

此外,DelayQueue基于JVM内存,机器重启会导致数据丢失,这就使得需要搭配数据库持久化来配合解决,并且现在很多应用都会基于集群部署,多个实例上的多个DelayQueue又无法很好的协作配合。

因此,基于JDK的DelayQueue方案仅适用于单机场景和数据量不大的情况,不建议在涉及分布式场景中使用。

三、Netty的时间轮

一种类似于前文提到的JDK自带DelayQueue的解决方案是基于时间轮的实现。

尽管DelayQueue的插入和删除操作的平均时间复杂度已经相当不错(O(nlog(n)),但时间轮方案可以将这两个操作的时间复杂度降低至O(1)。时间轮可被视为一种环形结构,分割为多个时间槽,每个槽表示一个时间段,其中可以存放多个任务。采用链表结构保存每个时间槽中所有到期的任务。随着时间的推移,时间轮的时针逐渐移动,执行每个槽中所有到期的任务。

image-20240102200446882

利用Netty的HashedWheelTimer,我们能够快速实现一个时间轮,这种方式和DelayQueue类似,也存在一些缺点,例如基于内存、集群扩展不够灵活、内存限制等。然而,相对于DelayQueue,时间轮方案在效率上更具优势,任务触发的延迟更低,并且代码实现更为精简。

因此,基于Netty的时间轮方案相对于基于JDK的DelayQueue更为高效、实现更为简单。然而,同样地,它只适用于单机场景和数据量不大的情况,不建议在涉及分布式场景中使用。

四、Kafka的时间轮

既然在基于Netty的时间轮中存在一些问题,是否有其他可行的时间轮实现呢?确实存在一种备选方案,即Kafka的时间轮。在Kafka内部,许多操作需要延时处理,例如延时生产、延时拉取和延时数据删除等。这些延时功能由内部的延时操作管理器专门处理,其底层采用了时间轮的实现。

特别值得一提的是,为了有效处理一些时间跨度较大的延时任务,Kafka引入了层级时间轮,该设计更灵活地控制时间粒度,以适应更为复杂的定时任务处理场景。Kafka中的时间轮实现由TimingWheel类完成,位于kafka.utils.timer包中。基于Kafka的时间轮同样能够实现O(1)时间复杂度,性能表现相当不错。

基于Kafka时间轮的实现方式,需要依赖于Kafka实现较为复杂,但其稳定性和性能表现更为卓越,尤其适用于分布式场景。

五、RocketMQ延迟消息

与Kafka相比,RocketMQ提供了一个强大的功能,即支持延迟消息。

延迟消息指的是在消息写入Broker后,并非立即被消费者处理,而是需要等待指定时长后才能被消费。这种机制使得我们能够在订单创建后发送一个延迟消息,例如,设置一个延迟30分钟的消息来取消订单。在过去的30分钟内,消息会保持未被消费状态,然后在时间到达后被消费者处理。消费者在接收到消息后,即可执行关单操作。

需要注意的是,RocketMQ的延迟消息并不支持任意时长的延迟,而是限定在一些预定义的时长内,如1秒、5秒、10秒、30秒、1分钟、2分钟等(RocketMQ商业版支持任意时长,这也是他们的一贯作风)。尽管RocketMQ的延迟消息简化了处理流程,实现了系统之间的完全解耦,但由于时长受到了限制,灵活性相对较低。

在业务中,如果订单关闭的时长恰好匹配RocketMQ延迟消息支持的时长,那么基于RocketMQ延迟消息是一种可行的实现方式。然而,如果时长不匹配,可能需要考虑其他更灵活的解决方案。

六、RabbitMQ

延迟消息不仅在RocketMQ中得到支持,在RabbitMQ中同样可以实现,可以选择使用死信队列或者基于rabbitmq_delayed_message_exchange插件进行实现。

死信队列

在RabbitMQ中,一旦一条正常消息因为TTL过期、队列长度超限或被消费者拒绝等原因无法被及时消费,它将成为Dead Message,即死信,会被重新发送到死信队列(。通过这一机制,我们可以实现延迟消息的效果。具体实现方式是给消息设定TTL,但不立即消费,等待其过期后进入死信队列,然后监听死信队列以完成消息的消费。

RabbitMQ中的TTL可以设置任意时长,解决了RocketMQ在这方面的不灵活之处。然而,死信队列实现方式可能导致队头阻塞问题,因为队列采用先进先出的原则,每次只判断队头消息是否过期。若队头消息的过期时间较长且一直不过期,整个队列都会受阻。即使队头后面的消息过期,也会受到阻塞影响。

基于RabbitMQ的死信队列,可以实现高度灵活的延迟消息机制,满足定时关单等需求。利用RabbitMQ的集群扩展性,实现高可用和大并发处理。然而,这种方案存在消息阻塞问题,且相对较为复杂,需要依赖RabbitMQ并声明多个队列(交换机),增加系统复杂度,在实际使用中不是很推荐。

rabbitmq_delayed_message_exchange 插件

在RabbitMQ中,也可以利用rabbitmq_delayed_message_exchange插件实现延时消息,该方案解决了通过死信队列引起的消息阻塞问题。请注意,该插件从RabbitMQ版本3.6.12开始提供支持,因此对版本有一定的要求。

image-20240102210052972

这官方插件经过认证,使用安全可靠。安装并启用该插件后,即可创建x-delayed-message类型的队列。相较于先将消息投递到正常队列,再在TTL过期后进入死信队列的方式,插件的实现方式略有不同。具体而言,消息并不会立即进入队列,而是先存储在基于Erlang开发的Mnesia数据库中。然后,通过定时器查询需要投递的消息,并将它们投递到x-delayed-message队列中。

七、Redis

基于Redis也可以实现延时消息的功能,有以下三种方案:

Redis过期监听

通过在配置文件redis.conf中增加配置notify-keyspace-events Ex 即可实现消息的过期监听,然后可以在业务代码实现KeyExpirationEventMessageListener监听器来接收过期消息,这样就可以实现延时关闭订单的操作。然而,Redis并不保证键在过期时立即删除,也不保证消息能够及时发出。并且在集群模式下,某个节点的key事件被触发了并不会扩散到所有节点。

image-20240109161736704

Redis的zset

虽然Redis过期监听方案并不完美,但我们还可以借助有序集合(zset)来实现此功能。在zset中,我们将订单超时时间的时间戳(下单时间+超时时长)与订单号分别设置为score和member,Redis会根据score延时时间来排序zset。最后通过Redis扫描任务,获取 “当前时间>score” 的延时任务,执行关闭订单业务逻辑。使用zset实现订单关闭的优点在于可借助Redis的持久化和高可用机制,避免数据丢失。 然而,在高并发场景下可能存在多个消费者同时获取相同订单号的问题,这就要求消费者一定要做好幂等处理。

关于幂等方案可以参考我DDD系列的这篇文章如何保证接口的幂等性?

Redisson

虽然zset方案看起来不错,但需要手动编写基于zset这种数据结构实现。一种更友好的方式是基于Redisson框架。

image-20240103152132205

Redisson是在Redis基础上实现的框架,提供了分布式的Java常用对象和许多分布式服务。其中,Redisson定义了分布式延迟队列RDelayedQueue,这是基于zset实现的延迟队列,它允许将元素以指定的延迟时长放入目标队列中。当我们要添加一个数据到延迟队列的时候,redission会把数据+超时时间放到zset中,并且起一个延时任务,当任务到期的时候,再去zset中把数据取出来,返回给客户端使用。通过Redisson,我们可以更轻松地实现延迟队列,不仅解决了zset方案中的并发重复问题,并且性能稳定高,推荐大家使用!

小结

本文介绍了七大类关于订单延时关闭的方案,其中在RabbitMQ和Redis的方案中又各自包含几个小方案,每种方案都有其独特的优缺点,适用于不同的业务场景。在实际开发中,应根据业务需求和系统架构选择最合适的方案。比如你是一个简单的单体应用并且业务量不大,选择Netty的时间轮或定时任务即可;如果你的项目恰好引入了RabbitMQ或者RocketMQ,那就可以;如果只是引入了Redis也可直接使用Redisson的RDelayedQueue方案。

例如,对于简单的单体应用且业务量不大的情况,可考虑使用Netty的时间轮或定时任务。如果项目已经引入了RabbitMQ或RocketMQ,直接使用RabbitMQ的插件方案或RocketMQ的延时消息是一个不错的方案。而对于仅引入了Redis的情况,可以直接利用Redisson的RDelayedQueue方案。

值得注意的是,使用Redis过期监听或RabbitMQ死信队列作为延时任务的方式可能会存在一些设计者未预料到的问题。这种出其不意的使用方式通常会带来一致性和可靠性的隐患,可能导致低吞吐量、资源泄漏等问题。因此,个人不推荐采用这些方式,而是建议选择更为稳定和可控的方案。

DailyMart中的实现方式

在Dailymart中我们已经引入了RocketMQ,所以可以直接采取Redis的延迟消息来实现订单的延时关闭。

首先,在领域服务保存订单数据时通过ApplicationEventPublisher发布订单创建事件。

@Transactional  
public void save(TradeOrder tradeOrder) {  
    orderRepository.save(tradeOrder);  
    eventPublisher.publishEvent(new OrderCreatedEvent(tradeOrder));  
}

然后,在应用层的事件监听器中,通过RocketMQ发布延时消息

@Order(3)  
@Component  
@Slf4j  
public class DelayCloseListener implements ApplicationListener<OrderCreatedEvent> {  
      
    @Resource  
    private RocketMQEnhanceTemplate rocketMQEnhanceTemplate;  
    
    /**  
     * 收到消息以后需要发送延时消息,用以确保订单及时支付  
     * @param event 订单创建事件  
     */  
    @Override  
    public void onApplicationEvent(@NotNull OrderCreatedEvent event) {  
        TradeOrder tradeOrder = (TradeOrder) event.getSource();  
        DelayCloseOrderEvent orderMessage = new DelayCloseOrderEvent(tradeOrder.getCustomerId(), tradeOrder.getOrderSn());  
        rocketMQEnhanceTemplate.sendDelay("TRADE-ORDER", "DELAY-CLOSE", orderMessage, DelayMessageConstant.THIRTY_MINUTE);  
    }  
}

最后,在订单服务基础设施层监听延时消息,当收到延时消息后执行关单操作

@Component  
@Slf4j  
@RocketMQMessageListener(consumerGroup = "dailymart_order_group", topic = "TRADE-ORDER", selectorExpression = "DELAY-CLOSE")  
public class OrderDelayClosedConsumer extends EnhanceMessageHandler<DelayCloseOrderEvent> implements RocketMQListener<DelayCloseOrderEvent> {  
      
    @Override  
    protected void handleMessage(DelayCloseOrderEvent delayCloseOrderEvent) {  
        // 1. 查看订单支付状态  
        // 1.1 如果已支付,则无需处理消息  
        // 1.2 如果未支付,则需要关闭订单, 释放库存  
        String orderSn = delayCloseOrderEvent.getOrderSn();  
        TradeOrder tradeOrder = orderRepository.findOrderByTransaction(orderSn);  
        if (Objects.equals(tradeOrder.getStatus(), OrderStatusEnum.WAITING_PAYMENT.getStatus())) {  
            tradeOrderService.changeOrderStatus(orderSn, OrderStatusEnum.CLOSED);  
            inventoryRemoteFacade.unWithhold(orderSn);  
        }  
    }
}

DailyMart是一个基于 DDD 和Spring Cloud Alibaba的微服务商城系统,采用SpringBoot3.x以及JDK17。旨在为开发者提供集成式的学习体验,并将其无缝地应用于实际项目中。该专栏包含领域驱动设计(DDD)、Spring Cloud Alibaba企业级开发实践、设计模式实际应用场景解析、分库分表战术及实用技巧等内容。如果你对这个系列感兴趣,可在本公众号回复关键词 DDD 获取完整文档以及相关源码。