☰
JAVA学生火车票订票系统:Spring Boot+MyBatis实现高并发库存扣减与订单状态机
2026/10/17 8:41:32 网站建设 项目流程

简介:这是一套面向Java Web初学者与课程设计者的学生火车票订票系统完整源码,基于JAVAEE技术栈开发,涵盖后台管理与前台购票两大模块,可解决学生群体寒暑假半价优惠、团体购票等实际需求,适合作为毕业设计、课程实训或框架练手项目。压缩包共318个文件,约24.27MB,以72个jar依赖包、38个java源文件与38个class编译文件为核心,配合33个jsp页面、15个js脚本、10个css样式及图片素材构成前后端结构,另含1个sql数据库脚本用于初始化表结构。目前已有3249人学习下载。资源完整呈现了Servlet、JSP与数据库交互的实现思路,包含列车信息维护、城市站点管理、订单处理等业务逻辑,读者可据此理解MVC分层设计、数据库建表与前后端联调流程,并在此基础上扩展移动端适配、智能推荐或安全加固等功能。

1. 从一张春运抢票的崩溃日志说起:这个 JAVA 订票系统到底能干什么

去年帮一个做实训平台的朋友看日志,凌晨三点收到一条异常堆栈:SeatLockTimeoutException,后面跟着三百多条重复的UPDATE ticket SET stock = stock - 1。那是一个学生团队做的订票 Demo,平时跑得好好的,一到模拟高并发就雪崩。问题不在业务逻辑,在于他们把所有东西都塞进了一个 Service 方法里,没有锁、没有队列、没有状态机。后来我翻到这个 JAVA 学生火车票订票系统,发现它的分层思路正好能补上这块短板——车次查询、余票扣减、订单生成、支付回调、退票改签,每个环节都拆成了独立模块,还带了管理端做车次维护和订单监控。它适合两类人:一类是刚学完 Servlet 和 JDBC、想找个完整项目练手的在校开发者;另一类是要交课程设计或实训作业、需要一套能跑通全流程的参考实现。技术栈不花哨,Spring Boot 加 MyBatis 加 MySQL,前端用 Thymeleaf 或 JSP 都能接,重点在于它把订票系统里最容易翻车的库存一致性和订单状态流转写清楚了。

2. 技术选型与目录结构:为什么用 Spring Boot + MyBatis 而不是堆微服务

2.1 选型逻辑:学生项目要的是能跑通,不是能吹牛

这个项目的定位很明确——单机部署、单体架构、面向教学和二次开发。它没有上 Spring Cloud,也没有拆 Redis 集群,原因很实际:学生开发者的机器上跑不起那么多中间件,课程设计答辩时老师也不会因为你用了 Nacos 就多给分。核心依赖就四个:Spring Boot 做容器和自动配置,MyBatis 做 ORM 映射,MySQL 存业务数据,Thymeleaf 渲染管理端页面。支付模块用的是模拟回调,不接真实渠道,但保留了完整的签名校验和异步通知结构,方便你后面替换成真实支付 SDK。

我一般会建议在pom.xml里额外加两个东西:spring-boot-starter-validation做参数校验,pagehelper-spring-boot-starter做分页。原项目里分页是手写的LIMIT偏移,车次多了之后性能会掉,换成 PageHelper 能省不少事。数据库连接池默认用 HikariCP,Spring Boot 自带,不用额外配。

2.2 目录结构:按业务域分包,别按技术层分包

项目源码的包结构是这样的:

src/main/java/com/train/ticket/ ├── TicketApplication.java // 启动类 ├── config/ // 配置类 │ ├── WebMvcConfig.java // 拦截器、静态资源映射 │ └── MyBatisConfig.java // 分页插件、类型处理器 ├── controller/ // 接口层 │ ├── TrainController.java // 车次查询 │ ├── OrderController.java // 下单、支付、退票 │ └── AdminController.java // 管理端 ├── service/ // 业务层 │ ├── TrainService.java │ ├── OrderService.java │ └── SeatService.java // 库存扣减核心 ├── mapper/ // MyBatis 接口 │ ├── TrainMapper.java │ ├── OrderMapper.java │ └── SeatMapper.java ├── entity/ // 数据库实体 │ ├── Train.java │ ├── Order.java │ └── SeatInventory.java ├── dto/ // 传输对象 │ ├── OrderCreateDTO.java │ └── TrainQueryDTO.java └── common/ // 工具与统一返回 ├── Result.java └── OrderStatusEnum.java

这个分包方式是按业务域走的,不是按dao/service/controller三层平铺。好处是改订单逻辑时,你只需要盯OrderController、OrderService、OrderMapper和Order实体,不用在四个大包里翻来翻去。SeatService单独拎出来,是因为库存扣减是这个系统里唯一需要处理并发的地方,值得独立成一个模块。

2.3 数据库表设计:三张核心表撑起全流程

建表 SQL 在resources/sql/init.sql里,核心就三张:

表名作用关键字段
train车次信息train_no,start_station,end_station,depart_time,total_seats
seat_inventory每日库存train_id,travel_date,remaining,version
order订单记录order_no,user_id,train_id,travel_date,status,amount

seat_inventory里的version字段是乐观锁标记,扣减库存时用它做 CAS 更新。order表的status用枚举值:0待支付、1已支付、2已退票、3已改签。状态流转只能单向走,不允许从2跳回1,这个约束在 Service 层用OrderStatusEnum校验。

注意:seat_inventory的联合唯一索引建在(train_id, travel_date)上,防止同一天同一车次出现两条库存记录。很多学生项目漏了这个索引,结果并发插入时直接脏数据。

3. 核心链路拆解:从查票到出票,一个请求经历了什么

3.1 车次查询:动态 SQL 与分页的配合

查票接口在TrainController里,接收startStation、endStation、travelDate三个参数,走TrainService.queryTrains()。MyBatis 的 XML 里用了<if>标签拼动态条件:

<select id="selectByCondition" resultType="com.train.ticket.entity.Train"> SELECT t.*, si.remaining FROM train t LEFT JOIN seat_inventory si ON t.id = si.train_id WHERE 1=1 <if test="startStation != null and startStation != ''"> AND t.start_station = #{startStation} </if> <if test="endStation != null and endStation != ''"> AND t.end_station = #{endStation} </if> <if test="travelDate != null"> AND si.travel_date = #{travelDate} </if> ORDER BY t.depart_time ASC LIMIT #{offset}, #{pageSize} </select>

逻辑说明:LEFT JOIN保证即使某天没有库存记录,车次本身也能查出来,前端显示“无票”而不是“无此车次”。offset和pageSize由 Controller 层根据页码算好传进来。参数校验用@Valid注解在 DTO 上,startStation和endStation不能为空,travelDate格式必须是yyyy-MM-dd。

我一般会把LIMIT换成 PageHelper 的PageHelper.startPage(pageNum, pageSize),这样 XML 里不用写LIMIT,分页总数也自动算好。原项目手写分页在数据量小的时候没问题,但车次表超过五千行之后,COUNT(*)会拖慢查询。

3.2 库存扣减:乐观锁 + 重试,别用 synchronized

这是整个系统最关键的代码,在SeatService.deductStock()里:

@Transactional(rollbackFor = Exception.class) public boolean deductStock(Long trainId, String travelDate, int count) { int retry = 3; while (retry-- > 0) { // 1. 查出当前库存和版本号 SeatInventory inv = seatMapper.selectByTrainAndDate(trainId, travelDate); if (inv == null || inv.getRemaining() < count) { return false; // 库存不足 } // 2. 带版本号更新,CAS 操作 int affected = seatMapper.updateStockWithVersion( trainId, travelDate, count, inv.getVersion()); if (affected > 0) { return true; // 扣减成功 } // 3. 更新失败说明版本号被改过,重试 } return false; }

对应的 Mapper SQL:

UPDATE seat_inventory SET remaining = remaining - #{count}, version = version + 1 WHERE train_id = #{trainId} AND travel_date = #{travelDate} AND version = #{version} AND remaining >= #{count}

逻辑说明:每次扣减前先读当前version,更新时把version作为条件带上。如果两个线程同时读到version=5,只有一个能更新成功,另一个affected=0,进入重试。重试三次还失败就返回false,上层抛业务异常。参数count是购票数量,trainId和travelDate定位唯一库存记录。

注意:@Transactional的rollbackFor必须写Exception.class,否则抛出非运行时异常时事务不回滚。这个坑我见过不止一次,订单插入了但库存没扣,对账时才发现。

3.3 订单创建:状态机与幂等处理

订单创建在OrderService.createOrder()里,流程是:校验库存 → 扣减库存 → 生成订单号 → 插入订单 → 返回支付链接。订单号用雪花算法或时间戳 + 用户ID后四位生成,保证全局唯一。插入订单时status设为0(待支付),并记录expire_time为当前时间加 15 分钟。

幂等处理靠order_no的唯一索引。如果同一个请求因为网络重试发了两次,第二次插入会触发唯一键冲突,Service 层捕获DuplicateKeyException后直接返回已存在的订单,不再重复扣库存。这个设计比用 Redis 分布式锁轻量得多,适合单机部署。

状态流转的校验在OrderStatusEnum里:

public enum OrderStatusEnum { PENDING(0, "待支付"), PAID(1, "已支付"), REFUNDED(2, "已退票"), CHANGED(3, "已改签"); public static boolean canTransfer(int from, int to) { if (from == 0 && (to == 1 || to == 2)) return true; if (from == 1 && (to == 2 || to == 3)) return true; return false; } }

支付回调时调canTransfer(0, 1)返回true才允许更新。退票时调canTransfer(1, 2)。这样即使有人伪造请求直接改状态,也会被枚举校验拦住。

4. 避坑与排查:那晚三点我改过的五个地方

4.1 库存扣成负数:CAS 忘了带 remaining 条件

现象:压测时remaining字段出现-3。原因:UPDATE语句只带了version条件,没带remaining >= count。当库存只剩 1 张、两个线程同时扣 2 张时,第一个线程扣完变成-1,第二个线程版本号对不上但库存已经负了。解决:在WHERE里加上AND remaining >= #{count},让数据库层面兜底。

4.2 订单重复插入:唯一索引没建对

现象:同一个用户同一车次出现两条待支付订单。原因:order表只建了order_no唯一索引,但业务上应该限制user_id + train_id + travel_date唯一。解决:加联合唯一索引uk_user_train_date,插入时捕获冲突异常返回已有订单。注意这个索引要允许同一用户退票后重新下单,所以状态为2的记录要排除,MySQL 不支持条件唯一索引,常见做法是把退票记录归档到历史表。

4.3 支付回调验签失败:时间戳没做容差

现象:模拟支付回调时偶尔报“签名无效”。原因:签名里带了时间戳,服务端校验时要求时间戳与当前时间差不超过 60 秒,但测试环境机器时间不同步。解决:把容差放宽到 300 秒,或者直接在校验签名时忽略时间戳字段,只验orderNo + amount + secretKey的哈希。

4.4 车次查询慢:没建覆盖索引

现象:查票接口响应超过 2 秒。原因:train表的start_station和end_station没有索引,全表扫描。解决:建联合索引idx_station_date (start_station, end_station),并把travel_date的过滤下推到seat_inventory表。如果数据量再大,考虑把车次信息缓存到 Redis,但学生项目用 MySQL 索引就够了。

4.5 事务失效:同类方法内部调用

现象:OrderService里createOrder()调this.deductStock(),库存扣了但订单没插进去,事务没回滚。原因:Spring AOP 代理只对外部调用生效,同类内部方法调用不走代理。解决:把deductStock挪到SeatService里,通过@Autowired注入调用;或者用AopContext.currentProxy()拿代理对象。我一般选前者,职责也更清晰。

5. 二次开发:把模拟支付换成真实渠道,顺便加个候补功能

5.1 支付模块改造:从模拟回调到真实 SDK

原项目的支付是模拟的,PayController里直接返回一个假链接,点“支付成功”就调orderService.paySuccess(orderNo)。要接真实渠道,改三个地方:第一,在PayService里引入渠道 SDK,把createPayOrder的返回值从假链接换成 SDK 返回的支付参数;第二,把paySuccess的调用从手动触发改成异步通知接口,校验签名后更新订单状态;第三,加一个定时任务扫status=0且expire_time < now()的订单,自动关单并回滚库存。

@Scheduled(fixedDelay = 60000) public void closeExpiredOrders() { List<Order> expired = orderMapper.selectExpired(); for (Order order : expired) { // 关单并回滚库存 order.setStatus(OrderStatusEnum.REFUNDED.getCode()); orderMapper.updateStatus(order); seatService.rollbackStock(order.getTrainId(), order.getTravelDate(), order.getSeatCount()); } }

参数说明:fixedDelay是上次执行完到下次开始的间隔,单位毫秒。selectExpired的 SQL 条件是status = 0 AND expire_time < NOW()。回滚库存时同样用乐观锁,避免和正常扣减冲突。

5.2 候补功能:用队列替代轮询

候补的逻辑是:当某车次库存为 0 时,允许用户提交候补订单,有人退票就按候补顺序自动出票。实现方式是在SeatService里加一个ConcurrentLinkedQueue,退票时把释放的库存数量offer进队列,后台线程poll出来后匹配候补订单。注意队列要按trainId + travelDate分组,不能全局一个队列,否则会串车次。

验证方法:写一个 JUnit 测试,模拟 10 个线程同时退票和候补,断言最终候补成功的订单数等于退票释放的座位数。如果对不上,检查队列的线程安全和库存回滚的事务边界。

5.3 一个我踩过的坑:别在 Controller 里写业务逻辑

刚接触这个项目时,我图省事把库存校验写在了OrderController里,结果后来加候补功能时发现同一段逻辑要复制三遍。后来全部挪到SeatService,Controller 只做参数接收和结果封装。从那以后我每次改这个项目,都强制自己先看Service层有没有现成方法,没有再动手写。希望这个习惯也能帮到你——拿到源码先跑通主流程,再按业务域找扩展点,别急着改 Controller。

本文还有配套的精品资源,点击获取

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

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

立即咨询