阿飞的博客

订单超时关闭:延迟消息、定时任务、补偿重试,三层兜底

一句话结论

延迟消息负责快,定时任务负责稳,补偿重试负责救火,带条件的 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 分钟里变了,我曾经在消息里塞过商品快照,结果回补库存时用错了数量。

标签 RabbitMQ踩坑记录

评论

0
加载中…
无需登录 · 同一 IP 每分钟限 3 条