4.电商系统中,用户创建订单之后不支付怎么办?
4.电商系统中,用户创建订单之后不支付怎么办?
做电商的兄弟肯定都遇过这糟心事儿:用户咔咔点了提交订单,库存也扣了、优惠券也锁了,结果扭头就忘了支付 —— 订单挂那儿占着资源,别的用户想买买不了,咱后台还得琢磨咋处理这些 “僵尸订单”。
别慌,这不是啥难事儿!这个问题的核心其实是,在电商系统中,订单是需要有一个过期时间,当订单到期,就应该自动关闭。订单超时自动关闭看似简单,实际上是一个涉及多个技术领域的复杂问题。当我们面对这个挑战时,需要从问题的本质出发思考,这实际上是在设计一个延时任务调度系统,在指定时间后自动触发业务逻辑,下面是几种常见方案:
下面牛哥就跟大家掰扯掰扯订单过期是设计的:从简单的定时扫表,到灵活的延时队列,再到轻巧的Redis时间轮,不同规模的电商该咋选方案,每个方案的坑点咋避,全给你讲透,让这些 “不付钱的订单” 不再拖业务后腿!
定时扫描兜底
我们先说一个大家第一时间就能想到的方案——定时任务扫描。定时任务扫描是电商系统中实现订单到期自动关闭的基础方案,虽然在实时性上不如延时队列,但胜在实现简单、不依赖额外中间件,很多中小规模系统或对实时性要求不高的场景会优先采用。
具体来说,这种方案的核心逻辑是通过定时任务(比如用 Spring Scheduled、Quartz 或者 XXL-Job 这类工具),按照设定的间隔(常见的是 1-5 分钟一次)去扫描数据库里的订单表。扫描时会带上筛选条件:只找状态为 待支付、且创建时间距离当前时间超过订单有效期(一般15到30 分钟)的订单,这些就是需要自动关闭的目标订单。
比如我们有如下的订单表:
字段名 | 数据类型 | 注释说明 |
---|---|---|
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 | 最后更新时间 |
其中
order_no是订单编号,格式参考202409021000001;
pay_status表示支付状态:0-待支付,1-已支付,2-已取消,3-已退款(控制订单支付流程)
;
order_status表示订单状态:0-待支付(未确认),1-已确认(待发货),2-已发货,3-已完成,4-已关闭(控制订单生命周期)
我们可以通过如下sql语句找到超时订单:
-- 筛选目标订单:待支付(pay_status=0)+ 已过期(expire_time < 当前时间)
-- 注:用expire_time筛选更直观,也可通过create_time计算(如:create_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE))
SELECT
id, order_no, user_id, lock_stock_ids, coupon_id
FROM
t_order
WHERE
pay_status = 0 -- 仅筛选待支付订单
AND expire_time < NOW() -- 已超过过期时间
AND order_status = 0 -- 双重校验,确保未被其他逻辑修改状态
LIMIT 100; -- 分批次处理:一次查100条,避免单次查询数据量过大压垮数据库
找到目标订单后,系统会逐一对它们执行关闭逻辑,除了更改订单状态之外,还要完成后续的资源释放操作,比如把锁定的库存退回库存池、将未使用的优惠券标记为可用,确保这些资源能重新流转给其他用户。
可以参考以下sql例子,来理解关闭逻辑:
-- 订单关闭事务:所有操作成功才提交,失败则回滚
START TRANSACTION;
-- 步骤1:更新订单状态为“已关闭”(乐观锁防止重复处理)
UPDATE t_order
SET
pay_status = 2, -- 支付状态:已取消
order_status = 4, -- 订单状态:已关闭
update_time = NOW()
WHERE
id = #{orderId} -- 待关闭订单ID
AND pay_status = 0 -- 校验:仍为待支付状态
AND order_status = 0; -- 校验:仍为未确认状态
-- 步骤2:释放锁定的库存(假设库存表t_stock含stock_id/lock_num字段)
UPDATE t_stock
SET
lock_num = lock_num - 1, -- 锁定数量减1(按实际锁定数量调整)
update_time = NOW()
WHERE
stock_id IN (#{splitStockIds}); -- splitStockIds:lock_stock_ids拆分后的列表(如1001,1002)
-- 步骤3:恢复优惠券为“未使用”(假设优惠券表t_coupon含id/user_id/use_status字段)
UPDATE t_coupon
SET
use_status = 0, -- 0-未使用,1-已使用
update_time = NOW()
WHERE
id = #{couponId} -- 订单关联的优惠券ID
AND user_id = #{userId};-- 关联用户,避免误操作他人优惠券
-- 事务提交(若任何步骤失败,执行ROLLBACK; 回滚)
COMMIT;
在实际应用中,为了让方案更稳定,还需要做一些优化。比如考虑到订单量增长后,全表扫描会变慢,需要给订单表的 状态和创建时间 字段建立联合索引,提升查询效率;
-- 为订单表 t_order 创建 pay_status 和 expire_time 的联合索引
CREATE INDEX idx_pay_status_expire_time
ON t_order (pay_status, expire_time);
另外,为了防止并发问题,比如多个定时任务实例同时处理同一批订单,需要加上分布式锁(比如用 Redis 的 SET NX 命令),确保每个订单只会被一个任务实例处理,避免重复关闭。
不过这种方案也有明显的局限,最主要的就是实时性不足 —— 订单的实际关闭时间会受定时任务间隔影响,比如设置 5 分钟扫描一次,那最晚可能要等 5 分钟后才会关闭到期订单,期间锁定的库存和优惠券会被占用,影响资源利用率。而且如果订单量持续增长,频繁的数据库扫描会增加服务器和数据库的压力,这时候可能需要配合其他方案做补充,比如用定时任务做兜底,搭配延时队列提升实时性。
总体来看,定时任务扫描方案适合订单量不大、对实时性要求不高(比如允许几分钟延迟)的场景,或者作为其他方案的兜底手段,确保即使中间件出现问题,也能通过定时扫描避免订单漏关。
延时消息队列
订单自动关闭这事儿,除了定时扫表,还有个更“机灵”的玩法——延时队列。别觉得这玩意儿听着复杂,牛哥给你掰扯明白,其实就是“先把消息存起来,到点再干活”的逻辑,比定时扫表的实时性强多了,尤其订单量大的时候,能少踩不少坑。
先跟大家说清楚,这方案的核心就是:订单刚创建完,咱不着急处理关闭的事儿,先往消息队列里扔个“定时炸弹”——等过了订单有效期(一般15-30分钟),这“炸弹”自己炸了,触发关闭逻辑。具体分三步走,每一步都有讲究,咱一步一步说。
Step1:订单创建时,顺便埋个定时任务
用户在前端点了提交订单,系统后台得先干正经事:扣库存(别让别人抢了)、写订单表(把订单信息存好)——这些核心操作必须先搞定,不能出岔子。
等这些事儿都弄完了,重点来了:立刻往延时队列里发一条“订单到期关闭”的消息。这里有两个细节得注意,是牛哥踩过坑总结的:
- 消息里得带啥?别只带个订单号就完事了,最好把「订单ID、用户ID、订单创建时间、有效期」都带上——后面校验的时候能少查一次库,省点性能;
- 延时时间咋设?就按订单有效期来,比如支付超时时间是15分钟,那消息就设成“15分钟后投递”,别搞复杂了,越简单越不容易出问题。
像咱常用的RabbitMQ、RocketMQ都支持这功能:RabbitMQ可以用死信队列+TTL(消息存活时间)实现,RocketMQ更直接,直接支持“定时消息”,填个延时等级就行,不用自己瞎折腾。
Step2:消息队列当“保管员”,到点再喊你干活
消息发出去之后,就不用咱管了——消息队列会把这条消息“锁起来”,在设定的延时时间没到之前,谁也拿不到它。
你想啊,要是用定时扫表,不管有没有过期订单,都得每隔几分钟查一次库,纯属浪费资源;但延时队列不一样,它就像个负责的保管员:没到点的消息,安安静静待在队列里,到点了才会“叫醒”消费端,说“该处理这个订单了”。
这里牛哥多提一句:选消息队列的时候,别光看功能,得看“可靠性”。比如RabbitMQ要开持久化,RocketMQ要开消息重试——万一队列宕机了,消息别丢了,不然订单没关,库存一直占着,用户又下不了单,麻烦就大了。
Step3:消费端“接活”,先校验再干活,别做无用功
等消息到点了,消费端就会收到这条“关闭订单”的指令。但别着急执行关闭逻辑,先做一步关键操作:用订单ID查数据库,校验订单当前状态。
为啥要校验?因为用户可能在有效期内已经付了钱了!比如用户刚下单5分钟,就把钱付了,那这条延时消息就是“无效消息”,要是不校验直接关订单,那不就搞反了?
所以正确的流程是:
- 拿消息里的订单ID,查订单表的「支付状态」和「订单状态」;
- 要是状态还是“待支付”,说明用户没付款,那就执行关闭逻辑——改订单状态为“已关闭”、把锁定的库存加回去、优惠券标为“未使用”;
- 要是状态已经是“已支付”或者“已关闭”,那就直接忽略这条消息,啥也不用干。
牛哥之前见过有人省了校验这步,结果用户付了钱,订单还被关了,客服电话被打爆,最后还得手动恢复,血的教训啊!
延时队列这方案好在哪?又有啥坑?牛哥给你说实在的。
先说好的方面:并发能力是真强。要是你家平台搞大促,每秒几百上千个订单创建,延时队列能轻松扛住——消息队列本身就是干高并发的活儿的,比定时扫表反复查库强太多,数据库压力能小一半。
而且实时性也靠谱:设定15分钟过期,消息到点就触发,最多差个几秒钟,不会像定时扫表那样,设5分钟间隔就可能延迟5分钟,库存和优惠券能更快释放,用户体验也更好。
但咱也别光说优点,它的局限性也得提:为了关个订单,得单独搭个消息队列,有点重。要是你家是小平台,每天就几千个订单,用定时扫表足够了,没必要折腾消息队列——又要维护集群,又要处理消息丢失、重试这些问题,增加了运维成本,有点“杀鸡用牛刀”。
所以牛哥的建议是:订单量小、运维资源少,选定时扫表;订单量大、追求实时性,或者本身已经在用消息队列(比如做异步下单、物流通知),那直接加个延时队列来关订单,性价比就很高了。
Redis时间轮方案
Redis 也可以通过 时间轮 的思路来实现订单到期自动关闭,这种方案适合中小规模场景,实现起来相对轻量。
咱不用纠结 “时间轮” 这词儿多玄乎,其实就是用 Redis 的 Sorted Set,把订单按 “到期时间” 排好队,再用定时任务 “到点捞订单”。核心逻辑就 3 个:
- 存订单:把订单 ID 当 元素,订单到期的时间戳(比如 1693651200,对应 2024-09-02 10:00:00)当 分数,这样 Sorted Set 会自动按分数排序,到期早的订单排在前面;
- 扫订单:开个定时任务(比如每秒跑一次),查 “分数≤当前时间戳” 的订单 —— 这些就是到期该关的;
- 处理订单:把到期订单拎出来,校验状态、关订单,同时从 Sorted Set 里删掉,避免重复处理。
下面我们用一个例子展开流程来说明。
step1:订单创建时,往Redis里“插个队”(关键命令示例)
用户下单后,除了扣库存、写订单表,咱多做一步:把订单信息塞进Redis的Sorted Set。
举个实际例子,假设:
- 订单ID是10086;
- 订单创建时间是 2025-09-06 10:00:00,有效期 15 分钟,那到期时间戳就是 2025-09-06 10:15:00 → 转成 Unix 时间戳1757124900;
执行Redis命令(用Redis-cli举例,代码里同理):
# ZADD:往名为“order:expire:timewheel”的Sorted Set里加订单
# 格式:ZADD 键名 分数(到期时间戳) 元素(订单ID)
ZADD order:expire:timewheel 1757124900 "orderId:10086"
这里还有个潜在优化小细节:别只存订单 ID!最好把 “用户 ID、优惠券 ID” 也存到 Redis 的 Hash 里,比如键名 “order:info:10086”,后面处理时不用再查库,省时间:
# HMSET:存订单详情到Hash
HMSET order:info:10086
"userId" "12345"
"couponId" "5678"
"createTime" "1757124000"
step2:开定时任务,每秒捞到期订单
接下来要开个定时任务,核心就是 “查到期订单→删订单→处理订单”,每一步都有坑要避。
定时任务每秒跑一次,先拿 “当前时间戳”(比如 1757125200,比刚才的到期时间戳大),查 Sorted Set 里 “分数≤当前时间戳” 的订单:
# ZRANGEBYSCORE:查分数在0到当前时间戳之间的订单,最多查100条(分批次,避免一次拿太多)
ZRANGEBYSCORE order:expire:timewheel 0 1757125200 LIMIT 0 100
为啥要加LIMIT 0 100?万一某秒到期订单特别多,比如大促后集中过期,一次拿太多会卡住,分批次拿更稳。
拿到订单 ID 后,千万别直接处理!先从 Sorted Set 里删掉这个订单 —— 而且必须用ZREM命令,它是原子操作(要么删成功,要么没删,不会出现 “两个线程同时拿到同个订单” 的情况):
# ZREM:从Sorted Set里删除订单ID,返回1代表删除成功,0代表已被其他线程删过
ZREM order:expire:timewheel "orderId:10086"
这里是核心避坑点:只有 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查起来会慢。
解决办法:按时间分桶—— 比如按小时建 Sorted Set,键名改成 “order:expire:timewheel:2024090210”(代表 2024-09-02 10 点的桶),订单到期时间在哪个小时,就塞哪个桶里。
定时任务只需要扫 “当前小时桶” 和 “上一小时桶”(防止有延迟的订单),查的范围小了,速度自然快。
坑 3:处理失败了,订单没人管咋办?
比如处理订单时,数据库突然断连,订单没关成,但已经从 Sorted Set 里删了 —— 这就漏了。
解决办法:加兜底扫表—— 每天凌晨开个定时任务,扫数据库里 “创建时间超过有效期、状态还是待支付” 的订单,再做处理。虽然麻烦点,但能保证不丢单,中小电商这样做成本最低。
总体来说,Redis 时间轮是一种平衡了实现复杂度和系统依赖的方案,在中小规模电商场景中比较实用。
它的优势是实现简单,延时精度可以做到秒级,而且Redis的高性能保证了扫描效率。使用时需要考虑Redis的持久化策略,避免重启后数据丢失.
三种方案对比
看到这里,相信大家对三种定时触发方案已经了如指掌了,在后端开发中,方案选择能力是一个很核心的人才评估标准,我们不光要知道每个方案是怎么运作的,还需要能总结梳理出这些方案的优缺点、适用场景。
简单来说:
- 定时扫表:最基础的玩法,不用额外中间件,改改 SQL、加个定时任务就成,但实时性差(延迟几分钟),订单多了会扫表慢;
- 延时队列(MQ):实时性最好(毫秒 / 秒级)、并发能扛,但得搭 MQ 集群,运维成本高,小订单量用着有点 “浪费”;
- Redis 时间轮:中间选手 —— 比定时扫表实时,比 MQ 轻量,复用 Redis 就行,但订单超 10 万级得做分桶优化,可以结合重试兜底定时扫表兜底。
这里牛哥也给大家梳理了一份表格,通过这个表格,我们能更直观清晰全面地进行方案对比:
对比维度 | 定时扫表方案 | 延时队列方案(RabbitMQ/RocketMQ) | Redis时间轮方案 |
---|---|---|---|
核心依赖 | 数据库(MySQL)+ 定时工具(Spring Scheduled/XXL-Job) | 消息队列(需独立部署维护) | Redis(复用现有集群) |
实时性 | 差(延迟1-5分钟,依赖扫表间隔) | 好(毫秒/秒级,到点即触发) | 中(秒级,依赖定时任务间隔) |
并发能力 | 弱(日均订单超10万级易扫表卡顿) | 强(支持高并发,MQ原生抗量) | 中(日均10万级内稳定,超量需分桶) |
实现复杂度 | 低(写SQL+定时任务,1-2天搞定) | 高(搭MQ集群+处理重试/死信,3-5天) | 中(Redis命令+分桶/兜底,2-3天) |
运维成本 | 低(仅需维护数据库和定时任务) | 高(需监控MQ集群、处理消息积压) | 中(需确保Redis持久化、监控缓存) |
关键坑点 | 全表扫描慢、并发重复关单 | 消息丢失、死信队列堆积 | Redis重启丢数据、处理失败漏单 |
推荐规模 | 刚起步、订单量小 | 有大促、订单量爆发 | 订单稳步增长、想平衡成本与性能 |
总结
从系统设计的全局视角来看,订单超时关闭只是订单生命周期管理的一个环节,但它的设计思路可以延伸到很多其他业务场景—用户注册 7 天没激活?自动发提醒;优惠券快到期?提前 2 天催着用,本质都是到点触发的需求。
选技术也不用死磕高大上,看情况来最实在:初创公司用数据库定时扫,简单又靠谱;中等电商搞个 Redis + 定时任务,性价比拉满;只有业务做到超大体量,再花精力搭复杂的延时队列才值当.
