☰
学生火车票订票系统:Spring Boot+MyBatis实战与并发防超卖
2026/9/25 6:43:39 网站建设 项目流程

简介:一份基于JAVAEE的学生火车票订票系统完整项目资源,面向Java Web学习者、毕业设计选题学生及需要快速搭建订票类管理系统的开发者。系统覆盖后台车次维护、学生优惠、团购购票等典型功能,有助于理解Servlet/JSP、MVC分层、数据库交互等企业级开发流程。压缩包共318个文件、约24.27MB,包含72个jar依赖库、38个java源文件与对应class文件、33个jsp页面及css/js/gif等前端素材,并附有sql数据库脚本,便于部署调试。资源还整合了完整源码与页面素材,目录结构清晰,可直接作为课程设计或实训项目参考。已有3248人学习下载,适合用来对照实现订票核心模块、掌握JavaWeb项目从设计到落地的整体思路。

1. 学生火车票订票系统:JAVA课程设计里最值得复现的实战项目

一到期末,不少同学从网上找JAVA课程设计案例源码,题目不是图书管理就是学生信息,做出来自己都不太愿意打开浏览器。学生火车票订票系统不一样,它身上带着真实的业务约束:余票不能卖超、退票要把座位还回去、同一个车次不能重复下单。这几条正好把JAVA基础里的面向对象、集合、JDBC串起来,再往上是Spring Boot、MyBatis、事务管理,一套下来课程设计和面试项目都有了着落。这篇文章从建表、登录、订票一路写到并发防超卖,把整个系统的落地路径拆开讲,同时标出那些容易翻车的参数和坑。适合正在做课程设计、期末作业,以及想给简历加一个带业务深度的Web项目的读者。

2. 技术选型与项目骨架:为什么学生火车票订票系统用Spring Boot + MyBatis

如果你在网上搜学生火车票订票系统源码,会看到一半是JSP+Servlet,一半是SSM,还有一小部分已经切到Spring Boot。并不是老方案不能跑,而是给学生项目选技术栈,要看三个指标:学习成本低、资料多、演示时不容易翻车。Spring Boot在这三点上几乎都占优,所以下面不是把所有方案罗列一遍,而是直接给出我一般会推荐的组合:Spring Boot + MyBatis + JSP,并把每一步为什么这样选讲清楚。

2.1 三种主流方案对比:为什么不是JSP+Servlet,也不是SSH

方案环境配置事务支持资料数量对新手友好度
JSP+Servlet手动配置Tomcat手动管理Connection多但乱一般
SSM大量Spring XML注解+XML多一般
Spring Boot + MyBatis自动配置内嵌Tomcat@Transactional很多高

JSP+Servlet适合JAVA基础刚结课的时候,所有逻辑都能用HttpServletRequest和HttpServletResponse完成,但到了订票这一步,你要手动管理数据库连接、手动处理字符编码、手动控制事务提交。不是不能做,而是精力全耗在了非业务的地方。SSM是经典企业开发组合,但Spring的XML配置和SpringMVC的组件扫描,对只有JAVA基础的人来说太抽象,往往项目还没跑起来,人先被配置文件劝退了。Spring Boot把自动配置做到极致,默认内嵌Tomcat,一个Application类即可启动;它帮你自动装配DataSource和MyBatis工厂,你只需要关注Mapper接口和SQL语句。

那为什么持久层选MyBatis而不选JPA?因为火车票系统里有明确的SQL,比如扣余票要写成UPDATE train SET remain_tickets = remain_tickets - 1 WHERE id = ? AND remain_tickets >= 1,这种带条件的更新写在MyBatis的XML里一目了然。JPA也能做,但新手在实体关系、级联操作上很容易触发意外更新。MyBatis的SQL作者可控,排查直观,答辩时老师问「你的SQL怎么写的」,你直接打开XML讲就行。

页面用JSP还是Thymeleaf,我推荐JSP。网上大量JAVA课程设计案例源码本身就是JSP,遇到问题容易对照。缺点是Spring Boot对JSP支持需要额外加tomcat-embed-jasper和jstl依赖,并配置视图解析器。如果你完全不想碰JSP,Thymeleaf也行,只是要额外学一套表达式语法,这看个人时间。

2.2 依赖配置:pom.xml里一版不会出错的写法

下面是一份常用的依赖配置,版本尽量选你本地JDK能匹配的稳定版。我用Spring Boot 2.7.18举例,它支持JDK8和JDK11,学校机房和老电脑基本都能跑。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <!-- Web启动器:内嵌Tomcat、SpringMVC --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis官方Spring Boot适配器 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <!-- MySQL驱动,Spring Boot管理版本,不需要写版本号 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 如果使用JSP页面,这两个依赖必加 --> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> </dependency> </dependencies>

注意几个细节:mybatis-spring-boot-starter不是Spring Boot官方starter,它不随parent管理版本,所以必须手动指定;如果用的是Spring Boot 3.x,则需要匹配JDK17和Jakarta包,很多学生电脑还没升到那个环境,不建议第一版就上。mysql-connector-j在2.7.x里由父依赖管理,可以不写版本号,如果你在更老的Spring Boot版本里,驱动的类名要改成com.mysql.jdbc.Driver,否则启动时会报找不到驱动。

2.3 项目分包和配置文件:一眼看出是用心做的项目

拿到一个项目源码,我会先看包结构。Controller、Service、Mapper、Entity四层分开,比把代码全塞在Controller里要专业得多。下面是推荐的目录:

src/main/java/com/example/train/ ├── TrainApplication.java ├── config/ │ └── WebConfig.java ├── controller/ │ ├── UserController.java │ └── TrainController.java ├── service/ │ ├── UserService.java │ └── TrainService.java ├── mapper/ │ ├── UserMapper.java │ ├── TrainMapper.java │ └── OrderMapper.java └── entity/ ├── User.java ├── Train.java └── Order.java src/main/resources/ ├── application.yml └── mapper/ ├── UserMapper.xml ├── TrainMapper.xml └── OrderMapper.xml src/main/webapp/WEB-INF/jsp/ ├── login.jsp ├── trainList.jsp └── orderList.jsp

包名不要用test这种含义不明的词。控制层只负责接收参数和返回视图,业务层处理规则,数据访问层写SQL,答辩时你能准确说出这三层各自的职责,这就是java基础里被反复提到的分层思想。

application.yml里这几个参数值得逐项注意:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/train_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username: root password: "123456" driver-class-name: com.mysql.cj.jdbc.Driver mvc: view: prefix: /WEB-INF/jsp/ suffix: .jsp mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.train.entity configuration: map-underscore-to-camel-case: true

serverTimezone=Asia/Shanghai解决数据库和本地时间差8小时的问题;characterEncoding=utf8解决中文乱码;map-underscore-to-camel-case让数据库的real_name字段自动映射成Java实体里的realName属性。这三项是后续避坑章的主角,现在配好了能省掉很多麻烦。

启动类其实就三行,但它是整个项目的入口:

@SpringBootApplication @MapperScan("com.example.train.mapper") public class TrainApplication { public static void main(String[] args) { SpringApplication.run(TrainApplication.class, args); } }

@SpringBootApplication是@Configuration、@EnableAutoConfiguration、@ComponentScan三个注解的合成,@MapperScan自动扫描mapper包下的所有接口,省得给每个Mapper单独加@Mapper。这里要特别提醒:@MapperScan的包路径必须和Mapper接口实际位置一致,否则启动后会告诉你找不到Mapper bean。

技术栈选好后,接下来的数据库设计决定了后面接口难不难写。火车票订票系统最核心的不是页面,而是三张表之间的关系。

3. 从建表到登录:三张核心表与第一个用户接口

火车票订票系统最怕的是表设计拍脑袋。很多JAVA基础阶段的小练习只建一张user表、一张ticket表就完事,但学生火车票订票系统至少需要三张表:用户、车次、订单。订单表是连接用户和车次的中间表,也承载状态流转。下面直接给一套能本地跑起来的SQL和登录链路。

3.1 建表SQL:用户表、车次表、订单表怎么设计不返工

先把建表语句完整写出来,再逐段解释字段取舍。

CREATE DATABASE IF NOT EXISTS train_db DEFAULT CHARACTER SET utf8mb4; USE train_db; -- 用户表:存学生账号和基础信息 CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(100) NOT NULL, `real_name` VARCHAR(50) NOT NULL, `student_no` VARCHAR(20) NOT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生用户表'; -- 车次表:一列车的一种座位类型算一条记录 CREATE TABLE `train` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `train_no` VARCHAR(20) NOT NULL, `origin` VARCHAR(50) NOT NULL, `destination` VARCHAR(50) NOT NULL, `depart_time` DATETIME NOT NULL, `arrive_time` DATETIME NOT NULL, `seat_type` VARCHAR(20) NOT NULL, `price` DECIMAL(8,2) NOT NULL, `student_price` DECIMAL(8,2) DEFAULT NULL, `remain_tickets` INT NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_train_seat` (`train_no`, `seat_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次表'; -- 订单表:记录谁买了哪趟车、几张票、当前状态 CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `user_id` BIGINT NOT NULL, `train_id` BIGINT NOT NULL, `ticket_count` INT NOT NULL DEFAULT 1, `total_price` DECIMAL(8,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=已支付 1=已退票', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_train_id` (`train_id`), KEY `idx_user_id` (`user_id`), CONSTRAINT `fk_orders_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`), CONSTRAINT `fk_orders_train` FOREIGN KEY (`train_id`) REFERENCES `train` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

用户表的username加唯一约束,因为登录时按用户名查找,不允许重复。主键用BIGINT而不是INT,课程设计里数据量不大,但BIGINT能避免以后导出数据溢出。车次表设计成「车次+座位类型」一行记录,例如G1001次列车的二等座是一行,一等座是另一行,这种设计能直接复用同一套订票逻辑;如果你把座位类型拆成单独关联表,查询时要join,对学生项目反而复杂。

orders表不用order做表名,因为ORDER是SQL关键字,写原生SQL时容易引发语法问题。order_no加唯一索引,对外展示用,避免暴露自增主键。status字段用TINYINT表示状态,0代表已支付,1代表已退票,Java端用常量替代魔法数字。保留外键约束,是为了直观展示表关系;真正的互联网级系统一般不建外键,因为影响写入性能,但课程设计保留它反而是加分项,答辩时能说出这个取舍即可。

3.2 第一批测试数据

没有数据,后面所有查询都是空跑。先插入一个学生用户和两个车次:

INSERT INTO `user` (`username`, `password`, `real_name`, `student_no`) VALUES ('stu001', '123456', '王小明', '20230001'); INSERT INTO `train` (`train_no`, `origin`, `destination`, `depart_time`, `arrive_time`, `seat_type`, `price`, `student_price`, `remain_tickets`) VALUES ('G1001', '北京南', '上海虹桥', '2025-07-01 08:00:00', '2025-07-01 13:30:00', '二等座', 553.00, 442.40, 500), ('G1001', '北京南', '上海虹桥', '2025-07-01 08:00:00', '2025-07-01 13:30:00', '一等座', 933.00, 746.40, 100);

密码123456是明文,仅限本地练习。外面下载的课程设计案例源码也大多这样,但如果要传到公网或者写进简历,必须换成BCrypt加密,否则等于裸奔。这里插入两个座位类型是为了验证唯一索引uk_train_seat:同一个train_no想再插入一次二等座会直接失败,这正是我们想要的约束。

3.3 登录链路:实体类、Mapper接口、XML、Service、Controller

登录是最经典的链路,从数据库到页面要走五层。先写实体类,字段对应数据库表:

public class User { private Long id; private String username; private String password; private String realName; private String studentNo; private LocalDateTime createTime; public Long getId() { return id; } // 其余getter/setter用IDE自动生成,这里省略 }

注意realName对应数据库的real_name,studentNo对应student_no,依赖application.yml里配置的map-underscore-to-camel-case自动映射。接下来是Mapper接口,只写一个按用户名查询的方法:

public interface UserMapper { User findByUsername(@Param("username") String username); }

对应的XML:

<select id="findByUsername" resultType="com.example.train.entity.User"> SELECT id, username, password, real_name, student_no, create_time FROM user WHERE username = #{username} </select>

这里必须用#{}而不是${},因为#{}会生成PreparedStatement参数占位符,能有效防SQL注入;${}是字符串拼接,把用户输入直接拼进SQL,a' OR '1'='1这种字符串能直接拖走整个表。这是j a v a基础里就该养成的习惯,面试也常问。

Service层处理业务逻辑,这里不直接在Controller里查数据库:

@Service public class UserService { @Autowired private UserMapper userMapper; public User login(String username, String password) { User user = userMapper.findByUsername(username); if (user != null && user.getPassword().equals(password)) { return user; } return null; } }

密码比对用equals,不要用==。==比较的是对象的引用地址,String的equals才比较内容。这是JAVA基础里最容易被考到的点。

Controller负责接收请求、调用Service、跳转页面:

@Controller @RequestMapping("/user") public class UserController { @Autowired private UserService userService; @PostMapping("/login") public String login(String username, String password, HttpSession session, Model model) { User user = userService.login(username, password); if (user == null) { model.addAttribute("error", "用户名或密码错误"); return "login"; } session.setAttribute("loginUser", user); return "redirect:/train/list"; } }

为什么登录失败返回login,成功要redirect而不是直接返回trainList?因为redirect是重定向,浏览器地址栏会变成/train/list,刷新页面时不会重复提交表单;如果直接return "trainList",刷新页面会再次触发POST登录请求,这就是日常里所说的页面刷新翻车。Model.addAttribute会把error信息带到页面上,JSP里用${error}就能显示。

到这为止,登录链路已经闭环。你会发现代码并不多,但每一层都在做自己该做的事。接下来是订票和退票,这才是真正考验系统设计的地方。

4. 余票查询与订票/退票:让系统扛得住并发

如果学生火车票订票系统只做到能登录、能添加车次,那它和图书管理系统没有本质区别。真正让它像样的是订票和退票这两个带状态变更的功能。这一章先把余票查询写通,再把订票改成并发安全的版本,最后处理退票。建议你实际把下面的代码跑一遍,看看并发下余票会不会变成负数,这比背一百道java面试题都管用。

4.1 余票查询:按出发地、目的地、日期过滤

查询接口不复杂,但要注意MyBatis多参数时必须用@Param标记,否则XML里拿不到参数。

public interface TrainMapper { List<Train> findTrains(@Param("origin") String origin, @Param("destination") String destination, @Param("departDate") String departDate); }

XML写法:

<select id="findTrains" resultType="com.example.train.entity.Train"> SELECT id, train_no, origin, destination, depart_time, arrive_time, seat_type, price, student_price, remain_tickets FROM train WHERE origin = #{origin} AND destination = #{destination} AND depart_time BETWEEN #{departDate} AND #{departDate} ORDER BY depart_time </select>

这里有一个小坑:如果直接写AND DATE(depart_time) = #{departDate},可读性好,但DATE函数会让depart_time上的索引失效。我一般用BETWEEN,把字符串日期转成当天开始和结束时间,语义更清楚。课程设计数据量小,用DATE()也能跑,但面试时你能说出这个优化点是加分项。

Service和Controller就简单了:

@Service public class TrainService { @Autowired private TrainMapper trainMapper; public List<Train> searchTrains(String origin, String destination, String departDate) { return trainMapper.findTrains(origin, destination, departDate); } }
@Controller public class TrainController { @Autowired private TrainService trainService; @GetMapping("/train/list") public String search(@RequestParam(required = false) String origin, @RequestParam(required = false) String destination, @RequestParam(required = false) String departDate, Model model) { List<Train> trains = trainService.searchTrains(origin, destination, departDate); model.addAttribute("trains", trains); return "trainList"; } }

三个参数全部可选,页面不填就查全部。这里省略了参数判空,实际项目里如果城市为空,应该提前返回提示,而不是让用户看到一张空列表。校验逻辑放Service层更好,Controller层保持薄一点。

4.2 订票接口:为什么不能先查询余票再扣减

先看一个看起来很有道理、实际上会超卖的写法:

// 错误示例:并发下一定超卖 Train train = trainMapper.findById(trainId); if (train.getRemainTickets() >= ticketCount) { train.setRemainTickets(train.getRemainTickets() - ticketCount); trainMapper.updateById(train); orderMapper.insert(buildOrder(train, ticketCount)); return true; } return false;

两个请求同时读到remain_tickets=1,各自判断1>=1成立,各自扣一次,最终数据库余票变成-1。顺序执行时没问题,但Java Web是并发处理的,这种先查后改在多个线程下就是典型的竞态条件。你把代码写成Thread.sleep模拟也一样复现。

正确的做法是让扣库存变成一个原子操作,由数据库来保证条件成立才更新:

@Transactional(rollbackFor = Exception.class) public boolean bookTicket(Integer userId, Integer trainId, Integer ticketCount) { int rows = trainMapper.decreaseRemain(trainId, ticketCount); if (rows == 0) { return false; } Train train = trainMapper.findById(trainId); BigDecimal price = train.getStudentPrice() != null ? train.getStudentPrice() : train.getPrice(); Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTrainId(trainId); order.setTicketCount(ticketCount); order.setTotalPrice(price.multiply(BigDecimal.valueOf(ticketCount))); orderMapper.insert(order); return true; }

对应的Mapper更新语句:

<update id="decreaseRemain"> UPDATE train SET remain_tickets = remain_tickets - #{count} WHERE id = #{trainId} AND remain_tickets >= #{count} </update>

这段UPDATE在InnoDB下会对id这一行加行锁。两个事务同时执行时,第一个事务持锁更新成功,第二个事务等锁释放后再更新,此时remain_tickets不再满足>=count,受影响行数是0,方法直接返回false,不会插入订单。这比在Java代码里用synchronized合理,以后如果系统部署成两台机器,进程内的synchronized锁根本管不住另一个进程。

事务必须加在Service的public方法上,不能加在Controller,也不能加到Mapper方法上。这里我用rollbackFor = Exception.class,让任何异常都触发回滚。Spring默认只回滚RuntimeException,如果你在代码里显式抛出检查异常,不加这个配置事务不会回滚,余票扣了订单却没生成,数据就错位了。

订单号生成我用时间戳加随机数,课程设计完全够用:

private String generateOrderNo() { return "T" + System.currentTimeMillis() + (int) (Math.random() * 1000); }

真正上线要考虑订单号在极端并发下的重复问题,可以用雪花算法,但那是后话。

4.3 退票接口:状态流转比删除更安全

退票如果直接delete订单,就丢失了已退票的历史,也没法做对账。所以订单表的status字段开始发挥作用:0表示已支付,1表示已退票。

@Transactional(rollbackFor = Exception.class) public boolean refund(Integer orderId, Integer userId) { int rows = orderMapper.updateStatusByUser(orderId, userId, 1); if (rows == 0) { return false; // 订单不存在、不是这个人的、或已经退过票 } Order order = orderMapper.findById(orderId); trainMapper.increaseRemain(order.getTrainId(), order.getTicketCount()); return true; }
<update id="updateStatusByUser"> UPDATE orders SET status = #{status} WHERE id = #{orderId} AND user_id = #{userId} AND status = 0 </update>
<update id="increaseRemain"> UPDATE train SET remain_tickets = remain_tickets + #{count} WHERE id = #{trainId} </update>

注意updateStatusByUser里携带了user_id条件,这是为了防止一个用户通过遍历orderId去退别人的票。同时带上status = 0条件,第二个退票请求要么等第一个提交后看到status=1,要么直接影响0行,不会重复释放余票。

前端Controller调用也很直观:

@PostMapping("/order/refund") public String refund(Integer orderId, HttpSession session, Model model) { User user = (User) session.getAttribute("loginUser"); boolean ok = orderService.refund(orderId, user.getId()); if (!ok) { model.addAttribute("error", "退票失败,可能订单不存在或已退过"); } return "redirect:/order/list"; }

退票成功之后重定向到订单列表,这样刷新页面不会重复退票。到这里,核心业务闭环就已经有了:登录、查车、订票、退票。但代码写完只是第一步,真正让人头疼的是本地环境的各种报错。

5. 学生火车票订票系统5个高频踩坑点:从环境变量到余票超卖

下面这五条不是零散报错,而是课程设计里几乎每届都能遇到的翻车现场。每条按现象、原因、解决三步给,可以直接对着排查。前两条和环境有关,后三条和代码有关,建议写进项目笔记里。

5.1 一启动就报「java: 警告: 源发行版 17 需要目标发行版 17」

现象:在IDEA里打开别人的JAVA学生火车票订票系统课程设计案例源码,一编译要么显示“java: 警告: 源发行版 17 需要目标发行版 17”,要么直接“不支持发行版本 17”,但自己的电脑只装了JDK8。

原因:项目pom.xml或IDEA的Project Structure里配置的编译器等级是17,而Maven运行在JDK8上。配置不一致就会报这个错,和代码本身关系不大。

解决:先确认本地JAVA环境变量配置。在命令行执行:

java -version mvn -v

看两个命令显示的Java版本是否一致。如果mvn显示JDK8而项目要求17,有两种处理:一是装JDK17并把JAVA_HOME指过去;二是把项目降级到JDK8。常见做法是检查pom.xml中的java.version,以及IDEA的Project Structure里的Project SDK和Maven的JDK设置,全部统一后再Refresh Maven:

<properties> <java.version>1.8</java.version> </properties>

如果改了java.version但pom里某个依赖还是Java 11编译的,也会继续报错。本质上就是JAVA_HOME、PATH和maven用了不同的JDK,三条路走成一条就没事了。

5.2 连不上MySQL:Public Key Retrieval is not allowed

现象:启动Spring Boot以后,控制台爆出Public Key Retrieval is not allowed,页面完全打不开。MySQL版本是8.0以上。

原因:MySQL 8.0默认认证插件是caching_sha2_password,驱动在非SSL连接下要取服务器公钥才能完成密码验证。如果JDBC连接串没设置allowPublicKeyRetrieval=true,驱动直接拒绝。

解决:在application.yml的url上补参数:

url: jdbc:mysql://localhost:3306/train_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=utf8

顺带说明:useSSL=false是因为本地开发不需要证书,serverTimezone=Asia/Shanghai解决日期差8小时,characterEncoding=utf8解决中文乱码。如果加上allowPublicKeyRetrieval之后还报Access denied,就检查用户名密码,或者用本地root账号,别再用旧依赖里已经停用的com.mysql.jdbc.Driver。

5.3 页面全是中文问号,数据库插入也乱码

现象:登录页和车次列表里的中文全变成问号,数据库里手工插入正常,一程序操作就乱码。

原因:字符集不一致。常见有三处:页面本身的编码不是UTF-8、JSP的contentType没设置、MySQL连接串没有characterEncoding=utf8。如果用了Spring Boot的CharacterEncodingFilter,还有一个容易忽略的坑:forceEncoding没设true,过滤器就只影响部分请求。

解决:第一,JSP文件顶部统一写:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>

第二,确保application.yml里的数据库连接串带characterEncoding=utf8。第三,如果项目自己注册了过滤器,可以用下面这段替换:

@Bean public CharacterEncodingFilter characterEncodingFilter() { CharacterEncodingFilter filter = new CharacterEncodingFilter(); filter.setEncoding("UTF-8"); filter.setForceEncoding(true); return filter; }

有一个判断技巧:在浏览器开发者工具里看响应头,如果Content-Type显示charset=ISO-8859-1,说明页面声明没用;如果数据库里直接查就是乱码,一般是连接串问题。乱码问题外表看着像玄学,绝大多数都能归结到这三处。

5.4 MyBatis查询出来的字段全是null

现象:登录能成功,但页面上的真实姓名、学号、车次、余票全是空白或null。后台打印日志能看到SQL查出了值。

原因:实体类字段是驼峰命名,比如realName,而数据库字段是下划线real_name。MyBatis没有开启mapUnderscoreToCamelCase时不会自动转换,结果集映射时找不到对应属性,于是全是null。老源码经常出现这个问题。

解决:在application.yml里加配置:

mybatis: configuration: map-underscore-to-camel-case: true

如果不想改全局配置,也可以在XML里写别名:

<select id="findById" resultType="com.example.train.entity.Train"> SELECT id, train_no AS trainNo, remain_tickets AS remainTickets FROM train WHERE id = #{id} </select>

但别名方案每条SQL都要维护,很烦。遇到字段全null,我会先查mapUnderscoreToCamelCase,再回头检查实体类有没有用Lombok的@Data,两者都确认后再在XML里打印返回结果。排查这种看不到报错的空值问题,最基本的方法就是先打开SQL日志,看查询到底有没有返回内容。

5.5 演示时一抢票,余票就变成负数

现象:两个账号同时下单同一车次最后一张票,结果两个订单都生成了,数据库余票变成-1。现场演示翻车,老师直接问:数据一致性怎么保证?

原因:Service里用了先查询余票、再判断够不够、最后UPDATE的顺序。两个并发线程都读到余票=1,都通过判断,都执行更新,最后写回-1。如果没有事务,扣了票却在插入订单时失败,数据会更乱。

解决:按第四章的做法,把扣余票的SQL改成原子条件更新:

<update id="decreaseRemain"> UPDATE train SET remain_tickets = remain_tickets - #{count} WHERE id = #{trainId} AND remain_tickets >= #{count} </update>

并在Service方法上加上@Transactional。之后验证表引擎:

SHOW CREATE TABLE train;

确认是InnoDB,因为MyISAM不支持行锁,条件更新也拦不住并发。如果之前是MyISAM,用:

ALTER TABLE train ENGINE = InnoDB;

改完以后再用两个浏览器无痕窗口同时抢票,观察余票不会小于0。这里纠正一个常见误用:不要用synchronized锁Controller里的方法,单机只能勉强能用,换两台机器部署就失效,锁范围太大还会把整个页面卡死。数据库行锁才是这个场景里最直接有效的方案。

提示:所有涉及状态变更的方法,写完后都要自己问一句:这个方法在并发调用两次会怎样?能回答清楚,才算是真的理解事务和锁。

6. 把JAVA学生火车票订票系统讲成项目亮点:验证方法与答辩技巧

代码写完,下一步是让别人相信它真的可靠。我的习惯是先写一个验证清单,把正常订票、余票不足、重复退票、并发抢票四种场景各跑一遍,并把每次数据库的余票数和订单状态记录下来。不管是课程设计答辩还是实习面试,你都能直接拿数据去向对方证明,而不是只会说「我做了个系统」。

验证方法分两层。第一层是功能:普通用户登录后搜北京南到上海虹桥的车次,选二等座,订2张票,orders表出现一条status=0的记录,train表对应行的remain_tickets从500变成498;退票后再查,orders.status变成1,remain_tickets回到500。第二层是并发:开两个浏览器无痕窗口,同时提交同一车次最后一张票的订单,最终只有一个订单成功,另一个返回余票不足。再进一步,可以用JMeter开两个线程组对同一接口发请求,看服务端日志里受影响行数为0的那次返回。这个实验本身就是很好的面试素材,把普通的学生火车票订票系统,描述成「用条件更新解决库存超卖」的并发项目,含金量立刻不一样。

答辩时不要背代码,而是讲三个决策点。第一,为什么车次表用「车次+座位类型」作为唯一记录,而不是一个车次一行;第二,为什么订单表加status字段而不是直接删除退票记录;第三,为什么扣余票用UPDATE...WHERE remain_tickets>=count,而不是先查再改。这三个问题能答清楚,老师就知道你真正把项目写进去了。

我自己当年做课程设计时,把所有代码堆在Controller里,数据库连接也是在一个Servlet里new Connection,结果答辩时老师让我演示两台机器同时订票,还没点两下页面就崩了。这件事给我的教训是:业务代码的分层不是八股文,事务和并发控制也不是面试前背两题就能糊弄过去的。后来再看JAVA课程设计案例源码,我第一眼不是看页面美不美,而是先看它有没有事务、有没有条件更新。

最后给一个口袋技巧:把上面说的测试用例和三个决策点写进项目根目录的README,导师或面试官打开代码库第一印象就会好很多。这个火车站订票系统真正的价值,不在于功能堆积,而在于你在一个看似普通的课程设计里,想清楚了并发和一致性。希望帮到你。

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

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

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

立即咨询