ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot火车票预订系统实战:从需求分析到并发扣票防超卖

Spring Boot火车票预订系统实战:从需求分析到并发扣票防超卖

在毕业设计选题里,“火车票预订系统”几乎是一个常青树。它不像电商那样需要复杂的商品规格和营销逻辑,也不像社交平台那样强调实时互动,但它恰好把 Web 开发中最核心的几个硬骨头都碰了一遍:用户登录态、条件查询、订单生成、库存扣减、事务一致性。前阵子我拿到一套基于 Spring Boot 的火车票预订系统源码,编号 35337,把它完整跑通、逐行读了核心代码,还专门用并发脚本压了压最容易出问题的余票扣减逻辑。这篇文章就把我从需求拆解到最终跑通的整个思考过程写下来,给正在做同类项目、或者准备拿这种项目去面试讲实战的开发者一个真实参考。

我尽量不把文章写成那种“点击按钮、输入框、保存成功”的流水账,而是把注意力放在三个真正值钱的地方:数据库字段为什么这么设计、下单过程的事务边界划在哪、并发扣余票时用什么方式防止超卖。把这三点搞清楚,这类系统你就吃透了八成。

1. 火车票预订系统的需求边界:先把“做什么”和“不做什么”讲清楚

每当我拿到一套别人写的源码,第一件事不是直接看代码,而是花半小时把需求还原出来。原因很简单:同一句话“火车票预订系统”,不同人做出来的东西可能完全不同。有的版本做了整整十几个页面,车次管理、订单导出、用户管理、公告发布全都有;有的版本就是一两个接口,剩下一堆静态页面。源码 35337 这个项目,从代码覆盖范围来看,业务主线非常清晰:用户注册登录、车次查询、在线下单、订单管理、退票处理,外加一个基础的管理后台。它没有做在线支付对接,也没有做选座和乘车人信息管理,这是这类教学性质项目的典型边界。

1.1 “预订”和“支付购买”不是一回事

这里得先把概念理顺。很多人第一次看到“车票预订系统”这几个字,会下意识认为它跟铁路官方购票软件一样,包含支付、退改签、实名认证、余票排队候补等一系列复杂流程。但在教学项目和课程设计里,“预订”的实际含义更接近“提交订单并扣减库存”,而不是真实意义上的在线支付购票。

也就是说,系统核心是一个“库存型订单系统”。用户选择车次、提交订单,后端校验余票、扣减库存、生成一条订单记录,订单状态标记为“已预订”或“已支付”。至于钱真正从哪里扣、支付回调怎么验签,这类项目通常不会碰。原因也很实际:接入真实支付渠道需要商户资质、回调接口、安全证书,已经超出了源码本身要传达的“Spring Boot 核心开发流程”这个教学目标。

所以我看这套系统的时候,直接把它定位成一句话:用户能在上面查车次、订票、退票,管理员能在后台管车次、看订单,所有操作背后的数据变化都要保证准确不冲突。能做好这一点,Spring Boot 开发的实战能力就已经得到了有效锻炼。

1.2 角色划分与功能模块清单

从这个源码的整体结构看,系统分为普通用户和管理员两个角色。普通用户端的功能比较常规:

  • 注册:填写用户名、手机号、密码,后端做唯一性校验后写入用户表。
  • 登录:验证账号密码,成功后把用户信息放进会话。
  • 车次查询:按出发站、到达站、出发日期筛选车次,展示余票和票价。
  • 下单购票:选择一个车次,提交预订请求,生成订单。
  • 个人订单:查看自己的订单列表和状态。
  • 退票:把订单状态改成已退票,车次余票自动恢复。

管理员端主要是三个模块:车次信息增删改查、订单列表查看和筛选、注册用户管理。这里的核心逻辑并不复杂,真正有含金量的是订单服务和车次服务这两个类里的事务处理与数据一致性控制。如果面试时有人问你“这个项目有什么难点”,你不需要讲什么高深算法,能把“下单扣票的并发一致性”讲透,就已经比绝大多数停留在 CRUD 层面的同学强很多了。

2. Spring Boot 工程的技术选型与分层骨架

这套 35337 项目是典型的 Spring Boot 单体应用。我整理一下它的技术栈选型逻辑,以及项目工程结构里值得保留的包划分方式。这部分内容对你自己从零搭项目也有参考价值。

2.1 为什么这个场景就该用 Spring Boot

单说“做一套 Web 系统”,可选的技术方案很多。但放到车票预订系统这个具体场景里,Spring Boot 几乎是教学项目和实践项目中最稳的一个选择。原因有三层:

第一,它把 Spring 框架里繁琐的 XML 配置全部自动化了,开发者只需要关注业务代码。Controller 一个注解就能接收 HTTP 请求,Service 一个注解就能开启事务,链路的效率和上手门槛都低了很多。

第二,它天然适合做单体分层系统。车票预订系统的核心业务都在一个进程里完成,不需要服务拆分。Spring Boot 内嵌的 Tomcat 让部署变成一个 jar 包就能启动的事,省掉了外部配置容器的麻烦。

第三,生态足够成熟。MyBatis、MySQL、Druid 这些数据层组件和 Spring Boot 整合已经有大量实践案例,踩坑成本极低。你项目里需要的无非是下面这些核心依赖:

spring-boot-starter-web mybatis-spring-boot-starter mysql-connector-java lombok druid-spring-boot-starter

很多学生会纠结要不要在这个基础上引入 Redis、消息队列、微服务组件。我的建议很明确:不要。对于课程设计和面试级项目,在单体应用里把事务和并发处理好,比堆一堆中间件更有说服力。中间件一旦引入,问题就会扩散到缓存一致性、消息可靠投递、服务发现这些更复杂的领域,反而让项目变成“配置一堆,逻辑没讲清”的状态。

2.2 三层架构与项目包结构

这套源码的代码组织结构是比较标准的 Spring Boot 三层架构。我把它画成一个表格,方便你对照自己的项目检查是否合理:

包目录职责典型类
controller接收 HTTP 请求、参数校验、返回统一结果TrainController、OrderController、UserController
service业务逻辑编排、事务控制OrderService、TrainService、UserService
mapper数据库访问层,定义 SQL 映射TrainMapper、OrderMapper、UserMapper
entity与数据表对应的实体类Train、Order、User
dto接口入参与出参的封装对象OrderCreateDTO、TrainQueryDTO
config跨域、拦截器、通用配置WebConfig、MyWebMvcConfig
common全局异常、统一返回封装Result、GlobalExceptionHandler、BusinessException

这个分层的好处是,无论后面你想替换数据库、修改业务规则,还是排查一个接口的完整调用链,都能按着 controller → service → mapper 这条线快速定位。比如用户下单后页面报错,你就可以先看 OrderController 有没有把参数接对,再看 OrderService 里事务有没有覆盖完整,最后看 OrderMapper 的 SQL 条件写没写对。这种“定位思路”在真实开发中比任何框架技巧都重要。

有一点想特别提醒:看源码时你会发现,很多项目把实体类和 DTO 混用,直接拿 entity 对象给前端做返回。短期看省事,但后续如果前端需要展示“余票状态有票/无票”这类派生字段,直接在实体上硬加字段会让表结构和接口结构纠缠在一起,越来越乱。哪怕课程设计规模不大,我也建议至少把入参封装成 DTO,返回结果尽量做一次字段转换。

3. 数据库设计:用户、车次、订单三张表的关键逻辑

数据库是整个车票预订系统真正的底座。前端的每一次点击,最终都转化成了某一张表里若干条记录的增删改查。这套系统里最核心的表就是三张:用户表、车次表、订单表。我先把通用且合理的字段设计列出来,再讲几个容易踩坑的设计点。

3.1 三张核心表的字段设计

用户表通常围绕账号和联系方式展开,字段大概是这样:

  • id:主键,自增。
  • username:用户名,唯一索引。
  • password:密码(很多课程项目用 MD5 存储,虽然现在更推荐 BCrypt,但不必强行改造)。
  • phone:手机号,用于找回或通知。
  • create_time:注册时间。

车次表相对复杂一些,因为它要承载“车次运行信息”和“余票信息”两类职责:

  • id:主键。
  • train_number:车次编号,比如某演示车次。
  • start_station:出发站。
  • end_station:到达站。
  • start_time:发车时间。
  • end_time:到站时间。
  • ticket_price:票价。
  • remaining_tickets:余票数量。
  • depart_date:发车日期。
  • status:是否停运等状态。

订单表则记录了“谁在哪一趟车的哪个日期买了票”:

  • id:主键。
  • order_no:订单号,通常由后端生成,带时间戳+随机数。
  • user_id:关联用户表。
  • train_id:关联车次表。
  • depart_date:乘车日期。
  • start_station / end_station:冗余上下车站点,便于订单列表直接展示。
  • ticket_price:订单价格。
  • status:订单状态,例如 0 待支付、1 已支付、2 已退票。
  • create_time:下单时间。

字段设计上有一个很重要的原则:订单表通过 user_id 和 train_id 关联用户与车次,而不是把用户名、车次号等展示信息全部复制到订单表里。当然,把上下车站点、票价这些字段冗余进订单表是合理的,因为这属于快照信息——如果车次表改了发车时间或票价,历史订单不应该跟着变。

3.2 余票字段设计的“经典矛盾”

余票是整个系统里业务敏感性最高的字段。最直接的设计就是把余票数量作为车次表里的一个整数字段,下单时扣减,退票时回补。对于这套源码的业务边界——“一个车次一种票价,不区分座位等级”——这种单字段设计是最合适的,简单、直观、好控制。

但如果你把场景扩展一下,车次分了“商务座、一等座、二等座”三个席别,那单一余票字段就完全不够用了。要么拆成三个字段,要么单独建一张座位库存表,每种席别一条记录,再和车次关联。设计时一定要根据业务复杂度做取舍,最忌讳的是“功能没做多复杂,表先拆得特别散”。

另一个值得注意的点是,车次表只存余票,不存总票数。这在退票逻辑里没有问题,因为退票只需要把 remaining_tickets 加回去。但如果你想做“该车次已售比例”之类的统计,就得推算出总票额,显然这张表做不到。很多课程设计项目其实并不需要这种统计,所以没必要为此加字段。

第三点,多数简化版本并没有单独建“乘车人表”,而是默认“当前登录用户就是乘车人”。订单表里的 user_id 就是唯一的乘车人关联。这种设计省掉了一个完整的选人、添加乘车人流程,大大降低了系统复杂度,和真实购票软件的差异是项目定位决定的,不算缺陷。你在写论文或答辩时,能把这个边界说清楚,反而显得思路清晰。

4. 查询、下单、退票三大流程的实现细节与事务控制

数据库表设计好了,接下来就看代码怎么把这些表串成完整的业务。我把这套系统里最有代表性的三个流程拉出来逐段分析:车次查询、下单购票、退票。这三个流程合在一起,基本覆盖了一个订单从创建到消亡的完整生命周期。

4.1 车次查询接口与 SQL 组织

查询是系统里看起来最简单、但实际最容易写乱的一环。核心查询条件是三个:出发站、到达站、乘车日期。对应的 SQL 大致是这样:

SELECT id, train_number, start_station, end_station, start_time, end_time, ticket_price, remaining_tickets FROM train WHERE start_station = #{startStation} AND end_station = #{endStation} AND depart_date = #{departDate}

这里有一个设计前提:车次表的 depart_date 代表的是“这趟车在哪天运行”。更复杂的系统会把车次和日期拆成两个维度,一天一列排期;在这套简化项目里,直接把日期放进车次表里也能支撑业务,代价是每一天的同一车次需要维护一条记录。

我从实际跑通的情况看,后端返回给前端的数据,最好别直接拿数据库表结构硬套。车次查询结果里一般会加一个“有票/无票/余票紧张”的派生状态,这些字段可以通过后端 Service 层根据 remaining_tickets 计算后放到 VO 里,而不是由前端去猜数值含义。这个习惯虽然小,但很能体现一个开发者的接口设计意识。

关键的一段思考是,查询接口虽然是只读操作,不需要事务控制,但它一定要在 Mapper 层把 SQL 条件写严谨。如果用户不选择出发站,SQL 里就必须动态拼接条件,不能查询全部车站再在内存里过滤。课程设计里数据量小看不出差别,但这是数据访问层的基本功。

4.2 下单购票:事务边界必须覆盖“扣余票”和“建订单”

下单购票是整套系统含金量最高的流程,没有之一。用户点下“预订”后,后端要做的事情包括:

  1. 从会话中取得当前登录用户 ID。
  2. 根据车次 ID 读取车次信息,做基本的空值校验。
  3. 检查余票是否充足,给用户一个友好提示。
  4. 执行余票扣减。
  5. 扣减成功后创建订单记录。
  6. 返回订单号给前端。

这里最核心的设计点是步骤 4 和步骤 5 必须放在同一个数据库事务里。也就是说,余票扣了,订单必须存在;订单创建失败,余票必须自动回滚。绝对不能出现“票扣了单没生成”或者“单生成了票没扣”这种中间状态。

代码结构大致是这样的:

@Transactional(rollbackFor = Exception.class) public Order createOrder(OrderCreateDTO dto) { // 1. 取出当前用户 Long userId = currentUserService.getCurrentUserId(); // 2. 查车次,做基本校验 Train train = trainMapper.selectById(dto.getTrainId()); if (train == null) { throw new BusinessException("车次不存在"); } // 3. 条件扣减余票,返回受影响行数 int rows = trainMapper.deductTickets(dto.getTrainId()); if (rows == 0) { throw new BusinessException("余票不足,请选择其他车次"); } // 4. 创建订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTrainId(dto.getTrainId()); order.setStatus(1); orderMapper.insert(order); return order; }

这里有一个很关键的细节我特意拆开写:代码里没有依赖“先 select 余票再判断是否大于 0”来做数据一致性保障。select 出来的余票只是一个呈现在业务层的“参考值”,真正起决定作用的是后面那条UPDATE train SET remaining_tickets = remaining_tickets - 1 WHERE id = ? AND remaining_tickets > 0。这条 SQL 的受影响行数才是判断“到底扣没扣成功”的唯一标准。项目里只要漏掉这一层,并发情况下就很容易出现库存超卖。

事务注解@Transactional(rollbackFor = Exception.class)其实也很讲究。很多同学知道要加 @Transactional,但不知道默认情况下它只对 RuntimeException 回滚,而对普通的 Exception 不回滚。如果你在 Service 里抛出的是自定义异常,而这个异常没继承 RuntimeException,同时你又忘了写 rollbackFor,那么订单创建失败时余票扣减可能已经提交了,数据就这样悄悄错了。这是一个非常隐蔽、又非常经典的坑。

4.3 退票:下单的逆向操作,同样需要条件更新

退票在业务上等价于“把订单状态置为已退票,同时把车次的余票加回去”。它同样是个两步操作,同样必须放在一个事务里。另外一个必须考虑的维度是权限校验。

理想情况下,退票接口不能只接收一个订单号就执行,它必须确认当前订单属于当前登录用户。否则系统里任何一个用户只要猜到或遍历到订单号,就能把别人的票退掉。实现上,与其先查询订单、再判断归属、再更新状态,不如把校验直接压进一条条件更新 SQL 里:

@Transactional(rollbackFor = Exception.class) public void refund(String orderNo) { Long userId = currentUserService.getCurrentUserId(); int rows = orderMapper.refundByOrderNo(orderNo, userId); if (rows == 0) { throw new BusinessException("订单不存在或无权退票"); } // 注意:这里需要先查出车次 ID,再回补余票 Order order = orderMapper.selectByOrderNo(orderNo); trainMapper.increaseTickets(order.getTrainId()); }

先更新订单状态,成功后回补余票,整个过程有事务兜底,不会出现“票退了但余票没加回来”的情况。用条件更新写法的好处是,把权限校验和状态流转合并成了一条原子操作,既减少了查询次数,又避免了并发下重复退票的可能。因为第一个退票请求执行成功后,订单状态已从前一个状态变为“已退票”,第二个请求再执行时受影响行数会是 0,就会抛出业务异常。

5. 余票并发扣减的防超卖实战:从踩坑到修复

这一节我想展开讲讲并发问题。很多人在写这个系统时,单机测试一切正常,就以为大功告成了。实际上,余票扣减在并发场景下是最容易翻车的地方。我在跑通 35337 后专门做了并发验证,整个现象非常典型,非常值得复现。

5.1 为什么“先查再减”一定会出错

先看一段最常见的错误示范:

Train train = trainMapper.selectById(trainId); if (train.getRemainingTickets() > 0) { train.setRemainingTickets(train.getRemainingTickets() - 1); trainMapper.updateById(train); }

这段代码在本机手动点几次,绝对测不出问题。但它的逻辑是“先读取余票到 Java 内存,判断大于 0,再把它减一,然后更新回数据库”。两个用户同时发起购票时,可能同时读到剩余 5 张票,同时进入 if 判断,又同时执行了减一更新。最后两个订单都生成成功,但数据库里余票只扣了一次。如果并发请求数更多,超卖的数量会非常夸张。

这类问题的根子在于,检查条件和扣减动作之间天然有时间窗口,而这个窗口在并发环境下会真实发生。解决办法不是靠 synchronized 或者加一个 Java 层的锁标志,而是想办法让“检查余票”和“扣减余票”变成数据库层面的一个原子操作。

5.2 项目里采用的防超卖写法

这套源码在关键位置用的是条件更新方案。核心 SQL 如下:

UPDATE train SET remaining_tickets = remaining_tickets - 1 WHERE id = #{trainId} AND remaining_tickets > 0

这条 SQL 能防超卖的原理其实很朴素:执行 UPDATE 时,MySQL 的 InnoDB 引擎会对命中的那一行记录加行锁,直到事务提交才释放。并发请求虽然同时到达,但同一时刻只有一条 UPDATE 能成功获得行锁并执行。其他请求会等待锁释放,等它们拿到锁时,remaining_tickets 已经变成了 0,因此条件remaining_tickets > 0不再满足,受影响行数为 0,对应的那些请求就会在业务代码层抛出“余票不足”。

我实际测试的效果是这样的:我写了 100 个并发线程,模拟 100 个用户同时抢某一车次最后 5 张余票。不加条件更新时,订单生成了 90 多张,数据库余票只剩 0,超卖得一塌糊涂;加上AND remaining_tickets > 0后,订单恰好生成 5 张,其余线程全部返回余票不足,数据完全一致。这个对比测试放在论文或者面试里,说服力非常强。

5.3 还有哪些值得顺手掌握的进阶方案

条件更新是当前项目最合适的方案,因为它简单、依赖少、不需要额外组件。但你可以顺手了解另外两种常见方案,以备面试时被追问。

一种是乐观锁。给车次表加一个 version 字段,更新时带上版本号:

UPDATE train SET remaining_tickets = remaining_tickets - 1, version = version + 1 WHERE id = #{trainId} AND version = #{version}

受影响行数为 0 时,说明版本已经变化,需要重试或提示用户。乐观锁适合并发冲突不严重的场景,但需要处理重试逻辑。

另一种是悲观锁。查询车次时直接用SELECT ... FOR UPDATE把该行锁住,等事务结束再释放。它在高并发时性能不如条件更新,但逻辑最直白。

对于单体课程设计项目,我的结论始终是:先学会条件更新,把 SQL 和事务边界写对。至于分布式锁、Redis 扣库存这些方案,属于另外一个量级的问题,等系统拆成多实例再考虑不迟。

6. 35337 源码从导入到跑通:环境配置与排错清单

最后说一下最有实操价值的部分:拿到源码之后怎么把它跑起来。很多人的项目最后死在环境上,而不是代码上。我把从导入到运行的关键步骤和踩到的坑整理成一份清单。

6.1 准备工作

这套源码的正常运行需要以下基础环境:

组件版本建议
JDK1.8 或 11
Maven3.6 以上
MySQL5.7 或 8.0
IDE推荐使用主流 Java IDE

项目基本都是用 Maven 管理的,导入 IDE 后需要等待依赖下载完成。如果下载很慢,建议在 Maven 的 settings.xml 里配置国内镜像仓库,否则可能卡在依赖拉取阶段。

6.2 数据库初始化与配置修改

项目目录下一般会有一个 sql 文件夹,里面放着初始化脚本。先用命令行导入:

mysql -u root -p < init.sql

导入后确认核心的几张表已经建立。有一点要特别注意:order 是 SQL 里的排序关键词,很多系统里订单表名就得写成order,但建表时会用反引号包起来,比如`order`。你在看 SQL 脚本时如果发现反引号,不用疑惑,这是正常的规避手段。

导入完成后,打开 application.yml,重点修改三处配置:数据库地址、数据库用户名、数据库密码。如果你本地 MySQL 是 8.0 版本,而项目里的连接串还是老版本写法,启动时往往会出现认证方式或时区相关的报错。建议把 JDBC 连接串写全一点:

spring: datasource: url: jdbc:mysql://localhost:3306/railway?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 你的密码

allowPublicKeyRetrieval=true 是 MySQL 8.0 连接时常见的一个坑,不加的话经常报无法获取公钥的异常。serverTimezone=Asia/Shanghai 则可以规避时区错乱导致的时间字段查询偏差。

6.3 启动与接口验证

配置完成后,在项目根目录执行:

mvn spring-boot:run

也可以直接用 IDE 运行启动类。等日志出现 Tomcat started on port(s): 8080 后就说明后端已经起来了。接着可以用浏览器访问前端页面,或者直接用接口工具验证核心接口。以车次查询为例,请求大概是:

curl "http://localhost:8080/api/train/search?startStation=某地&endStation=某地&date=2025-06-01"

如果前端页面请求报跨域错误,那就需要检查后端是否配置了 CORS,或者在浏览器请求里走代理转发。这类问题属于环境级问题,和业务代码无关。

我在实际操作中还遇到过一个小问题:数据库密码中包含特殊字符(比如@)时,直接写在 yml 里可能导致解析错误,需要做适当转义或者用环境变量注入。这个细节很容易让人卡住,遇到时别急着怀疑业务代码。

6.4 常见启动异常速查表

报错信息常见原因处理办法
Public Key Retrieval is not allowedMySQL 8.0 的认证插件问题连接串加 allowPublicKeyRetrieval=true
Unknown database指定的库不存在先执行建库语句,或改连接串里的库名
Access denied for user数据库账号或密码错误检查 yml 配置,注意特殊字符
Port 8080 was already in use8080 端口被占用改 server.port,或结束占用进程
Table xxx doesn't existSQL 脚本没导入完整重新导入,核对导入日志

这些坑都不算深,但几乎每个第一次跑这种项目的人都会撞上一两个。排查的时候记住一个原则:先确认数据库层面正常,再怀疑代码。

7. 跑通之后还能做什么:几点实用建议

这套系统跑通之后,我并不建议你马上转头去做下一个新项目。车票预订系统这种选题,最大的价值在于它的业务逻辑够典型、够聚焦,非常适合做深度复盘。

我自己在折腾 35337 的时候,最大的收获不是学会了某个接口怎么写,而是真正理解了“事务边界”和“数据一致性”这两个词落到代码里是什么样子。以前看课本,事务就是 ACID 四个字母;直到看到并发测试里几十张超卖订单出现时,才真正明白为什么扣库存必须用条件更新、为什么扣减和建单必须在同一个事务里。

如果你打算拿这类系统去答辩或者面试,我建议你做三件小事。第一,把 OrderService 里的下单方法单独翻出来,逐行说清楚每一步操作对应数据库里的哪一次变化;第二,自己写一个并发测试脚本,亲眼看一次不加条件更新的超卖现象;第三,把“为什么条件更新能防超卖”这个问题用行锁和事务隔离性的语言讲明白。做完这三件事,这个看似普通的课程设计项目,就会被你变成一个真正有深度、能扛住追问的实战项目。

至于源码本身,每个项目的细节习惯可能有所不同,但核心的设计思路和踩坑经验基本都是相通的。希望这篇文章能帮你少走几步弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进