前言
“用户下单后 30 分钟未支付,订单自动取消,库存自动释放”——这个需求几乎是每个电商系统的标配。根据行业统计,平均有 15%~30% 的订单因用户放弃支付而需要系统自动取消,在秒杀等高并发场景下该比例可能高达 50%。
我刚接手这个需求的时候,一拍胸脯:“简单!写个定时任务每 5 分钟查一次库就行!”结果上线第三天,数据库直接被查崩了。
今天就把这个经典场景从“定时任务暴力轮询”到“延迟消息优雅解耦”的演进过程掰开揉碎讲清楚,顺便聊聊那些年踩过的坑。
一、业务场景与核心需求
| 订单类型 | 超时场景 | 典型超时时间 |
|---|---|---|
| 电商普通订单 | 下单后未支付 | 30 分钟 |
| 电商预售订单 | 付定金后未付尾款 | 24 小时 |
| 外卖订单 | 商家未接单 | 15 分钟 |
| 酒店/票务订单 | 预订后未支付 | 1 小时 |
所有超时场景都要解决两个核心问题:状态闭环(订单从“待支付”转为“已取消”)和资源回补(释放库存、返还优惠券、解锁座位)。
- 数据一致性:不该取消的绝不取消,该取消的一个不漏
- 实时性:超时后在合理时间内取消(最好误差在 1 分钟内)
- 高并发支撑:大促期间订单量百万级,不能拖垮主流程
- 可配置:不同订单类型支持不同超时时间
二、方案演进:从“能用”到“好用”
2.1 第一阶段:定时任务轮询(新手村标配)
最直接的方案:启动一个定时任务,周期性地扫描数据库,查出所有“待支付”且“创建时间超过超时阈值”的订单,批量更新状态。
@Scheduled(fixedRate = 60 * 1000)
public void cancelExpiredOrders() {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(30);
List<Order> unpaidOrders = orderRepository.findByStatusAndCreateTimeBefore(
OrderStatus.UNPAID, threshold);
unpaidOrders.forEach(order -> {
order.cancel();
inventoryService.releaseStock(order.getItems());
});
}
缺点:
- 实时性差:如果每分钟跑一次,订单可能延迟近 60 秒才关闭
- 数据库压力大:随着订单量增长,全表扫描会拖垮数据库
- 大量无效查询:大部分扫描到的订单其实还没超时
优化手段:加 (status, create_time) 联合索引、分页查询、分布式锁防止集群重复执行。但治标不治本,数据量过亿时依然扛不住。
2.2 第二阶段:Redis 过期键通知(看似优雅,实则坑多)
网上流传很广的方案:下单时在 Redis 里存一个带过期时间的 key,利用 Redis 的键空间通知(Keyspace Notifications)监听过期事件来触发关单。
// 下单时设置过期键
redisTemplate.opsForValue().set("order_" + orderId, orderId, 30, TimeUnit.MINUTES);
// 监听过期事件
@Component
public class KeyExpiredListener extends KeyExpirationEventMessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String key = message.toString();
// 解析订单ID,执行关单逻辑
}
}
但这个方案有致命缺陷。
Redis 官方文档明确指出:过期事件是在 Redis 服务器删除键时生成的,而不是在 TTL 理论归零时生成的。Redis 的过期删除策略是定期扫描 + 惰性删除,从不保证在设定的过期时间立即删除并发送通知。实际上,过期通知晚于设定时间数分钟的情况也很常见。
更致命的是,键空间通知采用 “发送即忘”(fire and forget) 策略,不保证送达。订阅事件的客户端如果断线,会丢失所有断线期间的事件。
一句话总结:用 Redis 过期监听做订单超时,等于把核心业务逻辑建立在了一个不保证可靠性的机制上。 有领导甚至放话:“谁再用 Redis 实现过期订单关闭,立马滚蛋!”
2.3 第三阶段:延迟消息(生产级方案)
延迟消息的核心思想很朴素:给每个订单单独设个“闹钟”,到点自动响。用户下单时发一条延迟消息,消息队列在指定时间后才投递给消费者,消费者收到后检查订单状态并执行关单。
市面上主流方案有三条路:
方案 A:RabbitMQ TTL + 死信队列
RabbitMQ 原生不支持延迟消息,需要借助 TTL(消息存活时间)+ 死信交换机(DLX)组合来实现。
原理像“快递驿站 + 过期退货”:消息进入延迟队列后开始倒计时,过期后被扔进死信交换机,再转发到消费队列供消费者处理。
// 声明延迟队列(TTL=30分钟)
@Bean
public Queue delayQueue() {
return QueueBuilder.durable("order.delay.queue")
.withArgument("x-message-ttl", 1800000)
.withArgument("x-dead-letter-exchange", "order.dlx.exchange")
.withArgument("x-dead-letter-routing-key", "order.dlx.key")
.build();
}
坑点:如果队列中第一条消息的 TTL 很长,后续短 TTL 的消息即使过期也只能排队等着,无法提前投递。解决方案:按延迟时间分队列(5分钟队列、30分钟队列、2小时队列),或者直接使用官方延迟插件 rabbitmq-delayed-message-exchange。
方案 B:RocketMQ / Pulsar 原生延迟消息
如果技术栈允许,优先选择支持原生延迟消息的分布式 MQ(如 RocketMQ、Pulsar)。
RocketMQ 支持预设的延迟等级(从秒级到小时级),精度毫秒级,吞吐单队列百万级消息/秒。Pulsar 则支持任意时间精度的延迟消息,更加灵活。
// RocketMQ 延时消息示例
Message msg = new Message("OrderTopic", "订单创建,30分钟后检查支付状态".getBytes());
// 设置延迟级别,例如 30 分钟对应某个等级
msg.setDelayTimeLevel(16);
producer.send(msg);
优点:可靠性高(消息可持久化、支持重试)、实时性好、天然解耦。
方案 C:Redisson 延迟队列(轻量之选)
如果不想部署重型 MQ,Redisson 提供的 RDelayedQueue 是个不错的轻量级方案。
Redisson 延迟队列底层基于 Redis 的 ZSet 和 List/Queue 实现:用 ZSet 按延迟时间排序存储任务,时间到达后移到阻塞队列供消费者消费。
@Autowired
private RedissonClient redissonClient;
public void scheduleOrderTimeout(Long orderId, long delayMillis) {
RBlockingQueue<Long> blockingQueue = redissonClient.getBlockingQueue("order:queue");
RDelayedQueue<Long> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
// 将订单ID加入延迟队列,delayMillis后自动转到阻塞队列
delayedQueue.offer(orderId, delayMillis, TimeUnit.MILLISECONDS);
}
// 消费者:后台线程阻塞监听
@Component
public class OrderTimeoutConsumer {
@PostConstruct
public void start() {
new Thread(() -> {
RBlockingQueue<Long> queue = redissonClient.getBlockingQueue("order:queue");
while (true) {
Long orderId = queue.take(); // 阻塞等待
cancelOrder(orderId);
}
}).start();
}
}
关键细节:必须确保订单事务提交成功后再往延迟队列里放任务,避免“订单保存失败但取消任务已入队”的 Bug。可以用 Spring 的 TransactionSynchronization 做事务提交后的回调。
适用场景:中小型电商系统、轻量级延迟任务、不想部署重型 MQ 的项目。
三、方案对比速查表
| 方案 | 实时性 | 可靠性 | 实现复杂度 | 适用规模 |
|---|---|---|---|---|
| 定时任务轮询 | 差(分钟级) | 高 | 低 | 日均万级以内 |
| Redis 过期监听 | 中(但不保证) | 低 | 中 | 不推荐 |
| RabbitMQ TTL+DLX | 好 | 高 | 中 | 日均十万级 |
| RabbitMQ 延迟插件 | 好 | 高 | 中 | 日均十万级 |
| RocketMQ 原生延迟 | 极好(毫秒级) | 极高 | 中高 | 日均百万级以上 |
| Redisson 延迟队列 | 好 | 中 | 低 | 日均十万级以内 |
四、那些年踩过的坑
坑1:重复取消
分布式环境下,多个节点可能同时消费到同一个延迟消息,导致同一订单被重复取消、库存被重复释放。
解法:关单前用分布式锁(如 Redis 锁)加锁,或利用数据库乐观锁(UPDATE order SET status='CANCELLED' WHERE id=? AND status='UNPAID'),让数据库帮我们做幂等校验。
坑2:取消前最后一秒支付了
用户在超时前 1 秒完成了支付,但延迟消息也恰好触发了关单逻辑。
解法:关单前必须二次确认订单状态——只有状态仍为“待支付”时才执行取消,否则直接跳过。
坑3:资源回补失败
解法:关单和释放库存要放在同一个事务里,或者用本地消息表 + 补偿机制保证最终一致性。
坑4:延迟消息丢失
MQ 集群重启或网络抖动导致延迟消息丢失。
解法:兜底方案永远不能省——定时任务作为最终防线,每天凌晨扫描一次“待支付”且远超超时阈值的“僵尸订单”进行清理。
五、总结
订单延时自动关闭这个需求,从定时任务到延迟消息,折射出的其实是从“批量轮询”到“事件驱动” 的架构演进思维。
选型建议:
- 日均订单 万级以内,实时性要求不高 → 定时任务轮询 + 索引优化,简单够用
- 日均订单 十万级,已有 RabbitMQ → TTL+DLX 或延迟插件
- 日均订单 百万级以上,追求极致可靠性 → RocketMQ/Pulsar 原生延迟消息
- 不想部署重型 MQ,追求轻量 → Redisson 延迟队列