在平时工作中做电商项目,订单超时自动关单是个绕不开的需求:用户下单后锁了库存,如果他一直不付款,这部分库存就被占死了。所以必须有机制在 30 分钟未支付后自动关闭订单并回补库存。
我一开始的方案很"标准"------用 RabbitMQ 的延迟队列。但做到后面我发现,光靠 MQ 其实不够可靠,于是又加了一层定时任务兜底。
这篇文章就聊聊这个"双保险"是怎么来的,以及两个关键问题:
- 单靠 MQ 到底哪里不可靠?
- 两层机制同时触发同一个订单时,怎么保证不会重复关单?
一、第一版:RabbitMQ 延迟队列
为什么选延迟队列
要实现"30 分钟后触发",常见的几种做法:
| 方案 | 问题 |
|---|---|
| 定时任务轮询数据库 | 频率低了不精确,频率高了数据库压力大 |
JDK 的 DelayQueue |
单机内存队列,服务重启就丢,集群下更不行 |
| Redis 的 zset | 可行,但要自己维护轮询逻辑,还得保证可靠性 |
| RabbitMQ 延迟队列 | 消息中间件天然支持,可靠性有保障,落地成本低 |
所以我选了 RabbitMQ 的延迟队列。这里用的是 x-delayed-message 插件 ,不是"死信队列 + TTL"那套------后者有个经典坑:队列是先进先出的,如果队头消息 TTL 很长,后面的消息即使先到期也出不来,会造成延迟不准。用插件则没有这个问题。
配置延迟交换机
java
@Configuration
public class MqConfig {
public static final String ORDER_TIMEOUT_EXCHANGE = "order.timeout.exchange";
public static final String ORDER_TIMEOUT_QUEUE = "order.timeout.queue";
public static final String ORDER_TIMEOUT_ROUTING_KEY = "order.timeout";
/**
* 订单超时延迟交换机(依赖 rabbitmq_delayed_message_exchange 插件)
*/
@Bean
public CustomExchange orderTimeoutExchange() {
Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");
return new CustomExchange(
ORDER_TIMEOUT_EXCHANGE,
"x-delayed-message", // 延迟交换机类型
true, // durable
false,
args);
}
@Bean
public Queue orderTimeoutQueue() {
return new Queue(ORDER_TIMEOUT_QUEUE, true);
}
@Bean
public Binding orderTimeoutBinding() {
return BindingBuilder.bind(orderTimeoutQueue())
.to(orderTimeoutExchange())
.with(ORDER_TIMEOUT_ROUTING_KEY)
.noargs();
}
}
注意 x-delayed-type 这个参数------延迟交换机本身只是"壳",它需要指定一个真实的路由类型(这里是 direct)来实际路由消息。
下单时发送延迟消息
下单成功后,投递一条延迟 30 分钟的消息:
java
private void sendOrderTimeout(Long orderId, String orderNo) {
try {
OrderTimeoutMessage msg = new OrderTimeoutMessage(orderId, orderNo);
rabbitTemplate.convertAndSend(
MqConfig.ORDER_TIMEOUT_EXCHANGE,
MqConfig.ORDER_TIMEOUT_ROUTING_KEY,
msg,
m -> {
// 延迟 30 分钟(单位:毫秒)
m.getMessageProperties().setHeader("x-delay", 1800000);
return m;
});
log.info("订单超时消息已发送: orderNo={}", orderNo);
} catch (Exception e) {
// 发消息失败不能影响下单主流程
log.error("发送订单超时消息失败: orderNo={}", orderNo, e);
}
}
这里有个容易被忽略的细节:整个发消息动作包在 try-catch 里。因为"下单成功"是主业务,而"投递延迟消息"是辅助动作------如果 MQ 挂了导致下单失败,那才是本末倒置。
消费消息,执行关单
java
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderTimeoutConsumer {
private final OrderService orderService;
@RabbitListener(queues = MqConfig.ORDER_TIMEOUT_QUEUE)
public void handle(OrderTimeoutMessage msg) {
log.info("收到订单超时消息: orderId={}, orderNo={}", msg.getOrderId(), msg.getOrderNo());
try {
orderService.timeoutClose(msg.getOrderId());
} catch (Exception e) {
log.error("订单超时关闭异常: orderId={}", msg.getOrderId(), e);
throw e; // 抛出去,让 MQ 走重试
}
}
}
到这里,第一版就跑通了:下单 → 投递延迟消息 → 30 分钟后消费 → 关单回补库存。
二、我发现的问题:单靠 MQ 并不保险
第一版上线测试都没问题,但我后来意识到一件事:
只要业务依赖"某个组件在某个时刻一定触发",就会有风险。
延迟消息可能失手的场景,仔细想想还不少:
- 消息丢失:虽然 MQ 有持久化,但极端情况下仍可能丢
- 消费失败:消费端抛异常后如果没有合理的重试/死信机制,消息就卡住了
- 服务重启:重启期间如果消息到期,虽然有持久化兜底,但消费时机就不可控了
- 插件本身的坑 :
x-delayed-message是社区插件,版本兼容、集群行为都可能出问题
关键问题在于------订单超时关单这件事,"漏关"的后果是实打实的:库存一直被占着,其他用户买不了,业务上是不能接受的。
所以我决定:不能只依赖 MQ 这一条链路。
三、第二版:加一层定时任务兜底
思路很简单:每分钟扫描一次数据库,把"已经超时但还没关"的订单找出来关掉。
java
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderTimeoutTask {
/** 订单超时阈值:30 分钟(与 MQ 延迟消息保持一致) */
private static final int TIMEOUT_MINUTES = 30;
/** 单次扫描上限,防止大量历史订单一次性触发回补风暴 */
private static final int SCAN_LIMIT = 100;
private final OrderInfoMapper orderInfoMapper;
private final OrderService orderService;
@Scheduled(fixedDelay = 60000) // 每分钟执行一次
public void scan() {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(TIMEOUT_MINUTES);
// 查 status=1(待支付)且创建时间早于阈值的订单
List<OrderInfo> expired = orderInfoMapper.selectList(
Wrappers.<OrderInfo>lambdaQuery()
.eq(OrderInfo::getStatus, 1)
.lt(OrderInfo::getCreateTime, threshold)
.orderByAsc(OrderInfo::getCreateTime)
.last("LIMIT " + SCAN_LIMIT));
if (expired.isEmpty()) {
return;
}
log.info("扫描到 {} 个超时未支付订单,开始关单", expired.size());
int success = 0, failed = 0;
for (OrderInfo order : expired) {
try {
orderService.timeoutClose(order.getId());
success++;
} catch (Exception e) {
failed++;
log.error("定时任务关单失败: orderId={}", order.getId(), e);
}
}
log.info("订单超时兜底任务完成: 扫描={}, 关闭={}, 失败={}", expired.size(), success, failed);
}
}
这段代码里有三个我觉得值得说的设计点:
1. SCAN_LIMIT = 100 防止回补风暴
如果某天发现历史数据里有几万个超时订单,一次性全查出来处理,很可能把数据库和库存服务打挂。所以加了单次扫描上限,宁可分多批处理,也不要一次性冲击下游。
2. orderByAsc(createTime) 优先处理最老的
超时最久的订单最该先关,按创建时间升序保证处理顺序合理。
3. 单条失败不影响整批
循环里每个订单单独 try-catch,一个失败继续处理下一个,而不是整批中断。
这里有个关键决策:两条链路复用同一份逻辑
定时任务和 MQ 消费者,最终都调用同一个方法:
java
orderService.timeoutClose(orderId);
这是一个刻意的设计。如果两条链路各写一套关单逻辑,很容易出现"行为不一致"------比如 MQ 那条回补了库存,定时任务那条忘了回补。把核心逻辑下沉到 Service 层共用,两个入口只是触发方式不同,行为才能保证一致。
四、最关键的问题:两层会不会打架?
这是我在加法之前先想清楚的事。
设想一个场景:MQ 的消息在 30 分钟时正常到达,消费者正在处理;几乎同时,定时任务也扫描到了这个订单。两个线程同时执行关单------会怎样?
- 库存会不会被回补两次?
- 通知会不会发两条?
答案是不会,靠的是 CAS 条件更新:
java
@Override
public void timeoutClose(Long orderId) {
OrderInfo order = orderInfoMapper.selectById(orderId);
if (order == null) {
log.warn("订单不存在,忽略超时关单: orderId={}", orderId);
return;
}
// 前置判断:非待支付状态直接忽略
if (order.getStatus() != 1) {
log.info("订单已支付或已关闭,忽略超时关单: orderNo={}", order.getOrderNo());
return;
}
// 关键:条件更新(CAS),只有当 status 仍为 1 时才更新成功
boolean closed = orderInfoMapper.update(null,
new LambdaUpdateWrapper<OrderInfo>()
.eq(OrderInfo::getId, orderId)
.eq(OrderInfo::getStatus, 1) // <-- 乐观锁条件
.set(OrderInfo::getStatus, 5) // 5 = 已关闭
.set(OrderInfo::getCloseTime, LocalDateTime.now())) > 0;
if (!closed) {
log.info("订单状态已变化,并发跳过: orderNo={}", order.getOrderNo());
return;
}
// 只有 CAS 成功的那一方,才会走到这里
log.info("订单超时自动关闭: orderNo={}", order.getOrderNo());
stockRestoreService.restore(order); // 回补库存
sendNotify(order.getUserId(), "订单超时关闭", "...", ...); // 发通知
}
为什么"先 selectById 判断 status"还不够?
因为 select 和 update 之间有一个并发窗口:
ini
线程A: select -> status=1 ✓
线程B: select -> status=1 ✓ (A 还没 update,B 读到旧值)
线程A: update ... where status=1 -> 影响 1 行 ✓
线程B: update ... where status=1 -> 影响 0 行 ✗ (被挡住)
所以真正的保证不是那个 if 判断,而是 SQL 里的 WHERE status = 1------它把"判断"和"更新"合并成了一个原子操作。数据库层面只会有一个人成功,另一个人影响行数为 0,直接 return。
这就是"幂等"的正确做法 :不是靠代码里 if-else 判断,而是靠数据库层的原子条件更新。
五、总结
回头看这个"双保险"设计,核心思想其实是:
不要把关键业务押在一个组件的可靠性上。
两条链路的定位是不一样的:
| MQ 延迟队列 | 定时任务兜底 | |
|---|---|---|
| 角色 | 主链路,正常情况 30 分钟精确触发 | 备份链路,最坏延迟 60 秒 |
| 优点 | 精确、低延迟、不查库 | 不依赖 MQ,纯数据库扫描,最可靠 |
| 缺点 | 依赖 MQ 可用性、插件稳定性 | 有轮询开销、延迟不精确 |
它们故障域不同------MQ 出问题,定时任务还能兜住;反过来数据库出问题,MQ 那条链路照样能跑。这才是"双保险"的意义。
如果你的业务也有类似需求(订单超时、优惠券过期、任务超时),而且漏处理的代价比较高,那这个模式值得参考:
- 主链路用消息队列,保证时效性
- 兜底链路用定时任务扫描,保证不漏
- 核心逻辑下沉到 Service 层共用,保证行为一致
- 幂等靠数据库原子条件更新,保证并发安全
- 扫描加 limit,防止批量操作冲击下游
项目是开源的,完整代码在这里:github.com/29af29/af-mall
如果这篇文章对你有帮助,欢迎点个赞 👍 有问题也欢迎评论区讨论。