订单超时未支付?从“定时扫库”一步步到最终形态
今天想跟大家聊聊一个看似简单、实则坑很多的经典问题:订单超过 30 分钟没付款,系统怎么自动关闭它?
这个问题,说白了就是:怎么优雅地“催债”。你出了一个商品,用户拍了,结果钱没付,你还不能一直占着库存。你得给人留一点“等你付款”的时间,时间一到,就得把订单关掉,把库存释放出来,把优惠券送回去,最好再偷偷骂一句:爱买不买。
但怎么实现这个“到点自动关门”,就分三六九等了。我从单机到分布式,从土办法到正规军,一步一个坑踩过来,今天全部分享给你。
一、最原始方案:定时任务扫库
想象一下你开了一个小卖部,没有系统,你怎么知道谁没付钱?你只能每隔几分钟走到货架旁边,挨个翻订单本子,看看谁的付款时间过了。对,这就是数据库定时扫描。
代码也很简单:
@Scheduled(fixedDelay = 30000)
public void closeTimeoutOrders() {
// 把超时未支付的订单查出来
List<Order> expiredOrders = orderMapper.selectPayingExpiredOrders(new Date());
for (Order order : expiredOrders) {
closeOrder(order.getId());
}
}
每半分钟扫描一次订单表,找到状态是“待支付”而且过期时间小于当前时间的订单,批量关掉。
好处就是:简单,真简单。一个定时任务,一条 SQL,搞定。
坏处呢?订单量小的时候啥事没有,订单量一大,你的数据库就开始唉声叹气。你想想,每 30 秒全表或者大半张表扫一次,这哪是定时任务,这分明是让数据库每天做 2880 次仰卧起坐。而且你要是多部署了两台机器,还得小心重复执行。你关单,他也关单,一个订单被关两次,后台还相互打架。
所以这个方案只适合日订单千把人、能容忍分钟级延迟的小项目。超过这个量级,建议趁早换。
二、稍微高级点:内存延迟队列
后来订单越来越多了,我不想让数据库老这么累。于是我想,能不能把每个订单的“关门时间”记在脑子里,到点自动执行?JDK 里正好有个 DelayQueue,专门干这个。
订单创建之后,往队列里塞一个延迟任务,时间到了,消费者线程就拿走执行关闭。类似你给自己定了一万个闹钟,但每个闹钟只在响的时候才占用你的注意力。
OrderDelayTask task = new OrderDelayTask(orderId, expireTime);
delayQueue.add(task);
这个方案从“轮询”变成了“精准触发”,实时性很高,基本到点就关。
但是坑也来了:这个队列是存在 JVM 内存里的。你的机器内存就那么大,订单量一大,内存直接给你撑爆。更可怕的是,只要服务一重启,队列里的任务全清零,几十万个本该关闭的订单瞬间变成了“永不超时”。你要是忘了补库存,用户拍下的货就一直被锁着,别的买家想买都买不了。
所以内存延迟队列只适合单机、低并发、并且允许重启后手动补救的场景。想靠它扛大流量,你就是拿自己的内存开玩笑。
三、再搞个花活:Redis 过期监听
后来我又听说了 Redis 有个“键过期通知”功能。诶,这不是量身定做吗?订单创建的时候,往 Redis 里放一个 key,过期时间设置成支付截止时间。等 key 到期了,Redis 自动发个消息给你,说你那单过期了,快去关门。
思路很美,像请了一个门卫大爷帮你盯着时间,到点了喊你一声。
结果用起来才发现,这门卫大爷有三个毛病。
第一,他不保证准时。Redis 的过期通知是靠后台定时检测发现的,不是精确到毫秒触发。你的订单可能明明 10:00 过期,他 10:05 才想起来喊你。
第二,他会旷工。如果你的服务宕机了,Redis 照样过期,但你在睡觉,没人接收通知,这批消息就永远错过了。
第三,他只喊一声,没人应答他也不会重新喊。你想让他重试?人家不干。
所以这个方案,玩玩可以,用在交易核心链路等于裸奔。偶尔拿来发个“您的订单即将超时”的提醒短信,倒是勉强能接受。
四、终于像个正经系统:消息中间件延迟消息
这时候我开始认真了。既然 RabbitMQ、RocketMQ 这么常见,能不能把“订单超时”做成一条延迟消息?订单创建后,不是立刻发给消费者,而是让消息中间件“压一会儿”,等延迟时间到了,再发给消费者。
这就好像你写一封信,特意跟快递员说:这封信你 30 分钟后再送出去。只要快递员靠谱,他就能准时送达。
RabbitMQ 的姿势
传统方案是用“TTL + 死信队列”。给消息设置一个过期时间,消息先躺在延迟队列里装死,等过期了,被丢到死信交换机,再由死信队列的消费者吃掉,执行关单。
MessageProperties props = new MessageProperties();
props.setExpiration(String.valueOf(timeout));
rabbitTemplate.send("DELAY_EXCHANGE", "order", new Message(orderId, props));
不过要提醒你,RabbitMQ 的老式延迟队列有个“队头阻塞”的坑:如果队列里第一条消息 TTL 很长,后面的消息 TTL 很短,后面的消息也得等前面的大爷出了队列才能轮到。要解决这个问题,最好用 RabbitMQ 官方延迟插件。
RocketMQ 的姿势
RocketMQ 原生支持延迟消息,直接设一个延迟等级就行:
Message msg = new Message("ORDER_TOPIC", orderIdBytes);
msg.setDelayTimeLevel(16); // 16级对应30分钟
producer.send(msg);
注意,RocketMQ 旧版本的延迟时间只有几个固定档位,比如 1s、5s、10s、30s、1m、2m……如果没有恰好 30 分钟这个档位,你就得自己想办法拼一下。RocketMQ 5.x 已经支持任意时间定时消息,不过也要看部署环境。
消息中间件方案的好处很明显:消息是持久化的,服务重启不丢消息。消费者挂了还可以重试。实时性也高,基本就是秒级。坏处呢?你得会伺候消息中间件,还得做好幂等处理。因为消息可能重复投递,消费者处理前一定要先查订单状态,发现已经支付或者已经关闭就直接丢弃,别傻乎乎再执行一遍。
这个方案已经适合很多中型电商项目了。不过,仅仅依赖消息还是不够的。万一 MQ 崩了,或者消费者代码有 bug,那批到期订单谁来管?这时候就需要第五个方案。
五、终极兜底:分布式分片扫库
不管你用了多高级的延迟消息,我都要建议你保留一个“地毯式轰炸”方案,也就是定时扫库的进化版:分布式分片扫库。
你可以用 XXL-Job 或 ElasticJob,把订单表拆成多个分片,每个调度节点只扫自己负责的那一部分。
比如订单表按 ID 取模,分 10 片,10 台机器同时扫,每台机器只扫十分之一的数据。
@XxlJob("orderTimeoutScan")
public void scanTimeoutOrders() {
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
// 每次扫描500条,游标式遍历
List<Order> expiredList = orderMapper.selectExpired(
lastId, shardIndex, shardTotal, new Date(), 500);
for (Order order : expiredList) {
closeOrderSafely(order.getId());
}
}
配合上条件更新,保证不会关到已经支付的订单:
UPDATE `order`
SET status = 'CLOSED'
WHERE id = #{orderId}
AND status = 'PAYING'
AND expire_time < NOW()
更新行数为 0 说明订单已经不在待支付状态,直接放手。
这个方案实时性一般,扫描间隔你不敢设太短,不然数据库又要找你谈话。但它有一个巨大优势:不丢单。就算 MQ 那天摆烂,这个定时任务也会把你所有的超时订单全部揪出来关掉。就算两个任务同时处理同一个订单,数据状态也能保证只有一个成功。
所以它扮演的角色是“最后一道防线”。真正的大厂,几乎都得备上这么一手。
六、最终形态:组合拳
一个成熟的订单超时系统,从来不是你死我活的选择题,而是互相配合的团队作战。
我给你画一下最终流程图:
- 用户下单,订单状态变成待支付,支付截止时间写入订单表。
- 订单创建后,立刻给 MQ 发一条延迟消息,延迟时间就是支付截止时间。
- MQ 到点投递,消费者查询订单状态。如果还是待支付且已过期,执行关单。
- 同时, XXL-Job 分布式分片定时任务每 30 秒或 1 分钟扫描一次数据库,把漏网之鱼全部捞出来。
- 无论哪条线先执行,关单都用乐观更新,谁先成功谁说了算,另一个碰了一鼻子灰就自动退出。
- 关闭成功后,再通过异步事件释放库存、退回优惠券、发送通知。如果后续操作失败,用本地消息表和重试机制保证最终成功。
延迟消息负责“快、准”,定时扫库负责“稳、全”。一个像特种兵,一个像扫地阿姨。特种兵负责定点清除,扫地阿姨负责把所有角落都扫一遍。这两个角色缺一个都不安心。
七、总结:各方案外号一览
| 方案 | 江湖外号 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 单机定时扫库 | 广播体操 | 简单到爆 | 数据库压力大、延迟高 | 日订单几千的小项目 |
| 内存延迟队列 | 大脑记事本 | 实时性高 | 内存有限、重启全忘 | 单机、低并发、可容忍丢失 |
| Redis 过期监听 | 门卫大爷 | 看起来很美 | 不准时、会丢失 | 发提醒短信等边缘场景 |
| MQ 延迟消息 | 特快专递 | 持久化、可靠、实时 | 需要伺候中间件、要幂等 | 中型及以上电商 |
| 分布式分片扫库 | 扫地阿姨 | 不丢单、可控、能扩展 | 分钟级延迟、实现复杂 | 大型系统兜底 |
| 组合拳 | 正规军 | 实时性和可靠性全都要 | 成本高、架构复杂 | 大厂标准配置 |
技术选型没有绝对的好坏,只有合不合适的场景。你非要在一家小面馆的后厨上部署满汉全席级别的炒菜机器人,那可能连灶台都摆不下。反过来,你要是搞个千万单量的平台,还指着一个 @Scheduled 走天下,那你离故障通告就只差一次大促了。
所以我建议你,从最简单的方案入手,等真的跑到卡脖子的时候,再按本文顺序一步一步升级。记住,架构不是一步到位,而是跟着业务一起长胖的。