订单超时自动关单:RabbitMQ 延迟队列 + 定时任务"双保险"实践

在平时工作中做电商项目,订单超时自动关单是个绕不开的需求:用户下单后锁了库存,如果他一直不付款,这部分库存就被占死了。所以必须有机制在 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"还不够?

因为 selectupdate 之间有一个并发窗口

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 那条链路照样能跑。这才是"双保险"的意义。

如果你的业务也有类似需求(订单超时、优惠券过期、任务超时),而且漏处理的代价比较高,那这个模式值得参考:

  1. 主链路用消息队列,保证时效性
  2. 兜底链路用定时任务扫描,保证不漏
  3. 核心逻辑下沉到 Service 层共用,保证行为一致
  4. 幂等靠数据库原子条件更新,保证并发安全
  5. 扫描加 limit,防止批量操作冲击下游

项目是开源的,完整代码在这里:github.com/29af29/af-mall

如果这篇文章对你有帮助,欢迎点个赞 👍 有问题也欢迎评论区讨论。

相关推荐
二炮手亮子1 小时前
使用云服务+hermes+mino模型 我用微信操控服务器自己编程并部署实操
java·语言模型·ai编程
她的男孩1 小时前
安全同事找我要"上周所有登录失败记录",我查完数据库:一条都没有
java·后端·架构
m0_587383001 小时前
本地同城无人机作业接单系统源码:架构拆解与抢单调度实现
java·小程序·架构·需求分析
FantanLee1 小时前
面试级「分布式 ID 设计方案」
java
青春易逝丶1 小时前
Maven
java·maven
吠品2 小时前
纯HTML+ECharts构建交互式数据看板:实现思路与踩坑记录
java·服务器·数据库
盖伦发发2 小时前
Redis 核心教学: 数据结构, 缓存设计, 分布式锁
java·redis·后端·软件工程
卷毛的技术笔记2 小时前
RocketMQ事务消息:我把分布式事务这层窗户纸捅破了
java·分布式·后端·java-rocketmq
yxlalm2 小时前
零基础快速上手Trae创建Java项目
java·人工智能