订单超时未支付自动取消是一个典型的电商和在线交易业务场景,在该场景下,用户在购物平台上下单后,系统通常会为用户提供一段有限的时间来完成支付。如果用户在这个指定的时间窗口内没有成功完成支付,系统将自动执行订单取消操作。
当然类似的业务场景还有:
图片
本文主要介绍以下几种主流方案。
定时轮训的方式都是基于定时定任务扫描订单表,按照下单时间以及状态进行过滤,之后在进行判断是否在有效期内,如果不在,则取消订单。
如以下,我们使用SpringBoot中的定时任务实现:
我们先创建定时任务的配置,设置任务每隔5秒执行一次。
@Configuration@EnableSchedulingpublic 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。。。。。"); }}
运行结果如下:
图片
基于JDK的DelayQueue实现的延迟队列解决取消超时订单的方案,相比较于定时轮训有如下优点:
但是因为DelayQueue是基于内存的,这也导致它在实现上有一定的缺点:
在介绍时间轮算法实现取消超时订单功能之前,我们先来看一下什么是时间轮算法?
时间轮算法(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>
然后定义订单处理服务,在创建订单时定义订单超时时间,以及超时时取消订单。
@Servicepublic 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秒钟之后自动调用取消方法取消订单。
基于时间轮实现延迟任务来取消超时订单有如下优点:
但是相对应的也存在一些缺点:
对于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:
@Configurationpublic class RedisConfig { @Bean public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) { // 其余配置 如序列化等 return new StringRedisTemplate(factory); }}
创建订单创建以及自动取消服务:
@EnableScheduling@Servicepublic 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有序集合实现取消超时订单有一些优点:
但是也有一些缺点:
利用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过期时,将触发监听器中的逻辑,执行取消订单的操作。
@Componentpublic 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过期事件发生时被调用。
@Configurationpublic 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过期事件。
然后我们就可以实现具体的订单创建服务以及订单取消的逻辑了。这里我们模拟一下:
@Componentpublic 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过期监听事件,实现取消超时订单有以下优点:
但是也存在一些缺点:
使用消息队列实现取消超时订单的常见方法是利用延迟队列以及死信队列。比如RabbitMq,在介绍实现方式之前,我们先来了解一下RabbitMq的延迟队列以及死信队列。
我的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
延迟队列可以直接处理延迟消息,即消息在指定的延迟时间过后才被投递给消费者。在超时取消订单的场景中,订单创建时将订单信息封装成消息,并设置消息的延迟时间,当订单超时时,消息自动被投递到处理超时订单的队列,消费者接收到消息后执行取消操作。
引入依赖:
<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@EnableRabbitpublic 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消息的监听器,当消息延迟时间到了之后,就会被该监听器见听到,在这里判断订单是否已经被支付,如果没有支付则取消。
@Componentpublic class DelayedQueueListener { @Autowired private OrderMqService orderMqService; @RabbitListener(queues = RabbitConfig.ORDER_QUEUE) public void handleDelayedOrder(String orderId) { orderMqService.cancelOrder(orderId); } }
然后我们在订单创建时,将订单信息发送到MQ中,等延迟时间到了之后,如果订单还没有支付,则执行取消订单操作。
@Servicepublic 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秒之后开始执行取消。
使用延迟队列方案来实现订单超时取消等场景的优点:
但是也有一些缺点:
对于延迟队列,并非只有rabbitmq才有,RocketMQ也有延迟队列。在RocketMQ中,延迟消息的发送是通过设置消息的延迟级别来实现的。每个延迟级别都对应着一个具体的延迟时间,例如 1 表示 1 秒、2 表示 5 秒、3 表示 10 秒,以此类推。用户可以根据自己的需求选择合适的延迟级别。但是也可以看出他并没有支持的那么精确,如果想要精确的就必须使用RocketMQ的企业版,在企业版中可以自定义设置延迟时间。这里就不过多讲解,有兴趣的可以自己研究一下。
订单创建时,将订单信息发送到一个具有TTL的队列,当消息在队列中停留的时间超过了TTL(也就是订单的有效支付期限),消息就会变为死信。然后再配置队列,使得这些过期的死信消息被路由到一个预先设置好的死信队列。最后创建一个消费者监听这个死信队列,一旦有消息进来(即订单超时),消费者便处理这些死信,检查订单状态并执行取消操作。
使用的rabbitmq依赖以及配置同上使用延迟队列方案。我们来看一下创建处理订单即带有TTL的队列:
@Configurationpublic 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); }}
同理也是需要先创建交换机:
图片
创建订单业务类,将订单发送到订单消息队列:
@Servicepublic 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() +"取消"); // 更新订单状态、释放库存等操作... }}
在创建死信队列,私信队列交换机,通过订单队列路由将私信队列绑定到订单订单队列中:
@Configurationpublic 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); }}
在创建一个死信队列消息监听器,用于判断订单是否超时:
@Componentpublic class DelayedQueueListener { @Autowired private OrderMqService orderMqService; @RabbitListener(queues = RabbitMQConfig.DEAD_LETTER_QUEUE) public void handleDeadLetterOrder(String orderId) { orderMqService.cancelOrder(orderId); }}
然后我们在订单创建时,将订单信息发送到订单MQ中,等消息的TTL到期之后,会自动转到死信队列中。
@Servicepublic 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秒之后开始执行取消。
使用死信队列实现取消超时订单的优点:
当然他也有一些缺点:
订单超时自动取消是电商平台中非常重要的功能之一,通过合适的技术方案,可以实现自动化处理订单超时的逻辑,提升用户体验和系统效率。本文讨论了多种实现订单超时自动取消的技术方案,包括定时轮询、JDK的延迟队列、时间轮算法、Redis实现以及MQ消息队列中的延迟队列和死信队列。
有序集合(Sorted Set):利用有序集合的特性,定时轮询查找已超时的任务。优点是查询效率高,适用于分布式环境,减少数据库压力。缺点是依赖定时任务执行频率,处理超时订单的实时性受限,且在处理事务一致性方面需要额外努力。
Key过期监听:利用Redis键过期事件自动触发订单取消逻辑。优点是实时性好,资源消耗少,支持高并发。缺点是对Redis服务的依赖性强,极端情况下处理能力可能成为瓶颈,且键过期有一定的不确定性。
延迟队列(如RabbitMQ的rabbitmq_delayed_message_exchange插件):实现消息在指定延迟后送达处理队列。优点是处理高效,异步执行,易于扩展,模块化程度高。缺点是高度依赖消息队列服务,配置复杂度增加,可能涉及消息丢失或延迟风险,以及消息队列与数据库操作一致性问题。
死信队列:通过设置队列TTL将超时订单转为死信,由监听死信队列的消费者处理。优点是能捕获并隔离异常消息,实现业务逻辑分离,资源保护良好,方便追踪和分析问题。缺点是相比延迟队列,处理超时不够精确,配置复杂,且同样存在消息处理完整性及一致性问题。
不同方案各有优劣,实际应用中应根据系统的具体需求、资源状况以及技术栈等因素综合评估,选择最适合的方案。在许多现代大型系统中,通常会选择消息队列的延迟队列或死信队列方案,以充分利用其异步处理、资源优化和扩展性方面的优势。
本文链接:http://www.28at.com/showinfo-26-79986-0.html美团二面:如何设计一个订单超时未支付关闭订单的解决方案?我说使用Elastic-Job轮训判断。
声明:本网页内容旨在传播知识,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。邮件:2376512515@qq.com
上一篇: 干货必读: 测试开发既然都这么厉害了!为啥不直接转业务开发?
下一篇: 球盒模型:一切回溯穷举,皆从此法出