
最近在整理一套前后端分离的铁路订票管理系统技术栈是 Java SpringBoot Vue3 MyBatis MySQL。这个项目麻雀虽小五脏俱全既覆盖了后端接口开发、数据库建模、并发控制这些 Java 后端的核心技能点又牵扯 Vue3 组件化开发、前后端接口联调、跨域处理、打包部署等前端工程化问题很适合拿来当毕设项目、面试项目或者日常练手。我收到过不少私信问这种看起来到处都是源码的项目到底该怎么看、怎么改、怎么跑起来。这篇文章我不打算把源码一行行贴出来逐字解释而是把整个系统从需求拆解到库表设计、从后端核心逻辑到前端页面实现、从本地联调到部署上线完整拆开讲一遍。包括很多源码里根本看不出来的设计意图以及我在实际复现过程中踩过的坑。你要是准备基于这套东西二次开发或者想把它写进简历应该能省下不少时间。1. 这个铁路订票系统到底做了什么为什么选这套技术栈1.1 系统的功能边界与业务定位先把业务边界说清楚。这个系统面向两个角色普通用户和后台管理员。用户端核心功能是注册登录、车次查询、在线订票、退票、个人订单查看管理端则是车次管理、车站管理、余票监控、订单统计。听起来很常规但铁路订票四个字意味着数据库设计和后端逻辑要比普通增删改查系统复杂两个档次。核心难点在于车次不是简单的静态数据它关联了始发站、终点站、发车时间、到达时间、途经站点、车厢类型、座位等级还要和每一天的余票库存联动订单又和车次、乘车人、支付状态绑定。这是一套典型的主数据 库存 交易三层结构比单纯的学生管理系统挑战大得多。这个定位决定了项目的展示价值。面试官看到你做过订票系统大概率会追着问三件事多条件车次查询怎么实现的余票扣减怎么防止超卖订单和车票的状态怎么流转这三条线贯穿整个系统也是后面每个技术点选择的依据。1.2 技术选型背后的实际考量SpringBoot Vue3 MyBatis MySQL 这套组合在目前国内的中小团队项目里非常常见它不是最先进的但确实是最稳、最容易招人的组合。后端选 SpringBoot 而不是传统的 SSM最大好处是简化了配置。拿数据源来说早期 SSM 要写一堆 XML 配置 datasource、SqlSessionFactory、事务管理器SpringBoot 只要在 application.yml 里写几行连接信息再加个 starter 依赖就完事。这对快速迭代和二次开发非常友好。MyBatis 相比 JPA/Hibernate 的优势在于 SQL 完全由开发者掌控。订票系统里有大量动态 SQL比如车次查询要按出发站、到达站、日期、坐席类型做可选条件拼接MyBatis 的if标签写起来非常直观。JPA 在这方面虽然也能通过 Specification 实现但可读性和调优空间都不如直接写 SQL 舒服。前端选 Vue3 而不是 Vue2属于顺势而为。Vue3 的 Composition API 配合script setup写业务代码复用性和代码组织能力比 Options API 强很多。特别是订票流程这种涉及多步骤表单、多状态切换的场景用 Composition API 抽离逻辑非常顺手。构建工具选择 Vite本地开发启动速度快热更新体验比 Webpack 时代的 Vue CLI 好一个量级。MySQL 作为存储层没什么好说的8.0 之后性能、JSON 支持、窗口函数都够用小型订票系统根本碰不到瓶颈。真正需要注意的反而是驱动版本、时区设置、SSL 这些细节后面单独讲。1.3 前后端分离的模块划分工程结构上前端和后端是完全独立的两个项目各自维护、各自部署。后端只提供 RESTful API前端通过 Axios 调用接口互不干扰。我习惯的前端目录结构是这样的src/ api/ // 接口请求封装 router/ // 路由配置 stores/ // Pinia 状态管理 views/ user/ // 用户端页面 admin/ // 管理端页面 components/ // 公共组件 utils/ // 工具函数后端则按业务模块分包而不是按技术层次把 controller、service、mapper 全堆在一起。我推荐的方式是com.example.railway common/ // 通用返回体、异常处理、工具类 config/ // 跨域、拦截器、MyBatis 配置 controller/ // 控制层 service/ // 业务层 mapper/ // MyBatis Mapper 接口 entity/ // 实体类 dto/ // 前端交互的数据对象 vo/ // 返回给前端的数据对象这种模块化划分在源码里如果已经做好二次开发会非常舒服如果源码全部摊在一起我建议你先按这个思路重新整理一遍不然后面改需求的时候想死的心都有。2. 数据库设计车次、余票与订单这三条主线的建模思路2.1 核心表的划分与关系数据库是整个订票系统的地基。我见过不少半吊子源码数据库就三四张表车次和余票混在一张表里订单信息全塞在一张表里。这种设计演示可以上线绝对不行。一个相对合理的铁路订票系统核心表至少应该有这六张用户表sys_user、车站表station、车次表train、车次站点关系表train_station、余票表stock、订单表order再加一张乘车人表passenger和订单明细表车票表ticket。train表存车次的基础信息车次编号、车型、始发站、终点站、全程运行时长、状态。注意不要直接把途经站点塞进一个字段像北京-天津-济南-南京-上海这种字符串对查转乘、查区间完全没帮助一定要拆成独立的train_station表每一行记录一个车次在某一个站点的到达时间、出发时间、站序。station表单独拆出来是为了支持车站管理。以后想加按车站查询所有经停车次的功能直接就 JOIN 出来了。如果你把站名直接写死在 train 表里这种需求做起来想死的心都有。2.2 余票库存字段的设计为什么不用一张票一条记录订票系统最关键的库存设计有两个方向一种是为每个座位创建一条记录订票时通过锁定座位记录来实现占座另一种是在余票表里存余票数量字段订票时对数字做原子扣减。这个系统用的是第二种即余票表存数量。这种做法更贴近真实铁路场景因为铁路座位有共用区间同一列车不同区段可以卖同一张座位单纯用座位锁很难处理。而按车次 日期 出发站 到达站 坐席类型维度存一个余票数字扣减时对数字做条件更新简单高效也便于扩展出限售区间共用席位之类的复杂逻辑。余票表结构大致是这样stock_id BIGINT train_id BIGINT // 关联车次 travel_date DATE // 乘车日期 departure_id BIGINT // 出发站 arrival_id BIGINT // 到达站 seat_type TINYINT // 坐席类型二等座/一等座/卧铺 quantity INT // 余票数量 version INT // 乐观锁版本号这里一个容易被忽略的点是余票应该按区间存储而不是只按车次存总数。比如一趟车从北京到上海中间经过南京用户买北京到南京的票只占用北京到南京这段的库存如果你只存整列车余票那南京到上海的可售情况完全没法表示。所以余票表的粒度必须精确到出发站和到达站的组合。2.3 订单状态与支付状态的流转设计订单表的核心是状态机。一个订票订单从创建到完结至少要经过这几个状态待支付、已支付、已出票、已退票、已取消。支付状态和订票状态最好分开表达不然混在一起会出现支付成功但出票失败这种尴尬状态。订单表大致字段order_id BIGINT order_no VARCHAR // 订单号唯一 user_id BIGINT train_id BIGINT travel_date DATE departure VARCHAR arrival VARCHAR seat_type TINYINT ticket_count INT total_amount DECIMAL status TINYINT // 0待支付 1已支付 2已出票 3已退票 4已取消 create_time DATETIME出票之后一张票对应一个乘车人的记录就落到ticket表这样退票、改签都能找到对应票面信息。订单号生成不要用数据库自增建议用时间戳 随机数的方式拼一个全局唯一单号或者直接用 MyBatis 的 UUID 策略这样在多实例部署时不会重复。3. 后端 SpringBoot MyBatis 里最难啃的几个点3.1 余票扣减的并发控制乐观锁还是悲观锁余票扣减是这类系统最容易被面试官抓细节的地方。两个用户同时抢同一区间最后一张票如果代码是先 SELECT 余票数量判断大于 0再 UPDATE 减少那在并发场景下几乎必然超卖。解决办法有两种一种是使用悲观锁在事务里执行SELECT ... FOR UPDATE把这一行库存锁住等订单创建完成提交事务后再释放其他请求只能排队保证不会超卖。另一种是乐观锁在 UPDATE 语句的 WHERE 条件里带上版本号或者余票数量条件UPDATE stock SET quantity quantity - 1, version version 1 WHERE train_id #{trainId} AND travel_date #{date} AND departure_id #{departureId} AND arrival_id #{arrivalId} AND seat_type #{seatType} AND quantity 0这种写法返回的影响行数如果为 0说明库存已经不足或版本冲突业务层捕获到这个结果直接告诉用户余票不足就行根本不需要先查后改。我推荐用这种带条件扣减的乐观锁方案因为在高峰期能保持比较高的系统吞吐量不会因为行锁排队拖死整个订票接口。需要注意的坑是余额扣减必须和订单创建放在同一个事务里否则订单创建成功但库存没扣掉或者库存扣了但订单创建失败回滚不完整都会造成数据不一致。3.2 事务边界与订单创建的原子性订票的核心流程是三步扣减余票、创建订单、生成车票明细。这三步必须在一个事务里完成任何一个环节失败全部回滚。实现上在 Service 方法上加Transactional(rollbackFor Exception.class)是最基本的。注意默认情况下 Spring 只对 RuntimeException 回滚如果你在方法里自己 catch 了异常就要手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()否则事务照样提交这算是经典翻车现场。还有一个容易被忽略的点MySQL 默认事务隔离级别是 REPEATABLE READ在并发扣减库存的场景下如果使用了先查询再更新的写法可能会出现幻读问题。用前面那条带条件的原子 UPDATE 可以绕开这个问题。这也是我不建议在库存扣减上搞查-判断-改三步式代码的原因。3.3 MyBatis XML 映射中容易翻车的细节MyBatis 是这个项目里的重点也是我见过最多人写错的地方。几个高频问题第一if testseatType ! null and seatType ! 里参数类型如果是 Integer判断! 会有潜在问题如果是字符串类型遇到纯空格的参数还会漏判建议统一用seatType ! null判断。第二set标签在动态更新语句里要注意逗号位置很多人写多条件更新时把逗号放在字段前导致 SQL 语法错误。我习惯的做法是最后统一加逗号再在标签里处理或者直接用set来管理。第三resultMap 和实体类的驼峰映射经常出问题。MySQL 表的字段travel_date映射到实体travelDate如果你在配置文件里开启了map-underscore-to-camel-case: trueMyBatis 会自动做驼峰转换但如果有些字段你用了别名或者查出来的列名不匹配就必须显式写 resultMap。建议核心表都写上 resultMap别依赖自动映射排查问题会省事很多。第四IN 查询的动态拼接。车次查询经常需要传多个站点 ID 或者多个车次 IDMyBatis 的foreach是写这个场景的正确姿势但要注意 collection 参数名必须和接口入参对应否则会报There is no getter for property named之类的错误。3.4 给前端用的接口设计规范后端 API 设计直接影响前端的开发效率。订票系统的接口我建议全部返回统一结构体{ code: 200, message: success, data: {} }code 用 200 表示成功其余表示各类业务错误。注意业务错误和 HTTP 状态码解耦比如余票不足这种逻辑错误HTTP 200 但 code 返回 50001前端根据 code 做统一弹窗处理。这样做的好处是拦截器可以统一处理 Token 过期、权限不足这类全局异常前端 axios 拦截器也能集中处理错误而不是每次请求都写一遍繁琐的判断。分页查询建议统一用 PageResult 结构不要各个接口各写各的前端拿到{ total, list }直接渲染表格省心。车次查询、订单列表这类高频率接口后端还要关注 SQL 的索引使用情况建议在 Mapper 里写原生 SQL 时养成随手 EXPLAIN 的习惯别等到线上慢查询出现才开始怀疑数据库。4. 前端 Vue3 部分从页面到接口联调的实现细节4.1 项目初始化与路由/状态管理Vue3 项目我直接用 Vite 初始化命令是npm create vitelatest模板选 Vue JavaScript 或 TypeScript 都行。这个系统建议用 JavaScript 减少心智负担毕竟核心在业务逻辑上。路由用 Vue Router 4页面分用户端和管理端两块。用户端布局是顶部导航 主内容区管理端则是侧边栏 顶栏的经典后台布局。权限控制上路由 meta 里加requiresAuth和role字段在全局前置守卫里判断本地存的 Token 和用户角色没登录就跳登录页管理员页面普通用户不让进。状态管理建议用 Pinia比 Vuex 清爽很多。这个系统里用户信息、当前选中的车次信息、订票流程中填写的乘车人列表都可以放 Pinia。特别是订票流程跨多个组件不用 Pinia 的话 props 会传得乱七八糟。4.2 车次查询与余票展示的交互车次查询页面是这个系统前端的核心交互场景。查询条件是出发站、到达站、出发日期可能再加一个坐席类型。这部分前端要注意两个细节第一日期组件不要用自己的格式和后端约定好统一用yyyy-MM-dd传给后端不然一会儿2025-01-15一会儿2025/01/15接口联调时全是坑。第二查询结果的余票展示需要区分有票无票候补等状态可以纯前端根据后端返回的余票数量字段做显示逻辑也可以让后端直接返回一个枚举状态。我更推荐后者把状态判断放在后端前端只消费结果后续改规则时不用动前端代码。订票流程一般分三步选择车次 - 填写乘车人 - 确认订单并支付。这里推荐用 Element Plus 的 Steps 组件 多个子页面组合而不是一个长表单一步到底。每一步可以单独校验用户体验会清晰很多。4.3 前后端联调中的跨域和请求封装前后端分离开发最烦的就是跨域。本地开发时前端localhost:5173后端localhost:8080浏览器默认会拦截非同源请求。解决办法有两个第一后端配置 CORS在 SpringBoot 里实现 WebMvcConfigurerConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }第二前端配置 Vite 代理在vite.config.js里server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, // rewrite: path path.replace(/^\/api/, ) } } }两种方案我建议两个都做。本地开发靠 Vite 代理接口路径不用写全http://localhost:8080统一/api前缀就行生产环境前端和后端分开部署时让 Nginx 转发/api到后端服务CORS 配置兜底。请求封装建议在 src/api 目录下统一 axios 实例配置 baseURL 和拦截器请求拦截器里带上 Token响应拦截器里统一处理 code 非 200 的情况。这套东西做好之后每个业务页面只需要引入对应的 api 函数代码会干净很多。5. 部署、联调与实测中踩过的坑5.1 跨域与 Session 问题最早联调时遇到一个很典型的问题登录之后前端发起下一次请求后端发现 Session 对不上直接判定未登录。原因就是前端开发服务器5173和后端8080不同源而且 axios 默认不带 CookieSession ID 根本没传到后端。解决办法是跨域配置里allowCredentials(true)前端 axios 请求加上withCredentials: true。如果你用的是 Token 认证而不是 Session那这个坑就小很多但也要注意 Token 的存放位置。放进 localStorage 有被 XSS 窃取的风险放进 Cookie 又要处理 CSRF。大多数毕设级别项目放 localStorage 问题不大但你要有这个安全意识写简历面试时能说出来加分不少。5.2 MySQL 8.0 驱动和 SSL 连接的坑项目如果用的是 MySQL 8.0连接驱动是com.mysql.cj.jdbc.Driver这个驱动默认会尝试建立 SSL 连接。很多人在本地装完 MySQL 后连接串里没加 SSL 参数启动项目时报一个javax.net.ssl.SSLHandshakeException或者Public Key Retrieval is not allowed的错误。正确写法是在 JDBC 连接串里显式关闭 SSLurl: jdbc:mysql://localhost:3306/railway?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruecharacterEncodingutf8serverTimezoneAsia/Shanghai也一定要加不然连接成功后查询日期时间字段会发现跟本地时间差了 8 个小时。这是 MySQL 8 时区配置导致的。5.3 时间格式与 Jackson 反序列化问题前端传给后端的日期后端 Jackson 解析失败是前后端分离项目的高频问题。比如前端传2025-01-15 10:30:00SpringBoot 默认的 Jackson 配置可能只支持2025-01-15T10:30:00这种 ISO 格式。解决办法在 application.yml 里配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8如果RequestBody里某个字段是LocalDateTime这种全局时间格式配置基本能解决大部分问题。返回值里的时间格式化也可以加JsonFormat注解做局部兜底但别全局注解到处乱加最后会很难维护。5.4 打包部署Vue3 构建后如何放进 SpringBoot这个项目分两种部署方式。第一种是前后端完全分离前端构建出的 dist 目录丢到 Nginx后端打成 jar 单独跑通过 Nginx 反代/api请求。第二种是直接把前端构建产物复制进 SpringBoot 的src/main/resources/static打成单一 jar 包访问同一个端口。第二种做法的操作流程是前端npm run build生成 dist将 dist 目录下的所有文件复制到后端的src/main/resources/static/然后正常mvn package打包。这里有个关键细节如果你用的是 Vue Router 的 history 模式前端路由/admin这类路径在直接刷新时会 404因为 SpringBoot 只认静态文件路径不认前端路由。解决方案是在后端加一个路由转发控制器把非 API 的路径都转发到 index.html或者在部署环境用 Nginxtry_files兜底。如果那里没问题开发时建议把路由模式改成 hash省去很多麻烦。6. 这个项目还能怎么扩展以及我的一点体会6.1 从单体到微服务的演进路线这套单体架构跑起来没问题但如果你想在面试中展示自己的架构思维可以想想它未来怎么拆用户系统独立成服务解决多端登录和 Token 校验订单系统独立成服务承担交易和状态机流转库存系统独立成服务用 Redis 做余票的热点缓存。拆出来的服务之间通过 OpenFeign 或消息队列通信余票扣减可以改成先扣 Redis 再异步同步 MySQL这样在高并发场景下能扛住更大的流量。不过我想说的是不要为了微服务而微服务。单体架构性能不够优化 SQL、加索引、上 Redis 缓存基本能解决 90% 的问题。真到了需要拆分的时候再按上面这个思路演进反而是一种能力体现。6.2 给初学者的复现建议如果你拿到的源码一团乱麻不用怕按照我上面说的顺序重新搭一遍先建库再跑通后端接口然后用 Postman 自测最后再写前端页面。前段代码如果和后端接口对不上直接看接口文档或后端 Controller 的路径来调整。复现过程中最容易让人放弃的就是环境问题。JDK 版本别乱换建议 JDK 8 或 11 都行但 SpringBoot 版本如果过低就别配 MySQL 8 的高版本驱动会有兼容问题。Node 版本建议 16 以上不然 Vite 4 起不来。这些版本问题看着小实际能卡人一整天。个人经验这类项目最快的学习方式是先把数据库建出来再手工调用一遍所有接口把每张表、每个接口的意义记清楚最后才去读代码。代码不用每个文件都看重点看 Service 层的交易逻辑、Mapper 的 SQL 写法、前端的请求封装这三块看懂了整个系统的技术难点就全部拿下了。如果你准备拿这个项目去面试一定要把余票并发控制、车次动态查询、订单状态流转这三块讲得滚瓜烂熟然后坦诚地指出项目中可以优化的点——比如引入 Redis 缓存余票、引入消息队列削峰、用定时任务处理超时未支付订单。这些思考比项目本身更有说服力。