服务器之家:专注于VPS、云服务器配置技术及软件下载分享
分类导航

PHP教程|ASP.NET教程|Java教程|ASP教程|编程技术|正则表达式|C/C++|IOS|C#|Swift|Android|VB|R语言|JavaScript|易语言|vb.net|

服务器之家 - 编程语言 - 编程技术 - 美团二面:如何设计一个订单超时未支付关闭订单的解决方案?

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?

2024-03-28 15:07码农Academy 编程技术

定时轮训的方式都是基于定时定任务扫描订单表,按照下单时间以及状态进行过滤,之后在进行判断是否在有效期内,如果不在,则取消订单。

订单超时未支付自动取消是一个典型的电商和在线交易业务场景,在该场景下,用户在购物平台上下单后,系统通常会为用户提供一段有限的时间来完成支付。如果用户在这个指定的时间窗口内没有成功完成支付,系统将自动执行订单取消操作。

当然类似的业务场景还有:

  • 我们预约钉钉会议后,钉钉会在会议开始前15分钟、5分钟提醒。
  • 淘宝收到货物签收之后,超过7天没有确认收货,会自动确认收货。
  • 未使用的优惠券有效期结束后,自动将优惠券状态更新为已过期。
  • 用户登录失败次数过多后,账号锁定一段时间,利用延迟队列在锁定期满后自动解锁账号。而针对这种业务需求,我们常见的两中技术方向即:定时轮训订单之后判断是否取消以及延迟队列实现。而到具体的技术方案主要有以下几种:

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

本文主要介绍以下几种主流方案。

定时轮训(SpringBoot的Scheduled实现)

定时轮训的方式都是基于定时定任务扫描订单表,按照下单时间以及状态进行过滤,之后在进行判断是否在有效期内,如果不在,则取消订单。

如以下,我们使用SpringBoot中的定时任务实现:

我们先创建定时任务的配置,设置任务每隔5秒执行一次。

@Configuration
@EnableScheduling
public class CustomSchedulingConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        ThreadPoolTaskScheduler threadPoolTaskScheduler = threadPoolTaskScheduler();
        taskRegistrar.setTaskScheduler(threadPoolTaskScheduler); // 设置自定义的TaskScheduler
        // 根据任务信息创建CronTrigger
        CronTrigger cronTrigger = new CronTrigger("0/5 * * * * ?");
        // 创建任务执行器(假设TaskExecutor是实现了Runnable接口的对象)
        MyTaskExecutor taskExecutor = new MyTaskExecutor();
        // 使用自定义的TaskScheduler调度任务
        threadPoolTaskScheduler.schedule(taskExecutor, cronTrigger);
    }

    @Bean(destroyMethod = "shutdown")
    public ThreadPoolTaskScheduler threadPoolTaskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5); // 设置线程池大小
        scheduler.setThreadNamePrefix("scheduled-task-"); // 设置线程名称前缀
        scheduler.setAwaitTerminationSeconds(60); // 设置终止等待时间
        return scheduler;
    }
}

然后在MyTaskExecutor中实现扫描订单以及判断订单是否需要取消:

public class MyTaskExecutor implements Runnable{

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " 在 "+ LocalDateTime.now() +" 执行MyTaskExecutor。。。。。");
    }
}

运行结果如下:

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

采用定时任务机制来实现实时监测并取消超时订单的方法相对直接易行,我们可以运用诸如Quartz、XXL-Job或Elastic-Job等成熟的定时任务框架进行集群部署,从而提升任务执行效能。然而,此类方案存在显著局限:

首先,定时轮询订单表的方式在订单数量庞大的情况下会对数据库带来持续且显著的压力,因为频繁地全表扫描无疑会增加I/O负担和CPU使用率。

其次,定时任务执行的间隔设定颇为棘手。若设定的间隔时间较长,可能会导致订单超时后的取消动作出现延迟,影响用户体验;相反,若时间间隔设置得过短,则会导致大量订单被重复扫描和判断,不仅浪费计算资源,还可能导致不必要的并发问题和事务冲突,尤其是在高并发交易的高峰期。

在实际应用中,针对大流量订单场景下的超时处理,往往更倾向于采用延迟队列技术而非简单的定时任务轮询,以实现更为精确、高效的超时逻辑处理。

关于SpringBoot的定时任务实现的几种方式,请参考:玩转SpringBoot:SpringBoot的几种定时任务实现方式

JDK的延迟队列

使用JDK自带的DelayQueue实现一个延迟队列并处理超时订单,首先我们需要定义一个实现了Delayed接口的订单对象类,然后创建DelayQueue实例并不断从队列中取出已超时的订单进行处理。

我们定义一个包含订单信息和延迟时间的订单类:

@Getter
public class DelayedOrder implements Delayed {

    private final String orderNo;
    private final long expireTimeMillis; // 订单超时时间戳(毫秒)

    public DelayedOrder(String orderNo, long delayInSeconds) {
        this.orderNo = orderNo;
        // 设置订单在当前时间多少秒后超时
        this.expireTimeMillis = System.currentTimeMillis() + delayInSeconds;
    }

    @Override
    public long getDelay(TimeUnit unit) {
        long remainingNanos = expireTimeMillis - System.currentTimeMillis();
        return unit.convert(remainingNanos, TimeUnit.MILLISECONDS);
    }

    @Override
    public int compareTo(Delayed other) {
        if (other == this) {
            return 0;
        }
        DelayedOrder t = (DelayedOrder) other;
        long d = (getDelay(TimeUnit.MILLISECONDS) - t.getDelay(TimeUnit.MILLISECONDS));
        return (d == 0) ? 0 : ((d < 0) ? -1 : 1);
    }

    // 其他订单属性及方法...

    // 处理订单取消的逻辑
    public void cancelOrder() {
        // 在这里调用实际的服务接口或方法取消订单
    }
}

然后我们就可以使用DelayQueue处理超时订单:

@Component
public class OrderDelayQueue {
    private final DelayQueue<DelayedOrder> delayQueue = new DelayQueue<>();

    public void addOrderToQueue(DelayedOrder order) {
        delayQueue.put(order);
        System.out.println("订单 " + order.getOrderNo() + "在 "+LocalDateTime.now()+" 添加到延迟队列");
    }

    // 启动订单处理线程池
    @Autowired
    private ExecutorService executorService;

    @PostConstruct
    public void init() {
        executorService.execute(this::processOrders);
    }

    private void processOrders() {
        while (true) {
            try {
                DelayedOrder order = delayQueue.take(); // 从延迟队列中取出已经过期的订单
                System.out.println("订单 " + order.getOrderNo() + "在 "+ LocalDateTime.now() +" 取消");
                order.cancelOrder();
                // 在这里执行取消订单的逻辑,比如更新数据库状态等
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

我们实现一个创建订单的接口,模拟订单创建:

@RestController
@RequestMapping("orderDelay")
public class OrderDelayController {

    @Autowired
    private OrderDelayQueue orderDelayQueue;

    @PostMapping("add")
    public void addOrder() {
        // 202403221901   2秒后取消
        DelayedOrder delayedOrder = new DelayedOrder("202403221901", 2000);
        orderDelayQueue.addOrderToQueue(delayedOrder);
        // 202403221902 3秒后取消
        DelayedOrder delayedOrder1 = new DelayedOrder("202403221902", 3000);
        orderDelayQueue.addOrderToQueue(delayedOrder1);
        // 202403221903 5秒后取消
        delayedOrder = new DelayedOrder("202403221903", 6000);
        orderDelayQueue.addOrderToQueue(delayedOrder);

        delayedOrder = new DelayedOrder("202403221904", 8000);
        orderDelayQueue.addOrderToQueue(delayedOrder);
    }
}

请求接口,发现订单超过各自的时间之后,都超时了。当然真实场景是超时时间一致,只是订单创建时间不一致。

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

基于JDK的DelayQueue实现的延迟队列解决取消超时订单的方案,相比较于定时轮训有如下优点:

  1. DelayQueue基于优先级队列实现,内部使用了堆数据结构,插入和删除操作的时间复杂度为O(log n),对于大量订单的处理效率较高。
  2. 相比于定期查询数据库的方式,DelayQueue将待处理的订单信息保留在内存中,减少了对数据库的访问频率,降低了IO压力。
  3. DelayQueue是java.util.concurrent包下的工具类,本身就具备良好的线程安全特性,可以在多线程环境下稳定工作。

但是因为DelayQueue是基于内存的,这也导致它在实现上有一定的缺点:

  1. 所有待处理的订单信息都需要保留在内存中,对于大量订单,可能会造成较大的内存消耗。
  2. 由于所有的超时信息都依赖于内存中的队列,如果系统崩溃或重启,未处理的订单信息可能丢失,除非有额外的持久化措施。

时间轮算法

在介绍时间轮算法实现取消超时订单功能之前,我们先来看一下什么是时间轮算法?

时间轮算法(Time Wheel Algorithm)是一种高效处理定时任务调度的机制,广泛应用于各类系统如计时器、调度器等组件。该算法的关键理念在于将时间维度映射至物理空间,即构建一个由多个时间槽构成的循环结构,每个槽代表一个固定的时间单位(如毫秒、秒等)。

时间轮实质上是一个具有多个槽位的环形数据结构,随着时间的推进,时间轮上的指针按照预先设定的速度(例如每秒前进一槽)顺时针旋转。每当指针移动至下一槽位时,系统会检视该槽位中挂载的所有定时任务,并逐一执行到期的任务。

在时间轮中,每个待执行任务均与其触发时间点对应的时间槽关联。添加新任务时,系统会根据任务的期望执行时间计算出相应的槽位编号,并将任务插入该槽。对于未来执行的任务,计算所需等待的槽位数目,确保任务按时被处理。值得注意的是,时间轮设计为循环结构,意味着当指针到达最后一个槽位后会自动返回至第一个槽位,形成连续不断的循环调度。

借助时间轮算法,定时任务的执行时间以相对固定的时间槽来表示,而非直接依赖于绝对时间。任务执行完毕后,系统会及时将其从时间轮中移除,同时,对于不再需要执行的任务,也可以在任何时候予以移除,确保整个调度系统的高效运作和实时响应。

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

如上图为例,假设一个格子是1秒,则整个wheel能表示的时间段为8s,假设当前指针指向2,此时需要调度一个3s后执行的任务, 显然应该加入到(2+3=5)的方格中,指针再走3次就可以执行了;如果任务要在10s后执行,应该等指针走完一个round零2格再执行, 因此应放入4,同时将round(1)保存到任务中。检查到期任务应当只执行round为0的,格子上其他任务的round应减1.

所以,我们可以使用时间轮算法去试一下延迟任务,用于实现取消超时订单。

我们以Netty4为例,引入依赖:

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.68.Final</version>
</dependency>

然后定义订单处理服务,在创建订单时定义订单超时时间,以及超时时取消订单。

@Service
public class OrderService {

    private final Map<String, Timeout> orderTimeouts = new HashMap<>();
    private final HashedWheelTimer timer = new HashedWheelTimer();

    public void createOrder(String orderId) {
        System.out.println("订单"+orderId+"在"+ LocalDateTime.now() +"创建成功.");
        // 创建订单,设置超时时间为5秒钟
        Timeout timeout = timer.newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                // 超时处理逻辑,取消订单
                cancelOrder(orderId);
            }
        }, Duration.ofSeconds(5).toMillis(), TimeUnit.MILLISECONDS);
        orderTimeouts.put(orderId, timeout);
    }

    public void cancelOrder(String orderId) {
        // 取消订单的逻辑
        orderTimeouts.remove(orderId);
        System.out.println(orderId+"订单超时,在"+ LocalDateTime.now() +"取消订单:" + orderId);
    }
}

我们定义订单创建接口,模拟订单创建:

@RestController
@RequestMapping("orderTimeWheel")
public class OrderTimeWheelController {

    @Autowired
    private OrderService orderService;

    @PostMapping("/create")
    public String createOrder(String orderId) {
        orderService.createOrder(orderId);
        return "订单创建成功:" + orderId;
    }
}

我们分别请求接口,创建订单:

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

可以看见,订单在5秒钟之后自动调用取消方法取消订单。

基于时间轮实现延迟任务来取消超时订单有如下优点:

  1. 时间轮算法能够高效地管理大量的定时任务,其执行时间与任务数量无关,因此非常适合处理大规模的定时任务。
  2. 时间轮算法能够提供相对精确的超时控制,可以在指定的时间后执行任务或者取消任务,从而确保超时订单能够及时取消。并且时间轮算法允许灵活地管理时间间隔和超时时间,可以根据具体业务需求进行调整和优化。
  3. 时间轮算法的实现相对简单,算法本身比较容易理解,且现有的实现库如Netty的HashedWheelTimer已经提供了成熟的实现,因此可以很方便地集成到现有的系统中。
  4. 基于内存操作,减少一些IO压力。

但是相对应的也存在一些缺点:

  1. 时间轮算法需要维护一个槽的数据结构,因此会占用一定的内存和计算资源,对于一些资源受限的环境可能会存在一定的压力。同DelayQueue,在大量订单时会对内存造成较大的内存消耗。同时也会影响延迟精度。
  2. 同时,如果系统崩溃或者重启,未处理的订单信息可能丢失,除非有额外的持久化措施。

Redis实现

对于Redis实现延迟任务,常见的两种方案是使用有序集合(Sorted Set,通常简称为zset)和使用key过期监听。

定时轮训有序集合

利用有序集合的特性,即集合中的元素是有序的,每个元素都有一个分数(score)。在延迟任务的场景中,可以将任务的执行时间作为分数,将任务的唯一标识(如任务ID)作为集合中的元素。然后,定时轮询有序集合,查找分数小于当前时间的元素,这些元素即为已经到期需要执行的任务。执行完任务后,可以从有序集合中删除对应的元素。因此可以将订单的过期时间作为score,用于实现取消超时订单。

引入Redis依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>2.7.0</version>
</dependency>

配置一下RedisTemplate:

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
        // 其余配置 如序列化等
        return new StringRedisTemplate(factory);
    }
}

创建订单创建以及自动取消服务:

@EnableScheduling
@Service
public class OrderZSetService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // key: orders:timeout, value: order_id:order_expiration_time
    private static final String ORDER_TIMEOUT_SET_KEY = "orders:timeout";

    public void createOrder(String orderId) {
        System.out.println("订单"+orderId+"在"+ LocalDateTime.now() +"创建成功.");
        // 假设订单超时时间为5秒
        long expirationTime = 5 * 1000 + System.currentTimeMillis();
        redisTemplate.opsForZSet().add(ORDER_TIMEOUT_SET_KEY, orderId, expirationTime);
    }

    @Scheduled(fixedRate = 1000) // 每秒检查一次,实际频率根据业务需求调整
    public void checkAndProcessTimeoutOrders() {
        Long now = System.currentTimeMillis();
        Set<ZSetOperations.TypedTuple<String>> range = redisTemplate.opsForZSet().rangeByScoreWithScores(ORDER_TIMEOUT_SET_KEY, 0, now);

        for (ZSetOperations.TypedTuple<String> tuple : range) {
            String orderId = (String) tuple.getValue();
            if (tuple.getScore() <= now) {
                // 处理超时订单
                cancelOrder(orderId);
                // 从有序集合中移除已处理的超时订单
                redisTemplate.opsForZSet().remove(ORDER_TIMEOUT_SET_KEY, orderId);
            }
        }
    }

    private void cancelOrder(String orderId) {
        // 在这里实现订单取消的实际逻辑
        System.out.println("订单 " + orderId + " 在" + LocalDateTime.now() +"取消");
        // 更新订单状态、释放库存等操作...
    }
}

注意:因本例中基于@Scheduled实现定时轮训,所以需要使用@EnableScheduling开启Scheduled功能。具体请参考:玩转SpringBoot:SpringBoot的几种定时任务实现方式

我们定义订单创建接口,模拟订单创建:

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

可以看到订单5秒钟后自动取消。

使用Redis有序集合实现取消超时订单有一些优点:

  1. 有序集合可以根据分数(过期时间)快速定位到需要处理的超时订单,避免了对全部订单的全表扫描,提高了查询效率。
  2. 在分布式环境中,Redis作为缓存和中间件,可以很容易地实现在多节点间共享超时订单信息,有利于分布式系统中统一管理超时订单。
  3. 利用Redis内存数据结构,不需要频繁读写数据库,降低了数据库的压力,同时也节约了数据库资源。

但是也有一些缺点:

  1. 定时任务的执行频率决定了处理超时订单的精确程度,频率太低可能导致部分订单未能及时取消,频率太高则可能浪费系统资源。
  2. 在涉及事务处理的情况下,可能需要额外的手段来保证与数据库之间的数据一致性,防止因Redis处理超时订单后,数据库层面的更新失败导致的数据不一致问题。
  3. 在处理超时订单过程中,若出现异常,需要配套的重试机制。

使用Redis key过期监听

利用Redis的key过期监听功能。当设置一个key的过期时间时,可以设置一个回调函数,当key过期时,Redis会自动调用这个回调函数。即利用Redis的Keyspace Notifications功能,当一个键(Key)过期时,Redis会向已订阅了相关频道的客户端发送一个通知。

使用Redis的key的过期监听功能之前我们需要启用Redis Keyspace Notifications,在Redis配置文件(redis.conf)中启用Key Space Notifications,即打开如下配置:

notify-keyspace-events Ex

notify-keyspace-events设置为Ex,表示启用所有类型的键空间通知,包括过期事件。具体配置方法可能因Redis的版本和环境而有所不同,请根据实际情况进行配置。

然后我们就可以使用代码实现,首先实现MessageListener接口实现一个监听器来监听Redis的key过期事件。当订单的key过期时,将触发监听器中的逻辑,执行取消订单的操作。

@Component
public class OrderExpirationListener implements MessageListener {

    @Autowired
    private OrderExpirationService orderService;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String orderId = message.toString();
        // 调用服务取消订单
        orderService.cancelOrder(orderId);
    }
}

然后配置Redis key过期事件监听器,并将其注册到Redis连接工厂中。这样,监听器将会在Redis的key过期事件发生时被调用。

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
        // 其余配置 如序列化等
        return new StringRedisTemplate(factory);
    }

    @Bean
    public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
                                                   OrderExpirationListener listener) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listener, new ChannelTopic("__keyevent@0__:expired")); // 监听所有数据库的key过期事件
        return container;
    }
}

__keyevent@0__:expired是Redis的系统通道,用于监听所有数据库中的key过期事件。如果需要监听特定数据库的key过期事件,则可以修改对应的数据库号。例如,__keyevent@1__:expired表示监听第一个数据库的key过期事件。

然后我们就可以实现具体的订单创建服务以及订单取消的逻辑了。这里我们模拟一下:

@Component
public class OrderExpirationService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public void createOrder(String orderId) {
        System.out.println("订单"+orderId+"在"+ LocalDateTime.now() +"创建成功.");
        // 假设订单超时时间为5秒
        long expirationTime = 5;
        redisTemplate.opsForValue().set(orderId, "orderData", expirationTime, TimeUnit.SECONDS);
    }

    public void cancelOrder(String orderId) {
        // 在这里实现订单取消的实际逻辑
        System.out.println("订单 " + orderId + " 在" + LocalDateTime.now() +"取消");
        // 更新订单状态、释放库存等操作...
    }
}
@RestController
@RequestMapping("orderRedis")
public class RedisOrderController {

    @Autowired
    private OrderExpirationService orderService;

    @PostMapping("/create")
    public String createOrder(String orderId) {
        orderService.createOrder(orderId);
        return "订单创建成功:" + orderId;
    }
}

我们创建4个订单,模拟5秒钟后的订单取消

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

使用Redis的key过期监听事件,实现取消超时订单有以下优点:

  1. Redis键过期事件能在键过期时立即触发监听器,因此可以在订单超时的瞬间准确执行取消操作,大大提高了时效性。
  2. 相比定期轮询数据库查询超时订单的方式,Redis键过期事件是被动触发,节省了CPU和网络资源,减少了无效查询。
  3. Redis的键过期事件处理机制天然支持高并发场景,只要Redis集群足够强大,可以轻松处理大量订单的过期处理。
  4. 资源占用小,只需要维护Redis中少量的键,相对于数据库存储所有订单信息并做定时任务查询,内存和磁盘资源占用较少。

但是也存在一些缺点:

  1. 整个方案依赖于Redis服务的稳定性和性能,如果Redis服务出现问题,可能会影响订单超时处理。
  2. 在高并发场景下,Redis过期事件产生的速率可能非常高,如果处理不当,监听器本身的处理能力可能成为瓶颈,导致消息堆积,这时需要考虑消息队列或者其他缓冲机制。
  3. Redis的键过期并不是严格意义上的实时,而是基于定期检查机制,极端情况下可能存在一定的延迟。尽管在实践中这种延迟很小,但对于极高精度要求的场景,可能需要额外关注。

MQ消息队列

使用消息队列实现取消超时订单的常见方法是利用延迟队列以及死信队列。比如RabbitMq,在介绍实现方式之前,我们先来了解一下RabbitMq的延迟队列以及死信队列。

  1. 延迟队列:RabbitMQ本身并不直接支持延迟队列,但可以通过安装rabbitmq_delayed_message_exchange插件来实现延迟消息的功能。当启用这个插件后,你可以创建一个类型为x-delayed-message的交换机。在发送消息时,可以设置消息头中的x-delay字段,表示消息应该在多久之后才开始被路由到绑定的目标队列。这样,当一个订单创建时,可以将包含订单ID和过期时间的消息发送到延迟交换机,并设置相应的延迟时间。当延迟时间结束时,消息将被发送到处理超时订单的队列,随后由消费者进行订单状态检查和取消操作。

我的RabbitMq是部署在docker中的,所以顺带提议一下关于安装rabbitmq_delayed_message_exchange插件,我们需要在 Releases · rabbitmq/rabbitmq-delayed-message-exchange (github.com)下载.ez结尾的插件,然后使用docker cp命令将其拷贝到rabbitmq容器内:

docker cp <本地路径>/rabbitmq_delayed_message_exchange-3.13.0.ez <容器ID>:/plugins

然后我们进入容器后启动插件:

rabbitmq-plugins enable rabbitmq_delayed_message_exchange

然后验证一下插件是否开启成功:

rabbitmq-plugins list | grep delayed

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

我的rabbitmq的版本是3.13.0

  1. 死信队列死信队列是指当消息在原始队列中遇到某种情况(如消息过期、消息被拒绝等)时,会被重新路由到另一个预定义的队列中。当消息在队列中停留的时间超过TTL,该消息就会变成死信,并根据队列配置转发到死信队列。

基于RabbitMq的延迟队列

延迟队列可以直接处理延迟消息,即消息在指定的延迟时间过后才被投递给消费者。在超时取消订单的场景中,订单创建时将订单信息封装成消息,并设置消息的延迟时间,当订单超时时,消息自动被投递到处理超时订单的队列,消费者接收到消息后执行取消操作。

引入依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
    <version>2.7.18</version>
</dependency>

配置rabbitmq的相关参数:

spring.rabbitmq.host=localhost  
spring.rabbitmq.port=5672  
spring.rabbitmq.password=guest  
spring.rabbitmq.username=guest

配置延迟交换机,并且初始化延迟交换机、队列及绑定关系

@Configuration
@EnableRabbit
public class RabbitConfig {

    public static final String ORDER_EXCHANGE = "order.delayed.exchange";
    public static final String ORDER_QUEUE = "order.delayed.queue";
    public static final String ROUTING_KEY = "delayed-routing-key";

    @Bean
    public CustomExchange delayedExchange() {
        return new CustomExchange(ORDER_EXCHANGE, "x-delayed-message", true, false);
    }

    @Bean
    public Queue delayedQueue() {
        return new Queue(ORDER_QUEUE);
    }

    @Bean
    public Binding delayedBinding(CustomExchange delayedExchange, Queue delayedQueue) {
        return BindingBuilder.bind(delayedQueue).to(delayedExchange).with(ROUTING_KEY).noargs();
    }
}

这里交换机exchange,需要我们事先在rabbitmq中创建好,访问http://localhost:15672/在Exchanges中,添加Exchange,设置type= x-delayed-message,如图:

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

在定义一个监听rabbitmq消息的监听器,当消息延迟时间到了之后,就会被该监听器见听到,在这里判断订单是否已经被支付,如果没有支付则取消。

@Component
public class DelayedQueueListener {

    @Autowired
    private OrderMqService orderMqService;

    @RabbitListener(queues = RabbitConfig.ORDER_QUEUE)
    public void handleDelayedOrder(String orderId) {
        orderMqService.cancelOrder(orderId);
    }   
}

然后我们在订单创建时,将订单信息发送到MQ中,等延迟时间到了之后,如果订单还没有支付,则执行取消订单操作。

@Service
public class OrderMqService {

    private final AmqpTemplate rabbitTemplate;

    @Autowired
    public OrderMqService(AmqpTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    public void createOrder(String orderId) {
        System.out.println("订单"+orderId+"在"+ LocalDateTime.now() +"创建成功.");
        rabbitTemplate.convertAndSend(RabbitConfig.ORDER_EXCHANGE, RabbitConfig.ROUTING_KEY, orderId, message -> {
            message.getMessageProperties().setDelay(5 * 1000);
            return message;
        });
    }

    public void cancelOrder(String orderId) {
        // 在这里实现订单取消的实际逻辑
        System.out.println("订单" + orderId + " 在" + LocalDateTime.now() +"取消");
        // 更新订单状态、释放库存等操作...
    }
}

我们模拟创建订单请求:

@RestController
@RequestMapping("orderMq")
public class MqOrderController {

    @Autowired
    private OrderMqService orderMqService;

    @PostMapping("/create")
    public String createOrder(String orderId) {
        orderMqService.createOrder(orderId);
        return "订单创建成功:" + orderId;
    }
}

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

可以看见订单过了5秒之后开始执行取消。

使用延迟队列方案来实现订单超时取消等场景的优点:

  1. 延迟队列能够在消息到达指定时间后立刻触发处理,减少不必要的轮询查询,提高了处理效率和实时性。
  2. 订单超时处理是异步进行的,不会影响主线业务流程,有利于提升整体系统的响应速度和稳定性。
  3. 延迟队列方案使得订单创建、支付和超时取消三个环节相互独立,有利于系统的模块化和扩展性
  4. 当系统规模扩大时,可以通过增加消费者数量来应对更多的超时订单处理,实现水平扩展。

但是也有一些缺点:

  1. 高度依赖消息队列服务的可用性和稳定性,一旦消息队列出现故障,可能导致超时订单无法正常处理。
  2. 延迟队列方案涉及更多的中间件配置和管理,增加了系统的复杂性。
  3. 在分布式系统中,如果订单状态不仅在消息队列中维护,还要同步到数据库,需要额外保证消息队列处理和数据库操作的一致性。
  4. 虽然大部分消息队列的延迟机制相当可靠,但仍有极小概率出现消息延迟到达或丢失的情况,需要有相应的容错和补偿机制。

对于延迟队列,并非只有rabbitmq才有,RocketMQ也有延迟队列。在RocketMQ中,延迟消息的发送是通过设置消息的延迟级别来实现的。每个延迟级别都对应着一个具体的延迟时间,例如 1 表示 1 秒、2 表示 5 秒、3 表示 10 秒,以此类推。用户可以根据自己的需求选择合适的延迟级别。但是也可以看出他并没有支持的那么精确,如果想要精确的就必须使用RocketMQ的企业版,在企业版中可以自定义设置延迟时间。这里就不过多讲解,有兴趣的可以自己研究一下。

基于RabbitMq的死信队列实现

订单创建时,将订单信息发送到一个具有TTL的队列,当消息在队列中停留的时间超过了TTL(也就是订单的有效支付期限),消息就会变为死信。然后再配置队列,使得这些过期的死信消息被路由到一个预先设置好的死信队列。最后创建一个消费者监听这个死信队列,一旦有消息进来(即订单超时),消费者便处理这些死信,检查订单状态并执行取消操作。

使用的rabbitmq依赖以及配置同上使用延迟队列方案。我们来看一下创建处理订单即带有TTL的队列:

@Configuration
public class RabbitMQConfig {

    /**订单队列*/
    public static final String ORDER_QUEUE = "order.queue";
    /**死信队列交换机*/
    public static final String DEAD_LETTER_EXCHANGE = "order.deadLetter.exchange";
    /**死信队列*/
    public static final String DEAD_LETTER_QUEUE = "order.deadLetter.queue";
    /**死信路由*/
    public static final String ROUTING_KEY = "delayed-routing-key";

    /**
     * 创建订单队列
     * @return
     */
    @Bean
    public Queue orderQueue() {
        Map<String, Object> args = new HashMap<>();
        args.put("x-message-ttl", 5000L); // 设置订单队列消息有效期为30秒(可以根据实际情况调整)
        args.put("type", "java.lang.Long");
        args.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE);
        args.put("x-dead-letter-routing-key", ROUTING_KEY);
        return new Queue(ORDER_QUEUE, true, false, false, args);
    }
}

同理也是需要先创建交换机:

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

创建订单业务类,将订单发送到订单消息队列:

@Service
public class OrderMqService {

    private final AmqpTemplate rabbitTemplate;

    @Autowired
    public OrderMqService(AmqpTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

   public void createOrder(String orderId) {
        System.out.println("订单"+orderId+"在"+ LocalDateTime.now() +"创建成功.");
        rabbitTemplate.convertAndSend(RabbitMQConfig.ORDER_QUEUE, orderId);
    }

    public void cancelOrder(String orderId) {
        // 在这里实现订单取消的实际逻辑
        System.out.println("订单" + orderId + " 在" + LocalDateTime.now() +"取消");
        // 更新订单状态、释放库存等操作...
    }

}

在创建死信队列,私信队列交换机,通过订单队列路由将私信队列绑定到订单订单队列中:

@Configuration
public class RabbitMQConfig {

    /**订单队列*/
    public static final String ORDER_QUEUE = "order.queue";
    /**死信队列交换机*/
    public static final String DEAD_LETTER_EXCHANGE = "order.deadLetter.exchange";
    /**死信队列*/
    public static final String DEAD_LETTER_QUEUE = "order.deadLetter.queue";
    /**死信路由*/
    public static final String ROUTING_KEY = "delayed-routing-key";

    /**
     * 创建死信队列交换机
     * @return
     */
    @Bean
    public DirectExchange deadLetterExchange() {
        return new DirectExchange(DEAD_LETTER_EXCHANGE);
    }

    /**
     * 创建死信队列
     * @return
     */
    @Bean
    public Queue deadLetterQueue() {
        return new Queue(DEAD_LETTER_QUEUE);
    }

    /**
     * 将死信队列与私信交换机绑定通过路由帮订单订单队列中
     * @return
     */
    @Bean
    public Binding bindingDeadLetterQueue() {
        return BindingBuilder.bind(deadLetterQueue()).to(deadLetterExchange()).with(ROUTING_KEY);
    }
}

在创建一个死信队列消息监听器,用于判断订单是否超时:

@Component
public class DelayedQueueListener {

    @Autowired
    private OrderMqService orderMqService;

    @RabbitListener(queues = RabbitMQConfig.DEAD_LETTER_QUEUE)
    public void handleDeadLetterOrder(String orderId) {
        orderMqService.cancelOrder(orderId);
    }
}

然后我们在订单创建时,将订单信息发送到订单MQ中,等消息的TTL到期之后,会自动转到死信队列中。

@Service
public class OrderMqService {

    private final AmqpTemplate rabbitTemplate;

    @Autowired
    public OrderMqService(AmqpTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    public void createOrder(String orderId) {
        System.out.println("订单"+orderId+"在"+ LocalDateTime.now() +"创建成功.");
        rabbitTemplate.convertAndSend(RabbitMQConfig.ORDER_QUEUE, orderId);
    }

    public void cancelOrder(String orderId) {
        // 在这里实现订单取消的实际逻辑
        System.out.println("订单" + orderId + " 在" + LocalDateTime.now() +"取消");
        // 更新订单状态、释放库存等操作...
    }

}

我们模拟创建订单接口:

@RestController
@RequestMapping("orderMq")
public class MqOrderController {

    @Autowired
    private OrderMqService orderMqService;

    @PostMapping("/create")
    public String createOrder(String orderId) {
        orderMqService.createOrder(orderId);
        return "订单创建成功:" + orderId;
    }
}

美团二面:如何设计一个订单超时未支付关闭订单的解决方案?图片

可以看见订单过了5秒之后开始执行取消。

使用死信队列实现取消超时订单的优点:

  1. 死信队列可以捕获并隔离那些在原始队列中无法正常处理的消息,比如订单超时未支付等情况。这样有助于保障主业务流程不受影响,同时可以对异常情况进行统一管理和处理。
  2. 通过设置消息TTL(Time-to-Live)或最大重试次数等条件,将无法正常处理的消息转移到死信队列,可以避免消息堆积导致的资源浪费,如内存、磁盘空间等。
  3. 死信队列可以作为订单生命周期中特定阶段的处理通道,如订单超时后的处理流程,从而实现业务逻辑的清晰分离和模块化。
  4. 所有的死信消息都被记录在死信队列中,方便跟踪和分析订单处理过程中出现的问题,也有助于完善系统的监控报警和数据分析。
  5. 死信队列的处理过程也是异步进行的,不影响主线程的执行效率,增强系统的并发处理能力和响应速度。

当然他也有一些缺点:

  1. 相较于专门的延迟队列,死信队列机制通常不会自动将消息在特定时间后发出,需要通过设置消息TTL(过期时间)并在过期后触发转移至死信队列。这种方式对于精确到秒级别的超时处理不够友好,可能需要配合定时任务来检查即将超时的订单。
  2. 死信队列的配置相对复杂,需要设置死信交换机、绑定关系以及消息TTL等,而且在处理死信时也需要额外的逻辑判断。
  3. 如果没有妥善处理死信队列的消息,比如没有监听死信队列或者处理逻辑存在缺陷,可能会导致部分死信消息未被正确处理。
  4. 在分布式环境下,如果订单状态不仅在消息队列中维护,还涉及到数据库的更新,那么需要保证消息队列与数据库之间的事务一致性。

总结

订单超时自动取消是电商平台中非常重要的功能之一,通过合适的技术方案,可以实现自动化处理订单超时的逻辑,提升用户体验和系统效率。本文讨论了多种实现订单超时自动取消的技术方案,包括定时轮询、JDK的延迟队列、时间轮算法、Redis实现以及MQ消息队列中的延迟队列和死信队列。

  1. 定时轮询:基于SpringBoot的Scheduled实现,通过定时任务扫描数据库中的订单。优点是实现简单直接,但缺点是会给数据库带来持续压力,处理效率受任务执行间隔影响较大,且在高并发场景下可能引发并发问题和资源浪费。
  2. JDK的延迟队列(DelayQueue):基于优先级队列实现,减少数据库访问,提供高效的任务处理。优点是内部数据结构高效,线程安全。缺点是所有待处理订单需保留在内存中,可能导致内存消耗大,且无持久化机制,系统崩溃时可能丢失数据。
  3. 时间轮算法:通过时间轮结构实现定时任务调度,能高效处理大量定时任务,提供精确的超时控制。优点是实现简单,执行效率高,且有成熟实现库。缺点同样是内存占用和崩溃时数据丢失的问题。
  4. Redis实现:

有序集合(Sorted Set):利用有序集合的特性,定时轮询查找已超时的任务。优点是查询效率高,适用于分布式环境,减少数据库压力。缺点是依赖定时任务执行频率,处理超时订单的实时性受限,且在处理事务一致性方面需要额外努力。

Key过期监听:利用Redis键过期事件自动触发订单取消逻辑。优点是实时性好,资源消耗少,支持高并发。缺点是对Redis服务的依赖性强,极端情况下处理能力可能成为瓶颈,且键过期有一定的不确定性。

  1. MQ消息队列:

延迟队列(如RabbitMQ的rabbitmq_delayed_message_exchange插件):实现消息在指定延迟后送达处理队列。优点是处理高效,异步执行,易于扩展,模块化程度高。缺点是高度依赖消息队列服务,配置复杂度增加,可能涉及消息丢失或延迟风险,以及消息队列与数据库操作一致性问题。

死信队列:通过设置队列TTL将超时订单转为死信,由监听死信队列的消费者处理。优点是能捕获并隔离异常消息,实现业务逻辑分离,资源保护良好,方便追踪和分析问题。缺点是相比延迟队列,处理超时不够精确,配置复杂,且同样存在消息处理完整性及一致性问题。

不同方案各有优劣,实际应用中应根据系统的具体需求、资源状况以及技术栈等因素综合评估,选择最适合的方案。在许多现代大型系统中,通常会选择消息队列的延迟队列或死信队列方案,以充分利用其异步处理、资源优化和扩展性方面的优势。

原文地址:https://mp.weixin.qq.com/s/0v1ybGtpNVxHX8wg7AxPaA

延伸 · 阅读

精彩推荐