下单后 30 分钟不付款,自动取消怎么实现?

这是面试和实战都绕不开的"订单超时"问题。它不是只有一种解法——从扫库、延迟队列到时间轮,各有取舍。下面把所有主流方案讲清,并说透实践中到底用哪个、以及几个容易踩的坑。

先说清楚它到底是什么

本质是一句话:"在未来的某个时间点,检查某笔订单是否还处在『待支付』状态,若是则把它转为『已取消』"。难点不在"取消"动作,而在"如何让系统在 30 分钟后恰好去检查这一笔"——要给海量订单各自安排一个准时的"定时提醒"。

为什么要取消?释放被锁的库存、回收优惠券/限购资格、归还运力/座位,并避免对账与资损。

一个关键认知(贯穿全文):不要试图"到时间强制取消",而要"到时间去检查一次"。延迟机制只负责"提醒",真正的动作是检查时根据订单最新状态决定。这能天然规避"临界点付款 vs 取消"的并发冲突。

难点四个绕不开的核心挑战

① 海量定时任务的触发

双十一可能每秒上万笔新订单,每笔都要 30 分钟后被检查一次。需要一种高效、低资源地管理"海量定时器"的机制,不能每笔都开一个线程/一个定时任务。

② 可靠性与去重

消息可能丢、可能重复投递;服务可能重启。必须保证:不漏取消(资损)、不重复取消(副作用)。所以取消操作必须幂等。

③ 与"支付成功"的并发竞争

用户在 29 分 59 秒付款,取消任务在 30 分整触发——两者赛跑。处理不当会"钱付了订单却被取消",或"取消后又被支付覆盖"。

④ 精度 vs 成本

业务上 30 分钟是个大致边界,差几秒甚至几分钟通常可接受;但用"扫全库"追求精确会拖垮 DB。要在精度、延迟、成本间权衡。

全景八大方案一张图归位

按"派系"分四组:扫描派、内存派、Redis 派、消息队列/时间轮派。下文可点击逐一展开。

扫描派 · 数据库定时扫描 · 任务表 + Worker 扫描 特征:简单、全量兜底 缺点:有延迟、扫库重 内存派 · JDK DelayQueue · Netty 时间轮(单机) 特征:精度高、零依赖 缺点:重启丢、单机 Redis 派 · ZSet 轮询 · Redisson 延迟队列 · 键过期监听(不推荐) 特征:轻量、分布式 队列/时间轮派 · RabbitMQ 死信/插件 · RocketMQ 延迟消息 · 自研时间轮服务 特征:可靠、大厂首选 无论选哪条路,最后都要靠"定时全量扫描兜底"补漏——这是所有方案的共同保险。

图:八大方案按派系归位。注意"扫描派"既是独立方案,也是所有其他方案的兜底。

详解点方案,看原理与取舍

顺序从"最简单"到"最工业级"。每条都给了原理、优缺点和适用场景。

👈 点上面的方案看看

每个方案给出:核心原理 / 优点 / 缺点 / 适用场景。

对比一张表看清差别

方案精度可靠性实现复杂度分布式适用规模
数据库定时扫描秒~分级(看间隔)高(数据在库)极低天然小~中
JDK DelayQueue毫秒级低(重启丢)低不支持单机小量
Redis 键过期监听不精确低(易丢)低一般不推荐核心
Redis ZSet / Redisson秒级(看轮询)中~高低~中支持小~中
RabbitMQ 死信/延迟插件秒级高中支持中~大
RocketMQ 延迟消息级别级(含30m)高中支持大
自研时间轮延迟服务高高高支持超大/海量

实践关键设计原则(比选型更重要)

① 延迟"检查"而非"执行"

30 分钟到了,不是无条件取消,而是查订单当前状态:仍"待支付"才取消;已"已支付"就跳过。延迟机制只当闹钟,动作由状态决定。

② 取消必须幂等

同一条超时消息可能重复投递、或多方案并发触发。用状态机 + 原子 CAS:待支付 → 已取消 只在当前是待支付时成功,重复执行安全。

③ 定时全量扫描兜底

任何延迟队列都可能丢消息/处理失败。必须有一个兜底 Worker 周期性扫 status=待支付 AND create_time < now-30m,把漏网的取消掉——这是最终一致性的保险。

④ 别去"撤销"已发出的消息

用户付款后想取消那条超时消息?MQ(RocketMQ/RabbitMQ)很难撤回已投递的消息。正确做法:不撤销,靠 ① 的状态校验让它在触发时自动"无害跳过"。支付回调与超时触发谁先到都不怕。

临界点并发的标准解法:订单状态用 CAS 流转 待支付→已支付 / 待支付→已取消。支付成功做"→已支付"的 CAS;超时任务做"→已取消"的 CAS——谁先到谁赢,后到者自然失败,绝不会既付款又被取消。若支付先赢、订单已取消,可提示用户"重新下单/自动恢复"。
顺手回收资源:取消时别只改状态,要同步回滚库存、解锁优惠券/限购、释放运力,最好用同一笔本地事务或可靠事件保证一致,否则会出现"订单没了库存还占着"。

架构推荐落地架构(延迟消息 + 兜底)

工业界最稳的组合:延迟队列做"实时触发" + 定时扫描做"兜底补偿",二者都遵守"检查而非执行"和"幂等"。

① 用户下单 订单服务 ② 注册延迟事件 ③ 延迟队列 MQ / Redis / 时间轮 ④ 超时消费者 取消订单 ⑤ 支付回调 ⑥ 兜底扫描 Worker 创建待支付 ZADD/发消息 30min 后 触发 校验状态 已支付则跳过 兜底:扫 status=待支付 & 超时

图:下单即"注册延迟事件",到点后消费者只做"状态校验 + 幂等取消";支付回调走另一条路做"已支付"CAS;兜底 Worker 周期性补扫,三重保障不漏单。

决策我到底该选哪个?

按你手里的技术栈和规模,点下面的选项看推荐。

一句话总结

方案选型:大厂/已用 MQ → RocketMQ 延迟消息(30 分钟正好有默认级别)或 RabbitMQ 延迟插件;中小团队 → Redis ZSet / Redisson 延迟队列 或简单定时扫库;超大并发 → 自研时间轮延迟服务。

工程纪律(比选型更重要):① 延迟只"检查"不"执行" ② 取消幂等(状态机 + CAS)③ 定时全量扫描兜底 ④ 不撤销消息、靠状态校验化解支付并发 ⑤ 取消时回滚库存/券/运力。

记住:没有"完美单一方案",都是"延迟触发 + 兜底扫描"的组合拳。这套思路也适用于优惠券过期、预约提醒、会话超时等一切"未来某时检查状态"的场景。