分类:数据库与中间件 · 作者:管理员 · 发布于 2026-09-25 · 阅读 0 · 标签:订单超时 / RabbitMQ / 延迟队列 / Redis / 电商

场景:订单超时未支付,谁来关单?

在电商系统中,用户下单后通常有一个「支付超时时间」——比如 30 分钟。超过这个时间订单仍未支付,系统需要自动将其取消,释放库存、清理预占资源。

这个需求看起来简单,实现方式却有好几代演进。本文从一个真实中台项目的重构历程出发,对比三种方案的优劣,给出选型建议。

方案一:定时轮询扫描(最原始,但够用)

思路很简单:启动一个 ScheduledExecutorService 或 Spring @Scheduled 任务,每隔一定间隔扫描订单表中未支付且超时的记录,批量关单。

@Component
public class OrderTimeoutScanner {
    @Scheduled(fixedRate = 30_000) // 每 30 秒扫描一次
    @Transactional
    public void cancelExpiredOrders() {
        List<Order> expiredOrders = orderMapper.selectExpiredUnpaid(
            LocalDateTime.now().minusMinutes(30));
        for (Order order : expiredOrders) {
            order.setStatus(OrderStatus.CANCELLED);
            // 释放库存
            inventoryService.releaseStock(order.getSkuId(), order.getQuantity());
        }
    }
}

优点:简单、无中间件依赖、实现成本低。

缺点:

  • 时间精度低:扫描间隔 30s,订单最多延迟 30s 才被取消,用户体验差。
  • 数据库压力大:每次扫描都要扫全表(或大量索引范围),订单量百万级时很吃力。
  • 重复扫描:大部分扫描结果为空,造成无效查询。
  • 分布式冲突:多实例部署时需加分布式锁避免重复处理。

这个方案在日均订单几千的小体量下够用,但随着业务增长很快成为瓶颈。

方案二:Redis ZSet 延迟队列(内存级精准延迟)

利用 Redis Sorted Set 的 score 字段存储超时时间戳,实现逐秒精度的延迟调度。

@Component
public class RedisDelayQueue {
    private static final String KEY = "order:delay:cancel";

    public void addOrder(String orderId, long expireAt) {
        // expireAt 为毫秒时间戳
        redisTemplate.opsForZSet().add(KEY, orderId, expireAt);
    }

    @Scheduled(fixedRate = 1000) // 每秒轮询一次
    public void poll() {
        long now = System.currentTimeMillis();
        // 取出所有已到期的订单
        Set<String> expired = redisTemplate.opsForZSet()
            .rangeByScore(KEY, 0, now);
        for (String orderId : expired) {
            // 尝试移除(原子操作,避免重复消费)
            Long removed = redisTemplate.opsForZSet()
                .remove(KEY, orderId);
            if (removed != null && removed > 0) {
                cancelOrder(orderId);
            }
        }
    }
}

优点:

  • 每秒轮询,延迟控制在一秒内,精度高。
  • Redis 内存操作,性能远优于数据库扫描。
  • ZSet 原子 remove + score 范围查询天然幂等。

缺点:

  • 订单关单后需手动删除 ZSet 元素,否则内存泄漏。
  • Redis 宕机未持久化时丢失延迟任务(可配合 RDB/AOF)。
  • 每次轮询 rangeByScore 返回全量到期订单,订单集中到期时可能 OOM,需要分页拉取。
  • 依然占用线程定时轮询,不适合超大规模。

方案三:RabbitMQ 延迟消息(最终选择)

RabbitMQ 原生不支持延迟消息,但有两种社区方案实现。

3.1 TTL + 死信交换机(原生方案,无插件)

原理:消息设置 TTL,到期未被消费则进入死信交换机(DLX),由死信队列的消费者处理。

// 定义:订单业务队列绑定死信交换机
@Bean
public Queue orderQueue() {
    return QueueBuilder.durable("order.delay.queue")
        .deadLetterExchange("order.dlx.exchange")
        .deadLetterRoutingKey("order.cancel")
        .ttl(30 * 60 * 1000) // 30 分钟
        .build();
}

// 死信队列:真正的取消消费
@Bean
public Queue cancelQueue() {
    return new Queue("order.cancel.queue", true);
}

@RabbitListener(queues = "order.cancel.queue")
public void handleCancel(String orderId, Message message) {
    cancelOrder(orderId);
}

注意坑点:

  • 队列级别的 TTL 对所有消息统一生效,如果不同商品有不同超时时间(比如普通商品 30min、秒杀商品 10min),就需要不同的队列——极度不灵活。
  • 消息级别的 TTL 虽然可以单独设置,但在 RabbitMQ 中,处于队列头的消息过期前,后面的消息即使已过期也不会被投递到 DLX(队列头阻塞)。这是经典的大坑。

3.2 rabbitmq_delayed_message_exchange 插件(推荐最终方案)

安装社区插件后,使用 x-delayed-message type 的交换机,消息发布时直接指定延迟时间。

// 声明延迟交换机
@Bean
public CustomExchange delayExchange() {
    Map<String, Object> args = new HashMap<>();
    args.put("x-delayed-type", "direct");
    return new CustomExchange("order.delay.exchange",
        "x-delayed-message", true, false, args);
}

// 发送延迟消息
public void sendDelayCancel(String orderId, long delayMillis) {
    rabbitTemplate.convertAndSend(
        "order.delay.exchange", "order.cancel",
        orderId, message -> {
            message.getMessageProperties()
                .setDelay((int) delayMillis);
            return message;
        });
}

// 正常消费者,无需特殊处理
@RabbitListener(queues = "order.cancel.queue")
public void cancelOrder(String orderId) {
    // 执行关单逻辑
}

优点:

  • 每个消息独立设置延迟时间,不同订单类型用同一交换机。
  • 无队列头阻塞问题,消息到期顺序准确。
  • 不占用业务线程轮询,由 RabbitMQ 内部定时器触发。

缺点:

  • 需要安装插件,集群升级时需注意兼容性。
  • 延迟消息保存在内存中(磁盘交换有限),大量堆积时内存开销大。

方案四:时间轮(高性能场景补充)

如果对延迟精度和吞吐有极端要求(如每秒数万订单),可以考虑 HashedWheelTimer(Netty 实现)或 Kafka 时间轮。它在 JVM 内存中维护一个环形数组,每个槽位对应一个时间刻度,到期触发回调。但需要自行处理持久化和宕机恢复,不适合大多数业务系统。这里不做展开。

选型建议与踩坑总结

最终我们选择了 RabbitMQ 延迟插件(方案 3.2),原因如下:

  • 业务已使用 RabbitMQ 做消息中间件,复用成本低。
  • 订单超时时间多样(普通 30min、秒杀 10min、预售 24h),需要消息级延迟。
  • 插件成熟稳定,社区活跃,版本跟随 RabbitMQ 主版本。

踩坑记录:

  1. 队列头阻塞:刚开始用 TTL+DLX 方案,秒杀订单 10min 超时写在普通 30min 队列里,结果前面的普通订单没到期,秒杀订单一直不投递到 DLX——排查了两小时才发现是队列头阻塞。
  2. 延迟消息丢失:RabbitMQ 重启后,x-delayed-message 插件记录的延迟消息会丢失(插件默认不持久化延迟状态)。解决:在发送前先落库,配合消费者幂等做补偿。
  3. 消息积压导致处理延迟:高峰期大批订单同时到期,消费者处理不过来。解决:为取消队列配置独立线程池,并开启手动 ACK + prefetch=1 做流控。
  4. 幂等兜底:取消逻辑必须幂等。我们在关单前先 UPDATE 订单状态带条件 WHERE status = 'UNPAID',如果影响行数为 0 说明已经被处理过,直接丢弃。

总结

订单超时关单这个看似简单的需求,实现方案从简单轮询到 Redis ZSet 再到 RabbitMQ 延迟消息,背后对应的是业务体量的增长和对延迟精度/运维成本的权衡。

如果你的系统日均订单<1 万,定时扫描完全够用;日均 10 万级,Redis ZSet 轻量且高效;日均百万级并有现成 MQ 基础设施,选延迟插件方案最省心。

核心原则:方案演进跟着业务走,不要为了技术而技术。