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

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

今天想跟大家聊聊一个看似简单、实则坑很多的经典问题:订单超过 30 分钟没付款,系统怎么自动关闭它?

这个问题,说白了就是:怎么优雅地"催债"。你出了一个商品,用户拍了,结果钱没付,你还不能一直占着库存。你得给人留一点"等你付款"的时间,时间一到,就得把订单关掉,把库存释放出来,把优惠券送回去,最好再偷偷骂一句:爱买不买。

但怎么实现这个"到点自动关门",就分三六九等了。我从单机到分布式,从土办法到正规军,一步一个坑踩过来,今天全部分享给你。

一、最原始方案:定时任务扫库

想象一下你开了一个小卖部,没有系统,你怎么知道谁没付钱?你只能每隔几分钟走到货架旁边,挨个翻订单本子,看看谁的付款时间过了。对,这就是数据库定时扫描。

代码也很简单:

scss 复制代码
@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,专门干这个。

订单创建之后,往队列里塞一个延迟任务,时间到了,消费者线程就拿走执行关闭。类似你给自己定了一万个闹钟,但每个闹钟只在响的时候才占用你的注意力。

ini 复制代码
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 + 死信队列"。给消息设置一个过期时间,消息先躺在延迟队列里装死,等过期了,被丢到死信交换机,再由死信队列的消费者吃掉,执行关单。

arduino 复制代码
MessageProperties props = new MessageProperties();
props.setExpiration(String.valueOf(timeout));
rabbitTemplate.send("DELAY_EXCHANGE", "order", new Message(orderId, props));

不过要提醒你,RabbitMQ 的老式延迟队列有个"队头阻塞"的坑:如果队列里第一条消息 TTL 很长,后面的消息 TTL 很短,后面的消息也得等前面的大爷出了队列才能轮到。要解决这个问题,最好用 RabbitMQ 官方延迟插件。

RocketMQ 的姿势

RocketMQ 原生支持延迟消息,直接设一个延迟等级就行:

ini 复制代码
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 台机器同时扫,每台机器只扫十分之一的数据。

scss 复制代码
@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());
    }
}

配合上条件更新,保证不会关到已经支付的订单:

ini 复制代码
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 走天下,那你离故障通告就只差一次大促了。

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

相关推荐
需要8261 小时前
MySQL MVCC 与事务隔离级别:从一条 update 看版本链
java·数据库·spring boot·mysql·spring cloud
卷无止境2 小时前
便宜模型和贵模型到底差在哪儿?一份关于Claude与GPT分级体系的深度梳理
后端·python
hai_android2 小时前
LruCache 图片浏览器内存缓存
android·java·kotlin
玩美移动2 小时前
AI Skin Analysis API 技术解析:文件上传、异步任务与结果读取
java·人工智能·python
我是小白呀2 小时前
19-Temporal项目实战:将客户开通流程迁移到持久执行架构
java·开发语言·人工智能·架构·workflow
步行cgn2 小时前
Spring 负责注入的注解详解
java·sql·spring
摇滚侠2 小时前
《On Java 中文版 基础卷》阅读笔记 安装 Java 和本书示例 02
java·开发语言·笔记
阿狗童鞋2 小时前
SpringBoot微服务实战指南
spring boot·后端·微服务
FfHUCisI2 小时前
sync.Once 与 sync.Cond 源码与并发控制陷阱
服务器·开发语言·后端·golang