订单延时自动关闭,从定时任务到延迟消息的演进之路

3 阅读8分钟

前言

“用户下单后 30 分钟未支付,订单自动取消,库存自动释放”——这个需求几乎是每个电商系统的标配。根据行业统计,平均有 15%~30% 的订单因用户放弃支付而需要系统自动取消,在秒杀等高并发场景下该比例可能高达 50%。

我刚接手这个需求的时候,一拍胸脯:“简单!写个定时任务每 5 分钟查一次库就行!”结果上线第三天,数据库直接被查崩了。

今天就把这个经典场景从“定时任务暴力轮询”到“延迟消息优雅解耦”的演进过程掰开揉碎讲清楚,顺便聊聊那些年踩过的坑。

一、业务场景与核心需求

不同业务的超时规则差异很大,设计前得先分清楚:

订单类型超时场景典型超时时间
电商普通订单下单后未支付30 分钟
电商预售订单付定金后未付尾款24 小时
外卖订单商家未接单15 分钟
酒店/票务订单预订后未支付1 小时

所有超时场景都要解决两个核心问题:状态闭环(订单从“待支付”转为“已取消”)和资源回补(释放库存、返还优惠券、解锁座位)。

除了功能需求,还有几个硬性指标:

  • 数据一致性:不该取消的绝不取消,该取消的一个不漏
  • 实时性:超时后在合理时间内取消(最好误差在 1 分钟内)
  • 高并发支撑:大促期间订单量百万级,不能拖垮主流程
  • 可配置:不同订单类型支持不同超时时间

二、方案演进:从“能用”到“好用”

2.1 第一阶段:定时任务轮询(新手村标配)

最直接的方案:启动一个定时任务,周期性地扫描数据库,查出所有“待支付”且“创建时间超过超时阈值”的订单,批量更新状态。

@Scheduled(fixedRate = 60 * 1000)
public void cancelExpiredOrders() {
    LocalDateTime threshold = LocalDateTime.now().minusMinutes(30);
    List<Order> unpaidOrders = orderRepository.findByStatusAndCreateTimeBefore(
        OrderStatus.UNPAID, threshold);
    unpaidOrders.forEach(order -> {
        order.cancel();
        inventoryService.releaseStock(order.getItems());
    });
}

优点:实现简单,无需引入额外中间件,中小团队友好。

缺点:

  • 实时性差:如果每分钟跑一次,订单可能延迟近 60 秒才关闭
  • 数据库压力大:随着订单量增长,全表扫描会拖垮数据库
  • 大量无效查询:大部分扫描到的订单其实还没超时

优化手段:加 (status, create_time) 联合索引、分页查询、分布式锁防止集群重复执行。但治标不治本,数据量过亿时依然扛不住。

2.2 第二阶段:Redis 过期键通知(看似优雅,实则坑多)

网上流传很广的方案:下单时在 Redis 里存一个带过期时间的 key,利用 Redis 的键空间通知(Keyspace Notifications)监听过期事件来触发关单。

// 下单时设置过期键
redisTemplate.opsForValue().set("order_" + orderId, orderId, 30, TimeUnit.MINUTES);

// 监听过期事件
@Component
public class KeyExpiredListener extends KeyExpirationEventMessageListener {
    @Override
    public void onMessage(Message message, byte[] pattern) {
        String key = message.toString();
        // 解析订单ID,执行关单逻辑
    }
}

但这个方案有致命缺陷。

Redis 官方文档明确指出:过期事件是在 Redis 服务器删除键时生成的,而不是在 TTL 理论归零时生成的。Redis 的过期删除策略是定期扫描 + 惰性删除,从不保证在设定的过期时间立即删除并发送通知。实际上,过期通知晚于设定时间数分钟的情况也很常见。

更致命的是,键空间通知采用  “发送即忘”(fire and forget)  策略,不保证送达。订阅事件的客户端如果断线,会丢失所有断线期间的事件。

一句话总结:用 Redis 过期监听做订单超时,等于把核心业务逻辑建立在了一个不保证可靠性的机制上。  有领导甚至放话:“谁再用 Redis 实现过期订单关闭,立马滚蛋!”

2.3 第三阶段:延迟消息(生产级方案)

延迟消息的核心思想很朴素:给每个订单单独设个“闹钟”,到点自动响。用户下单时发一条延迟消息,消息队列在指定时间后才投递给消费者,消费者收到后检查订单状态并执行关单。

市面上主流方案有三条路:

方案 A:RabbitMQ TTL + 死信队列

RabbitMQ 原生不支持延迟消息,需要借助 TTL(消息存活时间)+ 死信交换机(DLX)组合来实现。

原理像“快递驿站 + 过期退货”:消息进入延迟队列后开始倒计时,过期后被扔进死信交换机,再转发到消费队列供消费者处理。

// 声明延迟队列(TTL=30分钟)
@Bean
public Queue delayQueue() {
    return QueueBuilder.durable("order.delay.queue")
        .withArgument("x-message-ttl", 1800000)
        .withArgument("x-dead-letter-exchange", "order.dlx.exchange")
        .withArgument("x-dead-letter-routing-key", "order.dlx.key")
        .build();
}

坑点:如果队列中第一条消息的 TTL 很长,后续短 TTL 的消息即使过期也只能排队等着,无法提前投递。解决方案:按延迟时间分队列(5分钟队列、30分钟队列、2小时队列),或者直接使用官方延迟插件 rabbitmq-delayed-message-exchange。

方案 B:RocketMQ / Pulsar 原生延迟消息

如果技术栈允许,优先选择支持原生延迟消息的分布式 MQ(如 RocketMQ、Pulsar)。

RocketMQ 支持预设的延迟等级(从秒级到小时级),精度毫秒级,吞吐单队列百万级消息/秒。Pulsar 则支持任意时间精度的延迟消息,更加灵活。

// RocketMQ 延时消息示例
Message msg = new Message("OrderTopic", "订单创建,30分钟后检查支付状态".getBytes());
// 设置延迟级别,例如 30 分钟对应某个等级
msg.setDelayTimeLevel(16); 
producer.send(msg);

优点:可靠性高(消息可持久化、支持重试)、实时性好、天然解耦。

缺点:需要部署和维护 MQ 集群,对中小项目有一定成本。

方案 C:Redisson 延迟队列(轻量之选)

如果不想部署重型 MQ,Redisson 提供的 RDelayedQueue 是个不错的轻量级方案。

Redisson 延迟队列底层基于 Redis 的 ZSet 和 List/Queue 实现:用 ZSet 按延迟时间排序存储任务,时间到达后移到阻塞队列供消费者消费。

@Autowired
private RedissonClient redissonClient;

public void scheduleOrderTimeout(Long orderId, long delayMillis) {
    RBlockingQueue<Long> blockingQueue = redissonClient.getBlockingQueue("order:queue");
    RDelayedQueue<Long> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
    // 将订单ID加入延迟队列,delayMillis后自动转到阻塞队列
    delayedQueue.offer(orderId, delayMillis, TimeUnit.MILLISECONDS);
}

// 消费者:后台线程阻塞监听
@Component
public class OrderTimeoutConsumer {
    @PostConstruct
    public void start() {
        new Thread(() -> {
            RBlockingQueue<Long> queue = redissonClient.getBlockingQueue("order:queue");
            while (true) {
                Long orderId = queue.take(); // 阻塞等待
                cancelOrder(orderId);
            }
        }).start();
    }
}

关键细节:必须确保订单事务提交成功后再往延迟队列里放任务,避免“订单保存失败但取消任务已入队”的 Bug。可以用 Spring 的 TransactionSynchronization 做事务提交后的回调。

适用场景:中小型电商系统、轻量级延迟任务、不想部署重型 MQ 的项目。

三、方案对比速查表

方案实时性可靠性实现复杂度适用规模
定时任务轮询差(分钟级)高低日均万级以内
Redis 过期监听中(但不保证)低中不推荐
RabbitMQ TTL+DLX好高中日均十万级
RabbitMQ 延迟插件好高中日均十万级
RocketMQ 原生延迟极好(毫秒级)极高中高日均百万级以上
Redisson 延迟队列好中低日均十万级以内

四、那些年踩过的坑

坑1:重复取消

分布式环境下,多个节点可能同时消费到同一个延迟消息,导致同一订单被重复取消、库存被重复释放。

解法:关单前用分布式锁(如 Redis 锁)加锁,或利用数据库乐观锁(UPDATE order SET status='CANCELLED' WHERE id=? AND status='UNPAID'),让数据库帮我们做幂等校验。

坑2:取消前最后一秒支付了

用户在超时前 1 秒完成了支付,但延迟消息也恰好触发了关单逻辑。

解法:关单前必须二次确认订单状态——只有状态仍为“待支付”时才执行取消,否则直接跳过。

坑3:资源回补失败

订单取消了,但库存没释放回来——这是最要命的情况。

解法:关单和释放库存要放在同一个事务里,或者用本地消息表 + 补偿机制保证最终一致性。

坑4:延迟消息丢失

MQ 集群重启或网络抖动导致延迟消息丢失。

解法:兜底方案永远不能省——定时任务作为最终防线,每天凌晨扫描一次“待支付”且远超超时阈值的“僵尸订单”进行清理。

五、总结

订单延时自动关闭这个需求,从定时任务到延迟消息,折射出的其实是从“批量轮询”到“事件驱动”  的架构演进思维。

选型建议:

  • 日均订单 万级以内,实时性要求不高 → 定时任务轮询 + 索引优化,简单够用
  • 日均订单 十万级,已有 RabbitMQ → TTL+DLX 或延迟插件
  • 日均订单 百万级以上,追求极致可靠性 → RocketMQ/Pulsar 原生延迟消息
  • 不想部署重型 MQ,追求轻量 → Redisson 延迟队列