Skip to content

如何用消息队列实现延迟任务?比如订单 30 分钟未支付自动取消? ​

  • 标签:后端、场景题、消息队列、延迟消息、定时任务

🏛 业务架构师 ​

用消息队列实现延迟任务(订单 30 分钟未支付自动取消),我按方案选型 → 实现要点 → 竞态兜底答,并把"延迟精度需求"作为方案分档的依据——订单取消是分钟级业务,不需要为秒级精度付高成本。

方案一:RocketMQ 延迟消息(业务量级中等,首选)

下单 → 发送延迟消息(延迟级别对应约30分钟)
      → 30分钟后消息投递 → 消费者检查订单状态
      → 仍未支付 → 执行取消(释放库存/优惠券)
  • 原生支持延迟级别(1s~2h 的固定档位,30 分钟正好命中),可靠性高(持久化、重试、死信兜底)
  • 消费逻辑要点:幂等(取消动作可能被重复触发)+ 状态校验(查当前订单状态再决定动作)

方案二:时间轮(海量 + 自定义精度)

  • Kafka 无原生延迟消息,用时间轮(如 Kafka 内部实现、Netty HashedWheelTimer、或 Redis ZSet 轮询方案:score=执行时间,定时任务扫到期任务)
  • Redis ZSet 轻量方案适合任务量十万级以内;任务量千万级、精度要求秒级才上分布式时间轮服务

方案三:定时扫表(小量级兜底,别看不起)

  • 每 2 分钟扫 WHERE status='待支付' AND create_time < now() - 30min
  • 日均订单几万以内,这是最稳的方案:零组件依赖、可查可补偿——延迟消息丢失了还能靠扫表兜底(我实际会两个同时上,消息做主力、扫表做保险)

必须处理的竞态(业务正确性核心)

"取消"触发瞬间用户刚好付款成功:

  • 取消动作用状态机条件更新(UPDATE ... WHERE status='待支付'),支付回调同样条件竞争——谁先抢到谁生效
  • 取消失败(已支付)→ 订单正常履约;取消成功后支付回调到达 → 自动退款
  • 这个竞态处理不好就是资损,延迟消息只是触发器,正确性靠状态机和幂等

收尾:选型口诀——量小扫表、量中延迟消息、量大时间轮,竞态一律状态机 + 幂等 + 对账兜底。

🏗 系统架构师 ​

用 MQ 实现订单 30 分钟未支付自动取消,我按选型对比 → 推荐方案 → 关键细节讲。

1. 方案对比(先给全景)

  • 定时扫表:每分钟扫超时订单。简单可靠,但精度差(分钟级)、全表扫描浪费、量大后拖慢;只适合做兜底
  • JDK DelayQueue:单机内存队列,重启丢失,多实例不共享——pass
  • Redis key 过期监听:过期事件不保证及时且可能丢(Redis 惰性删除、事件发布不可靠)——不可用;改用 Redis ZSet 轮询可行(score=到期时间,扫到期元素),但自建轮询与可靠性要自己造
  • RocketMQ 延迟消息(推荐):原生支持任意时间定时消息(5.x)/18 个延迟级别(4.x,30 分钟正好是 level 16),持久化、可水平扩展、到期精度秒级
  • 时间轮(HashedWheelTimer/Kafka 无原生延迟的场景):自建延迟层,复杂度高

2. RocketMQ 实现(主方案)

text
下单成功:
  发送延迟消息(延迟级别=30min,body=订单号,msgKey=orderId)
30 分钟后消费:
  1) 幂等校验(msgKey 去重)
  2) 状态检查:SELECT status FROM orders WHERE id=?
     - 已支付 → 直接 ACK(用户在窗口内付款)
     - 待支付 → 条件更新关单:
       UPDATE orders SET status=CLOSED
        WHERE id=? AND status=PENDING   ← 影响行数=1 才继续
  3) 关单成功 → 发"库存释放"消息(逆向链路)

3. 关键细节(拉开差距的部分)

  1. 下单与发消息的原子性:用 RocketMQ 事务消息(half 消息 + 本地事务 + 回查),或本地消息表兜底——否则"订单创建成功但消息没发出"导致永不关单
  2. 消费端条件更新防并发:用户 29:59 支付、消息 30:00 到达的竞态,靠 WHERE status=PENDING 的原子更新裁决,关闭失败(已支付)就忽略
  3. 幂等:消息重投、重复消费必须有 bizId 去重
  4. 兜底对账:每小时扫表查"待支付且超时未关"的漏网订单(消息丢失、版本升级丢消息场景),双保险是线上标配
  5. 量级估算:百万订单/天、平均 30 分钟窗口内在途延迟消息约 2 万条,单 MQ 集群毫无压力;扩展性上延迟消息与普通消息一样水平扩展

💻 全栈开发专家 ​

结论先行:首选 MQ 延迟消息(RocketMQ 5.x 支持任意时长),一致性要求高的再加 XXL-JOB 扫表兜底。

方案一:RocketMQ 延迟消息(主方案)

java
// 4.x:18 个固定级别(1s~2h),30 分钟选 level 16(30min)
Message msg = new Message("ORDER_TIMEOUT", "create", orderId.getBytes());
msg.setDeliverTimeMs(System.currentTimeMillis() + 30 * 60 * 1000); // 5.x 任意时长
producer.send(msg);
java
// 消费端:检查状态,幂等取消
consumer.subscribe("ORDER_TIMEOUT", "*");
consumer.registerMessageListener(msgs -> {
    String orderId = parse(msgs);
    Order o = orderMapper.selectById(orderId);
    if (o != null && o.getStatus() == WAIT_PAY) {   // 已支付的直接忽略
        orderService.close(o);                       // 状态机:WAIT_PAY→CLOSED
        mqProducer.send(new StockRestoreMsg(orderId)); // 回补库存,消费端幂等
    }
    return CONSUME_SUCCESS;
});

关键点

  • 幂等是命门:延迟消息可能重复投递(重平衡、重试),取消操作靠状态机保证——WHERE status='WAIT_PAY' 条件更新,影响行数为 0 说明已处理。
  • 4.x 固定级别不精确:要 30 分钟只能选 ≥30min 的级别,对"精确卡 30 分钟"的业务(如抢购锁座)改用 5.x timer wheel(时间轮,任意精度)或下面兜底方案。
  • 回补链路也要幂等:库存回补消息带 orderId,库存流水唯一约束防重复加。

方案二:其他 MQ 的对应做法

  • RabbitMQ:TTL + 死信队列(要每个延迟时长一个队列,否则队头阻塞导致时序错乱,这是经典坑);或 rabbitmq_delayed_message_exchange 插件。
  • Kafka 无原生延迟:用"时间分片轮询"topic(按延迟档位分 topic,消费者到点判断能否执行,不行投递到下一档)。

方案三:兜底与替代

  • XXL-JOB 每分钟扫 status=WAIT_PAY AND create_time < now()-30min 的分片任务,补偿延迟消息丢失的场景——我生产上两个都开,延迟消息做主力、扫表做对账。
  • 小规模(单机、千级)可用 Redis ZSet(score=到期时间)轮询,简单但要自己处理重启恢复和集群分片。

坑总结:延迟消息不保证准时(只保证不早于),业务要容忍分钟级误差;取消和支付的并发要用状态机 + 行锁兜底。