☰
Java学生火车票订票系统:事务、行锁与并发防超卖实战解析
2026/10/7 8:23:27 网站建设 项目流程

简介:一份基于JAVAEE技术栈实现的学生火车票订票系统完整项目源码,面向Java Web学习者、课程设计或毕业设计使用者。系统涵盖后台管理、学生半价优惠、团购购票、车次与城市维护等核心功能,并整合Spring、Hibernate/MyBatis、MySQL等常用企业级技术,有助于理解MVC分层结构与票务业务逻辑。压缩包共318个文件,约24.27MB,包含38个Java源文件及对应Class文件、33个JSP页面、72个依赖Jar包、SQL数据库脚本,以及CSS、JavaScript、图片等前端素材,目录结构清晰,便于导入开发工具直接阅读运行。已有3249人学习下载。借助源码、页面与数据库脚本,读者可快速搭建订票系统原型,并在此基础上扩展移动端适配、安全加固或智能推荐等功能,是学习JAVAEE整合开发与完整业务实现的实用参考。

1. JAVA 学生火车票订票系统:先从一个能跑通还能答辩的课设开始聊

上周有个学弟甩给我一份源码包,名字就叫 JAVA 学生火车票订票系统,说按 README 配完 Tomcat 还是启动失败。我帮他折腾了一晚上,最后发现是 JDK 版本和数据库编码连串了。跑起来之后他又问我:余票要是被两个人同时抢到怎么办?——这一问,比整套课设都值钱。今天拆的就是这套系统:它虽然顶着"学生课程设计"的帽子,但核心模块串起了 Java Web 开发里最容易被面试问到的三件事——事务、行锁、状态机。如果你正在准备 java 基础或 java 面试题,拿这套系统当项目复盘比背八股文要记得牢。适合三类人:要交课设的学生、想练 Spring Boot 的 Java 工程师、以及想搞懂"订单系统到底怎么防超卖"的读者。

2. 拆结构:这套订票系统的技术选型与数据库设计,先解决"车次余票"这个核心实体

2.1 为什么是 Spring Boot + MyBatis + MySQL:选型理由与优先级

网上一搜"JAVA学生火车票订票系统",出来的老版本大多是 JSP + Servlet + JDBC + Tomcat 那种写法。这种写法不是不能用,而是对新手极不友好:你要手动配置 web.xml、手动处理 JDBC 连接、手动管理事务,代码里还经常混着 HTML。我拆过好几个同名字的源码包,给我的感觉是"能跑但讲不清"。如果只是交课设,那没问题;但如果你想把这个项目写进简历,或者拿去应付 java 面试题里的基础问题,我建议按 Spring Boot + MyBatis + MySQL 重构一遍,业务逻辑不变,工程结构顺手得多。

我当时重构这套系统时,maven 依赖只加了 spring-boot-starter-web、mybatis-spring-boot-starter 和 mysql-connector-j。Spring Boot 自带内嵌 Tomcat,启动不用再单独装 Tomcat,也绕开了一半的"8080 端口被占用"问题。不过我实际拆的时候发现,源码包里的 pom 往往在版本上很随意,Spring Boot 2.x 对应 JDK 8,Spring Boot 3.x 对应 JDK 17,一旦版本错位就会出现UnsupportedClassVersionError或者编译直接失败。这种环境问题占了课设启动失败原因的七成。

项目结构我习惯这样拆:

train-ticket-system/ ├── pom.xml ├── src/main/java/com/example/ticket/ │ ├── controller/ │ │ ├── UserController.java │ │ ├── TrainController.java │ │ └── OrderController.java │ ├── service/ │ │ ├── UserService.java │ │ ├── TrainService.java │ │ └── OrderService.java │ ├── mapper/ │ │ ├── UserMapper.java │ │ ├── TrainMapper.java │ │ └── OrderMapper.java │ ├── model/ │ │ ├── User.java │ │ ├── Train.java │ │ └── Order.java │ └── config/ │ └── LoginInterceptor.java ├── src/main/resources/ │ ├── application.yml │ └── mapper/ │ ├── UserMapper.xml │ ├── TrainMapper.xml │ └── OrderMapper.xml └── sql/ └── train_db.sql

controller 层只做参数接收和返回封装,service 层写业务逻辑,mapper 层管数据库交互。答辩时被问"分层有什么用",你就回答"降低耦合,每个类职责单一",然后拿 order 下单流程举例:controller 收到请求,调到 service 的 createOrder,service 里管事务和锁,mapper 只负责执行 SQL。这样一条链路讲完,对面基本不会再往深里逼。

这里多说一句:源码包里如果是 JSP 版,你可能还要处理${pageContext.request.contextPath}路径问题,前后端资源混在一起。我习惯后改成前后端分离,静态页面放static目录,接口走 RESTful 风格。但课设答辩不见得需要前后端分离,你别为了追求工程复杂度把自己绕进去。先跑通,再谈架构。

2.2 数据库建模:学生表、车次表、订单表,以及"余票字段该不该冗余"

数据库是整个系统的地基,很多源码包里的建表脚本是直接从网上抄的,字段类型混乱,外键没加索引,还喜欢把order表用反引号包起来——这确实是关键字,但真正的问题是设计时没考虑并发。"学生火车票订票系统"核心表就三张:student、train、order。我给出的建表脚本如下:

CREATE TABLE student ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', password VARCHAR(64) NOT NULL COMMENT '密码(MD5加盐)', name VARCHAR(50) NOT NULL COMMENT '姓名', id_card VARCHAR(18) COMMENT '身份证号', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_student_no (student_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表'; CREATE TABLE train ( id BIGINT AUTO_INCREMENT PRIMARY KEY, train_no VARCHAR(10) NOT NULL UNIQUE COMMENT '车次编号', from_station VARCHAR(50) NOT NULL, to_station VARCHAR(50) NOT NULL, depart_time DATETIME NOT NULL, arrive_time DATETIME NOT NULL, total_seats INT NOT NULL COMMENT '总座席', remaining_seats INT NOT NULL COMMENT '余票数', price DECIMAL(10,2) NOT NULL COMMENT '成人票价', student_price DECIMAL(10,2) NOT NULL COMMENT '学生票价', KEY idx_route (from_station, to_station, depart_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次表'; CREATE TABLE `order` ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号', student_id BIGINT NOT NULL, train_id BIGINT NOT NULL, travel_date DATE NOT NULL, seat_type VARCHAR(10) COMMENT '硬座/硬卧/软卧', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3已退票', amount DECIMAL(10,2) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_student (student_id), CONSTRAINT fk_order_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_order_train FOREIGN KEY (train_id) REFERENCES train(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

价格字段必须用DECIMAL(10,2),不要用 double。这是 java 数据类型里的经典八股文:double 算金额会丢精度,比如 0.1 + 0.2 不等于 0.3。面试官最爱问这个节点,你直接说"金额用 BigDecimal,数据库用 DECIMAL"就能把话题引走。

至于remaining_seats到底该不该冗余,我的建议是:冗余。如果不冗余,余票就得用total_seats - 已支付订单数实时算,这意味着每次查询车次都要对订单表做聚合。更麻烦的是,退票和取消会让聚合逻辑变得支离破碎,一个学生来回退两次票,你就要考虑订单一辈子留痕的问题。冗余独立的remaining_seats字段,下单时直接减一,退票时加一,逻辑直观,性能也高。带来的代价是并发更新的风险,我把它留在第4章专门讲怎么避坑。

学生票价这里我单独存了一个student_price字段。真实的学生票规则是硬座五折、硬卧按硬座五折再加卧铺差价,如果你在 service 里实时算,代码里全是 if 嵌套。建表时直接把算好的学生票价存进去,下单时取这个字段,简单且不容易错。索引方面,idx_route一定要建在 from_station、to_station、depart_time 上,不然车次查询就是全表扫描,数据量一大页面直接卡死。外键虽然建了,但生产环境通常不建议物理外键,课设无所谓,方便你讲表关系。

3. 跑通核心流程:注册登录、车次查询、下单订票的事务与并发控制

3.1 从 HttpSession 到拦截器:登录态如何做到不改每个接口

学生系统最常见的入口是注册和登录。注册时校验学号唯一,密码用 MD5 加盐存。注意 MD5 现在已经不算安全,但课设里它仍然是最容易讲明白的散列方案。如果你想把项目做得更体面,可以换成 BCrypt,我只说一句:别用明文。登录成功后在 Session 里放studentId,后续所有需要登录的接口都由拦截器统一判断,而不是在每个 controller 里写重复的 if。

@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object studentId = request.getSession().getAttribute("studentId"); if (studentId == null) { // 未登录:Ajax 请求返回 401,普通页面重定向到登录页 String requestedWith = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(requestedWith)) { response.setStatus(401); } else { response.sendRedirect("/login.html"); } return false; } return true; } }

这里有三个容易被忽略的细节。第一,拦截器判断的是 Session 里的属性,不是 cookie,所以你必须先执行登录逻辑把studentId塞进 Session。第二,Ajax 请求不能重定向,否则前端拿到的是一整个登录页 HTML,然后解析成login.html的 JSON 报错,坑在本地怎么也看不出来。第三,@Component注解必须加上,然后在配置类里注册拦截路径,这样 Spring Boot 才能接管它。

@Configuration public class WebConfig implements WebMvcConfigurer { private final LoginInterceptor loginInterceptor; public WebConfig(LoginInterceptor loginInterceptor) { this.loginInterceptor = loginInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/login", "/api/register", "/api/train/search"); } }

/api/train/search放行是因为查车次不需要登录,这符合大多数订票类产品的习惯:先查票,再登录下单。/api/login和/api/register放行是显然的。这里注意拦截路径的粒度,如果addPathPatterns("/**")配得太狠,静态资源也会被拦,前端页面就白屏了。我见过一个学弟把静态资源全部排除,结果 CSS 全部 404,因为他在排除列表里忘了/static/**。

3.2 车次查询与"按日期动态取余票"

查询车次是订票的前置动作,也是初学者最容易写出"慢 SQL"的地方。前端传入出发地、目的地、日期,后端拼条件查询。我的TrainMapper.xml是这样写的:

<select id="selectByRoute" resultType="com.example.ticket.model.Train"> SELECT id, train_no, from_station, to_station, depart_time, arrive_time, total_seats, remaining_seats, price, student_price FROM train WHERE from_station = #{from} AND to_station = #{to} AND DATE(depart_time) = #{date} ORDER BY depart_time </select>

DATE(depart_time) = #{date}是取车次表里的出发时间,只匹配到"日",这样同一天的多个发车时刻都能查出来。为什么不用LIKE '2026-06-01%'?因为那样会影响索引命中,数据量小无所谓,但既然写了就要写对。排序也是一样,让数据库的ORDER BY depart_time去做,不要在 Java 内存里用Collections.sort。虽然车次数量级不大,用 Java 排序也能跑,但这也是个面试能聊的点——你至少知道"数据库排序走索引更快"这个常识。

对应的 service 方法可以长这样:

public List<Train> searchTrains(String from, String to, LocalDate date) { // 参数校验:出发地、目的地不能为空,日期不能早于今天 if (from == null || from.trim().isEmpty() || to == null || to.trim().isEmpty() || date == null) { throw new BizException("查询参数不完整"); } // 调 Mapper 执行查询,按出发时间升序返回 return trainMapper.selectByRoute(from, to, date); }

核心逻辑就是一句查询,但参数校验不能省。真实项目中,前端传from和to如果一样,属于无效查询;日期如果是过去时间,也应该直接拒绝。你可以在 SQL 里加AND depart_time >= NOW(),但更稳妥的做法是在 service 层判断,这样异常信息可以返给用户"请选择未来的出行日期",而不是数据库抛出一个看不懂的异常。

3.3 下单事务:先锁行再扣余票,订单表插入的先后顺序

下单是整个系统的灵魂。如果你只把订票流程写成"插入一条订单记录,然后把余票减一",那就是纯纯的黑匣子。真正的订单系统要同时完成四件事:锁住车次行、校验余票、扣减余票、生成订单。这四步必须在一个事务里,要么全部成功,要么全部回滚。我给出的代码是基于事务和行锁的经典方案:

@Transactional(rollbackFor = Exception.class) public Order createOrder(Long studentId, Long trainId, LocalDate travelDate) { // 1. 锁住车次行,防止并发超卖 Train train = trainMapper.selectByIdForUpdate(trainId); if (train == null) { throw new BizException("车次不存在"); } // 2. 校验余票 if (train.getRemainingSeats() <= 0) { throw new BizException("余票不足"); } // 3. 扣减余票 trainMapper.decreaseRemainingSeats(trainId, 1); // 4. 生成订单 Order order = new Order(); order.setOrderNo(generateOrderNo(trainId)); order.setStudentId(studentId); order.setTrainId(trainId); order.setTravelDate(travelDate); order.setAmount(train.getStudentPrice()); order.setStatus(0); orderMapper.insert(order); return order; }

其中最关键的 SQL 是selectByIdForUpdate:

SELECT id, train_no, remaining_seats, student_price FROM train WHERE id = #{id} FOR UPDATE

FOR UPDATE是 MySQL InnoDB 的行级锁,一旦执行,这条车次记录就会被锁住,其他事务想再对这个 id 做FOR UPDATE或更新时必须等前一个事务提交。所以流程是:先锁行,再判断余票够不够,然后扣减,最后插入订单。如果两个请求同时进来,第一个请求拿到锁,执行完扣减和插入,提交事务释放锁;第二个请求接着进来,它读到的是已经减过的remaining_seats,自然就会发现余票不足,抛异常。这正是防超卖的关键。

关于事务,这里有三个必须背下来的点。第一,@Transactional只能用在 public 方法上,且要保证方法被 Spring 代理调用,不能被同类内部this.createOrder()调掉,否则事务不生效。第二,异常一定要是 RuntimeException 或设置了rollbackFor = Exception.class,否则默认只回滚运行时异常,checked exception不会触发回滚。第三,扣减余票的decreaseRemainingSeats必须发生在锁行之后,如果先扣减再锁行,两个事务同时读到remaining_seats = 1,两个人都判断通过,最后还是超卖。

订单号我习惯这样生成:

private String generateOrderNo(Long trainId) { // 时间戳 + 车次ID + 3位随机数,尽量保证唯一 return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + trainId + String.format("%03d", new Random().nextInt(1000)); }

订单号不能直接用自增主键,因为你把订单号发给用户之后,自增 ID 太容易推测,别人可以通过遍历 ID 看到所有订单。加上时间和随机数之后,至少从表面上不容易撞号。真实系统会进一步用雪花算法,课设到这里已经够用了。别忘了在order表的order_no字段上加唯一约束,代码里哪怕撞号了,数据库也会给你兜底,否则最后的防线也没有。

4. 避坑:这套 JAVA 学生火车票订票系统最容易炸的地方

4.1 三个典型翻车现场:数据库连接编码、Tomcat 端口占用、JDK 版本不匹配

先说说我拆源码包时最常修的三个问题。

第一个翻车点是中文乱码。现象是前端注册时输入"张三",后端存到数据库变成"å¼ ä¸‰";查询车次时前端传"北京",后端收到的也是乱码。原因是 JDBC 连接串没有指定编码,或者数据库表用了 utf8 而不是 utf8mb4。解决方法是连接串加上characterEncoding=utf8,建表时统一CHARSET=utf8mb4。注意:MySQL 8.0 的驱动连接串还要加上serverTimezone=Asia/Shanghai,否则启动时可能报时区错误。这一点在 windows 上尤其常见,因为系统默认时区不是标准时区。

第二个翻车点是端口被占用,表现为 Spring Boot 启动报Web server failed to start. Port 8080 was already in use.。原因是你上一个 IDEA 的进程没关干净,或者别的程序占了 8080。解决方法是先看端口占用进程:Windows 上用netstat -ano | findstr 8080,查到 PID 后taskkill /f /pid 1234;Linux 上用lsof -i:8080然后kill -9。我一般更懒,直接在application.yml里改server.port: 8081完事。但你要知道,这种问题属于"环境问题",在面试时不算技术能力,别花太久纠缠。

第三个翻车点是 JDK 版本不匹配。现象是编译时报java: release version 17 not supported或者运行时报UnsupportedClassVersionError。原因是源码是用 JDK 8 写的,你却在 IDEA 里配了 JDK 17,或者反过来。解决方法是统一 JDK 版本,并且检查 IDEA 的 Project Structure 里的 SDK 和 Language Level 是否一致。我见过一个最隐蔽的情况:pom.xml里配了<java.version>1.8</java.version>,但 IDEA 的 compiler 设置里用了--release 17,照样报错。所以三个地方都要检查:Project SDK、Module Language Level、pom.xml的 properties。

4.2 并发订票时的脏数据:为什么你反复测都复现不了超卖

超卖是订票系统最经典的翻车现场,也是最难复现的 bug。现象是:一个车次只剩 1 张票,你开两个浏览器,用两个账号同时点下单,结果两个订单都生成了,余票变成 -1。你关掉一个浏览器再试一次,可能又正常了。原因是你没加锁,或者加了锁但没生效。很多刚做课设的同学在 controller 里写synchronized,以为能解决,但实际上 synchronized 锁的是当前 JVM 的一个对象,多个 Tomcat 实例部署时完全没用。而且 synchronized 锁住整个方法会导致接口串行,下单吞吐量直接降到 1,显然得不偿失。

正确的锁是数据库行锁。把selectByIdForUpdate用在事务方法里,并确保@Transactional生效。有一个特别容易踩的坑是"自调用导致事务失效":OrderService.createOrder调用了同类里的另一个方法,或者在某些框架里this调用绕过了 Spring 代理,@Transactional就不生效了。排查方法也很直接:在事务方法里故意抛一个RuntimeException,看数据库里的余票有没有回滚。如果没回滚,说明事务管理压根没起效。

还有一个坑是 MyBatis 的 Mapper 方法需要被 Spring 代理,你不能自己new一个 Mapper 然后去调。所有 Mapper 都应该注入到 Service 里,Spring Boot 才能帮你管理事务和连接。检查一下你的@Autowired或构造器注入有没有漏掉。我在源码包里见过有人把 Mapper 方法写成静态方法,结果事务完全失效,查了半天都找不到原因。

提示:如果你在同一个 Service 里调用this.saveOrder(),@Transactional不会再生效。正确做法是拆成两个 Bean,把需要事务的方法放到独立 Service 中,再注入调用。

另外,订单表没有加唯一约束也会放大超卖问题。就算你加锁了,理论上两个事务不会同时插入相同的订单号,但为了稳妥,order_no上的 UNIQUE KEY 强烈建议保留。它能作为最后一道防线,让非法数据在数据库层就报错。

5. 进阶:用 JMeter 压测并发订票,验证你的锁到底有没有用

5.1 压测三要素:线程组、HTTP 请求、断言响应

课设做到这一步,很多同学以为"能下单、能退票"就算完了。但我想让你多走一步:用 JMeter 做一次并发压测,亲眼看一看你的锁到底拦住了多少超卖。压测前先做数据准备:把train表里某个车次的remaining_seats重置为 20,然后造 20 个不同学号的学生,保证每个学生只下一单,避免同一用户重复下单被业务层挡住。

在 JMeter 里建线程组,线程数设 20,Ramp-Up(启动时间)设 1 秒,循环次数设 1,让 20 个请求几乎同时打向后端。HTTP 请求路径就是你下单的接口:POST /api/order/create,参数带上trainId=1&travelDate=2026-06-01。由于登录态在 Session,JMeter 里要加一个 HTTP Cookie 管理器,先执行一次登录请求,拿到 Session ID 后再跑下单请求。之后添加"查看结果树",并在下单请求里添加一个响应断言,断言内容包含success,这样能快速确认哪些请求成功了。

压测结束后,写一条聚合 SQL 验证结果:

SELECT t.train_no, t.remaining_seats AS current_seats, COUNT(o.id) AS order_count, t.total_seats - COUNT(o.id) - t.remaining_seats AS over_sold_count FROM train t LEFT JOIN `order` o ON o.train_id = t.id WHERE t.id = 1 GROUP BY t.train_no, t.remaining_seats, t.total_seats;

如果锁和事务逻辑正确,最终结果是:total_seats - order_count = remaining_seats,也就是说总共 20 个座位,20 个订单,余票 0。如果你看到order_count是 22 而remaining_seats是 -2,说明你的事务或锁失效了。这条 SQL 的over_sold_count会出现负数,负数绝对值就是超卖的张数。

这还不是终点。你还可以把线程数提高到 50、100,观察 JMeter 的聚合报告里的吞吐量和错误率。你会发现,加了行锁之后,虽然订单不会超卖,但请求会有排队,吞吐量可能不如不加锁时高。这正是"一致性和性能的权衡",面试官若顺着问下去,你就可以讲乐观锁和分布式锁。乐观锁的写法很简单,给train表加一个version字段,扣减时执行UPDATE train SET remaining_seats = remaining_seats - 1, version = version + 1 WHERE id = #{id} AND version = #{version},如果影响行数为 0 就重试或抛异常。当然,这个方案在极低并发下可能不如行锁直观,课设答辩时你只要说出来,就已经是加分项了。

从那以后,我每次拆订单类源码或自己重构下单逻辑,都强制走一遍 JMeter 并发压测,顺手查三个数字:订单数、余票数、异常日志数量。这个习惯救过我很多次,靠肉眼和"多试几次"根本找不出来并发问题。希望帮到你。

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

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

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

立即咨询