☰
Python+Vue火车购票系统实战:从Django到并发防超卖设计
2026/10/10 6:43:38 网站建设 项目流程

上个月刚帮朋友把一个基于 Python+Vue 的火车购票系统的设计与实现完整跑通,从技术选型到数据库设计再到并发扣票,踩了不少坑。如果你正准备用 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 版本的接口对照,方便他答辩时解释选型原因。

对比起来看更直观:

维度DjangoFlask
ORM内置,功能完整需安装 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.vue

Vue 路由方面有两个小细节值得注意。跳转下单页时通常要带上车次信息,我建议用路由参数而不是把整个对象塞到状态管理器里。比如列表页里点击"购买"按钮,通过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_length=20, unique=True) start_station = models.CharField(max_length=50) end_station = models.CharField(max_length=50) departure_time = models.TimeField() arrival_time = models.TimeField() base_price = models.DecimalField(max_digits=8, decimal_places=2) class TrainStock(models.Model): train = models.ForeignKey(Train, on_delete=models.CASCADE) travel_date = models.DateField() seat_type = models.CharField(max_length=20) # 二等座、一等座、卧铺 remaining = models.IntegerField(default=0) 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_id=train_id, travel_date=travel_date, seat_type=seat_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( user=user, train_id=train_id, travel_date=travel_date, seat_type=seat_type, ticket_count=count, total_amount=stock.base_price * count if hasattr(stock, 'base_price') else 0, status=0, # 待支付 ) return order

select_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_id=train_id, travel_date=travel_date, seat_type=seat_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(status=0, expire_at__lt=timezone.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 社区版完全够用,不用去折腾什么破解激活,这种免费正版工具用起来心里踏实。

环境搭建步骤我整理成了一套固定流程:

  1. 从官网下载对应系统的 Python 3.10 或 3.11 版本,安装时记得勾选 Add Python to PATH。
  2. 打开 PyCharm,新建项目时选择 Virtualenv 作为虚拟环境,Python 解释器指向刚安装的 Python。
  3. 在终端里安装后端依赖:
    pip install django djangorestframework django-cors-headers # 如果走 Flask 路线,用下面这行 pip install flask flask-sqlalchemy flask-cors flask-migrate
  4. 前端部分用命令创建 Vue 项目并安装依赖:
    npm create vue@latest 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,选择 Python,Script 参数填manage.py的完整路径,Parameters 填runserver 0.0.0.0:8000,这样每次点绿色按钮就能启动,不用敲命令。

如果跑的是 Flask,更简单:写一个app.py,在文件里配置app.run(host='0.0.0.0', port=8000, debug=True),然后在 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?trainId=123。如果担心刷新后查不到车次详情,可以在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 有没有 N+1 查询使用 select_related/prefetch_related
依赖装不上网络源和版本冲突换镜像源或加 legacy-peer-deps

对照表格排查完,大部分问题都能自己解决。剩下解决不了的,还有一个笨办法很有效:把 Django 控制台和浏览器 Network 面板同时打开,照着接口请求一步步走一遍,看是前端没发请求、后端报错还是数据格式没对上,基本一两轮就能定位到具体位置。

写这个项目最大的体会是:别急着写代码,先把"库存和订单状态谁在什么时间点被谁改变"这条线理清楚。我没想明白之前,写出来的接口改了两版才稳定,想明白之后整个后端几乎没怎么重构。如果你也在做火车购票系统,建议先花一晚上把数据模型和状态流转敲定,再用 Django 写事务扣票逻辑,最后用 Vue 做展示层,这个顺序最稳。演示时记得开两个浏览器窗口同时抢最后一张票,能亲眼看到没有超卖,那一刻你会觉得前面的坑都没白踩。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询