订单超时未支付?从“定时扫库”一步步到最终形态

51 阅读9分钟

订单超时未支付?从“定时扫库”一步步到最终形态

今天想跟大家聊聊一个看似简单、实则坑很多的经典问题:订单超过 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 那天摆烂,这个定时任务也会把你所有的超时订单全部揪出来关掉。就算两个任务同时处理同一个订单,数据状态也能保证只有一个成功。

所以它扮演的角色是“最后一道防线”。真正的大厂,几乎都得备上这么一手。

六、最终形态:组合拳

一个成熟的订单超时系统,从来不是你死我活的选择题,而是互相配合的团队作战。

我给你画一下最终流程图:

  1. 用户下单,订单状态变成待支付,支付截止时间写入订单表。
  2. 订单创建后,立刻给 MQ 发一条延迟消息,延迟时间就是支付截止时间。
  3. MQ 到点投递,消费者查询订单状态。如果还是待支付且已过期,执行关单。
  4. 同时, XXL-Job 分布式分片定时任务每 30 秒或 1 分钟扫描一次数据库,把漏网之鱼全部捞出来。
  5. 无论哪条线先执行,关单都用乐观更新,谁先成功谁说了算,另一个碰了一鼻子灰就自动退出。
  6. 关闭成功后,再通过异步事件释放库存、退回优惠券、发送通知。如果后续操作失败,用本地消息表和重试机制保证最终成功。

延迟消息负责“快、准”,定时扫库负责“稳、全”。一个像特种兵,一个像扫地阿姨。特种兵负责定点清除,扫地阿姨负责把所有角落都扫一遍。这两个角色缺一个都不安心。

七、总结:各方案外号一览

方案江湖外号优点缺点适合场景
单机定时扫库广播体操简单到爆数据库压力大、延迟高日订单几千的小项目
内存延迟队列大脑记事本实时性高内存有限、重启全忘单机、低并发、可容忍丢失
Redis 过期监听门卫大爷看起来很美不准时、会丢失发提醒短信等边缘场景
MQ 延迟消息特快专递持久化、可靠、实时需要伺候中间件、要幂等中型及以上电商
分布式分片扫库扫地阿姨不丢单、可控、能扩展分钟级延迟、实现复杂大型系统兜底
组合拳正规军实时性和可靠性全都要成本高、架构复杂大厂标准配置

技术选型没有绝对的好坏,只有合不合适的场景。你非要在一家小面馆的后厨上部署满汉全席级别的炒菜机器人,那可能连灶台都摆不下。反过来,你要是搞个千万单量的平台,还指着一个 @Scheduled 走天下,那你离故障通告就只差一次大促了。

所以我建议你,从最简单的方案入手,等真的跑到卡脖子的时候,再按本文顺序一步一步升级。记住,架构不是一步到位,而是跟着业务一起长胖的。