⚠️免责声明:本文项目仅供技术学习参考,严禁直接作为毕业设计或课程作业提交。请结合自身需求进行二次开发与创新,学术诚信从我做起 🙂
这事服务端不干,库存就锁死了
先还原一个事故现场。
你的商城里有一件爆款卫衣,库存30件。有人一口气点了3件下单,然后……不付款了。
订单挂在"待支付",库存照05篇讲的逻辑已经扣掉了。真正想买的人进来一看:库存不足。
那超时没人付的单,谁来取消?答案在后台。被取消的订单在管理后台长这样:
图:后台订单管理里,可以直接筛出「已取消」的订单
图:订单被取消后,用户在订单详情看到的最终状态
半小时后他还没付。一小时后还没付。
3件货被一个永远不会付的订单锁死了。
这篇解决的就是这件事:下单超过一定时间没付款,系统自动取消订单,把库存还回去。
核心动作就两个:定时扫描 + 取消回滚。取消回滚05篇写过,这篇的重点是"谁来定时、怎么定时、定时任务怎么不出事"。
一、为什么不能靠前端倒计时
很多人第一反应:在订单页放个30分钟倒计时,时间到了发个请求取消订单。
能跑。但只在用户还开着这个页面的时候能跑。
用户关了浏览器、断了网、切了App、直接杀进程,倒计时就没了,取消请求永远不会发。
库存照样锁着。
**前端倒计时只能用来"显示",不能用来"执行"。**真正的超时判断必须发生在服务端,而且不能依赖任何用户在线。
这是这一篇最先要想明白的事。
二、三种服务端方案,毕设选哪个
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 定时扫表 | 每隔几分钟查一次超时订单 | 简单、好理解、好答辩 | 有几分钟误差 |
| Redis过期监听 | 下单时写一个30分钟过期的key,过期触发取消 | 准时、性能好 | 要装Redis,通知可能丢,配置麻烦 |
| 延迟队列(死信队列) | 消息进队列,延迟30分钟再消费 | 工业级方案 | 对毕设太重 |
毕设选型:定时扫表 + APScheduler。
理由很实在:不增加新组件、代码量小、逻辑一眼能看懂、答辩好讲。几分钟的误差对毕设完全无所谓,真要纠结,把扫描间隔设短一点就行。
Redis方案我会在最后提一嘴,作为"我了解更优解"的答辩储备。
三、先给订单表加一个字段
判断超时,需要知道"这单是什么时候下的"。
01篇建表时本来就有created_at,可以直接拿它算。但更规范的做法是单独留一个取消时间,方便以后查"这单是用户取消的还是系统取消的":
ALTERTABLE`order_info`ADDCOLUMN`cancel_time`datetimeDEFAULTNULLCOMMENT'取消时间',ADDCOLUMN`cancel_type`tinyintDEFAULTNULLCOMMENT'取消类型:1用户取消 2系统超时';超时线就用created_at算:当前时间 - 下单时间 > 30分钟,且状态还是待支付(status=0),就是超时单。
四、写定时任务:扫描 + 批量回滚
先装库:
pipinstallapscheduler新建一个scheduler.py,专门放定时任务:
fromdatetimeimportdatetime,timedeltafromapscheduler.schedulers.backgroundimportBackgroundScheduler# 后台调度器:不阻塞 Flask 主线程scheduler=BackgroundScheduler(timezone='Asia/Shanghai')# 超时时间(分钟)ORDER_TIMEOUT_MINUTES=30# 每次最多处理多少单,防止一次捞出几万条把库打挂BATCH_SIZE=100defcancel_timeout_orders(app):"""取消超时未支付订单,并回滚库存"""# 定时任务不在请求上下文里,要手动推入 app 上下文才能用 dbwithapp.app_context():fromapp.modelsimportOrderInfo,OrderItem,Goods,db deadline=datetime.now()-timedelta(minutes=ORDER_TIMEOUT_MINUTES)# 关键条件:状态=待支付 且 下单时间早于截止线timeout_orders=OrderInfo.query.filter(OrderInfo.status==0,OrderInfo.created_at<deadline).order_by(OrderInfo.created_at.asc()).limit(BATCH_SIZE).all()ifnottimeout_orders:returntry:fororderintimeout_orders:# 再判一次状态,防止扫描到处理的间隙用户刚好付了款(幂等)iforder.status!=0:continue# 1. 回滚库存:加回库存,减回销量items=OrderItem.query.filter_by(order_id=order.id).all()foriteminitems:Goods.query.filter_by(id=item.goods_id).update({'stock':Goods.stock+item.quantity,'sales':Goods.sales-item.quantity})# 2. 订单置为已取消,标记是系统取消的order.status=4order.cancel_time=datetime.now()order.cancel_type=2db.session.commit()app.logger.info(f"定时任务取消超时订单{len(timeout_orders)}单")exceptExceptionase:db.session.rollback()app.logger.error(f"超时订单处理失败:{e}")definit_scheduler(app):"""注册任务并启动"""# interval:固定间隔。每5分钟跑一次scheduler.add_job(func=lambda:cancel_timeout_orders(app),trigger='interval',minutes=5,id='cancel_timeout_orders',replace_existing=True)scheduler.start()库存回滚那段,和05篇"用户手动取消订单"是同一套逻辑:stock加回去,sales减回来。区别只是触发方从用户点击变成了定时任务,以及多了一个cancel_type=2。
五、在应用启动时把任务挂上去
在 Flask 应用工厂(一般是app/__init__.py)里启动调度器:
from.schedulerimportinit_schedulerdefcreate_app():app=Flask(__name__)# ... 各种初始化、注册蓝图 ...# 生产/正常启动时才开调度器init_scheduler(app)returnapp然后正常flask run或python run.py,任务就活了:每5分钟醒一次,找超时单,处理,睡回去。
六、三个一定会踩的坑
坑1:debug模式下任务被执行两次
这是APScheduler配Flask最经典的坑。
Flask的debug模式默认会启动一个reloader 监视进程 + 一个真正的服务进程,等于应用被加载了两次,调度器也跟着启动了两个,同一批订单可能被处理两遍。
两种解决办法,任选:
# 办法一:启动时关掉 reloader(毕设调试期够用)app.run(debug=True,use_reloader=False)# 办法二:只在 reloader 的子进程里启动调度器(推荐)importosdefcreate_app():app=Flask(__name__)# ...# 环境变量 WERKZEUG_RUN_MAIN 为 true 说明是真正干活的子进程ifos.environ.get('WERKZEUG_RUN_MAIN')=='true':init_scheduler(app)returnapp记住一句话:调度器在整个系统里只能有一个实例。多实例不光重复跑,还可能重复回滚库存。
坑2:任务里报"Working outside of application context"
定时任务不是HTTP请求,没有请求上下文,直接用db.session会报错。
所以任务函数里必须套一层with app.app_context():,上面代码里已经写了。把app传进去就是为了这个。
坑3:时间别用数据库的NOW()拍脑袋
判断超时时,"当前时间"以应用服务器为准(datetime.now()),下单时间以created_at为准。如果以后上云,应用和数据库分属不同机器,要确认两边时区一致,统一用Asia/Shanghai,否则可能出现"订单刚下就被判超时"或者"永远不超时"的灵异现象。
调度器初始化时传timezone='Asia/Shanghai'也是这个原因。
七、答辩被追问"高并发怎么办"
定时扫表毕设够用,但如果老师追问"订单量大了这个方案有什么问题",你可以这样答:
扫表有两个局限:一是有轮询间隔,取消不够实时;二是订单量大了,每5分钟全表扫一次会有压力,需要加索引、分批处理。
更大的系统会用Redis:下单时往Redis写一个30分钟过期的key(或用ZSet,score存过期时间戳),过期/轮询到就发取消消息,由消费者去取消订单并回滚库存。这样准时,且不给主库添负担。
这段一说,老师就知道你不是只会写个定时器,而是清楚这个方案的边界在哪。
另外补一个工程细节:取消订单和回滚库存必须在同一个事务里(05篇的begin_nested),否则可能出现订单取消了、库存没还回去的半吊子状态。上面代码整体commit/rollback就是在保证这一点。
定时任务,答辩常问这几问
1. 为什么不在前端用倒计时自动取消?
答:前端倒计时依赖页面开着,用户关浏览器、断网、杀进程就失效了。超时判断必须放在服务端,不依赖用户是否在线。前端倒计时只负责给用户看,服务端定时任务才负责真正执行。
2. 定时任务和用户刚好付款,冲突了怎么办?
答:任务在真正改状态前会再查一次订单状态,只有仍然是"待支付"才取消。支付回调那边同样会先判状态。两边都做状态校验,保证一笔订单不会被重复处理,这就是幂等。
3. 取消订单时为什么要回滚库存,还要减销量?
答:下单瞬间已经按05篇的逻辑扣了库存、加了销量。订单取消意味着这笔交易没发生,库存要加回来,销量也要减回去,两个字段一起还原,数据才一致。而且这两步和订单状态更新在同一个事务里。
4. APScheduler 定时任务在debug下为什么会跑两次?
答:Flask debug的reloader会加载两次应用:一次监视进程、一次服务进程,调度器被启动了两个实例。用use_reloader=False,或者判断WERKZEUG_RUN_MAIN只在子进程启动,都能解决。一句话记牢:调度器全局只能有一个。
5. 一次查出几十万条超时订单怎么办?
答:用limit分批处理,每批比如100条,配合created_at索引;任务间隔内消化不完,下一轮接着处理。同时给status和created_at建联合索引,避免全表扫描。
超时取消这个功能,代码量不大,但它考的是一种思维:系统得能在"什么都没发生"的时候自己把残局收拾干净。
用户不付款、关掉页面、人间蒸发——这些才是真实世界的常态。好的系统不能假设每一步都有人盯着点。
把这一篇和05篇连起来看,下单扣库存、付款流转、超时自动释放,订单从生到死这一整条,就接上了。
梅雅达编程笔记原创 · 首发 CSDN · 转载请注明出处