美团二面:如何设计一个订单超时未支付关闭订单的解决方案?我说使用ElasticJob轮训判断。
订单超时未支付自动取消是一个典型的电商和在线交易业务场景,在该场景下,用户在购物平台上下单后,系统通常会为用户提供一段有限的时间来完成支付。如果用户在这个指定的时间窗口内没有成功完成支付,系统将自动执行订单取消操作。
当然类似的业务场景还有:
- 我们预约钉钉会议后,钉钉会在会议开始前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 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实现的延迟队列解决取消超时订单的方案,相比较于定时轮训有如下优点:
但是因为DelayQueue是基于内存的,这也导致它在实现上有一定的缺点:
时间轮算法
在介绍时间轮算法实现取消超时订单功能之前,我们先来看一下什么是时间轮算法?
时间轮算法(Time Wheel Algorithm)是一种高效处理定时任务调度的机制,广泛应用于各类系统如计时器、调度器等组件。该算法的关键理念在于将时间维度映射至物理空间,即构建一个由多个时间槽构成的循环结构,每个槽代表一个固定的时间单位(如毫秒、秒等)。
时间轮实质上是一个具有多个槽位的环形数据结构,随着时间的推进,时间轮上的指针按照预先设定的速度(例如每秒前进一槽)顺时针旋转。每当指针移动至下一槽位时,系统会检视该槽位中挂载的所有定时任务,并逐一执行到期的任务。
在时间轮中,每个待执行任务均与其触发时间点对应的时间槽关联。添加新任务时,系统会根据任务的期望执行时间计算出相应的槽位编号,并将任务插入该槽。对于未来执行的任务,计算所需等待的槽位数目,确保任务按时被处理。值得注意的是,时间轮设计为循环结构,意味着当指针到达最后一个槽位后会自动返回至第一个槽位,形成连续不断的循环调度。
借助时间轮算法,定时任务的执行时间以相对固定的时间槽来表示,而非直接依赖于绝对时间。任务执行完毕后,系统会及时将其从时间轮中移除,同时,对于不再需要执行的任务,也可以在任何时候予以移除,确保整个调度系统的高效运作和实时响应。
图片
如上图为例,假设一个格子是1秒,则整个wheel能表示的时间段为8s,假设当前指针指向2,此时需要调度一个3s后执行的任务, 显然应该加入到(2+3=5)的方格中,指针再走3次就可以执行了;如果任务要在10s后执行,应该等指针走完一个round零2格再执行, 因此应放入4,同时将round(1)保存到任务中。检查到期任务应当只执行round为0的,格子上其他任务的round应减1.
所以,我们可以使用时间轮算法去试一下延迟任务,用于实现取消超时订单。
我们以Netty4为例,引入依赖:
io.netty
netty-all
4.1.68.Final
然后定义订单处理服务,在创建订单时定义订单超时时间,以及超时时取消订单。
@Service
public class OrderService {
private final Map 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实现
对于Redis实现延迟任务,常见的两种方案是使用有序集合(Sorted Set,通常简称为zset)和使用key过期监听。
定时轮训有序集合
利用有序集合的特性,即集合中的元素是有序的,每个元素都有一个分数(score)。在延迟任务的场景中,可以将任务的执行时间作为分数,将任务的唯一标识(如任务ID)作为集合中的元素。然后,定时轮询有序集合,查找分数小于当前时间的元素,这些元素即为已经到期需要执行的任务。执行完任务后,可以从有序集合中删除对应的元素。因此可以将订单的过期时间作为score,用于实现取消超时订单。
引入Redis依赖:
org.springframework.boot
spring-boot-starter-data-redis
2.7.0
配置一下RedisTemplate:
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
// 其余配置 如序列化等
return new StringRedisTemplate(factory);
}
}
创建订单创建以及自动取消服务:
@EnableScheduling
@Service
public class OrderZSetService {
@Autowired
private RedisTemplate 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 range = redisTemplate.opsForZSet().rangeByScoreWithScores(ORDER_TIMEOUT_SET_KEY, 0, now);
for (ZSetOperations.TypedTuple tuple : range) {
String orderId = (String) tuple.getValue();
if (tuple.getScore() {
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的企业版,在企业版中可以自定义设置延迟时间。这里就不过多讲解,有兴趣的可以自己研究一下。
基于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 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秒之后开始执行取消。
使用死信队列实现取消超时订单的优点:
当然他也有一些缺点:
总结
订单超时自动取消是电商平台中非常重要的功能之一,通过合适的技术方案,可以实现自动化处理订单超时的逻辑,提升用户体验和系统效率。本文讨论了多种实现订单超时自动取消的技术方案,包括定时轮询、JDK的延迟队列、时间轮算法、Redis实现以及MQ消息队列中的延迟队列和死信队列。
有序集合(Sorted Set):利用有序集合的特性,定时轮询查找已超时的任务。优点是查询效率高,适用于分布式环境,减少数据库压力。缺点是依赖定时任务执行频率,处理超时订单的实时性受限,且在处理事务一致性方面需要额外努力。
Key过期监听:利用Redis键过期事件自动触发订单取消逻辑。优点是实时性好,资源消耗少,支持高并发。缺点是对Redis服务的依赖性强,极端情况下处理能力可能成为瓶颈,且键过期有一定的不确定性。
延迟队列(如RabbitMQ的rabbitmq_delayed_message_exchange插件):实现消息在指定延迟后送达处理队列。优点是处理高效,异步执行,易于扩展,模块化程度高。缺点是高度依赖消息队列服务,配置复杂度增加,可能涉及消息丢失或延迟风险,以及消息队列与数据库操作一致性问题。
死信队列:通过设置队列TTL将超时订单转为死信,由监听死信队列的消费者处理。优点是能捕获并隔离异常消息,实现业务逻辑分离,资源保护良好,方便追踪和分析问题。缺点是相比延迟队列,处理超时不够精确,配置复杂,且同样存在消息处理完整性及一致性问题。
不同方案各有优劣,实际应用中应根据系统的具体需求、资源状况以及技术栈等因素综合评估,选择最适合的方案。在许多现代大型系统中,通常会选择消息队列的延迟队列或死信队列方案,以充分利用其异步处理、资源优化和扩展性方面的优势。