Python+Vue火车购票系统实战:从Django到并发防超卖设计
上个月刚帮朋友把一个基于 PythonVue 的火车购票系统的设计与实现完整跑通从技术选型到数据库设计再到并发扣票踩了不少坑。如果你正准备用 PyCharm 做毕设、课程设计或者找工作的练手项目这篇文章应该能帮你少走弯路。下面以 Django 为主线展开同时会说明如果换成 Flask 该怎么调整前端 Vue 部分也会讲清楚对接细节。整个项目涉及用户注册登录、车次查询、下单购票、订单管理、后台管理这些模块麻雀虽小但五脏俱全特别适合用来理解真实业务系统里最常见的状态流转。1. 项目定位为什么火车购票系统适合拿来练手1.1 先搞清楚这个系统到底要做到什么程度火车购票系统的业务复杂度刚好卡在一个很舒服的位置。用户、车次、订单、余票、支付状态这些东西组合在一起既有常规的增删改查又有订单状态机的流转还有并发扣票这种面试官最爱问的场景。你要是做一个简单的图书管理系统聊起来太单薄但一上来做电商平台又容易陷入商品规格、促销、物流这些泥潭里出不来。购票系统是中间那个最合适的档位。拿到这个题目先别急着写代码把功能范围划清楚。我建议基础版本至少包含这些模块用户注册登录、车次信息管理、按出发地和目的地查询车次、选择车次和日期后下单购票、个人订单列表、模拟支付。如果能再加一个后台管理页面用来维护车次和余票项目完整度直接上一个台阶。这里给一张我常用的需求分级表照着拆就不会做过头模块基础版进阶版用户注册、登录、个人信息密码加密、邮箱验证码车次车次号、起终点、发到时间、票价多座位类型二等座/一等座/卧铺、余票分座位统计查询按起终点和日期查车次按车次号精确查询、余票不足自动过滤购票锁定余票、生成订单并发防超卖、订单超时自动取消订单待支付/已支付/已取消退票功能、改签后台管理车次余票手动调整、订单查询我建议毕设级别做到进阶版平时练手做到基础版加并发防超卖就够了。别把功能堆太多核心是把你做出来的每个功能讲清楚为什么这么做。1.2 Django 还是 Flask我的选型思路标题里同时出现了 Django 和 Flask这也是很多人在选型时纠结的地方。我的结论很直接如果你的目标是把系统做完整、模型关系多、后台管理省事选 Django如果你的目标是展示自己能够把控轻量架构、手写 API 和数据库操作选 Flask 也没问题。Django 自带的东西太多了。ORM、Admin 后台、迁移机制、用户认证这些全是购票系统需要的。车次、余票、订单、用户四个模型之间有一堆外键关系用 Django ORM 写起来很顺手而且它在并发控制上提供了select_for_update这是做扣票逻辑的关键。还有一点很实际Django Admin 能白嫖一个后台车次管理页面不用自己从头写省下的时间足够去打磨前端和并发部分。Flask 的优势是轻、灵活、代码结构完全由你自己控制。用 Flask 做这个项目的话通常要搭配 Flask-SQLAlchemy、Flask-Migrate、Flask-CORS 这几个扩展相当于用额外工作量换框架的掌控感。我在项目里给朋友准备的是一套 Django 主实现同时写了 Flask 版本的接口对照方便他答辩时解释选型原因。对比起来看更直观维度DjangoFlaskORM内置功能完整需安装 Flask-SQLAlchemy后台管理Admin 自带自己写或用 Flask-Admin迁移migrate 命令Flask-Migrate适合规模中大型业务系统小系统或重度自定义学习曲线偏陡但体系完善起步快但踩坑要多查资料选 Django 并不代表 Flask 是错的关键是答辩或面试时你能把自己的理由说清楚。我这边的做法是主线用 Django 做完整实现文档里单独列出 Flask 版本的等价写法。后面讲到扣票逻辑时我会把两边的代码都贴出来。2. 整体架构与数据模型设计2.1 前后端分离还是服务端渲染我在这个项目里选了前后端分离。后端专门提供 JSON 接口前端用 Vue 写页面两边通过 axios 通信。这样做的直接好处是Vue 这边的路由、组件、状态管理和后端 Python 代码完全解耦两个人协作开发时互相不干扰一个人写的时候脑子也不用在两种语言之间来回切。前后端分离不是说把项目拆成两个互不相干的文件夹就完事了。开发时后端跑在 8000 端口前端跑在 5173 端口跨域是绕不开的第一个问题。Django 这边我用django-cors-headers解决配置很简单在settings.py里把CORS_ALLOW_ALL_ORIGINS True打开仅限开发环境或者在CORS_ALLOWED_ORIGINS列表里写上 Vue 的开发地址。如果用 Flask就是装 Flask-CORS调用CORS(app)就完事了。Vue 这边的目录结构我习惯这样组织frontend/ src/ main.js # 入口挂载 router App.vue api/ request.js # axios 实例封装 router/ index.js # 路由 views/ Home.vue # 车次查询 Order.vue # 下单确认 Orders.vue # 订单列表 Login.vue后端 Django 我按子应用拆分。不是把所有的 model 和 view 堆在同一个文件夹里而是拆成apps.user、apps.train、apps.order三个子应用。一个子应用只负责一个领域查代码的时候不用翻几百行文件。要是用 Flask就对应改成蓝图Blueprint的方式一个模块一个蓝图效果是一样的。2.2 Django 子应用和 Vue 路由的对应关系后端接口和前端页面是一对一的关系设计的时候想清楚映射后面开发会非常顺。下面是一张我实际使用的接口规划表功能方法接口路径Vue 页面用户注册POST/api/auth/register登录注册页用户登录POST/api/auth/login登录注册页车次查询GET/api/trains?from...to...Home.vue创建订单POST/api/ordersOrder.vue订单列表GET/api/ordersOrders.vue模拟支付POST/api/orders/{id}/payOrders.vueVue 路由方面有两个小细节值得注意。跳转下单页时通常要带上车次信息我建议用路由参数而不是把整个对象塞到状态管理器里。比如列表页里点击购买按钮通过router.push({ path: /order/ trainId })跳过去Order.vue里再用route.params.trainId发起详情查询。这样刷新页面参数不会丢代码也好维护。另一个是路由守卫未登录用户直接访问订单页时拦截跳到登录页这个用beforeEach三十秒就写完。2.3 数据模型设计车次、余票、订单之间怎么分表数据模型是整个系统的地基设计师没想清楚后面写接口会很痛苦。我一开始就犯过一个典型错误在 Train 表上加一个remaining_tickets字段每卖一张就减一。这个设计在单日车次上还说得通但火车票是按日期卖的同一趟车 12 月 1 号和 12 月 5 号的余票明明是独立的用一个字段存一卖票全乱套了。正确的做法是把车次和某日期的余票分开。车次表只存车次本身的信息余票用一张独立的库存表TrainStock记录。下面的模型设计是精简版去掉了不必要的字段保留了最关键的部分from django.db import models class Train(models.Model): train_no models.CharField(max_length20, uniqueTrue) start_station models.CharField(max_length50) end_station models.CharField(max_length50) departure_time models.TimeField() arrival_time models.TimeField() base_price models.DecimalField(max_digits8, decimal_places2) class TrainStock(models.Model): train models.ForeignKey(Train, on_deletemodels.CASCADE) travel_date models.DateField() seat_type models.CharField(max_length20) # 二等座、一等座、卧铺 remaining models.IntegerField(default0) class Meta: unique_together (train, travel_date, seat_type)订单模型里最重要的是status字段。我建议直接用整数定义状态不要用字符串散落在代码各处然后在模型里写清楚注释。0 表示待支付1 表示已支付2 表示已取消3 表示已退票。之后所有判断都走这个字段。订单里还要有一个period或expire_at字段记录支付截止时间这是后面做超时取消的依据。订单表里我习惯存下单时的快照信息比如当时的车次、日期、票价、座位类型。这样就算以后车次信息被后台改过用户的订单依然能还原出当时买了什么。这个设计说起来简单但很多人会忽略等到联调时发现历史订单跟着车次改变化了才想起要存快照。3. 购票核心逻辑实现并发防超卖是重头戏3.1 为什么先查余票再下单一定会出事购票系统最容易被问倒的就是并发超卖问题。最简单的实现流程是前端提交购票请求后端先查一下TrainStock里的remaining是不是大于 0大于 0 就执行扣减并创建订单。这个流程跑起来没问题但并发一来就崩。举个例子一趟车的某座位等级只剩 1 张票两个用户在同一秒钟同时提交请求。请求 A 查到余票是 1请求 B 也查到余票是 1。A 继续扣减B 也继续扣减最后订单创建了两条但余票变成了负数或者连同库存行一起被改乱了。原因很简单查询和扣减不是原子操作两条请求之间存在时间差在这个时间差里另一个请求插了进来。解决思路就是要把判断余票 扣减余票 创建订单这三个操作绑成一个不可分割的整体。数据库事务是干这个用的但普通事务还不够还要配合行锁让并发的扣票请求排队执行。Django ORM 里对应的工具是select_for_update。3.2 用 Django 事务和行锁保证不超卖下面是核心购票接口的 Django 实现。关键点有两个transaction.atomic()开启事务以及select_for_update()对库存行加锁。from django.db import transaction from django.db.models import F from .models import TrainStock, Order def create_order(user, train_id, travel_date, seat_type, count): with transaction.atomic(): stock ( TrainStock.objects .select_for_update() .filter(train_idtrain_id, travel_datetravel_date, seat_typeseat_type) .first() ) if stock is None or stock.remaining count: raise ValueError(余票不足) stock.remaining F(remaining) - count stock.save(update_fields[remaining]) order Order.objects.create( useruser, train_idtrain_id, travel_datetravel_date, seat_typeseat_type, ticket_countcount, total_amountstock.base_price * count if hasattr(stock, base_price) else 0, status0, # 待支付 ) return orderselect_for_update()会锁住满足条件的库存行一直锁到当前事务结束。事务还没提交前其他请求如果也想对同一行执行select_for_update会进入等待状态直到前一个事务提交或回滚。这样就把并发买票从同时抢变成了排队买从而避免超卖。需要注意的一点是F(remaining)这个写法。它让扣减操作在数据库层面完成而不是先把 Python 对象里的remaining读出来再赋值存回去。这样可以减少一次查询也避免读到旧值。我一开始直接用stock.remaining - count再save并发测试时就出现过更新覆盖的问题后来改成F表达式才稳住。对应到 Flask 版本其实核心逻辑一样。用 Flask-SQLAlchemy 的话把select_for_update换成.with_for_update()stock TrainStock.query.filter_by( train_idtrain_id, travel_datetravel_date, seat_typeseat_type ).with_for_update().first() if not stock or stock.remaining count: db.session.rollback() raise ValueError(余票不足) stock.remaining - count db.session.add(stock) db.session.add(Order(...)) db.session.commit()不同框架的写法略有差异但思想完全一样事务内锁行别在锁外做判断。答辩时能把这段逻辑讲清楚这个项目的核心价值就体现了。讲到这我突然想起来很多人会在库存行不存在的时候直接创建一条新的库存记录这个操作在并发场景下也会出问题。更好的做法是后台提前把需要的日期和车次库存准备好下单只做查询和扣减不要在业务高峰期去初始库存。3.3 订单状态机待支付、已支付、已取消怎么流转有了库存扣减还要把订单状态管理好。我把订单状态定义成下面的流转过程创建订单置为 0待支付用户调用模拟支付接口成功后置为 1已支付。如果在支付截止时间内没付款订单要变成 2已取消同时把之前扣减的余票补回去。已支付的订单如果发起退票操作则状态改成 3已退票同样恢复余票。这里最实用的是超时取消的实现。很多毕设项目直接不做或者只在用户主动取消时恢复库存。我觉得至少要做一个简单版本订单创建时写入expire_at等于创建时间加 15 分钟。然后写一个 Django 管理命令定期扫描所有待支付订单超过expire_at的批量改为取消状态并恢复库存。# data check_expired_orders.py from django.core.management.base import BaseCommand from django.utils import timezone from order.models import Order class Command(BaseCommand): def handle(self, *args, **options): expired_orders Order.objects.filter(status0, expire_at__lttimezone.now()) for order in expired_orders: # 恢复余票找到对应 TrainStock 行remaining 加回 ticket_count order.status 2 order.save()在 PyCharm 里可以直接通过 Tools - Run manage.py Task 运行这个命令也可以用系统定时任务调用属于既能讲清楚又不用过度设计的方案。如果你想把架构显得更高级一点可以把扫描逻辑换成 Celery 定时任务但我个人觉得毕设完全没有必要重点是把状态流转和数据一致性做对。4. PyCharm 环境搭建与调试技巧4.1 从 Python 安装到虚拟环境配置工欲善其事必先利其器。我见过太多人代码写得没问题结果环境没配好卡了半天。在这个项目里我建议直接用 PyCharm 打开作为开发主界面。PyCharm 社区版完全够用不用去折腾什么破解激活这种免费正版工具用起来心里踏实。环境搭建步骤我整理成了一套固定流程从官网下载对应系统的 Python 3.10 或 3.11 版本安装时记得勾选 Add Python to PATH。打开 PyCharm新建项目时选择 Virtualenv 作为虚拟环境Python 解释器指向刚安装的 Python。在终端里安装后端依赖pip install django djangorestframework django-cors-headers # 如果走 Flask 路线用下面这行 pip install flask flask-sqlalchemy flask-cors flask-migrate前端部分用命令创建 Vue 项目并安装依赖npm create vuelatest frontend cd frontend npm install npm install axios vue-router element-plus npm run serve这里有个很容易踩的坑在 PyCharm 里明明安装了第三方库运行却报ModuleNotFoundError。十有八九是解释器选错了PyCharm 右下角可以切换解释器一定要确保指向的是当前项目的虚拟环境而不是全局环境。我在帮朋友调 bug 时至少有一半的环境问题出在这。4.2 在 PyCharm 里把 Django 和 Flask 跑起来Django 项目在 PyCharm 里的运行配置其实可以很顺手。先python manage.py runserver 0.0.0.0:8000跑起来然后 Add Configuration选择 PythonScript 参数填manage.py的完整路径Parameters 填runserver 0.0.0.0:8000这样每次点绿色按钮就能启动不用敲命令。如果跑的是 Flask更简单写一个app.py在文件里配置app.run(host0.0.0.0, port8000, debugTrue)然后在 PyCharm 里右键运行这个文件即可。Flask 的自动重载功能默认是开着的改完代码不用手动重启这点在开发调试时特别舒服。调试技巧方面我强烈建议用断点而不是打印日志来排查购票流程。在create_order函数里stock ...select_for_update()这行打一个断点用两个调试会话同时进入能非常直观地看到第二个请求在等待锁释放。我试过用这种调试方式给别人演示并发锁效果比讲十遍原理都管用。4.3 查看数据库查询和请求日志的实战方法Django 在 PyCharm 里查 SQL 有个小技巧在settings.py里加一段日志配置就能在控制台看到每个 ORM 操作实际执行的 SQL 语句。这对排查为什么我的查询这么慢为什么 select_for_update 没生效特别有帮助。LOGGING { version: 1, handlers: {console: {level: DEBUG, class: logging.StreamHandler}}, loggers: {django.db.backends: {handlers: [console], level: DEBUG}}, }看到 SQL 后你会发现很多性能问题。查车次列表的时候如果不小心写了TrainStock.objects.filter(...).select_related(train)生成的 JOIN 和循环查询差异很大。Django 的select_related和prefetch_related是热点词也是面试常问的优化手段建议在项目里用起来。比如订单列表需要显示车次号就可以在查询订单时select_related(train)避免每个订单都额外查一次车次表。前端 Vue 的调试主要靠浏览器开发者工具。打开 Network 面板看接口请求的状态码和返回报文比在代码里瞎猜高效得多。如果接口返回 500就切到 Django 控制台看完整堆栈如果返回 403重点检查用户认证如果返回跨域错误先查后端 CORS 配置。5. 常见问题与排查技巧实录5.1 前端 Vue 依赖和路由相关的坑Vue 项目开发中我遇到最多的坑集中在依赖安装和路由跳转。npm install报ERESOLVE unable to resolve dependency tree是很典型的问题通常是依赖版本冲突。社区里最直接的解决方式是在命令后面加--legacy-peer-deps能绕过 peerDependencies 的严格校验继续安装。我项目里用 Element Plus 配 Vue 3 的时候碰到过一次加了这个参数后顺利装完。路由参数这块有个经典细节用params传参时页面一刷新参数可能就没了尤其是直接访问 URL 的时候。所以涉及车次 ID 这类关键信息的传递尽量放到路径参数里比如/order/123而不是/order?trainId123。如果担心刷新后查不到车次详情可以在Order.vue里根据车次 ID 再调一次后端接口补齐信息这种做法最稳。源码分享给别人协作时建议不要直接把本地项目压缩包发过去完事。最好在项目根目录写一个README.md说清楚 Python 版本、依赖安装命令、数据库迁移命令、前端启动命令。我见过太多人花半小时装依赖发现版本不一样最后发现 README 里什么都没写。5.2 后端跨域、时间格式和字段命名问题跨域是前后端分离项目绕不开的坎。Django 配了django-cors-headers之后还要注意中间件的顺序CorsMiddleware要放在CommonMiddleware前面否则可能不生效。Flask 用 Flask-CORS 则简单很多直接在应用上调用CORS(app)即可但如果你用了蓝图需要确认蓝图的请求也经过了扩展处理。时间格式是另一个容易翻车的地方。Django 默认返回的datetime会被序列化成类似2024-12-01T08:30:00.123456Z的 ISO 格式。前端如果不处理直接渲染到页面会显示一串不明所以的字符串。我这里的方案是前端统一用dayjs做格式化展示成2024-12-01 08:30这种用户能看懂的样子。同时后端在settings.py里设置USE_TZ False如果只做国内场景或者统一转成指定时区保证接口返回的数据和前端展示一致。字段命名上也别提踩太深的坑。不要用order这种名字做模型类Django 自己到处都是Order相关的东西虽说不一定会冲突但搜索代码时很容易混淆。车的字段建议统一用train_no、start_station不要一会儿trainNo一会儿start_station前后端对接时大小写风格不一致会浪费大量时间。5.3 排查问题速查表最后整理一张排查表都是我实际开发时用过的定位思路照着查能节省不少时间现象优先检查项解决参考后端接口 500Django 控制台完整报错堆栈看最后几行异常类型前端跨域报错后端 CORS 中间件和 allowed originsdjango-cors-headers 配置接口能通但列表无数据数据库表是否有初始化数据先通过 Admin 后台添加车次刷新页面后订单信息丢失Vue 路由 params 使用方式改为路径参数并在页面内查询并发抢票还超卖是否使用了事务和行锁加 select_for_update 并检查锁范围查询很慢ORM 有没有 N1 查询使用 select_related/prefetch_related依赖装不上网络源和版本冲突换镜像源或加 legacy-peer-deps对照表格排查完大部分问题都能自己解决。剩下解决不了的还有一个笨办法很有效把 Django 控制台和浏览器 Network 面板同时打开照着接口请求一步步走一遍看是前端没发请求、后端报错还是数据格式没对上基本一两轮就能定位到具体位置。写这个项目最大的体会是别急着写代码先把库存和订单状态谁在什么时间点被谁改变这条线理清楚。我没想明白之前写出来的接口改了两版才稳定想明白之后整个后端几乎没怎么重构。如果你也在做火车购票系统建议先花一晚上把数据模型和状态流转敲定再用 Django 写事务扣票逻辑最后用 Vue 做展示层这个顺序最稳。演示时记得开两个浏览器窗口同时抢最后一张票能亲眼看到没有超卖那一刻你会觉得前面的坑都没白踩。