1. 业务场景与需求拆解
1.1 这个需求到底在解决什么问题
先把这个场景说透。用户在电商平台下单,订单状态是"待支付",平台通常会给一个支付时限,比如15分钟、30分钟,超过这个时间订单自动取消,同时把冻结的库存释放掉、把锁定的优惠券还回去。这个动作如果靠人工操作根本不现实——你得半夜爬起来关订单。所以必须由系统自动完成,这就是"自动关闭订单"这个需求的由来。
这个场景题在面试里出现频率非常高,因为它考察的不只是某个具体技术,而是对"可靠性、实时性、幂等性、一致性"的综合理解。在真实业务里,它直接关系到两个核心指标:一个是用户体验,一个是库存周转效率。订单超时了还不关,库存一直被占着,热门商品可能就没法卖给别人了;但如果系统误关了用户正在支付的订单,那又是一次事故级的用户体验问题。
所以设计这套系统的时候,不能只想着"怎么定时触发",要看清楚背后这几个硬性要求:
- 关闭动作要尽量准时,偏差不能过大。用户等了29分钟还在支付页,结果第30分钟订单被关了,体验极差。
- 关闭动作必须可靠,不能因为服务重启、消息丢失就漏掉一批订单。
- 同一个订单不能被重复关闭,关闭动作要幂等。
- 关闭订单和释放库存必须最终一致,不能出现"订单关了但库存没回来"或者反过来的情况。
这四个点,我会在后面每个方案里都对照着讲。
1.2 为什么不能靠简单的"睡30分钟再执行"
很多人第一反应是:我在程序里Thread.sleep(30分钟)再执行不就行了?这个思路在极端简单的场景勉强能跑,但只要订单量大一点、服务一重启,所有未执行的sleep就全没了,没有任何持久化,属于玩具方案。还有其他看似简单但同样不靠谱的做法,比如前端倒计时结束让用户手动刷新触发关单,这也不行——用户不刷新,订单就永远不关。
这里要先明确一个原则:凡是依赖客户端行为、依赖进程存活状态的方案,都只能做辅助,不能做主力。真正可靠的自动关单,核心思路只有两个方向:一个是"定期扫描",一个是"延迟触发"。下面这两种方向会衍生出很多变体,我一个个拆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主流方案全景对比:五种常见实现
2.1 定时任务扫表:最朴素也最兜底
第一种方案,也是最容易想到的:起一个定时任务,每隔一段时间(比如每分钟),去数据库里扫一遍"待支付且创建时间超过30分钟"的订单,批量改成已关闭状态。
实现上很简单,一个@Scheduled注解就能搞定,SQL大概是:
sql复制SELECT id FROM orders
WHERE status = 0
AND create_time <= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
LIMIT 500;
然后对这500个订单逐个执行关单流程。注意这里一定要limit,并且按id或create_time排序,不然大促期间几百万待支付订单全扫出来,内存和数据库压力都扛不住。
这个方案的优点就是简单、可靠,没有额外中间件依赖,只要数据库在,扫描就一直能跑。缺点是时间精度差——如果每分钟扫一次,最坏情况用户多等将近一分钟;而且如果订单表数据量巨大,这种扫表SQL即使加了索引,高频执行对数据库压力也不小。另外它天然是"批处理"模式,冲击数据库的次数跟订单规模强相关。
所以我的定位是:扫表方案通常不作为唯一的触发手段,但一定要作为"兜底"手段存在。哪怕上游延迟消息全挂了,这个兜底扫描能保证最终订单一定会被关,只是时间上晚一点。这种"准时触发为主 + 兜底扫描为辅"的组合,是我在实践中认为最稳的架构。
2.2 RabbitMQ TTL + 死信队列:经典的延迟消息
第二种方案,利用RabbitMQ的消息存活时间(TTL)和死信队列(DLX)机制。下单时把订单消息发到一个专门的延迟队列,设置消息TTL为30分钟,消息到期后不会被消费,而是被投递到绑定的死信交换机,然后路由到死信队列,由关单消费者监听死信队列来触发关单。
大概结构是:
plaintext复制订单服务 -> 延迟队列(queue.order.delay, x-message-ttl=1800000)
-> 消息到期 -> 死信交换机(exchange.order.dlx) -> 死信队列(queue.order.close)
-> 关单消费者
RabbitMQ的TTL有两种设置方式,一种是队列级别的x-message-ttl参数,队列里所有消息统一过期时间;一种是消息级别的expiration属性,逐条设置。这里有个坑我必须提醒:如果同一个队列里既有15分钟的消息又有30分钟的消息,RabbitMQ的死信判断是按队列头部消息的过期时间来的,队头消息不过期,后面的消息即使到了时间也不会被投递到死信队列,会造成"队头阻塞"。所以业务上如果超时时间有多种,一定要按超时时长拆多个队列,每个队列一个TTL,别混在一起。
这个方案的好处是消息有持久化机制,RabbitMQ重启后消息不会丢,可靠性较高,吞吐量也不错。缺点是需要引入RabbitMQ,而且TTL到期的投递不是精确到秒的,实测会有一定延迟;另外如果用的是云厂商版
