简介:基于Java实现的学生火车票订票系统完整项目包,适合毕业设计、课程设计及Java项目开发参考。系统围绕学生订票核心流程设计,支持学生基本信息管理、目的地维护、车票订票与退票、价钱与目的地查询、信息统计以及操作员管理等功能模块,开发环境为IDEA 2016.3、SQL Server 2014与JavaFX。资源包共72个文件、约8.87MB,涵盖Java源码、Hibernate等依赖JAR库、XML配置文件、界面截图、properties配置、PDF项目报告及MD说明文档等,目录结构清晰,便于按模块查阅与二次开发。目前已有41人学习下载。源码经过严格测试,可放心参考并在此基础上延伸使用;配套的数据库课程设计报告与README说明,能帮助快速理解系统架构、数据库设计及订票/退票/统计查询等关键实现,适合需要完整项目方案与文档支撑的开发者。
1. 学生火车票订票系统,到底在做什么
很多学 Java 的人第一次接触“完整项目”,不是商城,不是博客,而是这类订票系统。它没有复杂的分布式、没有高并发,却把一个业务系统该有的东西都占全了:车次查询、余票管理、订单流转、学生优惠资质核验、出票与退票。正因为如此,它才会同时出现在毕业设计、课程设计和项目开发三类场景里。这篇笔记要讲的,就是基于 Java 实现学生火车票订票系统时,从表结构设计到并发扣票、从代码组织到论文撰写的一条完整落地路径。目标读者是正在做课设或毕设的 Java 开发者,以及想把这类系统当作“第一个能讲的完整项目”的入门工程师。
2. 先理清业务再动手:车次、余票与订单状态机
2.1 车次与车站的基础模型:一张车站表还是两张
订票系统第一个容易想歪的地方是车次和车站的关系。你打开 12306,输入“北京南”到“上海虹桥”,系统先要回答两个问题:有哪些车次经过这条线路,每个车次在这个区间的余票是多少。如果把车次设计成“一个车次对应一个出发站、一个到达站”,查起来确实简单,但车次一经停就废了。比如 G101 从北京南始发、经停济南西、终到上海虹桥,你查济南西到上海虹桥,它也应该出现在结果里。
常见做法是把“车次”和“车站”拆开:一张 train 表存车次本身的信息,一张 train_station 表存车次经停的站点顺序、到发时刻和区间票价。查询某段行程时,先根据 train_station 表筛出同时包含出发站和到达站的车次,再校验出发站的下标小于到达站的下标,确保顺序正确。这样一张表中一个车次可以出现多行,每行代表一次经停,业务表达上最干净。
我的建议是再加一个 seat_count 字段放在 train 表上,表示该车次的物理座位总数,而不要在 train_station 表上放余票。原因在于余票是随订单实时变化的,如果登记在 train_station 上,退票和取消订单时要按区间去定位并更新那一行的值,容易写错条件。课程设计阶段老老实实按整列车次扣减余票,够演示,够答辩。
2.2 订单状态机:从锁定到出票的完整状态迁移
订票系统与普通 CRUD 最大的区别,是订单存在生命周期。一个订单从创建到结束,至少要经历这几个状态:待支付、已支付、已出票、已取消、已过期。不要用普通字段表达“这个订单还活着”,把状态集中到一个 status 字段上,取值用枚举约束,前端页面也只认枚举值。
待支付状态是一个“锁定”状态。用户点击“提交订单”时,系统先扣减余票、生成一条待支付订单,然后开始倒计时。倒计时结束没支付,订单自动变更为已过期,同时把余票加回来。这就是一套最简单的状态迁移逻辑。状态机的价值在于,所有状态变更都必须走同一个入口,比如 OrderService.changeStatus(orderId, fromStatus, toStatus),避免在业务代码里随手 setStatus,导致出现“已取消的订单还能支付成功”这种问题。
状态枚举和迁移方法建议放在一起维护。枚举里定义 PODING、PAID、TICKETED、CANCELLED、EXPIRED 五个值,迁移方法里校验 fromStatus 是否是当前状态,用数据库 UPDATE 语句带条件——UPDATE orders SET status = ? WHERE id = ? AND status = ?。条件更新一方面防止并发下状态错乱,另一方面也天然充当了乐观锁。
2.3 余票为什么不能先查再减:从订单视角理解“锁定”
不少初学者写出来的下单逻辑是这样的:先 SELECT 余票,判断余票大于 0,然后 INSERT 订单,再 UPDATE 余票减一。单线程跑一切正常,一旦有两个请求同时读到余票等于 1,两个都能通过判断,结果就是超卖。这不是概率问题,是必然问题。
正确思路是把“校验余票 + 扣减余票 + 生成订单”放在同一个事务里,用一条带条件的 UPDATE 完成扣减:UPDATE train SET remaining = remaining - 1 WHERE id = ? AND remaining > 0。受影响行数为 1 表示扣票成功,为 0 表示余票不足或车次不存在,直接回滚事务并给用户返回“余票不足”。先扣票、再生成订单,让车次表的余票成为唯一的可信数据源。
这里的参数设计有一个细节:扣票的 WHERE 条件里不要写 version 字段,直接依赖 remaining > 0 就足够了。因为只要 UPDATE 语句走到了数据库行锁这一步,同一时刻只有一个事务能执行成功。锁定时间也可以做成参数,常见做法是 15 分钟,放在配置文件中由管理员调整,不要硬编码到业务代码里。
3. 用 Spring Boot 把原型跑起来:默认配置与最小骨架
3.1 一个能启动的最小工程:依赖选择与首次启动
技术选型上,Java 版本优先考虑 1.8,Spring Boot 用 2.7.x 系列。原因很现实:学校机房的 JDK 大多还是 8,毕业设计的答辩环境不一定让你装 Java 17,Spring Boot 3 起步要求 17,切过去容易在现场翻车。工程骨架不需要自己从零搭,直接用 Spring Initializr 生成一个 Maven 工程,再把依赖补齐。核心依赖只有四个:spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java、lombok。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里选 MyBatis-Plus 而不是原生 MyBatis,是因为课设阶段单表 CRUD 占了绝大多数工作,MyBatis-Plus 的 BaseMapper 直接省掉几十个 XML 文件。但这不代表可以不写 SQL,订单和余票这类核心操作仍然推荐手写,放到 Mapper 接口的注解里或 XML 文件里,方便答辩时讲清楚。
application.yml 里需要配置数据源、MyBatis-Plus 的地图扫描、日志输出。日志级别建议把 SQL 打印出来,调试阶段能直接看到每条语句的执行情况,省得反复猜测。
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/train_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis-plus: mapper-locations: classpath*:mapper/*.xml type-aliases-package: com.student.train.entity configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImplserverTimezone 必须显式指定为 Asia/Shanghai,否则 MySQL 8 默认时区与 Java 本地时区不一致,日期查询会差 8 个小时。这是新手在订票系统里最容易遇到的“查出来日期总是前一天”的元凶。启动类用标准的 @SpringBootApplication 即可,不需要额外加别的注解。
3.2 五个核心表:把数据库设计成能讲清楚的结构
订票系统的数据库设计不建议一上来就做七八张表,那是给自己挖坑。一个课设项目,五张表足够:student(学生信息)、train(车次与总余票)、train_station(车次经停站)、orders(订单)、ticket(出票记录)。其中 ticket 用来记录已经出票的乘客座位信息,没有它订单就只是一条空壳记录,答辩时说不清楚“票去哪了”。
CREATE TABLE student ( id BIGINT AUTO_INCREMENT PRIMARY KEY, stu_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', stu_name VARCHAR(50) NOT NULL, id_card VARCHAR(30) NOT NULL COMMENT '身份证号', grade VARCHAR(20) COMMENT '年级', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE train ( id BIGINT AUTO_INCREMENT PRIMARY KEY, train_no VARCHAR(20) NOT NULL UNIQUE COMMENT '车次,如G101', train_name VARCHAR(50) COMMENT '列车名称', total_seat INT NOT NULL COMMENT '总座位数', remaining INT NOT NULL COMMENT '当前余票', version INT DEFAULT 0 COMMENT '乐观锁版本号' ); CREATE TABLE train_station ( id BIGINT AUTO_INCREMENT PRIMARY KEY, train_id BIGINT NOT NULL, station_no INT NOT NULL COMMENT '经停顺序', station VARCHAR(50) NOT NULL COMMENT '车站名', arrive_time VARCHAR(10), leave_time VARCHAR(10), price DECIMAL(8,2) COMMENT '从始发站到本站的票价' ); CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, student_id BIGINT NOT NULL, train_id BIGINT NOT NULL, start_station VARCHAR(50) NOT NULL, end_station VARCHAR(50) NOT NULL, price DECIMAL(8,2) NOT NULL, status VARCHAR(20) NOT NULL COMMENT 'PODING/PAID/TICKETED/CANCELLED/EXPIRED', pay_time DATETIME, expire_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE ticket ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, student_id BIGINT NOT NULL, train_id BIGINT NOT NULL, seat_no VARCHAR(10) COMMENT '座位号,如03A', ticket_no VARCHAR(32) UNIQUE COMMENT '票号' );student 表的 stu_no 必须加唯一约束,这是很多系统上线后才被重复数据击穿的防线。train_station 表的 price 字段设计成“从始发站到本站的票价”,两个站之间的票价用到达站价格减出发站价格计算,比每一对区间都存一条记录节省大量数据,这也是真实系统常用的做法。
orders 表把 start_station 和 end_station 直接冗余进来,而不是只存 train_station 的 id,是为了查询订单详情时不用再去联合查询站点表。冗余设计在课设阶段是被允许的,答辩时解释说“用空间换查询性能”即可。
3.3 下单核心代码:把事务边界划清楚
下单是这个系统最核心的一段代码,事务边界必须准确。整个流程分三步:扣减余票、生成订单、返回结果。如果订单插入失败,余票必须回滚,否则就会出现“票没了但订单不存在”的尴尬。
@Service public class OrderService { @Autowired private TrainMapper trainMapper; @Autowired private OrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public OrderCreateResult createOrder(Long studentId, Long trainId, String startStation, String endStation) { // 1. 条件扣减余票,remaining > 0 既是余票校验,也是并发控制 int affected = trainMapper.deductRemaining(trainId); if (affected == 0) { throw new BusinessException("余票不足或车次不存在"); } // 2. 查询车次信息,计算票价 Train train = trainMapper.selectById(trainId); BigDecimal price = calculatePrice(trainId, startStation, endStation); // 3. 生成待支付订单,锁定 15 分钟 Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setStudentId(studentId); order.setTrainId(trainId); order.setStartStation(startStation); order.setEndStation(endStation); order.setPrice(price); order.setStatus(OrderStatus.PODING.name()); order.setExpireTime(LocalDateTime.now().plusMinutes(15)); orderMapper.insert(order); return new OrderCreateResult(order.getOrderNo(), order.getExpireTime()); } }// TrainMapper.java public interface TrainMapper extends BaseMapper<Train> { @Update("UPDATE train SET remaining = remaining - 1, version = version + 1 " + "WHERE id = #{trainId} AND remaining > 0") int deductRemaining(Long trainId); }注意扣余票的 UPDATE 语句放在事务第一步执行,而不是先查车次再扣票。数据库行锁从 UPDATE 执行时开始持有,越晚执行,锁持有的时间越短,但如果你先 SELECT 再判断再 UPDATE,两个事务可能同时读到剩余 1 张票,判断都通过,最后两个 UPDATE 都执行成功——因为第二条 UPDATE 的 WHERE 条件已经不再校验 remaining > 0。上面这段代码里将校验条件全部塞进 UPDATE,从根上杜绝了这个问题。affected 这个返回值是后续所有判断的依据,不要忽略它。
4. 余票扣减的并发问题:从超卖到订单超时释放
4.1 为什么不用悲观锁:一个 UPDATE 比 FOR UPDATE 更省事
上节里用条件 UPDATE 直接完成了扣票,这就是乐观锁思想的简化形态。另一种常见方案是 SELECT ... FOR UPDATE 先把车次行锁住,再在 Java 代码里判断余票、执行 UPDATE。两种方案在课设场景下都能工作,但悲观锁有一个隐藏成本:你必须在事务里先执行 SELECT FOR UPDATE,再执行 UPDATE,整个过程中数据库连接都被这个事务占着,提交前不能释放。如果业务逻辑里再穿插几次网络调用或耗时的计算,数据库连接池会被占满。
而条件 UPDATE 的写法把锁的粒度压到最小——锁只在 UPDATE 语句执行期间持有,一旦执行完,行锁立即释放。它不需要额外加 @Version 字段,也不需要重试机制,一条语句同时完成了“校验”和“扣减”。MyBatis-Plus 的 @Version 注解在这个场景下反而有点多余,因为余票表不需要乐观锁的版本号语义,remaining > 0 本身就是最强的并发约束。
4.2 订单取消与超时释放:余票什么时候加回来
余票扣减只是第一步,释放余票同样容易出错。两种释放场景:用户主动取消待支付订单,系统自动把过期订单置为 EXPIRED。两种场景最终都要执行同一个动作——把余票加回来,再把订单状态改掉。这个动作必须放在同一个事务里,并且要保证幂等。
@Transactional(rollbackFor = Exception.class) public boolean cancelOrder(String orderNo) { // 1. 条件更新订单状态:只有待支付才能取消 int updated = orderMapper.cancelOrder(orderNo, OrderStatus.PODING.name(), OrderStatus.CANCELLED.name()); if (updated == 0) { return false; } // 2. 把余票加回去 Orders order = orderMapper.selectByOrderNo(orderNo); trainMapper.restoreRemaining(order.getTrainId()); return true; }@Update("UPDATE orders SET status = #{toStatus} WHERE order_no = #{orderNo} AND status = #{fromStatus}") int cancelOrder(@Param("orderNo") String orderNo, @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); @Update("UPDATE train SET remaining = remaining + 1 WHERE id = #{trainId}") int restoreRemaining(Long trainId);cancelOrder 里的条件更新是防止重复取消的关键。用户连点两次“取消按钮”,第一次执行成功后订单状态已经从 PODING 变成 CANCELLED,第二次执行时 WHERE status = 'PODING' 匹配不到行,直接返回 false。余票也只会加回一次,不会出现退票两次把余票加爆的问题。
自动超时释放则用定时任务,常见做法是 Spring 的 @Scheduled 注解,每 30 秒执行一次扫描,找出 expire_time 早于当前时间且 status 为 PODING 的订单,批量执行相同流程。这里要注意定时任务的方法不能加 @Transactional 包裹循环,否则一个大事务把几十个订单锁在一起,任何一个失败都会整体回滚。正确写法是循环里单独调 cancelOrder,每个订单独立事务。
4.3 并发测试:用多线程模拟抢票场景
写完扣减逻辑后,单纯用 Postman 手动点几次看不出并发问题。建议写一个简单的并发测试类,用 CountDownLatch 模拟 100 个线程同时抢 10 张票,断言最终成功的订单数恰好等于 10,且车次的 remaining 恰好为 0。
public class ConcurrencyTest { @Test public void testConcurrentBooking() throws InterruptedException { int threadCount = 100; int totalSeat = 10; CountDownLatch readyLatch = new CountDownLatch(threadCount); CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch endLatch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(0); for (int i = 0; i < threadCount; i++) { new Thread(() -> { readyLatch.countDown(); try { startLatch.await(); // 调用下单接口 boolean success = orderService.createOrder(...); if (success) { successCount.incrementAndGet(); } } catch (Exception e) { // 忽略竞争失败 } finally { endLatch.countDown(); } }).start(); } readyLatch.await(); startLatch.countDown(); endLatch.await(); System.out.println("成功订单数: " + successCount.get()); System.out.println("剩余余票: " + trainService.getRemaining(trainId)); Assert.assertEquals(totalSeat, successCount.get()); Assert.assertEquals(0, trainService.getRemaining(trainId)); } }这段测试代码本身有教学价值。readyLatch 保证所有线程都准备好后再同时开跑,startLatch 是发令枪,endLatch 等待所有线程执行完毕。最终断言语句验证“没有超卖”。跑一次如果成功订单数大于 10,说明扣减逻辑有问题,回到 4.1 找原因。这个测试类建议保留在项目里,论文的测试章节可以直接引用它的执行结果。
5. 避坑:毕业设计里最常翻车的七个点
5.1 车站名不一致导致查不到车次
现象:数据库里有的记录是“北京”,有的是“北京市”,前端查询时输入“北京”,一半车次查不出来。原因:站点数据通过 Excel 导入时没有做名称标准化,同一车站被录成了两个名字。解决:在建表时给 train_station.station 字段加唯一约束,导入数据前用脚本做一次 trim 和别名映射,例如建一张 station_dict 表维护常用别名与标准名的对应关系。
5.2 自调用导致 @Transactional 失效
现象:在 OrderService 内部写一个 public 方法,调用同类中另一个带 @Transactional 的方法,后者事务没有生效,订单扣票和生成订单分属两个事务。原因:Spring 的事务基于 AOP 代理,同类内部调用绕过代理对象,注解失效。解决:事务方法不要在同类的普通方法里直接调用,要么拆到另一个 Service 里注入调用,要么从 Controller 层直接调用事务方法。排查时先在方法里抛个异常看数据是否回滚,不回滚基本就是这个问题。
5.3 订单超时释放把已支付订单也取消了
现象:定时任务执行后,已支付订单被置为 EXPIRED,余票被错误加回。原因:定时任务扫描条件只写了 expire_time < now,没有过滤 status = 'PODING'。解决:cancelOrder 方法里已经用条件更新做了状态校验,但定时任务在查询时就应该把 status 条件带上,不要依赖 cancelOrder 去兜底。SQL 的 WHERE 条件永远比 Java 代码更可靠。
5.4 数据库日期少 8 小时
现象:订单创建时间是 23:00,存到数据库后变成前一天 15:00。原因:JDBC 连接串没配 serverTimezone,MySQL 使用服务器时区,Java 使用本地时区,二者不一致。解决:连接串显式加 serverTimezone=Asia/Shanghai,同时实体类的日期字段使用 LocalDateTime 而不是 java.util.Date,避免 Date 的 toString 方法在打印时产生误导。
5.5 分页查询结果重复或乱跳
现象:车次列表分页时,第二页出现第一页已经见过的数据,翻页顺序不稳定。原因:查询语句没有 ORDER BY 字段,MySQL 的默认排序不是主键序。解决:分页查询强制加 ORDER BY id 或按发车时间排序,保证每次查询的结果顺序稳定。这一条同样适用于订单列表和车次查询结果。
5.6 余票加回去时没有判断订单来源
现象:线上数据出现余票大于总座位数。原因:取消订单时没有判断车次是否存在,或者同一订单被取消两次后余票加了两次,而订单表的唯一约束没有拦住第二次状态更新。解决:加回余票的 UPDATE 语句跟取消订单的状态更新放同一事务,并且依赖状态条件更新保证幂等。加回余票的语句本身不用判断 remaining < total_seat,因为前置状态更新已经保证了唯一性。
5.7 MyBatis-Plus 的乐观锁插件在批量场景下失效
现象:实体加了 @Version 字段,但 UPDATE 时 version 没有自动加一,或者加一的语句没有带版本条件。原因:MyBatis-Plus 的乐观锁插件只在特定方法上生效,比如 updateById,而一旦你自己写了 UPDATE 语句,插件不会再次介入。解决:不用插件,自己在 UPDATE 语句里写 version = version + 1,逻辑透明可讲清楚。毕竟答辩时老师大概率会问“乐观锁怎么实现的”,手写版本号比背源码讲要自信得多。
6. 把项目文档和参考论文写到能直接提交的程度
很多同学代码写完了,栽在最后一步的文档上。课设和毕设的评分点里,文档占比通常不低于三分之一。项目文档不是把代码贴上去,而是让一个没看过你代码的老师能按图索骥理解系统。我一般建议按六个章节组织:需求分析、总体设计、数据库设计、详细设计、系统测试、总结与展望。需求分析里要写清楚三个角色——学生、售票员、管理员各自的用例;总体设计放一张架构图说明分层;数据库设计放建表语句和 ER 图;详细设计挑下单、退票、余票扣减三个核心模块,每个模块放流程图和核心代码片段。
参考论文不是把项目文档换个标题再写一遍,而是要把“为什么这样设计”讲透。论文的摘要部分写你解决了什么问题,第一章绪论里综述同类系统的现状,第二章放需求分析,第三章是系统概要设计,第四章是详细设计与实现,第五章测试,第六章总结。答辩老师最常问的几个点要提前准备好:为什么用这种扣余票方式、订单状态机如何防止状态错乱、并发场景怎么测试的。这些都是论文里可以直接铺开来写的内容。
关于数据初始化,有一个小技巧:不要手动往数据库里一条条插车次数据,写一个 DataInitializer 组件,项目启动时检测车次表为空就自动插入 G101、G102、D301 等若干常用车次和经停站信息。这样做有两个好处:第一,换一台电脑部署项目时不用再执行一遍 SQL 脚本;第二,答辩现场演示时删库重来,重启项目数据自动恢复,非常加分。
@Component public class DataInitializer implements ApplicationRunner { @Autowired private TrainMapper trainMapper; @Override public void run(ApplicationArguments args) { if (trainMapper.selectCount(null) > 0) { return; } // 插入演示用基础数据 Train train = new Train(); train.setTrainNo("G101"); train.setTrainName("北京南-上海虹桥"); train.setTotalSeat(100); train.setRemaining(100); trainMapper.insert(train); } }这个初始化器有一个需要留意的参数:selectCount(null) 的返回值是 Long,判断大于 0 即可。如果演示现场清了库,重启项目就能恢复数据,不用再打开 Navicat 手动导入。我自己的习惯是把初始化器放在最后一个包路径下,确保 Spring 扫描顺序靠后,避免它依赖的其他组件还没准备好。
写文档时另一个习惯是先用思维导图把表结构画出来,再写文字。数据库设计章节里附一张 ER 图,比一大段文字描述直观得多。生成 ER 图可以用 MySQL Workbench 的逆向工程功能,连上数据库后自动生成,导出成图片直接插入文档,比手画快也准确。论文里的图表同理,Visio 画用例图、时序图,导出后统一格式,不要混用多种风格,排版会显得很专业。
最后说一个实实在在的教训:交文档前一定要在干净环境里重新部署一遍项目。我见过不少人代码在自己电脑上跑得好好的,换到答辩机器上起不来,最后排查发现是 MySQL 版本从 5.7 换成了 8.0,驱动类名变了;还有人的 JDK 版本与 Spring Boot 不兼容,控制台直接报 UnsupportedClassVersionError。提前准备一个 README 文件,写清楚 JDK、MySQL、Maven 的版本要求,以及初始化数据库的步骤,能省掉现场一大半的麻烦。如果时间允许,把项目打成 jar 包,确保“双击就能跑”或者“一行命令就能启动”,这才是课设答辩里最稳的演示方式。希望这篇笔记能帮你少走几步弯路。
本文还有配套的精品资源,点击获取