下单不付款库存被占?Flask定时任务自动取消超时订单+回滚库存

⚠️ 免责声明 :本文项目仅供技术学习参考,严禁直接作为毕业设计或课程作业提交。请结合自身需求进行二次开发与创新,学术诚信从我做起 🙂


这事服务端不干,库存就锁死了

先还原一个事故现场。

你的商城里有一件爆款卫衣,库存30件。有人一口气点了3件下单,然后......不付款了。

订单挂在"待支付",库存照05篇讲的逻辑已经扣掉了。真正想买的人进来一看:库存不足。

那超时没人付的单,谁来取消?答案在后台。被取消的订单在管理后台长这样:

图:后台订单管理里,可以直接筛出「已取消」的订单

图:订单被取消后,用户在订单详情看到的最终状态

半小时后他还没付。一小时后还没付。

3件货被一个永远不会付的订单锁死了。

这篇解决的就是这件事:下单超过一定时间没付款,系统自动取消订单,把库存还回去。

核心动作就两个:定时扫描 + 取消回滚。取消回滚05篇写过,这篇的重点是"谁来定时、怎么定时、定时任务怎么不出事"。


一、为什么不能靠前端倒计时

很多人第一反应:在订单页放个30分钟倒计时,时间到了发个请求取消订单。

能跑。但只在用户还开着这个页面的时候能跑。

用户关了浏览器、断了网、切了App、直接杀进程,倒计时就没了,取消请求永远不会发。

库存照样锁着。

**前端倒计时只能用来"显示",不能用来"执行"。**真正的超时判断必须发生在服务端,而且不能依赖任何用户在线。

这是这一篇最先要想明白的事。


二、三种服务端方案,毕设选哪个

方案 原理 优点 缺点
定时扫表 每隔几分钟查一次超时订单 简单、好理解、好答辩 有几分钟误差
Redis过期监听 下单时写一个30分钟过期的key,过期触发取消 准时、性能好 要装Redis,通知可能丢,配置麻烦
延迟队列(死信队列) 消息进队列,延迟30分钟再消费 工业级方案 对毕设太重

毕设选型:定时扫表 + APScheduler。

理由很实在:不增加新组件、代码量小、逻辑一眼能看懂、答辩好讲。几分钟的误差对毕设完全无所谓,真要纠结,把扫描间隔设短一点就行。

Redis方案我会在最后提一嘴,作为"我了解更优解"的答辩储备。


三、先给订单表加一个字段

判断超时,需要知道"这单是什么时候下的"。

01篇建表时本来就有 created_at,可以直接拿它算。但更规范的做法是单独留一个取消时间,方便以后查"这单是用户取消的还是系统取消的":

sql 复制代码
ALTER TABLE `order_info`
  ADD COLUMN `cancel_time` datetime DEFAULT NULL COMMENT '取消时间',
  ADD COLUMN `cancel_type` tinyint DEFAULT NULL COMMENT '取消类型:1用户取消 2系统超时';

超时线就用 created_at 算:当前时间 - 下单时间 > 30分钟,且状态还是待支付(status=0),就是超时单。


四、写定时任务:扫描 + 批量回滚

先装库:

bash 复制代码
pip install apscheduler

新建一个 scheduler.py,专门放定时任务:

python 复制代码
from datetime import datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler

# 后台调度器:不阻塞 Flask 主线程
scheduler = BackgroundScheduler(timezone='Asia/Shanghai')

# 超时时间(分钟)
ORDER_TIMEOUT_MINUTES = 30
# 每次最多处理多少单,防止一次捞出几万条把库打挂
BATCH_SIZE = 100


def cancel_timeout_orders(app):
    """取消超时未支付订单,并回滚库存"""
    # 定时任务不在请求上下文里,要手动推入 app 上下文才能用 db
    with app.app_context():
        from app.models import OrderInfo, 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()

        if not timeout_orders:
            return

        try:
            for order in timeout_orders:
                # 再判一次状态,防止扫描到处理的间隙用户刚好付了款(幂等)
                if order.status != 0:
                    continue

                # 1. 回滚库存:加回库存,减回销量
                items = OrderItem.query.filter_by(order_id=order.id).all()
                for item in items:
                    Goods.query.filter_by(id=item.goods_id).update({
                        'stock': Goods.stock + item.quantity,
                        'sales': Goods.sales - item.quantity
                    })

                # 2. 订单置为已取消,标记是系统取消的
                order.status = 4
                order.cancel_time = datetime.now()
                order.cancel_type = 2

            db.session.commit()
            app.logger.info(f"定时任务取消超时订单 {len(timeout_orders)} 单")
        except Exception as e:
            db.session.rollback()
            app.logger.error(f"超时订单处理失败:{e}")


def init_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)里启动调度器:

python 复制代码
from .scheduler import init_scheduler

def create_app():
    app = Flask(__name__)
    # ... 各种初始化、注册蓝图 ...

    # 生产/正常启动时才开调度器
    init_scheduler(app)

    return app

然后正常 flask run 或 python run.py,任务就活了:每5分钟醒一次,找超时单,处理,睡回去。


六、三个一定会踩的坑

坑1:debug模式下任务被执行两次

这是APScheduler配Flask最经典的坑。

Flask的debug模式默认会启动一个 reloader 监视进程 + 一个真正的服务进程,等于应用被加载了两次,调度器也跟着启动了两个,同一批订单可能被处理两遍。

两种解决办法,任选:

python 复制代码
# 办法一:启动时关掉 reloader(毕设调试期够用)
app.run(debug=True, use_reloader=False)
python 复制代码
# 办法二:只在 reloader 的子进程里启动调度器(推荐)
import os

def create_app():
    app = Flask(__name__)
    # ...
    # 环境变量 WERKZEUG_RUN_MAIN 为 true 说明是真正干活的子进程
    if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
        init_scheduler(app)
    return app

记住一句话:调度器在整个系统里只能有一个实例。 多实例不光重复跑,还可能重复回滚库存。

坑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 · 转载请注明出处

相关推荐
软件技术NINI2 小时前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设
无所谓^_^1 天前
个人健康管理系统 | Springboot3 | Vue3
java·vue·毕业设计·springboot·课程设计·毕设·前后端分离
薛定谔的猫-菜鸟程序员2 天前
软件架构设计师开发资料大全(从入门到精通)
后端·python·系统架构·flask·架构设计师
echome8882 天前
Linux 定时任务:crontab 写法与常见失效原因
linux·运维·网络·定时任务·crontab
这个昵称不容易3 天前
SpringBoot 墙绘交易平台毕设:128个文件154个接口含论文,带运行截图
java·springboot·毕设
李纲明4 天前
WordPress 定时发布不准点或备份/邮件从不跑:先分清 WP-Cron 触发与 DISABLE_WP_CRON,再查回环与系统 Cron
运维·性能优化·wordpress·定时任务·devdiy·wp-cron
字节跳动的猫4 天前
LikeShop 营销活动自动启停二开:定时上下架、周期自动执行与效果自动复盘
电商系统·营销活动·营销策划
李纲明5 天前
WordPress 定时发布不跑、备份邮件延误:先核 WP-Cron 触发与回环,再改系统 Cron
运维·wordpress·定时任务·devdiy·wp-cron
软件技术NINI5 天前
基于SpringBoot和Vue的摇滚乐鉴赏网站
vue.js·spring boot·后端·spring·html·毕业设计·毕设