比如我们有如下的订单表:
字段名
数据类型
注释说明
id
bigint
订单ID
order_no
varchar(64)
订单编号
user_id
bigint
用户ID
total_amount
decimal(10,2)
订单总金额
pay_status
tinyint
支付状态
order_status
tinyint
订单状态
create_time
datetime
订单创建时间
expire_time
datetime
订单过期时间
coupon_id
bigint
使用的优惠券ID
update_time
datetime
最后更新时间
字段名
数据类型
注释说明
id
order_no
user_id
total_amount
pay_status
order_status
create_time
expire_time
coupon_id
update_time
其中:
pay_status表示支付状态:0-待支付,1-已支付,2-已取消,3-已退款(控制订单支付流程);
order_status表示订单状态:0-待支付(未确认),1-已确认(待发货),2-已发货,3-已完成,4-已关闭(控制订单生命周期)。
我们可以通过如下sql语句找到超时订单:
找到目标订单后,系统会逐一对它们执行关闭逻辑,除了更改订单状态之外,还要完成 后续的资源释放操作,包括把锁定的库存退回库存池、将未使用的优惠券标记为可用,确保这些资源能重新流转给其他用户。
在实际应用中,为了让方案更稳定,还需要做一些优化。比如考虑到订单量增长后,全表扫描会变慢,需要 给订单表的状态和过期时间字段建立联合索引,提升查询效率:
另外,为了防止并发问题,即防止多个定时任务实例同时处理同一批订单,需要加上 分布式锁(可以用 Redis 的 SET NX 命令),确保每个订单只会被一个任务实例处理,避免重复关闭。
不过这种方案也有明显的局限,最主要的就是实时性不足 —— 订单的实际关闭时间会受定时任务间隔影响。
比如设置 5 分钟扫描一次,那最晚可能要等 5 分钟后才会关闭到期订单,期间锁定的库存和优惠券会被占用,影响资源利用率。
而且如果订单量持续增长,频繁的数据库扫描会增加服务器和数据库的压力,这时候可能就需要配合其他方案做补充,比如用定时任务做兜底,搭配延时队列提升实时性。
总体来看,定时任务扫描方案适合 订单量不大、对实时性要求不高,比如允许几分钟延迟的场景,或者作为其他方案的 兜底手段,确保即使中间件出现问题,也能通过定时扫描避免订单漏关。
2
延时消息队列
订单自动关闭这件事,除了定时扫表,还有个更机灵的玩法——延时队列。别觉得这名字听着复杂,牛哥给你掰扯明白,其实就是“ 先把消息存起来,到点再干活”的逻辑,比定时扫表的实时性强多了,尤其订单量大的时候,能少踩很多坑。
先跟大家说清楚,这方案的核心就是:订单刚创建完,先往消息队列里扔个“定时炸弹”——等过了订单有效期(一般15-30分钟),这“炸弹”自己炸了,触发关闭逻辑。
具体分三步走,每一步都有讲究,咱一步一步说。
Step1:订单创建时,顺便埋个定时任务
用户在前端点了提交订单,系统后台得先干正经事: 扣库存(别让别人抢了)、 写订单表(把订单信息存好)——这些核心操作必须先搞定,不能出岔子。
等这些事都弄完了,重点来了:立刻往延时队列里发一条“ 订单到期关闭”的消息。
这里有两个细节得注意,是牛哥踩过坑的总结:
消息里得带啥?别只带个订单号就完事儿了,最好把「订单ID、用户ID、订单创建时间、有效期」都带上——后面校验的时候能少查一次库,省点性能;
延时时间咋设? 就按订单有效期来,比如支付超时时间是15分钟,那消息就设成“15分钟后投递”,别搞复杂了,越简单越不容易出问题。
消息里得带啥?别只带个订单号就完事儿了,最好把「订单ID、用户ID、订单创建时间、有效期」都带上——后面校验的时候能少查一次库,省点性能;
延时时间咋设? 就按订单有效期来,比如支付超时时间是15分钟,那消息就设成“15分钟后投递”,别搞复杂了,越简单越不容易出问题。
像我们常用的RabbitMQ、RocketMQ都支持这功能:RabbitMQ可以用死信队列+TTL(消息存活时间)实现,RocketMQ更直接,直接支持定时消息,填个延时等级就行,不用自己瞎折腾。
Step2:消息队列当保管员,到点再喊你干活
消息发出去之后,就不用我们管了——消息队列会把这条消息锁起来,在设定的延时时间没到之前,谁也拿不到它。
你想啊,要是用定时扫表,不管有没有过期订单,都得每隔几分钟查一次库,纯属浪费资源;但延时队列不一样,它就像个负责的保管员:没到点的消息,安安静静待在队列里, 到点了才会“叫醒”消费端,说“该处理这个订单了”。
这里牛哥多提一句:选消息队列的时候,别光看功能,得看可靠性。比如RabbitMQ要开持久化,RocketMQ要开消息重试——万一队列宕机了,消息别丢了,不然订单没关,库存一直占着,用户又下不了单,麻烦就大了。
Step3:消费端接活,先校验再干活,别做无用功
等消息到点了,消费端就会收到这条“关闭订单”的指令。但别着急执行关闭逻辑,先做一步关键操作:用订单ID查数据库,校验订单当前状态。
为啥要校验?因为用户可能在有效期内已经付钱了!比如用户刚下单5分钟,就把钱付了,那这条延时消息就是“无效消息”,要是不校验直接关订单,那不就搞错了?
所以正确的流程是:
拿消息里的订单ID,查订单表的「支付状态」和「订单状态」;
要是状态还是“待支付”,说明用户没付款,那就执行关闭逻辑——改订单状态为“已关闭”、把锁定的库存加回去、优惠券标为“未使用”;
要是状态已经是“已支付”或者“已关闭”,那就直接忽略这条消息,啥也不用干。
拿消息里的订单ID,查订单表的「支付状态」和「订单状态」;
要是状态还是“待支付”,说明用户没付款,那就执行关闭逻辑——改订单状态为“已关闭”、把锁定的库存加回去、优惠券标为“未使用”;
要是状态已经是“已支付”或者“已关闭”,那就直接忽略这条消息,啥也不用干。
这个延时队列这方案好在哪?又有什么坑?牛哥给你说实在的。
先说好的方面:并发能力是真强。要是你家平台搞大促,每秒几百上千个订单创建,延时队列能轻松扛住——消息队列本身就是干高并发的活儿,比定时扫表反复查库强太多,数据库压力能小一半。
而且实时性也靠谱:设定15分钟过期,消息到点就触发,最多差个几秒钟,不会像定时扫表那样,设5分钟间隔就可能延迟5分钟,库存和优惠券能更快释放,用户体验也更好。
但咱也别光说优点,它的局限性也得提:为了关个订单,得单独搭个消息队列,有点重。要是你家是小平台,每天就几千个订单,用定时扫表足够了,没必要折腾消息队列——又要维护集群,又要处理消息丢失、重试这些问题,增加了运维成本,有点杀鸡用牛刀的感觉。
所以牛哥的建议是:订单量小、运维资源少,选定时扫表;订单量大、追求实时性,或者本身已经在用消息队列,比如做异步下单、物流通知,那直接加个延时队列来关订单,性价比就很高了。
3
Redis时间轮方案
定时消息扫描实时性太差,延时队列又太重,那有没有又轻量实时性又强的方案?聊到这里, 就该Redis时间轮粉墨登场了。
Redis时间轮可以实现订单到期自动关闭,这种方案适合中小规模场景,实现起来相对轻量。
咱不用纠结 “时间轮” 这词多玄乎,其实就是用 Redis 的 Sorted Set,把订单按 “到期时间” 排好队,再用定时任务 “到点捞订单”。核心逻辑就 3 个:
扫订单:开个定时任务,比如每秒跑一次,查 “分数≤当前时间戳” 的订单 ——这些就是到期该关的;
处理订单:把到期订单拎出来,校验状态、关订单,同时从 Sorted Set 里删掉,避免重复处理。
下面我们还是用一个例子展开说明这三步流程是怎么玩的。
step1:订单创建时,往Redis里“插个队”(关键命令示例)
用户下单后,除了扣库存、写订单表,咱多做一步:把订单信息塞进Redis的Sorted Set。
举个实际例子,假设:
订单ID是10086;
订单ID是10086;
执行Redis命令(用Redis-cli举例):
这里还有个潜在优化小细节:别只存订单 ID!最好把 “用户ID、优惠券ID” 也存到 Redis 的 Hash 里,比如键名 “order:info:10086”,后面处理时不用再查库,省时间:
step2:开定时任务,每秒捞到期订单
接下来要开个定时任务,核心就是 “查到期订单→删订单→处理订单”,每一个操作都有坑要避。
为什么要加LIMIT 0 100?万一某秒到期订单特别多,比如大促后集中过期,一次拿太多会卡住,分批次拿更稳。
拿到订单 ID 后,千万别直接处理!先从 Sorted Set 里删掉这个订单 —— 而且必须用 ZREM命令,它是 原子操作,要么删成功,要么没删,不会出现两个线程同时拿到同个订单的情况:
这里是核心避坑点: 只有 ZREM 返回 1,才继续处理订单。如果返回 0,说明这订单已经被别的线程处理过了,直接跳过,避免重复关单。
Step3: 执行关闭
删完订单,就该执行关闭逻辑了,步骤和之前类似,还是要注意先查库校验:
用订单 ID 查数据库,看订单状态是不是 “待支付”—— 如果已经支付或关闭,直接忽略;
要是待支付,就执行关闭:改订单状态为已关闭、把对应的库存加回去、把优惠券标为未使用;
最后别忘了用DEL order:info:10086命令删 Redis 里的订单详情 ,避免占内存。
时间轮方案虽然好用,但是还有几个潜在的坑一定要注意:
坑 1:Redis 重启后,订单数据丢了咋办?
Redis 默认是内存存储,一重启,Sorted Set 里的订单就没了,会导致漏关订单。
解决办法:开 Redis 持久化—— 用 RDB+AOF 混合模式。RDB定时快照,比如每 5 分钟存一次全量;
结合AOF记录每一条写命令,重启时重新执行命令恢复数据。这样就算 Redis 重启,订单数据也能找回来。
坑 2:订单量太大,Sorted Set 卡了咋办?
如果日均订单超 10 万,Sorted Set 里的元素太多,ZRANGEBYSCORE查起来会慢。
定时任务只需要扫 “当前小时桶” 和 “上一小时桶”,防止有延迟的订单,查的范围小了,速度自然快。
坑 3:处理失败了,订单没人管咋办?
如果处理订单时,数据库突然断连,订单没关成,但已经从 Sorted Set 里删了 —— 这就漏了。
解决办法:加兜底扫表—— 每天凌晨开个定时任务,扫数据库里 “创建时间超过有效期、状态还是待支付” 的订单,再做处理。虽然麻烦点,但能保证不丢单,中小电商这样做成本最低。
总体来说,Redis 时间轮是一种平衡了实现复杂度和系统依赖的方案,在中小规模电商场景中比较实用。
它的优势是实现简单,延时精度可以做到秒级,而且Redis的高性能保证了扫描效率。使用时需要考虑Redis的持久化策略,避免重启后数据丢失。
4
三种方案对比
看到这里,相信大家对三种定时触发方案已经了如指掌了。在后端开发中,方案选择能力是一个很核心的人才评估标准,我们不光要知道每个方案是怎么运作的,还需要能总结梳理出这些方案的优缺点、适用场景。
简单来说:
定时扫表:最基础的玩法,不用额外中间件,改改 SQL、加个定时任务就成,但实时性差(延迟几分钟),订单多了会扫表慢;
延时队列(MQ):实时性最好(毫秒 / 秒级)、并发能扛,但得搭 MQ 集群,运维成本高,小订单量用着有点浪费;
Redis 时间轮:中间选手 —— 比定时扫表实时,比 MQ 轻量,复用 Redis 就行,但订单超 10 万级得做分桶优化,可以结合重试兜底定时扫表兜底。
定时扫表:最基础的玩法,不用额外中间件,改改 SQL、加个定时任务就成,但实时性差(延迟几分钟),订单多了会扫表慢;
延时队列(MQ):实时性最好(毫秒 / 秒级)、并发能扛,但得搭 MQ 集群,运维成本高,小订单量用着有点浪费;
Redis 时间轮:中间选手 —— 比定时扫表实时,比 MQ 轻量,复用 Redis 就行,但订单超 10 万级得做分桶优化,可以结合重试兜底定时扫表兜底。
这里牛哥也给大家梳理了一份表格,通过这个表格,我们能更直观清晰全面地进行方案对比:
5
总结
从系统设计的全局视角来看,订单超时关闭只是订单生命周期管理的一个环节,但它的设计思路可以 延伸到很多其他业务场景:用户注册 7 天没激活?自动发提醒;优惠券快到期?提前 2 天催着用。这些场景本质都是到点触发的需求。
咱们选择延时技术也不用死磕高大上,看情况来最实在:初创公司用数据库定时扫,简单又靠谱;中等电商搞个 Redis + 定时任务,性价比拉满;只有业务做到超大体量,再花精力搭复杂的延时队列才值当。
希望这篇文章能帮你彻底掌握过期订单的设计,无论是日常工作中的需求迭代,还是面试时的能力展现,都能成为你技术体系里的加分符♪返回搜狐,查看更多