☰
基于Spring Boot的高铁售票系统:从需求到部署全解析
2026/10/15 6:30:20 网站建设 项目流程

一年一度的毕业设计季又到了,不少学Java方向的同学都在焦头烂额地选题。如果你正在找一个“业务完整、技术主流、拿得出手也有东西可讲”的题目,这套基于Spring Boot的高铁售票系统可以说是个非常经典的选项。它把在线预订、车次余票查询、订单支付、退票改签、后台运营管理这些票务平台的核心环节全部串了起来,既有数据库设计的复杂度,也有并发扣减库存这种能拿来当亮点的技术点,作为毕设来说含金量足够。这篇内容我就围绕这个项目,把前后端的整体设计、核心表结构、关键接口实现、部署思路,还有我在实际动手过程中踩过的坑,完整拆开讲一遍。

这套系统名义上叫“高铁售票系统”,实际上你做的时候完全可以把它理解成轨道交通客运售票平台,覆盖高铁、动车、城际列车的在线售票场景。它面向两类使用人群:一类是普通乘客,负责注册登录、查车次、下单购票、支付、退改签;另一类是平台运营人员,负责车次管理、车站管理、票价维护、订单审核和运营数据统计。对毕设而言,这个功能边界刚刚好,不会大到失控,也足够铺开三四章的论文内容。不管你是打算直接用Spring Boot + Vue写前后端分离,还是想在架构上加点分布式缓存、消息队列之类的进阶设计,这个题目的扩展余地都很大。

1. 毕业设计定位与整体架构设计

1.1 这个选题到底在解决什么问题

很多人拿到这个题目第一反应是“不就是类似12306吗”,但真动手的时候才发现,难点根本不在页面长什么样,而在业务规则怎么落库、并发情况下库存怎么扣。高铁售票系统的核心矛盾是:同一个车次,不同出发站和到达站组合,占用的库存是同一个物理座位,但售卖逻辑却完全不同。这意味着你不能像普通电商那样,给一件商品存一个库存数字然后下单减一就结束了。

所以这个选题真正的考察点,是学生有没有能力把现实世界的交通业务抽象成数据模型,同时能不能处理高并发下的数据一致性问题。在毕设答辩里,这两点做到了,项目的高度就出来了。哪怕你的并发量只是用JMeter模拟出来的,只要设计思路清晰,数据库表字段合理,扣库存的SQL写得对,就已经能跟“XX管理系统增删改查”拉开明显差距。

从学习价值上讲,这个项目覆盖的技术栈也非常完整:Spring Boot负责后端服务,MyBatis或MyBatis-Plus负责持久层,MySQL存业务数据,Redis缓存热点车次和登录状态,前端用Vue + Element UI做管理界面,ECharts画统计图表。一套下来,Java后端、数据库、缓存、前端四大块全都练到了,对你后续找工作写简历也有直接帮助。

1.2 技术选型:为什么用Spring Boot而不是更“重”的方案

以前很多老教程喜欢用SSH(Spring + Struts + Hibernate),后来流行SSM(Spring + Spring MVC + MyBatis),但今天做毕设,Spring Boot几乎是默认答案。原因很现实:第一,Spring Boot把配置简化到了极致,内嵌Tomcat,一个java -jar就能跑起来,省去部署Web应用的繁琐步骤;第二,社区资料最多,你遇到任何问题搜索一下都有人踩过坑;第三,它面向的是“约定优于配置”的开发方式,你不需要花大量时间在XML配置上,可以把精力放在自己的业务逻辑上。

在这个项目里,我的选型组合是这样的:

层次技术选型说明
后端框架Spring Boot 2.7.x稳定版本,资料丰富,兼容性最好
ORM框架MyBatis-Plus单表CRUD不用写SQL,复杂查询手写XML
数据库MySQL 8.0存储业务数据,支持事务和行级锁
缓存Redis 6.x缓存热点车次查询结果、分布式锁扣库存
认证方案JWT无状态登录,前后端分离友好
接口文档Swagger / Knife4j自动生成在线接口文档,演示效果好
前端Vue 2 + Element UI适合毕设,组件齐全,上手快
构建工具Maven依赖管理标准方案

至于为什么不引入微服务、Spring Cloud那套,我的观点是:毕设阶段完全没必要。单体应用在这个业务体量下是最合适的选择,逻辑都在一个应用里,本地事务天然生效,调试也方便。如果你论文里想体现扩展性,可以在“未来展望”里写一套微服务拆分方案,但项目本身不需要强上Nacos、Feign那套东西,复杂度上去了,稳定性反而容易出问题。

1.3 系统功能模块与整体流程拆解

整个系统从前端页面的角度划分,大致是这样一个结构:

  • 乘客端:注册登录、车次查询、下单购票、在线支付(模拟)、订单查看、退票改签、个人信息管理。
  • 管理端:车站管理、车次管理、票价管理、订单管理、数据统计大盘。

核心业务流程图大概是:乘客输入出发城市、到达城市、出发日期,系统根据车次经停关系查库,返回符合条件的车次列表,并实时显示余票数量和票价;乘客选择车次、选择座位类型、填写乘车人信息,系统扣减对应区间的余票,生成一笔待支付订单;乘客完成支付后,订单状态更新为已支付,出票成功;如果行程有变,乘客可以申请退票或改签,退票会回补库存,改签则先释放旧车票再购买新车票。

这里有一个设计重点需要一开始就想清楚:车次查询必须基于“车站与车次的关联关系”,而不是简单地在车次表里存一个“起点站”和“终点站”就完事。因为乘客查的是任意两站之间的车次,比如查北京到南京,一辆从北京开往上海的高铁经过南京,它就必须出现在结果里。这个查询逻辑是整个系统数据库设计的第一个关键点。

2. 核心表结构与业务逻辑设计

2.1 数据表设计:一张一张拆开看

数据库设计决定了这个项目后续好不好做,我花时间最多的地方也在这。整个系统我最终拆了八张核心表,这个规模对毕设很合适。主表有用户表(user)、车站表(station)、车次表(train)、车次经停表(train_station)、区间库存表(seat_inventory)、票价表(ticket_price)、订单主表(order_info)、订单明细表(order_item)。

车站表和车次表比较简单,重点说车次经停表。这张表是用来表达“每个车次依次经过哪些车站、几点到、几点走”的,核心字段是station_index,代表该车站在这个车次中的经停顺序。我见过不少同学在设计时直接在train表里存出发站和到达站两个外键,结果乘客一旦查询中间站就查不出来,最后只能把所有经停站拼到一个字符串字段里,用like去查,性能差且逻辑混乱。正确做法就是拆一张关联表出来,即车次和时间信息解耦。

车次经停表(train_station)的字段大致是:id、train_id、station_id、station_name(冗余站名,方便展示)、station_index、arrive_time、depart_time、stopover_minutes。这里的station_name属于冗余字段,但我建议保留,因为在列表页要频繁显示站名,每次join车站表虽然也行,但冗余字段可以减少一次关联查询,代价只是多占一点存储空间,对毕设项目来说完全值得。

2.2 库存与票价:区间扣减方案的完整说明

余票库存是很多同学容易搞砸的地方。如果只在车次表里存一个总余票字段,下单直接减一,那就会出问题:一辆车从北京到上海,经停济南,乘客买北京到济南的票和买济南到上海的票,到底扣的是哪个库存?

正确的建模方式是“按相邻区间拆分库存”。具体来说,一趟车次有若干个经停车站,相邻两个站构成一个区间段。比如北京到上海的高铁,经停济南,那它的区间就是“北京-济南”和“济南-上海”。一张票从北京买到上海,占用了这两个区间的座位。系统扣库存的时候,要把这两个区间各自减一。只有从济南买到上海的乘客,才只扣“济南-上海”这一个区间。

区间库存表(seat_inventory)的核心字段:id、train_id、seg_start_station_id、seg_end_station_id、seat_type(商务座/一等座/二等座)、total_seats、remain_seats。注意seg_start和seg_end指的是相邻两个站,而不是任意出发到达站。比如一个车次经停5个站,就有4条库存记录,每个座位类型一组。

票价表(ticket_price)的建模思路则与库存略有不同,按照任意起终点车站组合来存票价,字段是train_id、start_station_id、end_station_id、seat_type、price。因为票价是按照全程里程或者政府指导价定的,和坐多少个区间没有严格线性关系,单独拆一张票价表最灵活。也有的实现方案是按照里程动态计算票价,但毕设里直接用一张票价表,管理端后台可以手动维护,既简单又合理。

扣减库存的SQL是防止超卖的核心,我用的是带条件的更新语句:

<update id="deductStock"> UPDATE seat_inventory SET remain_seats = remain_seats - 1 WHERE train_id = #{trainId} AND seat_type = #{seatType} AND seg_start_station_id = #{startStationId} AND seg_end_station_id = #{endStationId} AND remain_seats &gt; 0 </update>

这里最关键的是最后的remain_seats > 0条件。MySQL在更新的时候会对命中的行加行级锁,如果剩余票数已经为0,条件不成立,影响行数返回0,程序里判断影响行数不为1就直接抛异常、回滚事务。这个方案不需要在代码里先查库存再扣减,天然避免了“查询时还有票,更新时已经被抢走”的问题。它的原理其实就是乐观锁的一种应用,理解起来也不难:把判断逻辑交给数据库去执行,而不是靠Java代码去if。

2.3 订单状态机与支付流程怎么设计

订单状态我用一个整数字段status表示,采用状态机管理。建议的状态流是:0待支付、1已支付、2已出票、3已退票、4已改签、5已取消。乘客下单后生成待支付订单,支付成功后状态变为已支付并出票,退票时从已支付或已出票状态流转到已退票。数据库里用一个字段简单存储,但在代码里我会写一个枚举类或者常量类统一管理,避免到处写魔法数字。

支付环节在这个项目里不推荐真对接支付宝或微信支付。原因很实际:个人开发者申请商户接口门槛高,而且毕设答辩时也不可能扫码真实付款。我采用的做法是写一个模拟支付接口,前端点击支付按钮后,后端直接走“支付成功”的流程更新订单状态。如果你想在论文里增加一点真实感,也可以接一个支付平台的沙箱环境,演示时可以走完整支付流程,但核心业务逻辑不变。做毕设讲究的是把业务讲透,模拟支付完全够用。

订单表我拆了主表和明细表,主表记录订单号、用户id、总金额、状态、创建时间,明细表记录每个乘车人的车次、出发站、到达站、座位类型、票价。看似多了一张表,但其实极大方便了后续退票改签,因为你可以针对明细里的单张票做退票操作,而不影响同订单里其他人。

3. 项目搭建与核心功能实操

3.1 环境准备与工程骨架搭建

动手写代码之前,先把环境准备好。我用的是JDK 8,因为Spring Boot 2.7系列对JDK 8的支持最成熟,学校机房或评委电脑上装的环境兼容性也最好。MySQL 8.0、Redis 6.x、Maven 3.8、Node.js 14,这些版本组合经过了大量验证,基本不会出现莫名其妙的版本兼容问题。

创建项目我用的是Spring Initializr,在IDEA里New Project直接选Spring Boot 2.7,勾选Web、MyBatis、MySQL Driver、Redis、Lombok这些依赖即可。唯一要注意的是Lombok的版本要和IDEA兼容,不然启动时会有莫名其妙的报错,建议IDE版本别太老。

项目的包结构,我建议按业务模块划分,而不是按技术层次划分。也就是说不要把controller、service、mapper按三个大包堆在一起,而是按user、train、order、pay这样拆分,在每个模块内部再放controller、service、mapper。这样代码的组织逻辑更清晰,论文里画架构图也好看。main方法启动类放在根包下,保证@ComponentScan能扫到所有子包。

3.2 车次余票查询:最核心的查询接口

车次查询接口是整个系统的门面,乘客打开首页第一件事就是查车次。它的难点在于SQL怎么写。正确的查询方式是在train_station表里,按出发站和到达站各查一条记录,然后自连接,要求出发站在该车次中的经停序号小于到达站的经停序号,并且车次状态为正常。以北京到济南为例,先找到北京在这个车次的station_index,再找济南在这个车次的station_index,如果前者的索引小于后者,说明这趟车确实是先到北京再到济南,符合乘客的出行方向。

我用MyBatis手写的核心查询SQL如下:

SELECT t.id AS trainId, t.train_no, t.train_type, t.start_station_name, t.end_station_name, ts_from.station_name AS fromStation, ts_to.station_name AS toStation, ts_from.depart_time AS departTime, ts_to.arrive_time AS arriveTime, tp.price AS ticketPrice FROM train t INNER JOIN train_station ts_from ON ts_from.train_id = t.id INNER JOIN train_station ts_to ON ts_to.train_id = t.id INNER JOIN ticket_price tp ON tp.train_id = t.id AND tp.start_station_id = #{fromStationId} AND tp.end_station_id = #{toStationId} WHERE ts_from.station_id = #{fromStationId} AND ts_to.station_id = #{toStationId} AND ts_from.station_index < ts_to.station_index AND t.status = 1 ORDER BY ts_from.depart_time

这里我故意在ticket_price表上也直接按出发站和到达站关联,一套查询直接返回票价,避免回到Service再走一遍价目查询。如果你的票价格表是按“任意站点之间”禁用了的,这个SQL就很干净。

当这个接口写好之后,OP的大部分查询需求已经满足了,但乘客还经常会问:这趟车还有没有票?所以我在返回车次信息的同时,还要统计余票数。做法是结合seat_inventory表,把从fromStation到toStation所有相邻区间的剩余票数的“最小值”查出来,因为一个区间的票卖完了,整个车次的可用余票就成了0。

需要展示商务座、一等座、二等座各自余票的时候,就按座位类型分别取最小值。这个逻辑用一条SQL不太好写,因为涉及“先从经停关系找区间集合,再去库存表里找这些区间的最小值”。我是分两步实现的:先查出这两个站在该车次之间的所有相邻区间段,然后查这些区间段的剩余票数。虽然是两步,但都有索引命中,毕设场景的数据量跑下来性能完全没问题。

这里再加一层Redis的缓存,服务层直接把查询结果序列化成JSON字符串,存到Redis里,key设计成ticket:search:{fromStationId}:{toStationId}:{travelDate},过期时间设置在120秒左右。这样同一个城市对同一天的车次查询,第二次开始直接命中缓存,数据库压力骤减。要注意的是缓存的数据里余票信息会稍微有点延迟,所以在购票下单成功之后,要主动删除对应的缓存key,让下次查询回源数据库拿最新余票。

3.3 下单购票与库存扣减的事务实现

下单接口是整个系统里我最强调事务的地方。一个完整的购票动作包含了多个数据库操作:校验车次和票价、扣减每个相邻区间的库存、插入订单主表、插入订单明细表。任何一个步骤失败,前面做的操作都必须全部回滚,否则就会出现库存扣了但订单没生成,或者订单生成了但库存超卖的情况。

Service层的方法我加了@Transactional注解,然后在方法内部按顺序处理。创建一个订单的核心流程如下:

  1. 根据传入的车次id、出发站id、到达站id、乘车日期,查出这趟车当天是否开行,票价是多少。
  2. 计算出该出发站到到达站之间经过的所有相邻区间,例如北京到上海有3个区间,那就要遍历这3个区间。
  3. 遍历每个区间执行扣库存SQL,每次判断影响行数,如果有一次扣减失败,立即抛出运行时异常,让整个事务回滚。
  4. 全部区间扣减成功后,生成订单号,插入订单主表和明细表,状态置为待支付。
  5. 删除这个车次对应该区间的查询缓存,防止其他乘客看到过期的余票。
  6. 返回订单id和支付所需信息给前端。

有人可能会问:如果多个乘客同时抢同一趟车的最后一张票,会不会超卖?我在前面提到过,扣减SQL里带了remain_seats > 0条件,MySQL执行行级更新时会对该行加锁,所以同时并发请求只会有一个请求得到“影响行数为1”的结果,另一个得到0然后回滚。这个方案虽然在高并发大流量场景下不是最优(可以用Redis分布式锁或者Lua脚本进一步提升),但在毕设里,它能让学生在答辩时讲清楚并发安全的基本原理,非常合适。

我还尝试过在扣除库存时加一个Redis分布式锁,把同一个车次同一天的所有购票请求串行化。这个思路在压测时确实稳定,但它会降低系统的并发处理能力,而且如果只是每趟车分几个区间,每个区间票量有限,数据库行锁的并发粒度已经足够。毕设项目里,“用数据库原子更新防止超卖”已经是很好的回答了。

3.4 退票改签与订单管理怎么做

退票的逻辑看起来是下单的逆操作,但里头也有不少细节。乘客提交退票申请后,如果订单状态是已支付或已出票,系统要做三件事:把对应区间的余票加回来、把订单状态改成已退票、记录退票时间。余票回补不能用“先查再更新”的方式,因为回补本身就是简单的把remain_seats加1,不存在并发问题,直接UPDATE即可。要注意的是退票只能退还未出行的票,如果车次已经发车,就不能退了,这个校验在接口里要写上。

改签我采用的是“先退旧、再买新”的组合实现。乘客提交改签请求后,系统先把原车票释放库存并修改原订单状态为已改签,再去调用一次购票逻辑购买新车票。显然,这会有一个问题:如果新票已经抢不到,旧票也已经退了,乘客就两边落空。更稳妥的做法是加一个“改签中”的中间状态,先把新票锁定,再释放旧票。但在毕设中,我承认这个简化实现是常见的,答辩时能讲清楚它的优缺点即可。如果想让系统更完整,可以在改签Service里用编程式事务,先查新车次余票数量,不足则直接提示“改签失败,新票库存不足”,不影响用户原票。这样虽然存在极小的并发超卖窗口,但作为毕设演示已经够用了。

管理端的订单管理模块相对简单,主要是按订单状态筛选、查看订单详情、处理异常订单。如果订单创建后超过15分钟未支付,状态自动变为已取消,这个我是在定时任务里实现的,Spring Boot自带的@Scheduled就能做,写一个方法扫描待支付订单,如果创建时间和当前时间超过15分钟就把状态改成已取消,同时回补库存。

4. 前端交互与联调部署实操

4.1 前端页面结构与接口如何高效对接

前端我用的是Vue 2 + Element UI。页面不算多,乘客端有注册登录页、车次查询列表页、购票下单页、订单中心页;管理端有车次管理页、车站管理页、票价管理页、订单管理页、数据统计页。整体框架用Vue Router做路由,状态管理如果页面间共享的数据多可以引入Vuex,但毕设项目我实际做下来,大多数状态通过URL参数和路由传参就够了,不引入Vuex反而更轻量。

前后端联调最大的坑就是跨域。前端开发服务器跑在8080端口,后端跑在8081端口,浏览器的同源策略会直接拦截请求。我最终选择了在后端配置一个全局CORS配置类,允许所有来源的请求访问,把allowedHeaders、allowedMethods都放开。这样前端开发环境下可以直接联调,生产环境部署时如果用Nginx反向代理,跨域问题自然就没了。

前后端对接约定接口时统一使用RESTful风格,响应结果封装成统一结构——code、message、data。前端axios封装成公共请求方法,在响应拦截器里统一判断code,如果code是401,说明登录过期,跳转到登录页;如果code是500,弹出错误提示。这个统一响应格式能省掉大量重复的错误处理代码。

4.2 接口文档与Swagger配置

毕设答辩的时候,评委经常会在看代码之前先问你“系统有哪些接口”,这时候Swagger的在线文档就能派上大用场。我在项目中集成了Knife4j,一个基于Swagger的增强文档框架,界面比原生Swagger好看很多,操作也方便。

集成步骤很简单,引入依赖后在启动类上加@EnableSwagger2,然后在配置类里写好分组信息,我按乘客端接口和管理端接口分了两个分组,这样文档看起来层次分明。每个接口上我加了@ApiOperation注解描述这个接口的作用,参数上用@ApiImplicitParam标注含义。有了Swagger之后,我调试接口基本就不再依赖Postman了,直接在网页上调试,非常顺手。

4.3 前后端打包部署与演示环境

部署方案我推荐前后端分离分别部署。后端在IDEA里执行Maven的package命令打成jar包,服务器上只要装了JDK 8,一条java -jar xxx.jar就能启动。前端执行npm run build之后生成dist目录,里面是纯静态文件。

我实际部署时用的是Nginx托管前端静态文件,后端jar包单独跑一个进程。Nginx的配置里做一个反向代理,把/api开头的请求转发到后端服务的8081端口。这样部署的好处是:浏览器和Nginx都在80端口交流,Nginx再把API请求转发给后端,从乘客的角度看只有一个服务地址,完全感受不到后端的存在。生产环境的跨域问题也因为有了Nginx这个中转层被彻底消除了。

这里有一个小建议:演示的时候,数据库里提前预置一点“生动”的数据,比如“北京-上海”这趟热门车次,每天有早晨7点、上午10点、下午3点等多个班次,各类座位的余票也各不相同,让评委在演示时一眼就能看到列表页的对比变化,效果比空表数据好得多。你甚至可以准备两个账号,一个普通乘客账号、一个管理员账号,在演示时快速切换角色展示不同功能。

5. 常见问题与避坑实战

5.1 高频问题排查速查表

在整个项目开发过程中,我记录了一堆问题,有些问题查了很久才发现原因,这里整理出来给大家参考。

常见问题产生原因解决方案
扣库存后订单创建失败,但库存没恢复忘记加@Transactional在创建订单方法上添加@Transactional,并确保异常传播正确
查询车次接口返回数据很慢train_station表缺少联合索引为train_id、station_id建联合索引,查询前先执行EXPLAIN
后端接口报跨域错误前端端口和后端端口不同在后端配置全局CORS,放行所有来源
前端传日期到后端总是差8小时前后端日期格式和时区处理不一致统一使用字符串yyyy-MM-dd传参,后端用LocalDate接收
订单状态无法正确流转使用Integer字段直接比较定义订单状态常量类或枚举,避免魔法数字
缓存数据太久导致余票数量不对Redis过期时间设置过长设置120秒较短过期时间,下单成功时主动删除相关缓存key
MyBatis查询结果存在null字段实体字段名和数据库下划线字段没有映射开启MyBatis-Plus驼峰映射,或使用@TableField注解
模拟支付后订单查询仍然显示待支付前端异步请求被拦截未刷新支付成功后通知前端刷新订单详情,必要时增加轮询接口

5.2 实操心得:几个容易忽略的细节

我印象最深的一个坑是事务方法内部调用不生效的问题。我在写改签功能的时候,为了让代码结构清晰,把“释放旧票”和“购买新票”拆成了两个Service方法,然后在改签方法里先调释放再调购买,结果发现一个方法抛异常时另一个方法的事务没有回滚。原因很简单:Spring的事务是通过代理对象生效的,同一个类内部方法直接调用时,不会经过代理,所以@Transactional就不起作用。解决方法是把两个方法拆到不同的Service类里,或者直接在同一个事务方法里顺序编排逻辑。

另一个想提醒的是关于“删除缓存”的时机。我第一次做的时候,下单成功后只删了车次查询缓存,但退票和改签没有删,结果演示的时候乘客明明已经退票了,列表页显示的余票还是之前的数字,这就是缓存和数据库不一致的典型案例。后来我写了一个公共方法,专门负责清除某个车次相关的所有缓存key,下单、退票、改签三个地方都调用它,问题就彻底解决了。

再有一点,SQL的UPDATE语句的“受影响行数”要用int接收,而不是boolean。因为MySQL里即使更新前后的值相同,也可能返回影响行数是0,如果你返回boolean再去判断true/false,可能判断出错。我统一用int判断,扣库存必须等于1才算成功。

写到最后的一点经验

前后做了两三周,最深的体会是:毕设项目的代码量其实不大,真正的难度在于对业务的理解和对边界情况的处理。高铁售票系统最出彩的地方,不是登录注册或者页面多好看,而是“区间库存扣减”这一个点,它能把数据库设计、并发控制、事务处理三个大块技术全部串起来,论文答辩时这一块讲透了,整个项目的技术高度就立住了。如果你学有余力,可以在这个基础上去优化,比如用Redis+Lua脚本扣库存、引入MQ做订单异步处理、对车次查询做更细粒度的缓存等,这些都是很好的论文扩展方向,但对当前版本来说,先把基础功能做稳才是正事。希望这篇文章能帮你把项目主线的思路理顺,少走一些我当年走过的弯路。

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

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

立即咨询