← 返回文章列表
16 min readPublished

订单超时自动关单:RabbitMQ TTL + 死信队列的工程化实现

订单超时这块我当时卡了挺久,因为它看上去是“定时任务”,本质上却是“交易状态一致性”问题。 如果超时回收做得粗糙,要么库存假占用,要么已支付订单被误取消。 后来我把轮询改成 TTL + DLX,让过期消息驱动释放逻辑,并在监听端严格做状态判断。 下面的配置和监听代码都来自我本机项目里的真实实现。 整篇只讲技术链路,不讲产品层面的延伸内容。

查看关联项目:基于 Spring Boot 3 的影评与票务管理系统

1. 为什么放弃定时轮。

轮询方案的缺陷非常直接:它会周期性扫描大量无变化数据。 当订单规模上来后,数据库会被重复读取和条件过滤拖慢。 同时,轮询间隔越长,超时处理越滞后;间隔越短,资源占用越重。 这在高峰期通常是两头不讨好。 因此我把“超时检查”从同步查询改成消息过期触发。

事件驱动的好处在于它天然按需执行。 只有真正到期的订单会被转发并消费,不再需要全量扫描。 这让订单中心主链路更轻,也让超时处理逻辑更独立。 从系统分层角度看,超时任务被从业务请求线程中剥离了出来。 后续如果要叠加通知、统计、审计,也能直接在消费侧扩展。

2. 消息拓扑:TTL 队列 + 死信交换。+ 消费队列

订单创建时发送消息到 TTL 队列,消息在队列内等待固定时长。 一旦超。15 分钟,消息不会被直接丢弃,而是按死信规则路由到释放队列。 释放队列由监听器消费,消费动作包含关单与释放座位锁。 这条链路把时间控制权交给消息基础设施,而不是应用层轮询器。 在工程实践里,这种分工更清晰,也更利于后续观测和重试。

@Bean
public Queue orderTtlQueue() {
    return QueueBuilder.durable(ORDER_TTL_QUEUE)
            .ttl(900000) // 15分钟 TTL
            .deadLetterExchange(ORDER_DLX_EXCHANGE)
            .deadLetterRoutingKey(ORDER_DLX_ROUTING_KEY)
            .build();
}

@Bean
public Queue orderReleaseQueue() {
    return new Queue(ORDER_RELEASE_QUEUE, true);
}

@Bean
public Binding orderDlxBinding() {
    return BindingBuilder.bind(orderReleaseQueue())
            .to(orderDlxExchange())
            .with(ORDER_DLX_ROUTING_KEY);
}

3. 监听器关键点:只处理仍处于待支付状态的订单

监听器收到过期消息后,第一件事不是写库,而是查订单当前状态。 因为订单。TTL 到达前可能已经被支付,不能被超时逻辑误取消。 我在代码里明确做。status==0 判断,只有待支付订单才执行关单。 状态更新完成后再释。Redis 座位锁,恢复可售库存。 这是一条典型的“状态校验优先于动作执行”的交易规则。

@RabbitListener(queues = RabbitMQConfig.ORDER_RELEASE_QUEUE)
public void listenOrderTimeout(String orderNo) {
    if (orderNo == null) return;

    QueryWrapper<Order> query = new QueryWrapper<>();
    query.eq("order_no", orderNo);
    Order order = orderService.getOne(query);
    if (order == null) return;

    if (order.getStatus() == 0) {
        order.setStatus(2); // 2-已取。        orderService.updateById(order);

        String lockKeyPrefix = "lock:seat:" + order.getScheduleId() + ":";
        List<String> seats = List.of(order.getSeatInfo().split(","));
        List<String> lockKeys = seats.stream()
            .map(seat -> lockKeyPrefix + seat)
            .collect(Collectors.toList());
        redisTemplate.delete(lockKeys);
    }
}

4. 状态一致性与并发边界处理

这条链路的核心不是“能自动关单”,而是“不会误关单”。 支付动作和超时动作存在并发窗口,必须通过状态检查缩小错误覆盖面。 我在消费端坚持先读后写,并把状态流转限定为待支。-> 已取消。 支付成功路径则走待支。-> 已支付,两个分支通过状态值天然互斥。 只要状态迁移规则明确,消息重复投递和重试都更容易做幂等控制。

另外一个实践重点是。Redis 锁释放与订单状态更新放在同一业务语义中。 如果只更新订单不释放锁,库存依然不可售;如果只释放锁不更新状态,会出现账务语义错乱。 这两个动作必须作为同一条“超时处理事务”看待。 虽然技术上跨了数据库和缓存,但业务上它们共同定义了订单最终态。 我在实现时就是按这个原则组织监听器逻辑的。

5. 前端状态同步与体验闭环

后端异步关单之后,前端也要尽快反映状态变化,否则用户会看到“假待支付”。 我的前端列表页通过订单状态映射展示待支付、已支付、已取消三种状态。 当超时监听器执行完成后,用户刷新列表即可看到订单转为超时取消。 这虽然不是复杂前端技术,但对于交易透明度非常关键。 库存和订单状态在用户界面上及时一致,才能减少误解和重复操作。

const fetchOrders = async () => {
  const res = await getMyOrders({ page: 1, size: 10 });
  orderList.value = res.data.records.map((item) => {
    if (item.status === 0) item.statusText = "待支。;
    else if (item.status === 1) item.statusText = "已支。;
    else if (item.status === 2) item.statusText = "已取。(超时)";
    return item;
  });
};
返回顶部