这是面试和实战都绕不开的"订单超时"问题。它不是只有一种解法——从扫库、延迟队列到时间轮,各有取舍。下面把所有主流方案讲清,并说透实践中到底用哪个、以及几个容易踩的坑。
本质是一句话:"在未来的某个时间点,检查某笔订单是否还处在『待支付』状态,若是则把它转为『已取消』"。难点不在"取消"动作,而在"如何让系统在 30 分钟后恰好去检查这一笔"——要给海量订单各自安排一个准时的"定时提醒"。
为什么要取消?释放被锁的库存、回收优惠券/限购资格、归还运力/座位,并避免对账与资损。
双十一可能每秒上万笔新订单,每笔都要 30 分钟后被检查一次。需要一种高效、低资源地管理"海量定时器"的机制,不能每笔都开一个线程/一个定时任务。
消息可能丢、可能重复投递;服务可能重启。必须保证:不漏取消(资损)、不重复取消(副作用)。所以取消操作必须幂等。
用户在 29 分 59 秒付款,取消任务在 30 分整触发——两者赛跑。处理不当会"钱付了订单却被取消",或"取消后又被支付覆盖"。
业务上 30 分钟是个大致边界,差几秒甚至几分钟通常可接受;但用"扫全库"追求精确会拖垮 DB。要在精度、延迟、成本间权衡。
按"派系"分四组:扫描派、内存派、Redis 派、消息队列/时间轮派。下文可点击逐一展开。
图:八大方案按派系归位。注意"扫描派"既是独立方案,也是所有其他方案的兜底。
顺序从"最简单"到"最工业级"。每条都给了原理、优缺点和适用场景。
每个方案给出:核心原理 / 优点 / 缺点 / 适用场景。
| 方案 | 精度 | 可靠性 | 实现复杂度 | 分布式 | 适用规模 |
|---|---|---|---|---|---|
| 数据库定时扫描 | 秒~分级(看间隔) | 高(数据在库) | 极低 | 天然 | 小~中 |
| JDK DelayQueue | 毫秒级 | 低(重启丢) | 低 | 不支持 | 单机小量 |
| Redis 键过期监听 | 不精确 | 低(易丢) | 低 | 一般 | 不推荐核心 |
| Redis ZSet / Redisson | 秒级(看轮询) | 中~高 | 低~中 | 支持 | 小~中 |
| RabbitMQ 死信/延迟插件 | 秒级 | 高 | 中 | 支持 | 中~大 |
| RocketMQ 延迟消息 | 级别级(含30m) | 高 | 中 | 支持 | 大 |
| 自研时间轮延迟服务 | 高 | 高 | 高 | 支持 | 超大/海量 |
30 分钟到了,不是无条件取消,而是查订单当前状态:仍"待支付"才取消;已"已支付"就跳过。延迟机制只当闹钟,动作由状态决定。
同一条超时消息可能重复投递、或多方案并发触发。用状态机 + 原子 CAS:待支付 → 已取消 只在当前是待支付时成功,重复执行安全。
任何延迟队列都可能丢消息/处理失败。必须有一个兜底 Worker 周期性扫 status=待支付 AND create_time < now-30m,把漏网的取消掉——这是最终一致性的保险。
用户付款后想取消那条超时消息?MQ(RocketMQ/RabbitMQ)很难撤回已投递的消息。正确做法:不撤销,靠 ① 的状态校验让它在触发时自动"无害跳过"。支付回调与超时触发谁先到都不怕。
工业界最稳的组合:延迟队列做"实时触发" + 定时扫描做"兜底补偿",二者都遵守"检查而非执行"和"幂等"。
图:下单即"注册延迟事件",到点后消费者只做"状态校验 + 幂等取消";支付回调走另一条路做"已支付"CAS;兜底 Worker 周期性补扫,三重保障不漏单。
按你手里的技术栈和规模,点下面的选项看推荐。
方案选型:大厂/已用 MQ → RocketMQ 延迟消息(30 分钟正好有默认级别)或 RabbitMQ 延迟插件;中小团队 → Redis ZSet / Redisson 延迟队列 或简单定时扫库;超大并发 → 自研时间轮延迟服务。
工程纪律(比选型更重要):① 延迟只"检查"不"执行" ② 取消幂等(状态机 + CAS)③ 定时全量扫描兜底 ④ 不撤销消息、靠状态校验化解支付并发 ⑤ 取消时回滚库存/券/运力。
记住:没有"完美单一方案",都是"延迟触发 + 兜底扫描"的组合拳。这套思路也适用于优惠券过期、预约提醒、会话超时等一切"未来某时检查状态"的场景。