火车票订票系统这种项目,我最早是在某高校的课程设计里接手的。当时项目组拿到的需求清单不长:用户注册登录、车次查询、余票展示、在线下单、订单管理,后台能维护车次和票价。看起来就是个标准的“管理系统Plus”,但真正动手之后才发现,订票系统的复杂度根本不在CRUD,而在状态流转和并发扣减这两个点。尤其是Flask加Vue这个组合,单看任何一方都很常规,可一旦把它们接到同一个订票流程里,接口协议、事务边界、前后端状态同步全是坑。
这篇文章不打算贴完整源码,而是把整个项目的拆解过程和踩坑记录写清楚。内容包括数据模型怎么设计、Flask后端如何分层、Vue前端怎么做状态管理、以及一次真实发生的超卖事故的完整排查链路。无论你是为了交课程设计,还是想把这个项目深化成简历上的作品,都可以从中直接抄思路。
1. 为什么最终选型Flask+Vue:前后端分离带来的边界收益
前后端分离在今天已经是默认选项,但放在一个课程设计或中小型项目里,很多人会犹豫:是不是用Flask加Jinja2模板渲染更简单?我当时也纠结过,最后坚持用Vue做前端,不是因为它潮,而是因为火车票订票系统的页面交互确实有“分离”的硬需求——车次列表要实时刷新余票,订单面板要随时回显支付状态,个人中心要切换多个Tab。如果用模板渲染,这些交互全部要走后端路由和页面重载,体验会很差。
1.1 初期被否掉的方案:Flask渲染模板的“伪敏捷”陷阱
项目组最开始提的方案是Flask加Jinja2,所有页面由后端生成,前端只写简单的JavaScript。这个方案的优点很明显:部署时只需要维护一套Python代码,不用关心跨域,也不用做接口文档。但问题是,一旦涉及异步刷新、局部组件更新、状态共享,模板渲染就不够用了。比如车次列表页,用户点“查询”之后,前端要立即展示列车信息、途经站、余票数,如果这些全部通过整页刷新来做,用户每切换一次筛选条件都要等一次完整页面响应,而中间还夹杂着用户已填写的表单状态丢失问题。
另一个被否掉的方案是使用Django。Django自带Admin后台、ORM、迁移工具,确实省事,但对于这种以接口为核心、前端负责交互的项目,Django的“全家桶”特性反而显得重。Flask的轻量在这里反而是一种优势:我可以精确控制每一个路由、每一个事务边界,不会被框架的默认行为绑架。
1.2 Flask的定位:不是大而全,而是小而精准
在订票系统里,Flask承担的是“业务逻辑服务层”,它不需要管前端怎么渲染,只需要做好三件事:接收请求、执行业务逻辑、返回JSON。这和Flask的设计哲学完全吻合。我们用Flask的Blueprint按模块拆分了用户、车次、订单、管理四个业务域,每个Blueprint对应一个Python包,内部再分route层、service层、model层。这样做的好处是,后续如果要换成FastAPI或者把核心服务拆出去,底层模型和Service逻辑可以原样保留,只是换一层路由暴露方式。
这里有一个容易被忽略的细节:Flask的App工厂模式。我们没有在单个文件里直接创建app,而是用create_app函数,在函数内部注册扩展、注册Blueprint、初始化数据库。这样测试时可以创建多个独立App实例,不会出现配置相互污染的问题。很多课程设计项目把Flask写成一个巨型单文件,刚开始跑得通,一旦加了新模块,变量名冲突和循环导入就开始出现。
1.3 Vue选2还是选3:取决于团队对Composition API的熟悉度
前端选型时,项目组内部争论过Vue 2还是Vue 3。当时Vue 3已经比较稳定,但团队里大多数人对Options API更熟。我的结论是:既然这是一个要拿出去展示的项目,直接上Vue 3加Composition API。原因不是Vue 3“新”,而是火车票订票系统的订单状态、用户信息、车次筛选条件这些状态,在多个页面之间共享,Composition API把相同逻辑聚合到同一个函数里的组织方式,比在组件里堆data和methods更清晰。
实际开发下来,Vue 3的setup函数确实让代码更紧凑。比如订单确认页需要同时依赖车次信息、乘客人信息、登录状态,这些在Vue 2里往往分布在不同的data属性和mounted钩子里,在Vue 3里可以通过一个useCreateOrder函数集中处理。这也是后期排查问题时能快速定位的关键原因之一。
2. 订票系统的数据模型设计:从车次到订单的状态闭环
数据模型是整个项目的地基。我在第一次设计时犯过一个错误:把座位表和订单表设计得太复杂,试图模拟真实铁路系统的每一个细节,结果项目根本推进不下去。后来我简化了模型,只保留解决核心问题所必需的几张表。
2.1 基础表拆分:车次、车站、运行区间、车厢与座位
车次表(train_info)保存车次编号、类型(高铁/普快)、始发站、终点站、发车时间、到达时间、历时、标准票价。车站表(station_info)保存车站名称和所属城市。运行区间表(train_station)保存每个车次经停的所有站点及顺序、到达时刻和离开时刻,这里的关键是排序字段,它决定了前端的“途经站”展示和余票区间计算。
座位表的具体拆分方式决定了后续的余票算法。我采用的方案是:为每一个车次的每一种座席类型(二等座、一等座、硬座、硬卧等)维护一个总票数和一个已售数量,而不是为每个实际座位建一行。这种设计的代价是牺牲“选具体座位”的功能,很多真实系统也未必开放选座,换来的是余票判断极快,只需要一次数值比较。后面加上了具体的座位分配表,保存每个区间段已售的座位ID集合,用于防止一个座位在重叠区间被重复出售。
2.2 订单状态机:待支付、已支付、已出票、已取消、已退票
火车票订单和其他电商订单最大的不同在于“票”和“座位”绑定。用户下单后并没有真正锁定座位,只是锁定了一个购买资格。我把订单状态设计为五个:待支付、已支付、已出票、已取消、已退票。
- 待支付:用户提交订单,系统锁定余票,但未付款。时限设为15分钟,超时自动取消并释放余票。
- 已支付:支付接口回调成功,订单变为已支付。
- 已出票:模拟出票流程,生成票号、座位号。这里可以由后台定时任务批量处理,也可以在下单时同步完成。我选择在支付成功后同步出票,减少一个状态机分支。
- 已取消:用户主动取消或超时系统取消。
- 已退票:已出票订单允许退票,退票时释放对应区间的余票。
状态流转图并不复杂,但每个状态的转移都必须严格记录时间和操作源。我在订单表里额外加了status_history字段,存JSON数组,每次状态变化时追加一条记录。排查问题时非常有用,比如用户说“我明明支付了为什么没出票”,直接查这条订单的状态历史就能定位是哪一步断了。
2.3 余票扣减的时机:下单锁票还是支付后锁票
这是整个项目最重要的决策之一。如果把余票的扣减放在“用户下单”时刻,那么恶意用户可以用大量未支付订单占住票,导致真实用户买不到。如果放在“支付成功”时刻,那么下单时就必须先做一次占用检查,防止两个人同时买同一区间的同一座位。
我的最终方案是结合两者:下单时先做“预占”——在redis中用原子操作减少对应区间的余票数,并生成一个带过期时间的锁。如果15分钟内未支付,事务回滚并释放余票。支付成功后,再把“预占”变成真正的“扣减”,并写入订单表和座位分配表。这种做法的核心是:数据库中的已售数量只记录有效订单,而Redis中的余票数用于承接高频查询和预占。在两套数据之间存在一个15分钟的最终一致性窗口,但在这个项目场景下完全可接受。
3. 后端Flask的分层实现:接口、事务与业务逻辑的组织方式
前面提到按Blueprint拆分模块,这里具体说一下每一层在干什么。对于一个接入Vue前端的项目来说,后端不只是写几个接口,还要考虑返回格式、异常处理、登录鉴权、数据库事务边界。
3.1 Blueprint按业务域切分,不按技术层切分
Blueprints如果按“controllers、models、services”这种技术层切分,会导致同一个业务域的功能散落在多个目录里,改一个订单功能要同时动三个包。我采用的是按业务域切分:
- user_blueprint:注册、登录、获取个人信息、修改密码。
- train_blueprint:车次查询、车站列表、车次详情。
- order_blueprint:创建订单、取消订单、支付回调、退票、订单列表、订单详情。
- admin_blueprint:车次维护、车站维护、订单管理、数据统计。
每个Blueprint包内部自己持有models、services、schemas。用Flask-RESTful或者直接写method views都可以,我这里用的是Flask-RESTful的Resource,可以让路由和HTTP方法绑定更明显。
3.2 SQLAlchemy的事务边界:一个请求一个事务
用Flask-SQLAlchemy开发时,最忌讳的就是在代码里到处调用db.session.commit()。我要求所有写操作集中在Service层完成,一个业务操作一个事务单元。比如创建订单的Service方法,内部依次执行余票预占、创建订单记录、写入状态历史,全部成功后一次commit,任何一步抛异常则rollback。
有一点需要注意:Flask的Session生命周期和请求生命周期是绑定的,默认情况下,请求结束时如果还有未提交的事务,SQLAlchemy会自动回滚。这个行为在调试时会让人摸不着头脑,尤其是你明明执行了db.session.add但没调commit,以为数据保存了,一刷页面发现没了。所以我在所有写操作的方法上都加了明确的事务提交,同时用装饰器捕获异常并回滚。
3.3 用户认证:JWT还是Session
课程设计里最常见的做法是Flask-Login加Session。但前端是Vue,部署后前后端大概率不在同一个域名下,Session的Cookie跨域问题会带来一堆额外配置。我们最后选了JWT。
JWT的实现没有用复杂的扩展,只用了PyJWT库。登录成功后生成access_token和refresh_token,access_token有效期30分钟,refresh_token有效期7天。前端把access_token放在请求头里,每次请求前由Axios拦截器自动附加。过期时,Axios会根据状态码401自动调用刷新接口换新token。
实践中有个坑:Flask中解析JWT时如果你的SECRET_KEY变了,所有已签发的token全部失效。我们在某次部署时把配置文件的SECRET_KEY换成随机生成的字符串,结果所有用户都掉线了。后来将SECRET_KEY固定为一个环境变量,并要求部署脚本不得随意更改。
3.4 接口返回格式:统一套壳,前端才不用到处判断
前后端分离的项目,最怕的就是每个接口返回格式不统一。我在所有Blueprint的route返回时,统一走一个make_response函数,格式固定为:
{ "code": 0, "message": "success", "data": { } }code为0表示成功,非0表示业务错误。业务错误分两类:参数错误和业务逻辑错误,比如“余票不足”“订单已超时”“车次不存在”。所有异常在全局错误处理器里统一捕获,不允许直接把Python堆栈信息返回给前端。这个设计看起来简单,但前端联调时可以省掉大量if-else,因为除了code之外,HTTP状态码也做了约定:2xx表示请求成功,4xx表示参数或权限问题,5xx表示服务端异常。
4. 前端Vue的实现思路:路由设计、状态管理和接口对接
前端是用户能直接感知的部分,也是把后端能力串起来的胶水层。订票系统的前端交互链路比较固定:查询车次-查看详情-填写乘客-提交订单-模拟支付-查看订单。每一步之间都可能要回退、修改,状态管理必须跟得上。
4.1 页面架构与路由划分
我们用Vue Router配置了以下页面:
- /:首页,包含车次查询表单、热门车次入口。
- /trains:车次列表页,展示符合条件的车次,支持按发车时间、历时、票价排序。
- /trains/:id:车次详情页,展示经停站时间线、各席别余票。
- /order/confirm:订单确认页,选择乘车人、席别,核对票价。
- /order/result/:orderId:下单结果页,展示支付倒计时和支付按钮。
- /orders:订单列表页,展示当前用户所有订单。
- /admin:后台管理页,仅管理员可访问。
路由守卫按角色区分,用户页面要求登录,后台页面要求is_admin为true。一个容易遗漏的地方是:前端路由守卫只是体验优化,真正的鉴权判断必须由后端接口完成。如果只依赖前端路由跳转,任何人手动修改Vue Router访问后台页面,虽然看不到数据,但会触发后台接口并暴露出接口的存在,进一步可能被暴力尝试。所以后端接口也要做严格的权限校验。
4.2 Pinia状态管理与Axios封装
Vue 3我们配的是Pinia,替代Vuex。全局状态主要存三块:用户信息、当前查询条件、订单倒计时。其中“当前查询条件”很重要,因为用户从车次列表进入详情页后,返回列表时希望保留之前的筛选条件和页码。如果这些只存在组件本地,页面一销毁就丢了。
Axios封装做了三件事:请求拦截器自动附加JWT、响应拦截器统一拆包、401时自动刷新token并重放原请求。刷新token的逻辑用了Promise队列,避免多个请求同时401时触发多次刷新请求。具体实现是:
let isRefreshing = false let pendingQueue = [] axios.interceptors.response.use( response => response.data, error => { if (error.response.status !== 401) { return Promise.reject(error) } if (!isRefreshing) { isRefreshing = true return refreshToken().then(token => { isRefreshing = false pendingQueue.forEach(cb => cb(token)) pendingQueue = [] }).finally(() => { isRefreshing = false }) } return new Promise(resolve => { pendingQueue.push(token => { error.config.headers.Authorization = `Bearer ${token}` resolve(axios(error.config)) }) }) } )这个方案在实际运行中很稳。不过需要说明,refresh_token本身也有可能过期,所以后端在刷新接口里要判断refresh_token的有效期,并返回特定的业务码让前端跳转登录页。
4.3 车次余票显示的实时性问题
火车票的余票是动态变化的,但前端不可能频繁轮询所有车次的余票,那样后端压力会非常大。我的折中方案是:车次列表页只在两个时机请求余票数据——第一次加载时和用户手动刷新时。每个车次卡片上的余票数字并不是发起一次独立请求,而是由列表接口一次性返回。
用户进入车次详情页后,才对单个车次做更细的余票轮询,间隔30秒。这样做是因为详情页是下单前的最后一步,用户对余票数字的即时性要求最高。轮询在组件卸载时清理定时器,防止用户离开页面后还在发请求,这也是前端性能优化的一个细节。
5. 下单并发的真实事故:一次超卖票的根因分析
任何一个涉及“库存扣减”的系统,都要面对并发问题。我们的订票系统在联调阶段就发生过一次典型的超卖:两个用户同时购买同一车次同一区间的最后一张票,结果两个人都收到了下单成功的提示。这节完整记录排查过程,比直接给出现成方案更有参考价值。
5.1 事故现象和第一反应
测试同学反馈:某车次只剩最后一张二等座,两个账号同时下单,都进入了支付页面,且订单状态都是“待支付”。第一反应是看代码:创建订单的Service方法里,确实先查询了余票,if count > 0,然后insert订单,最后update已售数量。经典的三步操作,单线程下没问题,并发下就翻车了。
5.2 排查链路:从日志到SQL的执行顺序
我先查了后端日志,发现两个请求几乎同时到达,时间差只有几十毫秒。继续跟踪SQL,看到了问题本质:
请求A执行SELECT余票,得到count=1; 请求B执行SELECT余票,也得到count=1; 请求A执行UPDATE已售数量,把1变成2; 请求B执行UPDATE已售数量,把2变成3。
这个顺序说明,两个请求都在“查余票”这一步没有互相阻塞,因为在MySQL默认的REPEATABLE READ隔离级别下,普通的SELECT是不加锁的,两个事务都能读到同一个快照。
进一步检查,发现创建订单时,事务并没有在“查询余票”和“更新余票”之间锁定任何行,也没有唯一约束兜底。两个事务都认为“还有一张票”,双双插入订单成功。这就是典型的“先查后写”导致的并发覆盖。
5.3 修复方案:从乐观锁到Redis原子扣减
修复思路有三条路:
- 方案一:行级锁。在查询余票时使用SELECT ... FOR UPDATE,锁住车次余票那一行。这样事务B必须等事务A提交后才能查询。缺点是并发度下降,且如果事务A迟迟不提交,后面的请求会大量堆积等待。
- 方案二:乐观锁。UPDATE余票表时加一个version字段,UPDATE语句里带version条件,如果影响行数为0,说明数据已被别人修改,事务回滚。优点是简单,缺点是业务方收到“更新失败”后要重试或提示用户。
- 方案三:Redis原子扣减。在Redis里为每个车次区间的每个席别维护一个key,下单时用DECR或Lua脚本原子扣减,如果扣减后小于0,说明无票,拒绝下单。Redis的单线程模型天然避免了并发写冲突。
我最终采用了方案三作为主方案,同时保留数据库乐观锁作为兜底。原因有两点:一是Redis的扣减性能远高于数据库行锁,可以承受高并发查询和预占;二是Redis的key过期时间正好和15分钟未支付取消机制对齐。具体做法是:车次和席别的余量key,在后台维护车次时初始化,比如“train:1001:seat:2:remain”存数字。创建订单时用一段Lua脚本完成扣减:
if redis.call('GET', KEYS[1]) and tonumber(redis.call('GET', KEYS[1])) > 0 then return redis.call('DECR', KEYS[1]) else return -1 end支付超时取消时,再用脚本INCR回去。这里有一个细节:如果Redis服务重启且没有持久化,内存中的余票数会丢失。所以我在启动脚本里加了同步任务,从数据库中的“总票数-已售数量”重建所有余票key。数据库始终是权威数据源,Redis只是加速和预占的层。
这次的教训很深刻。之前总觉得课程设计级别的项目不用考虑并发,但超卖恰恰是这类系统最容易出现的生产事故。排查过程中对照SQL日志一步步定位,比直接谷歌“MySQL防超卖”有效得多,因为你能看到自己的事务到底是怎么交错的。
6. 项目部署与日常维护:从开发机到服务器的完整落地方案
项目开发完,总要部署到服务器上跑起来。这里有很多和开发环境不一样的坑,包括静态资源路径、跨域、进程守护、数据库备份。
6.1 前后端构建产物与Flask静态文件托管
Vue项目执行npm run build之后,生成dist目录,里面有index.html和一堆带hash的静态资源。部署时有两种选择:用Nginx直接托管dist目录,同时反向代理后端接口;或者把dist目录交给Flask统一托管。我倾向于前者,因为Nginx处理静态文件的性能远好于Python,而且配置简单。
用Nginx托管时,关键配置是location /api/的代理转发:
server { listen 80; server_name example.com; location / { root /var/www/train-ticket/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里需要注意try_files对Vue Router的作用:Vue Router的history模式在直接访问/trains时,服务器上没有这个物理路径,必须回退到index.html,让前端路由接管。如果忘了配置try_files,用户一刷新详情页就会404。
6.2 Gunicorn启动参数与进程守护
后端不推荐直接python app.py跑,因为单进程无法利用多核,而且崩溃后没有自动重启机制。我们用Gunicorn启动Flask:
gunicorn -w 4 -b 127.0.0.1:8000 -t 120 app:app-w 4启动4个worker进程,-t 120设置超时时间。火车票查询接口可能会因为数据库慢查询超过默认的30秒超时,所以调到120秒。在实际使用中发现,如果某个接口耗时太长,超时后Gunicorn会杀掉worker,如果频繁出现,需要排查接口本身而不是调大超时时间。
进程守护我用的是systemd,配置一个service文件,重点参数是Restart=always,这样进程意外退出后自动拉起。部署脚本里把整个流程写成一个shell脚本:拉代码、建虚拟环境、安装依赖、编译前端、数据库迁移、重启服务。脚本每次执行完都要检查Gunicorn是否成功监听端口,避免“脚本没报错但服务其实没起来”的假象。
6.3 数据备份与定时任务
数据库备份我用了mysqldump,每天凌晨跑一次全量备份,保留最近7天。同时用阿里云或者腾讯云的定时快照功能做磁盘快照,防止误改数据。日志方面,Gunicorn的访问日志和错误日志分别输出到不同文件,配了logrotate按天切分。前端Vue的页面异常利用window.onerror做采集,把错误信息POST到后端 /api/log/client 接口,方便在后台看到用户端的报错。
这里需要特别说一个日常维护的细节:车次数据不是固定的,项目里要做后台的车次新增、停运、票价调整功能。票价调整后,已生成的订单是不应该受影响的,因为订单表里冗余存了当时的票价、席别、车次编号。这也说明了表设计时冗余字段的重要性,查询订单时不要再去关联车次表拿当前票价,否则历史订单会跟着票价调整一起变。
7. 从这次项目中总结的几点个人实操体会
做完整套订票系统,我最想分享的不是某个具体技术,而是三个层面的经验。
第一,小项目也要严格定义状态机和接口契约。订票系统涉及的状态比普通CRUD多得多,没有明确的状态机,前端和后端很容易对“待支付、已取消、已出票”这些词理解不一致。接口返回格式必须是全局强约束,不要有任何一个接口单独返回一个“ok”字符串。
第二,并发问题不能等出现了再改。像余票扣减这种写多读少的场景,在设计表结构时就要考虑锁和原子性。如果用普通字段自增去做已售数量,早晚会出事。哪怕项目只是课程设计,也要在代码里保留“设计说明”的痕迹,比如Redis扣减的Lua脚本、乐观锁版本号,这些都是后续面试时可以展开讲的亮点。
第三,部署阶段的配置管理要提前规划。SECRET_KEY、数据库连接串、Redis地址这些不要写死在代码里,统一走环境变量。前面提到改SECRET_KEY导致所有JWT失效的事故,就是没有环境变量管理意识导致的。我还配了一个启动前置检查脚本,检查环境变量、检查数据库连通性、检查Redis连通性,任何一个失败都直接中止启动,而不是等到运行时再报错。
最后再分享一个小技巧:给订单号加一个自定义规则,比如前缀加日期再加随机串,看起来复杂,但排查问题时能一眼看出订单是哪天创建的。火车票订票系统这个选题,技术含量不在于功能多,而在于你是否把状态流转和并发边界想透了。把这些核心问题处理干净,这个项目完全可以包装成一个有深度的个人作品。