场景:订单超时未支付,谁来关单?
在电商系统中,用户下单后通常有一个「支付超时时间」——比如 30 分钟。超过这个时间订单仍未支付,系统需要自动将其取消,释放库存、清理预占资源。
这个需求看起来简单,实现方式却有好几代演进。本文从一个真实中台项目的重构历程出发,对比三种方案的优劣,给出选型建议。
方案一:定时轮询扫描(最原始,但够用)
思路很简单:启动一个 ScheduledExecutorService 或 Spring @Scheduled 任务,每隔一定间隔扫描订单表中未支付且超时的记录,批量关单。
@Component
public class OrderTimeoutScanner {
@Scheduled(fixedRate = 30_000) // 每 30 秒扫描一次
@Transactional
public void cancelExpiredOrders() {
List<Order> expiredOrders = orderMapper.selectExpiredUnpaid(
LocalDateTime.now().minusMinutes(30));
for (Order order : expiredOrders) {
order.setStatus(OrderStatus.CANCELLED);
// 释放库存
inventoryService.releaseStock(order.getSkuId(), order.getQuantity());
}
}
}
优点:简单、无中间件依赖、实现成本低。
缺点:
- 时间精度低:扫描间隔 30s,订单最多延迟 30s 才被取消,用户体验差。
- 数据库压力大:每次扫描都要扫全表(或大量索引范围),订单量百万级时很吃力。
- 重复扫描:大部分扫描结果为空,造成无效查询。
- 分布式冲突:多实例部署时需加分布式锁避免重复处理。
这个方案在日均订单几千的小体量下够用,但随着业务增长很快成为瓶颈。
方案二:Redis ZSet 延迟队列(内存级精准延迟)
利用 Redis Sorted Set 的 score 字段存储超时时间戳,实现逐秒精度的延迟调度。
@Component
public class RedisDelayQueue {
private static final String KEY = "order:delay:cancel";
public void addOrder(String orderId, long expireAt) {
// expireAt 为毫秒时间戳
redisTemplate.opsForZSet().add(KEY, orderId, expireAt);
}
@Scheduled(fixedRate = 1000) // 每秒轮询一次
public void poll() {
long now = System.currentTimeMillis();
// 取出所有已到期的订单
Set<String> expired = redisTemplate.opsForZSet()
.rangeByScore(KEY, 0, now);
for (String orderId : expired) {
// 尝试移除(原子操作,避免重复消费)
Long removed = redisTemplate.opsForZSet()
.remove(KEY, orderId);
if (removed != null && removed > 0) {
cancelOrder(orderId);
}
}
}
}
优点:
- 每秒轮询,延迟控制在一秒内,精度高。
- Redis 内存操作,性能远优于数据库扫描。
- ZSet 原子 remove + score 范围查询天然幂等。
缺点:
- 订单关单后需手动删除 ZSet 元素,否则内存泄漏。
- Redis 宕机未持久化时丢失延迟任务(可配合 RDB/AOF)。
- 每次轮询
rangeByScore返回全量到期订单,订单集中到期时可能 OOM,需要分页拉取。 - 依然占用线程定时轮询,不适合超大规模。
方案三:RabbitMQ 延迟消息(最终选择)
RabbitMQ 原生不支持延迟消息,但有两种社区方案实现。
3.1 TTL + 死信交换机(原生方案,无插件)
原理:消息设置 TTL,到期未被消费则进入死信交换机(DLX),由死信队列的消费者处理。
// 定义:订单业务队列绑定死信交换机
@Bean
public Queue orderQueue() {
return QueueBuilder.durable("order.delay.queue")
.deadLetterExchange("order.dlx.exchange")
.deadLetterRoutingKey("order.cancel")
.ttl(30 * 60 * 1000) // 30 分钟
.build();
}
// 死信队列:真正的取消消费
@Bean
public Queue cancelQueue() {
return new Queue("order.cancel.queue", true);
}
@RabbitListener(queues = "order.cancel.queue")
public void handleCancel(String orderId, Message message) {
cancelOrder(orderId);
}
注意坑点:
- 队列级别的 TTL 对所有消息统一生效,如果不同商品有不同超时时间(比如普通商品 30min、秒杀商品 10min),就需要不同的队列——极度不灵活。
- 消息级别的 TTL 虽然可以单独设置,但在 RabbitMQ 中,处于队列头的消息过期前,后面的消息即使已过期也不会被投递到 DLX(队列头阻塞)。这是经典的大坑。
3.2 rabbitmq_delayed_message_exchange 插件(推荐最终方案)
安装社区插件后,使用 x-delayed-message type 的交换机,消息发布时直接指定延迟时间。
// 声明延迟交换机
@Bean
public CustomExchange delayExchange() {
Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");
return new CustomExchange("order.delay.exchange",
"x-delayed-message", true, false, args);
}
// 发送延迟消息
public void sendDelayCancel(String orderId, long delayMillis) {
rabbitTemplate.convertAndSend(
"order.delay.exchange", "order.cancel",
orderId, message -> {
message.getMessageProperties()
.setDelay((int) delayMillis);
return message;
});
}
// 正常消费者,无需特殊处理
@RabbitListener(queues = "order.cancel.queue")
public void cancelOrder(String orderId) {
// 执行关单逻辑
}
优点:
- 每个消息独立设置延迟时间,不同订单类型用同一交换机。
- 无队列头阻塞问题,消息到期顺序准确。
- 不占用业务线程轮询,由 RabbitMQ 内部定时器触发。
缺点:
- 需要安装插件,集群升级时需注意兼容性。
- 延迟消息保存在内存中(磁盘交换有限),大量堆积时内存开销大。
方案四:时间轮(高性能场景补充)
如果对延迟精度和吞吐有极端要求(如每秒数万订单),可以考虑 HashedWheelTimer(Netty 实现)或 Kafka 时间轮。它在 JVM 内存中维护一个环形数组,每个槽位对应一个时间刻度,到期触发回调。但需要自行处理持久化和宕机恢复,不适合大多数业务系统。这里不做展开。
选型建议与踩坑总结
最终我们选择了 RabbitMQ 延迟插件(方案 3.2),原因如下:
- 业务已使用 RabbitMQ 做消息中间件,复用成本低。
- 订单超时时间多样(普通 30min、秒杀 10min、预售 24h),需要消息级延迟。
- 插件成熟稳定,社区活跃,版本跟随 RabbitMQ 主版本。
踩坑记录:
- 队列头阻塞:刚开始用 TTL+DLX 方案,秒杀订单 10min 超时写在普通 30min 队列里,结果前面的普通订单没到期,秒杀订单一直不投递到 DLX——排查了两小时才发现是队列头阻塞。
- 延迟消息丢失:RabbitMQ 重启后,x-delayed-message 插件记录的延迟消息会丢失(插件默认不持久化延迟状态)。解决:在发送前先落库,配合消费者幂等做补偿。
- 消息积压导致处理延迟:高峰期大批订单同时到期,消费者处理不过来。解决:为取消队列配置独立线程池,并开启手动 ACK + prefetch=1 做流控。
- 幂等兜底:取消逻辑必须幂等。我们在关单前先 UPDATE 订单状态带条件
WHERE status = 'UNPAID',如果影响行数为 0 说明已经被处理过,直接丢弃。
总结
订单超时关单这个看似简单的需求,实现方案从简单轮询到 Redis ZSet 再到 RabbitMQ 延迟消息,背后对应的是业务体量的增长和对延迟精度/运维成本的权衡。
如果你的系统日均订单<1 万,定时扫描完全够用;日均 10 万级,Redis ZSet 轻量且高效;日均百万级并有现成 MQ 基础设施,选延迟插件方案最省心。
核心原则:方案演进跟着业务走,不要为了技术而技术。