小程序电商订单超时自动取消的三种实现方案:定时扫描、Redis 过期监听、延迟消息对比与生产落地
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 < FROM_UNIXTIME(#{expireBefore}/1000)
AND id > #{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 = 过期事件)。
这个方案我们实测后没有在主链路使用,原因是它有三个硬伤:
- 消息不可靠。 Redis 的过期事件采用"发后即忘"模式,订阅方宕机、网络抖动、消息处理时应用重启,事件就永久丢失,没有 ACK、没有重投。订单会永远挂在"待支付"。
- 过期触发本身不实时。 Redis 对过期 key 的清理是"惰性删除 + 定期抽样",key 逻辑上过期不等于立刻发事件。大量 key 同时过期时,事件通知会明显延迟和堆积。
- 订阅端处理能力受限。 过期事件走 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 过期事件只做提醒不做决策。 这样无论哪个环节出问题,订单都不会"卡"在待支付,库存也不会重复释放。
更多推荐




所有评论(0)