SpringBoot+Vue火车票订票系统开发实战:余票建模与并发控制 📅 发布时间:2026/9/18 4:08:38 👁 浏览次数: 毕设阶段被“火车票订票系统”困住的人我估计不在少数。这个题目看着简单——不就是查车次、下订单、付个款真正动手写代码才发现余票怎么算、座位怎么分、并发下单会不会超卖、前后端接口对不齐随便一个点都能卡你好几天。作为已经帮人调过很多次这类项目的过来人我把整套 SpringBootVue 火车票订票系统的完整实现拆开讲一遍包括源码结构、SQL 脚本设计、接口文档的写法以及跑通之后才会暴露出来的隐蔽坑。这篇文章适合正在做 Java Web 毕设、或者想拿前后端分离项目练手的人照着做能少走很多弯路。1. 别把订票系统做成增删改查先想清楚难点在哪很多同学拿到这个题目后的第一反应是建五张表用户表、车次表、订单表、乘客表然后开始写 CRUD。交上去之后老师问一句“你这余票是怎么算的”当场卡壳。这个项目看着业务简单实际上有一点没想明白就会全盘崩掉——余票不是一张表里的数字而是一段区间内的座位归属关系。1.1 业务边界要拆成四个核心场景谁买票、在哪买、买什么座位、付不付钱这四个场景对应的是注册登录用户体系涉及 JWT 或 Session 鉴权车次查询按出发地、到达地、出发日期筛选车次展示余票数下单购票选择一个车次的具体某一段区间锁定座位生成订单支付与取消支付超时释放座位退票则回收座位其中的核心难点集中在第二和第三个场景。车次查询难点在于“余票数”的实时计算下单购票难点在于“锁座位”的并发控制。这两块如果只按普通 CRUD 来做大概率会出现库存超卖、同一座位卖两次的情况。1.2 真正的难点余票库存怎么建模火车票订票和普通商品秒杀最大的区别在于商品库存就是一个字段stock - 1但火车票的库存是“行政区间的余量”。举个例子北京到上海的高铁沿途停靠济南、徐州、南京。一个人买北京到南京的票占用的不只是北京到南京这一段这段座位在济南到徐州区间同样被占用——它不能同时卖给“济南到徐州”的另一个乘客。所以余票不能简单地存一个count字段然后减一而是要把座位和站序的关系建模出来。这套系统的数据模型必须落在“车次-车厢-座位-站点”的矩阵关系上这也是整个项目最有含金量的部分。1.3 订单状态机的设计订单状态我建议直接定死五个状态前端后端都按这个枚举走状态枚举值含义待支付0下单成功但未付款已支付1支付成功出票有效已取消2用户主动取消或超时未支付已退票3支付后申请退票已出行4车次已过出发时间订单完成状态流转要写一个统一的 Service 方法去处理比如取消订单时释放座位支付成功时把座位状态从“锁定”改成“已售”。不要让前端页面自己改状态后端被动接收否则后面报表和统计全会乱套。2. 技术栈与版本选型为什么你照着教学视频写还是报错SpringBoot 和 Vue 的技术栈本身没什么争议但版本差距很容易让人崩溃。很多网上的教程代码是基于 SpringBoot 2.x Vue 2.x 写的你照着配结果新建项目默认是 SpringBoot 3.x代码一跑全是红叉。我先把我实测可行的版本组合列出来。2.1 SpringBoot 2.7.x 而不是 3.x 的理由SpringBoot 3.x 默认基于 JDK 17很多毕设教学代码基于 JDK 8并且用的javax.servlet命名空间在 3.x 里被换成了jakarta.servlet。如果你找的参考代码都是import javax.servlet.*而你用的是 SpringBoot 3.x编译会直接报“程序包不存在”。这不是你代码的问题是版本代差的问题。我这套采用的是SpringBoot 2.7.18 JDK 8依赖管理简单教学资源也多Tomcat 内嵌版本是 9.x对javax兼容良好。如果你的老师强制要求新版当然也可以用 3.x但建议搞清楚jakarta包路径的问题别卡在环境上耗时间。2.2 Vue 2 Element UI 的选型逻辑前端部分 Vue 2 比 Vue 3 更适合这类项目原因不是 Vue 3 不好而是大量的后台管理模板、Table 表单组件、分页组件在 Vue 2 Element UI 的组合下资料最全遇到问题搜一下基本上都有答案。Element UI 对表单校验、表格数据绑定、下拉联动的封装很完善适合毕设场景下快速出效果。Vue 3 的生态虽然已经成熟但普通学生临时切换 Composition API 和新的 Element Plus 组件写法成本比想象中高。如果你不是非要追求新版Vue 2.6.14 Element UI 2.15.6 这套组合搭配 vue-router 3.x 和 axios跑起来非常稳。2.3 IDEA 创建项目时最常见的三个坑用 IDEA 创建 SpringBoot 项目时很多人会卡在初始化阶段。我的建议是创建项目时选 Spring InitializrServer URL 选start.spring.io如果网络不稳定可以改阿里的镜像地址JDK 版本要和项目要求的保持一致不要在 JDK 17 环境下跑一个 target 为 JDK 8 的项目容易报invalid source release错误Maven 仓库地址改成阿里云镜像否则下载依赖会慢到怀疑人生Maven 的settings.xml里加上这段镜像配置下载速度能有质变mirror idaliyunmaven/id mirrorOfcentral/mirrorOf name阿里云公共仓库/name urlhttps://maven.aliyun.com/repository/public/url /mirror3. 数据库设计车次、座位、余票三者的映射关系数据库是整个订票系统的地基表结构没设计好后面写任何功能都别扭。我直接给出这套系统的核心表设计思路以及 SQL 脚本里的关键字段。3.1 六张核心表与字段说明train车次表车次编号、车次类型高铁/普快、始发站、终点站、出发时间、到达时间、历时、票价等级train_station车次站点表车次 ID、站点 ID、站序、到达时间、离开时间、站点间距离carriage车厢表车次 ID、车厢号、车厢类型一等座/二等座/硬座/硬卧、座位数seat座位表车厢 ID、座位号、座位位置靠窗/过道orders订单表订单号、用户 ID、车次 ID、出发站 ID、到达站 ID、乘车日期、车厢号、座位号、金额、状态、创建时间passenger乘客表用户 ID、乘客姓名、身份证号、座位类型偏好其中seat表是核心中的核心它的每一行代表一个物理存在的座位。为什么不能只用一个剩余票数字段因为同一辆车在 A 站到 B 站的余票数和 A 站到 C 站的余票数不一定相同必须通过座位在区间上的占用关系推导而不是存一个汇总数字。3.2 余票不是一个数字是一段区间我来画一个具体的推演场景。G101 次车沿途站点按站序编号站点1: 北京 站点2: 济南 站点3: 徐州 站点4: 南京座位 05A它被承载在一个车厢中。如果有人买北京到南京的票系统把座位 05A 标记成“占用区间 1 到 4”。此时查询济南到徐州的余票时发现 05A 的占用区间覆盖了 2 到 3那么 05A 就不能再卖。但如果查询的是北京到济南区间那么 05A 在这个区间内已经占用同样不能再卖如果查济南到南京区间依旧不能卖。到这一步你会发现规律只要乘客行程的区间和已售座位的占用区间存在交集那这个座位就算不可用。所以每次查询余票时需要遍历该车次所有座位查看座位是否被一个与之有时间交集的订单占用。这个逻辑听起来朴素但实现时要控制查询性能。火车座位数量在 500 到 1000 之间单次查询遍历所有座位并判断所有订单压力不大但接口联调时一定要在 SQL 里把车次和乘车日期两个条件配合索引加上否则后期数据量稍大查询就很慢。3.3 日期对不上的坑车次表为什么要单独维护运行日期有的同学设计表时直接在车次表上放一个运行日期字段这个思路是错的。G101 这趟车今天有、明天有但可能每周三停运。如果车次日期写死那么查询“本周三 G101”就会查到一条无效数据。正确做法是有一张train_date车次日历表字段为车次 ID、运行日期、是否运行。用户查询时先按日期过滤train_date就能天然过滤掉停运日期。这个表也方便后续接入节假日调图这种复杂规则毕设阶段用简单的字段控制即可。3.4 SQL 脚本里的初始数据和索引源码包里附带的 SQL 脚本我习惯把建库、建表、插入初始数据拆成三个步骤。初始数据一定要包含3 到 4 条车次覆盖“高铁”和“普快”两种类型每个车次至少 8 个站点站点名称可以用真实城市名城市间距离填真实值方便演示区间余票每个车次至少 2 个车厢每个车厢 20 到 50 个座位用户表插入 1 个测试用户方便登录演示索引方面orders表的train_id、travel_date字段必须建复合索引seat表的carriage_id建普通索引train_station表的train_id加station_order联合索引。这些索引直接决定余票查询和下单校验的响应速度脚本里提前建好别等代码写完了再补。4. 核心业务实现余票查询、锁票下单与座位分配全系统真正能体现出业务深度的是下面这三段核心逻辑。别怕复杂拆开来看其实是固定的套路只是第一次接触时容易绕晕。4.1 余票查询的区间计算逻辑前端传参是一个 JSON大概长这样{ trainId: G101, fromStationId: 1, toStationId: 4, travelDate: 2025-06-01 }后端拿到trainId后按照当前车次查找站点列表确定出发站的站序号startIdx和到达站的站序号endIdx。这个区间代表用户需要的行程范围。然后查两张表一是查所有在travelDate当天、车次为 G101、状态合法的订单拿到每个被占用的座位号以及它的出发站序号和到达站序号。二是拿出seat表中所有座位逐个判断如果该座位没有对应的占用订单则是可售的如果有则比较订单占用的区间和本次查询的区间是否存在交集。区间重叠判断用这个经典条件// 存在交集的条件已订区间不完全落在需求区间之外 boolean hasOverlap !(existingStart queryEnd || existingEnd queryStart);遍历所有座位统计满足可售条件的数量就是当前区间的余票量。顺带还能按座位类型一等座/二等座分组返回前端显示不同类型余票数。4.2 下单时的并发锁防止同一座位被卖两次查询接口是不加锁的可以并发读。但生成订单时必须保证同一个座位同时只能被一个用户锁定。这里最实用的方案不是 Redis 分布式锁而是利用数据库的行锁。伪代码逻辑如下Transactional public Order createOrder(CreateOrderDTO dto) { // 1. 检查用户校验乘客信息 // 2. 查询车次信息确认该日期有票 // 3. 查询候选座位用 SELECT ... FOR UPDATE 锁住这条座位记录 Seat lockSeat seatMapper.selectForUpdate(seatId); // 4. 校验该座位在目标区间是否已被占用 // 5. 如果空闲插入订单记录状态为待支付 // 6. 插入订单明细和乘客关联 // 7. 返回订单号 }第 3 步的SELECT ... FOR UPDATE是在事务内锁定座位行这样在高并发场景下两个用户同时下单同一个座位时只有一个请求能拿到行锁另一个必须等待锁释放后重新读取数据发现座位被占就会下单失败。这就是防止超卖的底层机制。4.3 座位分配自动分配与手动选座部分订票系统支持手动选座但大多数用户习惯“系统自动分配”。自动分配的算法很简单但有个优化点基本策略优先分配同一车厢内空闲的座位按车厢号从小到大、座位号从小到大分配相邻偏好如果同一订单有两张以上车票优先分配相邻或同排的座位减少用户上车后找人换座的烦恼实现时先查某个车厢内所有空闲座位按车厢号和座位号排序再判断它们是否连续分配在同一排。这个逻辑在毕设阶段可以简化成跳过已经占用的座位把空位按顺序分配给该订单的多个乘客但相邻优先级确实能给演示加分。4.4 支付超时释放与订单状态流转支付超时是另一个隐藏难点。下单单后用户迟迟不付款座位始终被占着会影响其他乘客购票。实测中建议这样处理下单时给订单设置一个expire_time默认 15 分钟定时任务每 30 秒扫描一次待支付订单发现超时的调用取消订单逻辑把座位释放用户手动取消订单时也调用同样的释放逻辑超时处理不要自己写 while 循环死等用 Spring 自带的Scheduled注解就能实现定时任务类在启动类上加上EnableScheduling即可。4.5 Redis 在项目里用在哪很多毕设项目会把 Redis 当成“加分项”硬塞进来。在这个订票系统里Redis 的合理使用场景有三个缓存热点车次的余票查询结果比如车次信息 日期 区间缓存 30 秒减少数据库压力存储用户登录 token替代 Session实现前后端分离下的登录鉴权分布式锁防止重复下单虽然用数据库行锁已经够用但用 Redis 锁能让项目在答辩时更有说头实测下来Redis 在毕设项目中最大的价值不是提升性能而是让你的技术方案在描述时多一个“缓存层面优化”的亮点。4.6 定时任务的落地写法如果你实在不想引入 Redis只用数据库也能完成整套流程但定时任务释放超时订单是绕不开的。代码实现大致是Component public class OrderTimeoutTask { Autowired private OrderService orderService; Scheduled(fixedDelay 30000) public void releaseTimeoutOrders() { ListOrder timeoutOrders orderService.findTimeoutOrders(); for (Order order : timeoutOrders) { orderService.cancelOrder(order.getOrderId()); } } }这个任务在开发环境会很实用部署到服务器后也要确保它正常运行。注意fixedDelay表示每次执行完后间隔 30 秒再执行下一次任务本身要保证快速结束不要在里面做耗时操作。5. 接口文档与前后端联调字段名对不齐最伤感情源码包里附带的接口文档很多同学会以为只是“交差用的”。实际上接口文档写得好不好直接决定前后端联调要花一周还是一天。我见过太多前后端各写各的字段名一个叫userId一个叫user_id联调时改来改去全是在浪费时间。5.1 接口清单12 个接口覆盖全部功能点按模块划分这套系统对外暴露的核心接口大概有这些模块接口路径方法说明用户/api/user/loginPOST登录返回 token用户/api/user/registerPOST注册车次/api/train/searchGET按出发地、到达地、日期查车次车次/api/train/{id}/stationsGET查车次站点列表车次/api/train/{id}/remainingGET查某车次某区间余票数订单/api/order/createPOST创建订单订单/api/order/listGET查当前用户订单列表订单/api/order/detailGET查订单详情订单/api/order/payPOST模拟支付订单/api/order/cancelPOST取消订单订单/api/order/refundPOST退票后台/api/admin/train/savePOST维护车次信息可扩展接口返回统一采用这种结构前端拿到后只做一次解包{ code: 200, message: 操作成功, data: {} }5.2 接口文档必须写清楚的四件事别把接口文档写成一堆 URL 的列表。真正有用的接口文档每个接口至少包含请求方法、URL、请求头是否需携带 token请求参数表格参数名、类型、必填、说明响应成功案例 JSON响应失败案例 JSON状态码含义比如401表示未登录400表示参数校验失败建议用在线接口文档工具维护导出 Markdown 或 PDF 放进源码包老师看起来也方便。接口文档写得好答辩时“项目工程化管理能力”这一项评分就能拉上去。5.3 Vue 端的请求封装与拦截器Vue 项目里别在每一个页面直接写 axios必须统一封装。我在项目中用的是import axios from axios import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) service.interceptors.response.use( response { return response.data }, error { if (error.response.status 401) { router.push(/login) } return Promise.reject(error) } ) export default service把baseURL设为/api后开发环境通过 Vue CLI 的 proxy 把请求转发到后端 8080 端口生产环境则通过 Nginx 反向代理统一转发前端代码不用改动。5.4 跨域问题的根治方案前后端分离项目跨域问题几乎一定会遇到。我的建议是后端使用统一配置类而不是每个 Controller 加CrossOrigin注解。一个全局的 CORS 配置就能覆盖所有接口而且对预检请求 OPTIONS 也做了处理。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }如果用了 JWT 做鉴权跨域时allowCredentials(true)和allowedOriginPatterns(*)要配合使用只填allowedOrigins(*)在带凭证请求时会直接报错。6. 从源码到服务器启动、打包、部署全流程实录代码写完了真正考验人的是部署环节。我整理一遍从本地跑通到服务器上线的完整操作流程这些步骤我反复验证过照着做基本不会翻车。6.1 后端从 IDEA 启动的完整步骤拿到源码后第一次启动的流程是用 IDEA 打开后端项目等待 Maven 依赖下载完成修改application.yml里的数据库连接账号密码改成你自己的 MySQL 账号密码先执行 SQL 脚本建库建表插入初始数据运行主启动类看到 Spring Banner 出现且端口 8080 启动成功浏览器访问http://localhost:8080/api/train/search测试接口是否通实测中很常见的一个报错是Access denied for user rootlocalhost原因基本都是密码不对或 MySQL 服务没启动。先在命令行用mysql -u root -p手动连一遍确认账号密码再回过来改配置文件。6.2 前端 Vue 项目的依赖安装与配置前端部分更磨人尤其是 Node 版本太新导致依赖安装失败的问题。我用的是 Node 16配合 npm 8安装依赖时npm install --registryhttps://registry.npmmirror.com这个命令会强制使用国内镜像装依赖的速度大幅提升。装完后跑npm run serve如果出现Error: Cannot find module node-sass不要硬装这个是版本兼容问题去项目的package.json里看下依赖版本多半是缺了node-sass或sass-loader把版本对上后npm install一次即可。6.3 Vue 打包后的经典布局异常怎么解决npm run build打包之后打开dist/index.html发现样式丢失或者资源 404这是新手最崩溃的报错。根本原因在于Vue 默认的publicPath是根路径/你的文件如果部署在服务器子目录下它就会去找/static/js/xxx.js当然 404。解决办法是修改vue.config.jsmodule.exports { publicPath: ./, outputDir: dist, assetsDir: static }publicPath改成./之后打包资源路径会变成相对路径部署到任意子目录都不会出问题。这个改动对本地npm run serve的开发模式没有影响但生产打包必须加。6.4 部署到服务器的两种方案方案一前端打包后把dist文件夹里的内容复制到后端项目的src/main/resources/static目录下重打后端 Jar 包直接java -jar xx.jar启动。这种方式适合演示和毕设答辩一个端口搞定全部最省事。方案二前后端彻底分离部署。后端 Jar 监听 8080前端dist放到 Nginx 的 html 目录Nginx 监听 80 端口并把/api路径反向代理到 8080。Nginx 核心配置server { listen 80; server_name your_server_ip; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意proxy_pass那里结尾不带斜杠带了斜杠路径会被重写我第一次配置就因为这个浪费了半天。6.5 服务器上 SQL 导入与常见报错服务器上导入 SQL 脚本时很多人习惯用 Navicat 连接上去执行整个脚本文件如果脚本里有中文注释容易出现乱码或中断。稳妥的做法是先在本地用命令行确认脚本编码是 UTF-8导入时mysql -u root -p database_name train_ticket.sql如果服务器上 MySQL 版本和本地不一致或者脚本里用了ENGINEInnoDB DEFAULT CHARSETutf8mb4导入后启动项目报Unknown database先检查 MySQL 是否成功建库再检查账号权限。7. 答辩演示和代码收纳几个容易被追问的技术细节好不容易把项目跑通了答辩环节如果答不上来“为什么这样设计”照样会扣分。下面这几个问题你最好提前准备好答案。7.1 给老师演示时最稳妥的路径演示不要一上来就登录不要一上来就下单。稳妥的顺序是先搜车次输入出发地、到达地、日期展示车次列表和余票数量然后选择一个车次进入详情页看站点图。接着登录选票下单故意把支付页面打开不付钱等定时任务把订单释放了再重新下单体现你的订单超时释放机制是真实生效的。最后展示支付成功后的订单列表和退票功能。这套流程走完核心功能全都有覆盖而且每一步都有技术点可以展开说老师一眼就能看出项目是真正做出来的不是抄来的。7.2 老师最爱问的三个刁钻问题第一余票数是怎么算出来的为什么不是查一个字段你要回答的是座位区间占用模型以及区间交集判断的 SQL 逻辑。第二两个用户同时买同一个座位怎么办你要回答的是数据库行锁SELECT FOR UPDATE结合事务保证只有一个请求能成功。第三超时订单是怎么释放的你要回答 Spring 定时任务扫描待支付订单调用与手动取消同一个状态流转方法避免座位永久占用。这三个问题答上来基本就说明你是真懂这套系统而不是只会 CtrlC 和 CtrlV。7.3 用 Git 管理源码的习惯虽然是毕设但最好从一开始就用 Git 管理代码。前端和后端分两个仓库每次改动写清楚 commit message比如feat: 完成余票查询接口、fix: 修复订单超时释放时座位未重新标记为空闲。等答辩结束后重新梳理代码你会发现 Git 历史本身就是一份很好的文档。我在实际做过多个类似项目后的体会是这类订票系统真正拿分的不是花哨的页面特效而是你是否清楚数据模型背后的业务约束以及并发场景下如何保证数据一致。把这些讲透源码本身反而是次要的了。