订单超时关闭:延迟消息、定时任务、补偿重试,三层兜底
一句话结论
延迟消息负责快,定时任务负责稳,补偿重试负责救火,带条件的 UPDATE 负责不重复。 四件事各管一段,缺一层就有一类故障漏出去。
问题场景
订单创建后进入「待支付」,30 分钟内没付款就要自动关闭,并把占用的库存还回去。
这条规则必须可靠:没有它,库存会被一直占着,超卖风险就从「并发问题」变成「长期占用问题」。
但这条规则横跨三个不可靠的环节:MQ 可能丢消息、定时任务可能扫漏、跨服务的库存回补可能失败。
第一层:延迟消息(快)
用的是 RabbitMQ 的 x-delayed-message 插件,不是 TTL + 死信队列那套。
交换机声明:
args.put("x-delayed-type", "direct");
return new CustomExchange(ORDER_TIMEOUT_EXCHANGE, "x-delayed-message", true, false, args);
下单成功时发一条延迟 30 分钟的消息:
rabbitTemplate.convertAndSend(
MqConfig.ORDER_TIMEOUT_EXCHANGE,
MqConfig.ORDER_TIMEOUT_ROUTING_KEY,
msg,
m -> {
m.getMessageProperties().setHeader("x-delay", 1800000); // 30 分钟
return m;
});
消费者:
@RabbitListener(queues = MqConfig.ORDER_TIMEOUT_QUEUE)
public void handle(OrderTimeoutMessage msg) {
try {
orderService.timeoutClose(msg.getOrderId());
} catch (Exception e) {
// 抛出异常让 MQ 重试,也可以不抛、由定时任务兜底
throw e;
}
}
为什么用 x-delayed-message 而不是 TTL + DLX
TTL + 死信队列有两个坑:队头阻塞(前面的消息 TTL 长,后面到期的消息会被卡住)和每条消息一个队列(延迟时间不同就得建不同的队列)。
x-delayed-message 是把延迟能力放在交换机上,延迟时间只是一个消息头,队列还是那个队列。
第二层:定时任务(稳)
延迟消息可能丢:交换机被删、消费者当时挂了、消息被 purge。所以有一条每分钟跑的兜底任务:
@Scheduled(fixedDelay = 60000)
public void scan() {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(TIMEOUT_MINUTES); // 30
List<OrderInfo> expired = orderInfoMapper.selectList(
Wrappers.<OrderInfo>lambdaQuery()
.eq(OrderInfo::getStatus, 1) // 只看待支付
.lt(OrderInfo::getCreateTime, threshold) // 只看创建超 30 分钟的
.orderByAsc(OrderInfo::getCreateTime) // 先处理最老的
.last("LIMIT " + SCAN_LIMIT)); // 一次最多 100 单
for (OrderInfo order : expired) {
try {
orderService.timeoutClose(order.getId());
} catch (Exception e) {
// 单个失败不影响整批
log.error("定时任务关单失败: orderId={}", order.getId(), e);
}
}
}
三个细节:只扫待支付、按创建时间升序(老订单优先,避免饿死)、分批限量(别一次把库压垮)。
第三层:补偿重试(救火)
关单成功之后还要调 mall-product 回补库存,这是跨服务调用,也可能失败。
public void restore(OrderInfo order) {
List<OrderItem> items = ...;
for (OrderItem item : items) {
boolean ok = tryRestore(item.getSkuId(), item.getQuantity());
if (!ok) {
registerRetry(order.getId(), item.getSkuId(), item.getQuantity()); // 记一条待补偿
}
}
}
StockRestoreRetryTask 定时扫这张待补偿表,最多重试 MAX_RETRY = 5 次,超过就告警等人处理。
为什么要单独抽一个服务? 因为「关单」和「库存回补」的可靠性要求不同:关单必须成功(否则用户一直被扣库存),而回补可以先失败再补偿。混在一起会让一个失败拖垮另一个。
关键:幂等收口
三条路径都可能命中同一张订单,所以关单必须幂等:
public void timeoutClose(Long orderId) {
OrderInfo order = orderInfoMapper.selectById(orderId);
if (order == null || order.getStatus() != 1) {
return; // 已支付或已关闭,直接跳过
}
// 关键:带状态条件的 UPDATE,只有 status 还是 1 才允许跃迁到 5(已关闭)
boolean closed = orderInfoMapper.update(null,
new LambdaUpdateWrapper<OrderInfo>()
.eq(OrderInfo::getId, orderId)
.eq(OrderInfo::getStatus, 1)
.set(OrderInfo::getStatus, 5)
.set(OrderInfo::getCloseTime, LocalDateTime.now())) > 0;
if (!closed) {
return; // 并发场景下别人抢先关了
}
// 只有真正关成功的那一次,才去回补库存
stockRestoreService.restore(order);
sendNotify(order.getUserId(), "订单超时关闭", ...);
}
update ... where status = 1 的影响行数,就是判断「我是不是第一个」的依据。 只有赢家才做后续动作(回补库存、发通知),输家直接返回。
这里最关键的一点:回补库存必须放在带条件的 UPDATE 之后。如果放在前面,两条路径同时进来会各回补一次,库存越卖越多。
复盘
我最初只做了「延迟消息 + 定时任务」两层,觉得够了。后来压测时模拟 mall-product 不可用,发现订单关了但库存没还回来,而且没有任何记录能查到是哪一单漏了——只能加第三层补偿表。
教训是:跨服务调用的失败不能只靠重试,重试本身也要有落盘的凭据,否则你既不知道失败了什么,也无法恢复。
另一个细节:消息体里我只放 orderId 和 orderNo,消费端重新查库。因为商品信息可能在 30 分钟里变了,我曾经在消息里塞过商品快照,结果回补库存时用错了数量。