订单超时取消是电商系统中的核心功能,也是面试高频考点。本文详细介绍三种实现方案:定时任务扫描、Redis ZSet 延迟队列、RabbitMQ 延时队列,从原理到代码,手把手教你搞定这个功能。
📑 目录
- 一、业务场景与需求
- 二、为什么不能用简单定时任务?
- 三、方案一:定时任务扫描
- [四、方案二:Redis ZSet 延迟队列](#四、方案二:Redis ZSet 延迟队列 "#%E5%9B%9B%E6%96%B9%E6%A1%88%E4%BA%8Credis-zset-%E5%BB%B6%E8%BF%9F%E9%98%9F%E5%88%97")
- [五、方案三:RabbitMQ 延时队列](#五、方案三:RabbitMQ 延时队列 "#%E4%BA%94%E6%96%B9%E6%A1%88%E4%B8%89rabbitmq-%E5%BB%B6%E6%97%B6%E9%98%9F%E5%88%97")
- 六、三种方案对比
- 七、核心代码实现
- 八、常见踩坑与解决方案
- 九、总结
一、业务场景与需求
1.1 典型业务场景
markdown
用户下单
↓
订单创建(状态:待支付)
↓
30分钟内完成支付 → 支付成功 → 订单完成
↓(超时未支付)
订单自动取消 → 库存释放 → 优惠券退回
1.2 核心需求
| 需求 | 说明 |
|---|---|
| 时间精确 | 下单后30分钟自动取消 |
| 状态一致 | 取消前必须检查订单状态 |
| 幂等处理 | 防止重复取消 |
| 高可用 | 不能漏掉任何超时订单 |
二、为什么不能用简单定时任务?
2.1 定时任务扫描的问题
diff
问题1:时间精度差
- 定时任务最快也是分钟级
- 用户感知:下单29分钟还在挂着,第31分钟才取消
- 体验不好
问题2:数据库压力大
- 每次扫描全表 or 大量数据
- 订单量大了数据库扛不住
问题3:分布式重复执行
- 多台服务器部署定时任务
- 没有协调机制会重复取消
2.2 延时队列的核心思想
vbnet
把"时间"变成"数据结构"
定时任务:轮询时间 → 判断是否过期
延时队列:时间戳作为key → 时间到了自动触发
核心区别:定时任务是主动"拉",延时队列是自动"推"
三、方案一:定时任务扫描
3.1 原理
ini
定时任务(每分钟执行)
↓
查询:status='待支付' AND create_time < NOW()-30分钟
↓
逐个处理:检查状态 → 取消订单 → 释放库存
3.2 代码实现
java
@Service
@Slf4j
public class OrderTimeoutJob {
@Autowired
private OrderMapper orderMapper;
@Autowired
private OrderService orderService;
/**
* 每分钟扫描一次超时订单
*/
@Scheduled(fixedDelay = 60000) // 60秒 = 1分钟
public void cancelTimeoutOrders() {
log.info("开始扫描超时订单...");
// 计算超时时间点:当前时间 - 30分钟
LocalDateTime expireTime = LocalDateTime.now().minusMinutes(30);
// 查询超时未支付的订单
List<Order> timeoutOrders = orderMapper.selectList(
new LambdaQueryWrapper<Order>()
.eq(Order::getStatus, OrderStatus.PENDING_PAYMENT) // 待支付
.lt(Order::getCreateTime, expireTime) // 创建时间早于超时时间
);
log.info("发现{}个超时订单", timeoutOrders.size());
// 逐个取消
for (Order order : timeoutOrders) {
try {
orderService.cancelOrder(order.getId(), "超时自动取消");
log.info("订单{}已超时取消", order.getId());
} catch (Exception e) {
log.error("取消订单{}失败", order.getId(), e);
}
}
}
}
3.3 优缺点
| 优点 | 缺点 |
|---|---|
| ✅ 实现简单 | ❌ 时间精度差(分钟级) |
| ✅ 代码好维护 | ❌ 数据库压力大 |
| ✅ 适合小系统 | ❌ 分布式下可能重复 |
四、方案二:Redis ZSet 延迟队列
4.1 原理
css
┌─────────────────────────────────────────────────────────┐
│ Redis ZSet 延迟队列 │
└─────────────────────────────────────────────────────────┘
下单时:
ZADD order:timeout {过期时间戳} {订单ID}
定时轮询(每秒):
ZRANGEBYSCORE order:timeout 0 {当前时间戳}
→ 获取所有已过期的订单ID
处理完成:
ZREM order:timeout {订单ID}
4.2 代码实现
java
@Service
@Slf4j
public class OrderTimeoutService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private OrderService orderService;
/** 延时队列的Key */
private static final String TIMEOUT_QUEUE_KEY = "order:timeout:pending";
/** 超时时间:30分钟 */
private static final long TIMEOUT_MS = 30 * 60 * 1000;
/**
* 下单时:加入延时队列
*/
public void addToDelayQueue(Long orderId) {
// 过期时间 = 当前时间 + 30分钟
long expireTime = System.currentTimeMillis() + TIMEOUT_MS;
// ZSet:score = 过期时间戳
redisTemplate.opsForZSet().add(TIMEOUT_QUEUE_KEY, orderId.toString(), expireTime);
log.info("订单{}已加入延时队列,过期时间:{}", orderId, new Date(expireTime));
}
/**
* 定时任务:每秒检查一次延时队列
*/
@Scheduled(fixedDelay = 1000) // 1秒
public void processTimeoutOrders() {
long now = System.currentTimeMillis();
// 获取所有已过期的订单(score <= 当前时间)
Set<String> expiredOrders = redisTemplate.opsForZSet()
.rangeByScore(TIMEOUT_QUEUE_KEY, 0, now);
if (expiredOrders == null || expiredOrders.isEmpty()) {
return;
}
log.info("发现{}个过期订单", expiredOrders.size());
for (String orderId : expiredOrders) {
// 1. 从队列移除(防止重复处理)
Long removed = redisTemplate.opsForZSet().remove(TIMEOUT_QUEUE_KEY, orderId);
if (removed == null || removed == 0) {
// 已经被其他节点处理过了
continue;
}
// 2. 取消订单
try {
orderService.cancelOrder(Long.parseLong(orderId), "超时自动取消");
log.info("订单{}已超时取消", orderId);
} catch (Exception e) {
log.error("取消订单{}失败", orderId, e);
// 处理失败,重新加入队列(可选)
addToDelayQueue(Long.parseLong(orderId));
}
}
}
}
4.3 优缺点
| 优点 | 缺点 |
|---|---|
| ✅ 时间精度高(秒级) | ❌ 需要额外部署 Redis |
| ✅ 数据库压力小 | ❌ Redis 挂了会丢任务 |
| ✅ 单节点处理,无重复 | ❌ 需要额外处理 Redis 持久化 |
五、方案三:RabbitMQ 延时队列
5.1 原理
arduino
┌─────────────────────────────────────────────────────────┐
│ RabbitMQ 延时队列 │
└─────────────────────────────────────────────────────────┘
下单时:
发送消息到 order.delay.queue(TTL=30分钟)
30分钟后:
消息TTL过期 → 进入死信队列 order.cancel.queue
消费者:
监听死信队列 → 检查订单状态 → 取消订单
5.2 配置类实现
java
@Configuration
public class RabbitMQDelayConfig {
/** 延时交换机 */
public static final String DELAY_EXCHANGE = "order.delay.exchange";
/** 死信交换机 */
public static final String DLX_EXCHANGE = "order.dlx.exchange";
/** 延时队列(TTL=30分钟) */
public static final String DELAY_QUEUE = "order.delay.queue";
/** 死信队列(真正消费) */
public static final String CANCEL_QUEUE = "order.cancel.queue";
/** 路由Key */
public static final String ROUTING_KEY = "order.timeout";
/**
* 延时队列配置
* - 设置 TTL = 30分钟
* - 过期后进入死信交换机
*/
@Bean
public Queue delayQueue() {
return QueueBuilder.durable(DELAY_QUEUE)
.ttl(30 * 60 * 1000) // 30分钟 TTL
.deadLetterExchange(DLX_EXCHANGE) // 死信交换机
.deadLetterRoutingKey(ROUTING_KEY) // 死信路由Key
.build();
}
/**
* 死信队列配置
*/
@Bean
public Queue cancelQueue() {
return QueueBuilder.durable(CANCEL_QUEUE).build();
}
/**
* 延时交换机
*/
@Bean
public DirectExchange delayExchange() {
return new DirectExchange(DELAY_EXCHANGE);
}
/**
* 死信交换机
*/
@Bean
public DirectExchange dlxExchange() {
return new DirectExchange(DLX_EXCHANGE);
}
/**
* 绑定延时队列
*/
@Bean
public Binding delayBinding() {
return BindingBuilder
.bind(delayQueue())
.to(delayExchange())
.with(ROUTING_KEY);
}
/**
* 绑定死信队列
*/
@Bean
public Binding cancelBinding() {
return BindingBuilder
.bind(cancelQueue())
.to(dlxExchange())
.with(ROUTING_KEY);
}
}
5.3 生产者和消费者
java
@Service
@Slf4j
public class OrderServiceImpl implements OrderService {
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private OrderMapper orderMapper;
/**
* 创建订单
*/
@Override
@Transactional
public Order createOrder(CreateOrderDTO dto) {
// 1. 创建订单
Order order = new Order();
order.setStatus(OrderStatus.PENDING_PAYMENT);
order.setCreateTime(new Date());
orderMapper.insert(order);
// 2. 发送延时消息(30分钟后投递)
rabbitTemplate.convertAndSend(
RabbitMQDelayConfig.DELAY_EXCHANGE,
RabbitMQDelayConfig.ROUTING_KEY,
order.getId()
);
log.info("订单{}创建成功,30分钟后检查超时", order.getId());
return order;
}
}
/**
* 订单取消消费者
*/
@Component
@Slf4j
public class OrderCancelConsumer {
@Autowired
private OrderService orderService;
@Autowired
private OrderMapper orderMapper;
/**
* 监听死信队列,处理超时订单
*/
@RabbitListener(queues = RabbitMQDelayConfig.CANCEL_QUEUE)
public void handleTimeoutOrder(Long orderId, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) {
log.info("收到超时订单消息:{}", orderId);
try {
// 1. 查询订单当前状态
Order order = orderMapper.selectById(orderId);
if (order == null) {
log.warn("订单{}不存在", orderId);
channel.basicAck(deliveryTag, false);
return;
}
// 2. 检查订单状态(可能用户已经支付了)
if (order.getStatus() == OrderStatus.PENDING_PAYMENT) {
// 3. 取消订单
orderService.cancelOrder(orderId, "超时自动取消");
log.info("订单{}已超时取消", orderId);
} else {
log.info("订单{}已支付,无需取消,状态:{}", orderId, order.getStatus());
}
// 4. 确认消息
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
log.error("处理超时订单{}失败", orderId, e);
// 5. 拒绝消息,重新入队(会再次尝试)
try {
channel.basicNack(deliveryTag, false, true);
} catch (IOException ex) {
log.error("消息拒绝失败", ex);
}
}
}
}
5.4 优缺点
| 优点 | 缺点 |
|---|---|
| ✅ 时间精度高(秒级) | ❌ 实现复杂度高 |
| ✅ 高可靠性(消息持久化) | ❌ 需要额外部署 RabbitMQ |
| ✅ 支持重试和死信队列 | ❌ 运维成本高 |
| ✅ 天然支持分布式 | ❌ 消息顺序性问题 |
六、三种方案对比
| 维度 | 定时任务 | Redis ZSet | RabbitMQ 延时队列 |
|---|---|---|---|
| 实现复杂度 | 低 ⭐⭐ | 中 ⭐⭐⭐ | 高 ⭐⭐⭐⭐⭐ |
| 时间精度 | 分钟级 | 秒级 | 秒级 |
| 可靠性 | 低 | 中 | 高 |
| 数据库压力 | 高 | 低 | 低 |
| 可扩展性 | 差 | 中 | 好 |
| 运维成本 | 低 | 中 | 高 |
| 适用规模 | 小于1万订单/天 | 1-100万订单/天 | 百万级以上 |
选型建议
markdown
┌─────────────────────────────────────────────────────────┐
│ 选型决策树 │
└─────────────────────────────────────────────────────────┘
订单量 < 1万/天?
↓ 是
定时任务足够(简单直接)
订单量 1万-100万/天?
↓ 是
Redis ZSet(性能与复杂度平衡)
订单量 > 100万/天 或 需要高可靠?
↓ 是
RabbitMQ 延时队列
七、核心代码实现
7.1 订单服务
java
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private InventoryService inventoryService;
@Autowired
private CouponService couponService;
/**
* 取消订单(核心方法)
*/
@Override
public void cancelOrder(Long orderId, String reason) {
// 1. 查询订单
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new BusinessException("订单不存在");
}
// 2. 幂等检查:只有待支付状态才能取消
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
log.info("订单{}状态不是待支付,无需取消,当前状态:{}",
orderId, order.getStatus());
return;
}
// 3. 更新订单状态
order.setStatus(OrderStatus.CANCELLED);
order.setCancelTime(new Date());
order.setCancelReason(reason);
orderMapper.updateById(order);
// 4. 释放库存
inventoryService.releaseStock(order.getProductId(), order.getQuantity());
// 5. 退回优惠券(如有)
if (order.getCouponId() != null) {
couponService.returnCoupon(order.getCouponId());
}
log.info("订单{}已取消,原因:{}", orderId, reason);
}
}
7.2 单元测试
java
@SpringBootTest
class OrderTimeoutTest {
@Autowired
private OrderTimeoutService timeoutService;
@Autowired
private OrderService orderService;
@Test
void testAddToDelayQueue() {
// 创建测试订单
Order order = orderService.createOrder(new CreateOrderDTO());
// 加入延时队列
timeoutService.addToDelayQueue(order.getId());
// 验证
Order saved = orderService.getById(order.getId());
assertEquals(OrderStatus.PENDING_PAYMENT, saved.getStatus());
}
@Test
void testCancelAfterTimeout() {
// 创建并加入队列
Order order = orderService.createOrder(new CreateOrderDTO());
timeoutService.addToDelayQueue(order.getId());
// 模拟超时(实际需要等待30分钟)
// 这里直接调用处理方法
timeoutService.processTimeoutOrders();
// 验证订单已取消
Order cancelled = orderService.getById(order.getId());
assertEquals(OrderStatus.CANCELLED, cancelled.getStatus());
}
}
八、常见踩坑与解决方案
8.1 踩坑1:取消用户刚支付的订单
问题:用户在超时前1秒支付,但定时任务刚好在支付之前扫描到了订单。
解决方案:
java
// 取消前必须检查状态
Order order = orderMapper.selectById(orderId);
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
return; // 已支付,无需取消
}
orderService.cancelOrder(orderId, "超时自动取消");
8.2 踩坑2:分布式下重复取消
问题:多台服务器部署,每个节点都在扫描,重复取消。
解决方案:
java
// Redis ZSet 方案:先删除再处理
Long removed = redisTemplate.opsForZSet()
.remove(TIMEOUT_QUEUE_KEY, orderId);
if (removed == null || removed == 0) {
return; // 已被其他节点处理
}
8.3 踩坑3:Redis 宕机丢任务
问题:Redis 重启后延时队列数据丢失。
解决方案:
java
// 1. 开启 Redis 持久化(AOF + RDB)
// 2. 或者定期将延时队列持久化到 DB
// 3. 服务启动时从 DB 恢复延时任务
8.4 踩坑4:消息重复投递
问题:RabbitMQ 消费者处理失败,消息重新入队,导致重复取消。
解决方案:
java
// 消费者必须幂等
@RabbitListener(queues = "order.cancel.queue")
public void cancel(Long orderId) {
// 取消前检查状态
if (order.getStatus() != PENDING_PAYMENT) {
return; // 幂等:不是待支付状态,直接返回
}
orderService.cancel(orderId);
}
8.5 踩坑5:超时时间配置硬编码
问题:30分钟硬编码,后续修改麻烦。
解决方案:
java
// 配置化
@Value("${order.timeout.minutes:30}")
private int timeoutMinutes;
public void addToDelayQueue(Long orderId) {
long expireTime = System.currentTimeMillis() + timeoutMinutes * 60 * 1000;
redisTemplate.opsForZSet().add(key, orderId.toString(), expireTime);
}
九、总结
9.1 核心要点回顾
| 要点 | 说明 |
|---|---|
| 三种方案 | 定时任务、Redis ZSet、RabbitMQ 延时队列 |
| 核心原则 | 下单注册 → 过期触发 → 幂等取消 |
| 定时任务 | 简单但精度差,适合小系统 |
| Redis ZSet | 性能好,需要额外运维 Redis |
| RabbitMQ | 高可靠,复杂度高,适合大规模系统 |
9.2 一句话总结
订单超时取消 = 下单时注册延时任务 + 过期时检查状态 + 幂等取消;小系统用定时任务,中等用 Redis ZSet,高可靠用 MQ 延时队列!
9.3 延伸阅读
如果这篇文章对你有帮助,欢迎点赞、评论、转发!