"下单后 30 分钟未支付自动取消"写进 PRD 只要一行,落地却要回答四个问题:判定精确到秒还是分钟、取消后是否回补库存、超时后用户还能不能付款、取消与支付同时到达谁赢。这四个问题定不下来,选哪种实现都是错。
下面把六种做法摊开,逐一给出机制、精度、可靠性、成本,最后给一张按量级选型的判据表。
1. 先把业务语义钉死
| 问题 | 选项 | 对实现的影响 |
|---|---|---|
| 判定精度 | 秒级 / 分钟级 / 小时级 | 决定能否用扫表 |
| 超时后能否支付 | 可以 / 不可以 | 决定是否需要"关单"这一独立状态 |
| 库存处理 | 下单即扣 / 支付才扣 | 决定取消时是否需要回补 |
| 优惠券处理 | 占用 / 未占用 | 同上,且回补要幂等 |
| 通知 | 需要 / 不需要 | 需要则多一个下游依赖 |
第二行最容易被漏掉。若"超时后仍可支付",就不能简单置为已取消,而要设计成已取消但可唤起支付、成功后恢复状态的流程,或干脆不关单只提醒。这一条决定后面一半的复杂度。
2. 六种实现的总览
| 方案 | 机制 | 精度 | 可靠性 | 成本 |
|---|---|---|---|---|
| 定时任务扫表 | 周期性 SELECT ... WHERE status=0 AND expire_at<=NOW() |
取决于调度间隔,分钟级 | 高(数据在库,重启不丢) | 低,但扫描压力随数据增长 |
| 延迟队列(MQ 延迟消息) | 消息在 Broker 延迟投递,到期消费 | 受延迟等级/定时精度限制 | 中高(需处理消息丢失) | 中,需要 MQ |
| Redis 过期监听 | 订阅 __keyevent@0__:expired |
不保证 | 低(事件可能丢) | 低 |
| 时间轮 | 内存环形槽,按 tick 触发 | 毫秒级 | 低(单机,重启丢) | 低 |
| 数据库定时事件 | MySQL EVENT 周期执行 |
最小 1 秒 | 中(主从/单点问题) | 低,但难治理 |
应用内 ScheduledExecutor |
线程池 + DelayQueue |
毫秒级 | 低(重启丢、多实例重复) | 最低 |
"可靠性"一列是关键差异:数据在库里最安全,时间在内存里最快但最脆。单机内存方案在多实例下会变成"每实例执行一次",把压力转给了取消动作的幂等性。
3. 方案一:定时任务扫表
-- 必须有 (status, expire_at) 联合索引,否则每分钟一次全表扫描
SELECT id FROM t_order
WHERE status = 0
AND expire_at <= NOW()
AND id % 8 = ? -- 分片参数,8 个实例各扫一片
ORDER BY expire_at
LIMIT 200;
@Component
public class OrderExpireScanner {
@Scheduled(fixedDelay = 30_000) // 上一轮结束后 30 秒再跑,避免堆积
public void scan() {
int shard = ShardHolder.current(); // 0..7
List<Long> ids = orderMapper.selectExpiredIds(shard, 200);
for (Long id : ids) {
try {
cancelService.cancelIfUnpaid(id, "TIMEOUT"); // 内部幂等
} catch (Exception e) {
log.warn("cancel failed, id={}", id, e); // 单条失败不影响整批
}
}
}
}
三个固有问题:
| 问题 | 表现 | 缓解 |
|---|---|---|
| 扫描压力 | 表越大,单次扫描越贵 | 联合索引 + LIMIT + 分片键 |
| 时间窗精度 | 30 秒调度 → 实际取消时间在 [30, 60) 分钟之间漂移 | 接受漂移,或用更短间隔 + 更小批量 |
| 任务重复执行 | 多实例同时跑、调度框架重试 | 分片参数 + cancelIfUnpaid 幂等 |
第三个最危险:@Scheduled 在多实例下每个实例都会执行,不分片就是 8 倍压力。ShedLock 或分片参数都能解决,后者更均匀。
精度要写进 PRD:"30 分钟"实际是"30 到 30.5 分钟之间",多数业务可接受,秒杀类不可接受。
4. 方案二:MQ 延迟消息
// RocketMQ:下单成功后投递一条延迟消息(延迟等级按具体版本核对,5.0 起支持任意时间)
Message msg = new Message("order-delay", orderId.toString().getBytes(StandardCharsets.UTF_8));
msg.setDelayTimeLevel(16); // 对应某个固定延迟档位
producer.send(msg);
@RocketMQMessageListener(topic = "order-delay", consumerGroup = "order-expire")
public class OrderDelayConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String orderId) {
cancelService.cancelIfUnpaid(Long.valueOf(orderId), "TIMEOUT");
}
}
可靠性取决于两件事:下单与投递是否原子、消息丢了怎么办。前者用事务消息或本地消息表,后者必须有兜底扫描——即使消息全丢,扫表也能把订单捞出来。省掉兜底,MQ 抖一次就有一批订单永久停在待支付。
RocketMQ 4.x 的延迟等级是固定档位,要"30 分钟"只能选最接近的一档,精度由档位决定;5.0 的定时消息支持指定时间戳。按自己的 Broker 版本核对档位表。
5. 方案三与四:Redis 过期监听、时间轮
notify-keyspace-events "Ex" # redis.conf,E=keyevent,x=expired
在 __keyevent@0__:expired 上挂监听器就能拿到过期通知,但这条链路不可靠:过期走惰性删除 + 定期抽样,key 到期后可能很久才被真正删除;事件走 pub/sub 无持久化,订阅端断线期间的直接消失;主从切换时可能不投递。只能做提醒,不能当关单主链路。
// Netty 时间轮:单机内存,重启即丢
HashedWheelTimer timer = new HashedWheelTimer(100, TimeUnit.MILLISECONDS, 512);
timer.newTimeout(t -> cancelService.cancelIfUnpaid(orderId, "TIMEOUT"),
30, TimeUnit.MINUTES);
时间轮的优势是单机内海量定时任务调度开销很低,代价是任务只在内存:重启丢失、多实例重复、无法跨机迁移,三者都靠扫表兜底补齐。
6. 方案五与六:数据库事件、应用内调度
-- MySQL EVENT:需要 event_scheduler=ON,且只在主库执行
CREATE EVENT ev_cancel_expired_order
ON SCHEDULE EVERY 1 MINUTE
DO
UPDATE t_order
SET status = 20, cancel_reason = 'TIMEOUT', cancel_time = NOW()
WHERE status = 0 AND expire_at <= NOW()
LIMIT 500;
数据库事件有三个限制:event_scheduler 默认关闭;主从架构下只在主库生效,切换后要重新确认;批量 UPDATE 持锁,LIMIT 太小追不上、太大锁表。
应用内 DelayQueue 与 ScheduledExecutorService 同类:进程内、重启丢、多实例重复,仅在单体单实例时可用。
7. 取消动作的幂等与并发
用户在第 29 分 59 秒点支付、任务在第 30 分整触发取消,两者必须只有一个赢。"先查再更新"是错的:查与更新之间有窗口,两个线程都能通过。正确做法是把判定压进 UPDATE 的条件,用影响行数决定胜负:
-- 取消:只有仍是待支付才生效
UPDATE t_order
SET status = 20, cancel_time = NOW(), cancel_reason = 'TIMEOUT'
WHERE id = ? AND status = 0 AND pay_time IS NULL;
-- affectedRows = 0 → 说明已被支付或已被取消,直接返回成功
-- 支付回调:同样带状态条件,与取消争抢同一行
UPDATE t_order
SET status = 10, pay_time = NOW()
WHERE id = ? AND status = 0;
-- affectedRows = 0 → 已取消,进入"超时后支付"的退款或补单流程
状态机本身要定清楚:
| 当前状态 | 事件 | 下一状态 | 条件 |
|---|---|---|---|
| 待支付(0) | 支付成功 | 已支付(10) | status=0 |
| 待支付(0) | 超时 | 已取消(20) | status=0 且 expire_at<=NOW() |
| 已取消(20) | 关单归档 | 已关闭(30) | status=20 且超过保留期 |
| 已支付(10) | 退款完成 | 已关闭(30) | status=10 |
| 已取消(20) | 支付成功(迟到) | 待人工处理 | 记录冲突,走补单 |
最后一行真实存在:支付网关回调可能晚于取消到达。订单已取消钱却收了,必须有"迟到支付"处理(退款或补单),否则就是资损。
库存回补同样要幂等:写一张 t_stock_restore_log (order_id, sku_id, qty) 并在 (order_id, sku_id) 上建唯一索引,重复回补时抛 DuplicateKeyException,捕获后视为成功。
8. 选型判据
| 日订单量 | 精度 | 推荐方案 | 理由 |
|---|---|---|---|
| 万级以下 | 分钟级 | 扫表(单实例 + 索引) | 最简,重启不丢,够用 |
| 十万级 | 分钟级 | 扫表分片 或 MQ 延迟 + 扫表兜底 | 单机扫不动,需要分摊 |
| 百万级以上 | 秒级 | MQ 延迟消息 + 扫表兜底 | 精度与吞吐都要 |
| 任意量级 | 秒级且强一致 | 延迟消息 + 状态机条件更新 | 一致性靠 DB 保证 |
| 仅需提醒、不关单 | 不敏感 | Redis 过期监听 | 丢了只是少条提醒 |
通用规则:任何方案都要配一个低频扫表兜底。延迟消息会丢、时间轮会重启、Redis 事件会掉,扫表不会。兜底频率可以很低。
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 订单大量停在待支付 | 延迟消息丢失且无兜底 | 补扫表兜底,检查投递与下单是否原子 |
| 同一订单被取消两次 | 取消无幂等,多实例重复执行 | 条件更新 + 回补日志唯一索引 |
| 取消后库存被回补两次 | 回补未做幂等 | 唯一索引兜底,或状态机前置校验 |
| 扫表任务把库打满 | 缺 (status, expire_at) 索引 |
补索引 + LIMIT + 分片 |
| 用户付了钱订单却是已取消 | 取消与支付回调竞态未处理 | 条件更新 + 迟到支付流程 |
| Redis 过期事件收不到 | notify-keyspace-events 未开或订阅断线 |
不作为主链路,改扫表 |
10. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 超时后仍允许支付 | 不关单,只发提醒 | 关单会制造迟到支付冲突 |
| 库存下单即扣 | 必须有回补且幂等 | 不回补就是超卖 |
| 库存支付才扣 | 取消无需回补 | 复杂度大幅下降 |
| 多实例部署 | 禁止纯内存方案 | 会重复执行 |
| 需要精确到秒 | 扫表不适用 | 调度间隔决定精度上限 |
| 已有成熟 MQ | 优先延迟消息 + 兜底扫表 | 复用基础设施,少一套组件 |
一句话:精度靠调度,可靠性靠数据库,一致性靠条件更新。三者分开考虑,选型就不会被"哪个方案更先进"带偏。
11. 自测
- "超时后仍可支付"这个业务要求,为什么会改变状态机设计?
- 扫表方案里,
LIMIT 200和id % 8分别解决什么问题? - Redis 过期事件为什么不能作为关单主链路?
UPDATE ... WHERE id=? AND status=0的影响行数为 0,代表哪两种可能?- 延迟消息方案为什么还要配一个低频扫表?
- 支付回调迟到时,为什么不能简单把订单改回已支付?