实现”订单 30 分钟未支付自动取消”,本质是把一条消息延后消费。最稳走 MQ 延迟消息,轻量用 Redis ZSet,最省事是定时任务扫库。下面给出各方案代码与取舍。
一、四种方案横向对比
| 方案 | 实时性 | 可靠性 | 复杂度 | 适用规模 |
|---|---|---|---|---|
| 定时任务扫库 | 低(受扫描间隔) | 中(需幂等) | 低 | 小系统、低频 |
| Redis ZSet 延迟队列 | 中(秒级) | 中(依赖 Redis 高可用) | 中 | 中等规模 |
| RabbitMQ 死信队列 | 高 | 高(持久化+确认) | 高 | 中大型生产 |
| RocketMQ 延迟消息 | 高(固定级别) | 高 | 中 | 已用 RocketMQ 的团队 |
订单超时关单属于典型延迟任务,同类还有:下单 60 秒后发通知、退款状态定时核对、新店 N 天未上架发激活短信。
二、方案 A:定时任务扫库
用 @Scheduled 或 Quartz 周期性查”待支付且创建超 30 分钟”的订单批量置为已取消,实现简单、不依赖中间件,但扫描间隔带来时间误差。落地按四步推进:
- 建查询方法按状态与截止时间取超时订单;
- 用
@Scheduled设固定间隔(如 60 秒)触发; - 批量更新为已取消并释放库存;
- 集群下加分布式锁,避免多节点重复取消。
@Scheduled(fixedDelay = 60_000)
public void cancelExpiredOrders {
Date deadline = new Date(System.currentTimeMillis - 30 * 60_000);
List<Order> list = orderMapper.selectUnpaidBefore(deadline);
for (Order o : list) {
orderMapper.updateStatus(o.getId, OrderStatus.CANCELED);
inventoryService.release(o.getId);
}
}
三、方案 B:Redis ZSet 延迟队列
把订单号作为 member、超时时间戳(创建时间 + 30 分钟)作为 score 写入 ZSet;后台线程每秒用 ZRANGEBYSCORE 捞 score 小于当前时间的元素并删除。
3.1 入队与消费
import redis, time
r = redis.Redis
def enqueue(order_id, delay_sec=1800):
r.zadd("delay:order", {order_id: time.time + delay_sec})
def consume_loop:
while True:
due = r.zrangebyscore("delay:order", 0, time.time, start=0, num=100)
for oid in due:
if r.zrem("delay:order", oid) and query_status(oid.decode) == "UNPAID":
cancel_order(oid.decode)
time.sleep(1)
多消费者可能取到同一订单号,用 ZREM 的返回值判定谁抢到,保证幂等。
四、方案 C:RabbitMQ 死信队列
消息带 TTL 发往没有消费者的缓冲队列,30 分钟过期后变死信,进死信队列由消费者取消订单。
4.1 声明与发送
@Bean
public Queue bufferQueue {
return QueueBuilder.durable("buffer.q")
.ttl(30 * 60 * 1000)
.deadLetterExchange("dlx.exchange")
.deadLetterRoutingKey("dlx.key")
.build;
}
rabbitTemplate.convertAndSend("buffer.exchange", "buffer.key", orderId);
死信消费者收到后查订单状态,未支付才取消并释放库存。注意:RabbitMQ 队列级 TTL 下,第一条消息没过期,后面的消息即便先到期也不提前投递,造成队头阻塞。3.5.8 之后建议装 rabbitmq_delayed_message_exchange 插件,按单条消息延时。
五、方案 D:RocketMQ 延迟消息
RocketMQ 原生支持延迟消息,但只有 18 个固定级别,其中正好含 30m 这一级。
Message msg = new Message("order_topic",
("cancel:" + orderId).getBytes);
msg.setDelayTimeLevel(16);
producer.send(msg);
消费者收到后检查订单状态,未支付则取消。任意时间精度无法用固定级别表达。
六、选型与可靠性注意
MQ 方案带来解耦与高可用,但也引入消息丢失、重复消费、顺序性问题,必须做消费幂等和失败重试。小系统用定时任务扫库最省心;要较好时效性又不想引入重中间件,选 Redis ZSet;已在用 RabbitMQ/RocketMQ 且要求可靠投递,直接用各自的延迟能力。
常见问题(FAQ)
Q1:定时任务扫库能用在生产吗?
能,但订单量大时压库且有时延,集群需做幂等防重复取消。
Q2:RabbitMQ 死信队头阻塞怎么破?
装 delayed message 插件,按单条消息设 x-delay,避免队头阻塞。
Q3:RocketMQ 能任意延时吗?
不能,仅 18 个固定级别,但含 30m,刚好覆盖订单超时场景。