1. 背景:为什么不能靠"定时扫全表"

小程序电商有一条铁律:用户下单后锁定库存,但未支付订单必须在超时后(常见 15~30 分钟)自动取消并释放库存。我们的平台上日均订单量在数十万级,高峰期(拼团、秒杀)QPS 会冲到日常的十几倍。这个"超时取消"看起来是个小功能,实际踩坑不少:

  • 定时任务每分钟扫全表,订单表越滚越大,status = 0 AND create_time < ? 的扫描从最初的几百毫秒涨到几秒,还和交易请求抢数据库连接;
  • 任务多实例部署后重复执行,同一订单被关两次,库存释放错乱;
  • 秒杀瞬间几十万未支付订单在同一时刻到期,取消任务形成"洪峰",把数据库和库存服务打满。

本文完整记录我们对比过的三种方案:数据库定时任务扫描、Redis Key 过期监听、MQ 延迟消息,给出可运行代码,并讲清楚最终在生产上的组合架构与幂等设计。技术栈:Spring Boot 3 + MyBatis-Plus + MySQL 8 + Redis 7 + RocketMQ。

2. 订单状态机先立规矩

不管哪种方案触发,取消动作必须收敛到统一的状态机方法上,这是后面所有方案的前提:

待支付(0) --支付成功--> 已支付(1) --> 已发货(2) --> 已完成(3)
待支付(0) --超时/主动取消--> 已取消(4)   (只有 0 能进 4)
已支付(1) --退款--> 已退款(5)

关键约束:只有"待支付"状态可以被取消。取消动作本质是一次带状态条件的 CAS 更新:

// 统一的关单入口:所有触发源都走这里
@Transactional(rollbackFor = Exception.class)
public boolean cancelTimeoutOrder(Long orderId) {
    // 1. 乐观更新:WHERE status = 0,影响行数为 0 说明已支付或已取消,直接返回
    int rows = orderMapper.cancelIfUnpaid(orderId);
    if (rows == 0) {
        return false; // 幂等:重复触发/已支付,安全跳过
    }
    // 2. 释放库存(走库存服务的释放接口,内部同样幂等)
    Order order = orderMapper.selectById(orderId);
    order.getItems().forEach(item ->
        stockService.release(item.getSkuId(), item.getQuantity(), orderId));
    // 3. 退优惠券、释放营销活动占用
    couponService.release(order.getCouponId(), orderId);
    return true;
}

对应 SQL:

<update id="cancelIfUnpaid">
    UPDATE t_order
    SET status = 4, update_time = NOW(), cancel_reason = 'TIMEOUT'
    WHERE id = #{orderId} AND status = 0
</update>

WHERE status = 0 这一行是整个功能的安全底座:数据库行锁保证并发下只有一个线程能改成功,天然幂等。三种触发方案的区别,只在于"谁来、多准时地调用这个方法"。

3. 方案一:数据库定时任务扫描

最朴素的做法:定时轮询待支付订单,把超时的捞出来取消。

@Component
@Slf4j
public class OrderTimeoutScanner {

    private static final int TIMEOUT_MINUTES = 30;
    private static final int PAGE_SIZE = 500;

    @Scheduled(fixedDelay = 30_000) // 每30秒一轮,别用每分钟,误差太大
    public void scan() {
        long expireBefore = System.currentTimeMillis() - TIMEOUT_MINUTES * 60_000;
        long lastId = 0L;
        // 游标分页,避免 LIMIT offset 深分页
        while (true) {
            List<Long> ids = orderMapper.selectTimeoutOrderIds(
                    expireBefore, lastId, PAGE_SIZE);
            if (ids.isEmpty()) break;
            for (Long id : ids) {
                try {
                    orderService.cancelTimeoutOrder(id);
                } catch (Exception e) {
                    log.error("关单失败 orderId={}", id, e); // 单条失败不阻断整批
                }
            }
            lastId = ids.get(ids.size() - 1);
            if (ids.size() < PAGE_SIZE) break;
        }
    }
}
<select id="selectTimeoutOrderIds" resultType="long">
    SELECT id FROM t_order
    WHERE status = 0
      AND create_time &lt; FROM_UNIXTIME(#{expireBefore}/1000)
      AND id &gt; #{lastId}
    ORDER BY id ASC
    LIMIT #{pageSize}
</select>

配套索引idx_status_create (status, create_time) 是必须的,否则随表增长会退化为全表扫描。

多实例部署下用 ShedLock 保证同一时刻只有一个实例执行扫描:

@Scheduled(fixedDelay = 30_000)
@SchedulerLock(name = "orderTimeoutScan", lockAtMostFor = "55s", lockAtLeastFor = "25s")
public void scan() { /* ... */ }

优缺点:

维度表现
实现成本极低,不引入任何中间件
时间精度差,最大误差 = 轮询间隔(30秒~1分钟)
数据库压力高,随订单量增长持续扫描;与交易争抢连接
洪峰处理差,秒杀到期订单集中在一轮内被批量处理,容易压垮下游
适用场景日订单万级以内的小系统,或作为兜底方案

4. 方案二:Redis Key 过期监听(Keyspace Notifications)

思路:下单时在 Redis 写一个 TTL = 超时时间的 key,key 过期时 Redis 发布过期事件,应用订阅后触发关单。

// 下单成功后设置过期 key
public void afterOrderCreate(Order order) {
    String key = "order:timeout:" + order.getId();
    redisTemplate.opsForValue().set(key, order.getId(),
            Duration.ofMinutes(30));
}

// 订阅过期事件
@Component
@Slf4j
public class OrderExpireListener extends KeyExpirationEventMessageListener {

    public OrderExpireListener(RedisMessageListenerContainer container) {
        super(container);
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String key = new String(message.getBody(), StandardCharsets.UTF_8);
        if (!key.startsWith("order:timeout:")) return;
        Long orderId = Long.valueOf(key.substring("order:timeout:".length()));
        try {
            orderService.cancelTimeoutOrder(orderId);
        } catch (Exception e) {
            log.error("过期监听关单失败 orderId={}", orderId, e);
        }
    }
}

Redis 需要开启配置:notify-keyspace-events Ex(E = 键事件通知,x = 过期事件)。

这个方案我们实测后没有在主链路使用,原因是它有三个硬伤:

  1. 消息不可靠。 Redis 的过期事件采用"发后即忘"模式,订阅方宕机、网络抖动、消息处理时应用重启,事件就永久丢失,没有 ACK、没有重投。订单会永远挂在"待支付"。
  2. 过期触发本身不实时。 Redis 对过期 key 的清理是"惰性删除 + 定期抽样",key 逻辑上过期不等于立刻发事件。大量 key 同时过期时,事件通知会明显延迟和堆积。
  3. 订阅端处理能力受限。 过期事件走 Redis 的 pub/sub 通道,单线程广播,消费慢了会丢消息,也无法水平扩展消费能力。

它可以作为辅助的"准时提醒"信号用(比如快超时给用户推个支付提醒),但不适合作为关单的唯一触发源。

5. 方案三:MQ 延迟消息(生产主方案)

我们最终在主链路上用 RocketMQ 的延迟消息。下单时发一条延迟等级为"30分钟"的消息,消息到期后由消费者执行关单;同时保留低频的 DB 扫描作为兜底。

// 下单事务提交后发延迟消息(事务消息/本地消息表均可保证不丢)
public void sendOrderTimeoutMsg(Order order) {
    Message<String> msg = MessageBuilder.withPayload(
            String.valueOf(order.getId()))
            .setHeader(MessageConst.PROPERTY_DELAY_TIME_LEVEL, "16") // RocketMQ 开源版18个延迟等级,16=30m
            .build();
    rocketMQTemplate.syncSend("order-timeout-topic", msg);
}

// 消费者:到期消费 -> 关单
@RocketMQMessageListener(
        topic = "order-timeout-topic",
        consumerGroup = "order-timeout-cancel-group",
        consumeMode = ConsumeMode.CONCURRENTLY)
@Component
@Slf4j
public class OrderTimeoutConsumer implements RocketMQListener<String> {

    @Override
    public void onMessage(String orderIdStr) {
        Long orderId = Long.valueOf(orderIdStr);
        try {
            orderService.cancelTimeoutOrder(orderId);
        } catch (Exception e) {
            // 抛异常触发 MQ 重试,进入重试队列,最终进死信队列人工介入
            log.error("延迟消息关单异常 orderId={}", orderId, e);
            throw new RuntimeException("cancel failed", e);
        }
    }
}

RocketMQ 开源版延迟等级固定(1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h),30 分钟对应等级 16。如果业务需要任意秒级延迟,可以用 RocketMQ 5.x 的定时消息(基于时间轮)或 RabbitMQ 插件 rabbitmq_delayed_message_exchange

整体架构:

下单成功
  │
  ├──(事务提交后)──> 发延迟消息(orderId, delay=30m)
  │                        │
  │                        ▼
  │                 30分钟后消息投递
  │                        │
  │                        ▼
  │                 消费者集群并发消费
  │                        │
  ▼                        ▼
DB 低频扫描(每5分钟, ShedLock单实例)  ──┐
                                        ├──> 统一关单入口 cancelTimeoutOrder()
Redis过期事件(仅做支付提醒, 不做关单) ──┘        │
                                             ├─ CAS更新 status 0->4 (幂等底座)
                                             ├─ 释放库存(幂等)
                                             └─ 释放优惠券
  ▼
异常消息 -> MQ重试(16次) -> 死信队列 -> 告警 + 人工/对账修复

为什么是"延迟消息为主 + DB 扫描兜底"的双保险:

  • 延迟消息精度高(秒级)、不扫数据库、消费者可水平扩容,天然把秒杀到期的几十万订单摊平成持续消费流,不会形成数据库洪峰;
  • MQ 有重试和死信机制,消费失败不丢;
  • 但 MQ 也不是 100% 不丢(极端情况下消息存储故障、人为误删 topic),所以保留一个每 5 分钟一轮、只扫超时 35 分钟以上的低频扫描任务,把漏网的订单兜住。它扫描频率低、数据量小(正常订单都被 MQ 提前处理了),对库几乎无压力。

6. 几个生产上的关键细节

1. 取消和支付的竞争。 用户可能恰好在第 30 分钟支付成功。此时关单消息和支付回调并发执行,靠的就是 UPDATE ... WHERE status = 0 的行级 CAS:支付成功是 status 0->1,关单是 status 0->4,谁先执行谁成功,后执行的影响行数为 0 直接退出。如果关单成功后支付回调才到,支付侧要做"已关单则自动发起退款"的补偿。

2. 库存释放必须幂等。 释放接口以 orderId 做幂等键(去重表或 Redis SETNX),防止 MQ 重试 + DB 兜底重复释放导致超卖回滚。

3. 时钟问题。 延迟时间以服务端下单时间为准,不要信任客户端传参;超时判断统一用数据库 NOW() 或服务端时钟。

4. 延迟等级与业务配置化。 不同品类超时时间不同(秒杀 5 分钟、普通订单 30 分钟),把延迟等级做成订单类型的配置项,发消息时动态选择。

5. 死信队列要接告警。 关单连续失败通常意味着下游(库存服务/数据库)出了系统性问题,死信队列消息堆积要第一时间告警,不能静默。

7. 三种方案选型结论

方案精度可靠性DB压力扩展性建议
DB定时扫描分钟级小系统主用;大系统降级为兜底
Redis过期监听不保证低(可丢)不建议用于关单,可做提醒
MQ延迟消息秒级高(重试+死信)中大型系统主方案

一句话总结:关单逻辑幂等化(状态机 CAS),触发通道用 MQ 延迟消息保精度和吞吐,用低频 DB 扫描兜底防丢,Redis 过期事件只做提醒不做决策。 这样无论哪个环节出问题,订单都不会"卡"在待支付,库存也不会重复释放。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐