☰
Java学生火车票订票系统:Spring Boot+Redis防超卖实战
2026/10/16 1:44:15 网站建设 项目流程

简介:这是一套面向计算机专业学生与JavaWeb初学者、课程设计或毕业设计开发者的学生火车票订票系统源码,基于JAVAEE技术栈实现,涵盖后台管理与前台购票两大模块,重点解决学生寒暑假半价优惠、团体购票等特殊需求。资源包共318个文件,约24.27MB,以72个jar依赖库、38个java源文件与38个class编译文件为核心,配合33个jsp页面、15个js脚本、10个css样式及图片素材,另含1个sql数据库脚本,完整呈现MVC分层结构。系统功能包括列车信息维护、出发与途经城市设置、学生折扣计算、团购下单及前端搜索选座支付流程,可帮助读者理解Servlet、JSP与ORM框架的整合方式,并作为二次开发或功能扩展的参考模板。目前已有3249人学习下载,适合需要完整项目案例、排错思路与目录结构参考的开发者。

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

去年帮一个做实训平台的朋友看后台,凌晨两点收到告警:某高校订票模拟环境的订单表出现大量重复扣减,两个学生同时点“提交订单”,同一张硬座被卖了三次。翻日志发现是典型的并发写冲突,没有加锁、没有幂等、没有事务边界。这套JAVA学生火车票订票系统就是冲着这类真实痛点来的——它不是玩具级的增删改查,而是把车次查询、余票扣减、订单生成、支付回调、退票改签串成一条完整链路,适合正在做课程设计、想拿一个能讲清楚“并发和事务”的 Java Web 项目练手的开发者。技术栈是 Spring Boot + MyBatis + MySQL + Redis,前端 Thymeleaf 或 Vue 都留了接口,拿来就能跑,改起来也有明确的切入点。

2. 技术选型与整体架构:为什么是 Spring Boot + MyBatis + Redis 这套组合

2.1 分层结构与模块划分

拿到源码先别急着mvn spring-boot:run,花十分钟把包结构看一遍,后面改代码能省掉大量“这方法到底在哪”的翻找时间。这个项目按经典三层走,但把订票特有的业务逻辑单独抽了一层 service,避免 controller 里堆 SQL。

src/main/java/com/train/ticket/ ├── controller/ # 接口层:车次查询、下单、支付回调、退票 │ ├── TrainController.java │ ├── OrderController.java │ └── PayController.java ├── service/ # 业务层:余票扣减、订单状态机、超时释放 │ ├── TicketService.java │ ├── OrderService.java │ └── StockService.java ├── mapper/ # 持久层:MyBatis 接口 + XML │ ├── TrainMapper.java │ ├── OrderMapper.java │ └── StockMapper.java ├── entity/ # 实体:Train、SeatStock、Order、Passenger ├── config/ # RedisConfig、MybatisConfig、WebMvcConfig └── common/ # 统一返回、全局异常、分布式锁工具

controller只做参数校验和调用,service承担事务和并发控制,mapper只负责 SQL。这样拆的好处是:当你需要把余票扣减从“数据库行锁”换成“Redis 预扣减”时,只动StockService和StockMapper,上层接口一行不用改。

2.2 数据库表设计的关键字段

订票系统的表设计有几个字段如果一开始没留,后面改起来非常痛苦。下面这张表是核心的余票库存表,注意version和sold_count这两个字段,它们是后面解决超卖的基础。

字段名类型说明
idbigint主键
train_novarchar(20)车次编号,如 G1024
seat_typetinyint座位类型:1 商务、2 一等、3 二等
total_countint总座位数
sold_countint已售数量
versionint乐观锁版本号
depart_datedate发车日期

余票不是单独存一个remain_count,而是用total_count - sold_count实时算。这样做的好处是避免“总票数、已售、余票”三个字段互相不一致——我见过太多项目因为同时维护三个字段,退票时只更新了两个,导致余票越跑越多。

2.3 依赖清单与版本约束

pom.xml里几个关键依赖的版本不能乱升,尤其是 MyBatis 和 Spring Boot 的兼容性。常见做法是锁在 Spring Boot 2.7.x + MyBatis Starter 2.3.x,MySQL 驱动用 8.0.33,Redis 用 Lettuce 默认客户端。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>

提示:如果你的 JDK 是 17,Spring Boot 2.7.x 可以正常跑;但如果升到 Spring Boot 3.x,MyBatis Starter 要换 3.0.x,且javax.*全部要改成jakarta.*,新手不建议一上来就动这个。

3. 核心链路拆解:从查票到下单,余票扣减到底怎么防超卖

3.1 车次查询与缓存策略

查票是读多写少的典型场景,每次请求都打 MySQL 没必要。项目里用 Redis 缓存车次余票,key 设计成train:stock:{trainNo}:{date},value 存一个 Hash,field 是座位类型,value 是剩余数量。

public List<TrainVO> search(String from, String to, String date) { String cacheKey = "train:list:" + from + ":" + to + ":" + date; // 先查 Redis,命中直接返回 List<TrainVO> cached = (List<TrainVO>) redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return cached; } // 未命中查库,回写缓存,过期时间 5 分钟 List<TrainVO> list = trainMapper.selectByCondition(from, to, date); redisTemplate.opsForValue().set(cacheKey, list, 5, TimeUnit.MINUTES); return list; }

逻辑说明:缓存 key 按出发站、到达站、日期三个维度拼,避免不同查询条件互相覆盖。过期时间设 5 分钟是个折中——太短缓存没意义,太长会出现“明明有人退票但查不到”的体验问题。参数上from和to要做车站编码转换,别直接用中文站名当 key,否则 Redis 里全是乱码。

3.2 下单接口的幂等与锁

下单是整个系统最容易翻车的地方。用户手抖点两下、网络重试、前端重复提交,都会产生重复订单。项目里用“Redis 分布式锁 + 数据库唯一索引”双保险。

public OrderResult createOrder(OrderRequest req) { // 1. 幂等:用用户ID+车次+日期+座位类型拼唯一键 String idempotentKey = "order:lock:" + req.getUserId() + ":" + req.getTrainNo() + ":" + req.getDate() + ":" + req.getSeatType(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(idempotentKey, "1", 10, TimeUnit.SECONDS); if (Boolean.FALSE.equals(locked)) { throw new BizException("请勿重复提交"); } try { // 2. 扣减库存,走乐观锁 int affected = stockMapper.deductStock( req.getTrainNo(), req.getDate(), req.getSeatType()); if (affected == 0) { throw new BizException("余票不足"); } // 3. 生成订单,状态为待支付 Order order = buildOrder(req); orderMapper.insert(order); return OrderResult.success(order.getOrderNo()); } finally { // 4. 释放锁,注意要判断是不是自己的锁 redisTemplate.delete(idempotentKey); } }

对应的扣减 SQL 用乐观锁版本号:

UPDATE seat_stock SET sold_count = sold_count + 1, version = version + 1 WHERE train_no = #{trainNo} AND depart_date = #{date} AND seat_type = #{seatType} AND total_count - sold_count > 0 AND version = #{version}

逻辑说明:setIfAbsent保证同一用户同一车次 10 秒内只能提交一次,这是幂等层。真正的防超卖靠 SQL 里的total_count - sold_count > 0条件,MySQL 在更新时会加行锁,多个请求串行执行,扣到 0 之后后面的affected就是 0。参数version是可选加强,如果并发量不大,光靠sold_count条件更新已经够用。

3.3 订单状态机与超时释放

订单不是插入就完事,它有明确的状态流转:待支付 → 已支付 → 已出票,或者待支付 → 已取消。项目里用状态字段status控制,配合定时任务释放超时未支付订单。

@Scheduled(fixedDelay = 60000) public void releaseTimeoutOrder() { // 查 15 分钟前创建且仍未支付的订单 List<Order> timeoutList = orderMapper.selectTimeout( LocalDateTime.now().minusMinutes(15)); for (Order order : timeoutList) { // 回滚库存 stockMapper.rollbackStock(order.getTrainNo(), order.getDepartDate(), order.getSeatType()); // 更新订单状态为已取消 orderMapper.updateStatus(order.getId(), OrderStatus.CANCELED); } }

逻辑说明:fixedDelay表示上一次执行完再等 60 秒,避免任务堆积。回滚库存和更新状态要放在同一个事务里,否则会出现“库存回来了但订单还是待支付”的脏数据。参数 15 分钟是业务约定,改这个值只动minusMinutes的参数即可。

4. 本地跑起来:环境准备、配置修改与启动验证

4.1 数据库初始化与配置

源码里一般带一个sql/init.sql,先建库再导数据。常见做法是库名train_ticket,字符集utf8mb4,否则站名里的生僻字会乱码。

mysql -uroot -p -e "CREATE DATABASE train_ticket DEFAULT CHARSET utf8mb4;" mysql -uroot -p train_ticket < sql/init.sql

然后改application.yml:

spring: datasource: url: jdbc:mysql://localhost:3306/train_ticket?useSSL=false&serverTimezone=Asia/Shanghai username: root password: 你的密码 redis: host: localhost port: 6379 database: 0

注意:serverTimezone一定要写,否则插入订单时间会差 8 小时,退票超时判断全乱。Redis 如果没设密码,password留空即可,别写null字符串。

4.2 启动与接口验证

配置改完直接跑:

mvn clean package -DskipTests java -jar target/ticket-system-1.0.jar

启动后先验证查票接口:

curl "http://localhost:8080/api/train/search?from=BJP&to=SHH&date=2025-06-01"

返回 JSON 里能看到车次列表和余票数量。再验证下单:

curl -X POST http://localhost:8080/api/order/create \ -H "Content-Type: application/json" \ -d '{"userId":1001,"trainNo":"G1024","date":"2025-06-01","seatType":3}'

如果返回orderNo说明链路通了。接着用同一个请求再发一次,应该返回“请勿重复提交”,说明幂等生效。

4.3 压测验证防超卖

光跑通不算数,得验证并发下不超卖。用ab或jmeter对下单接口发 100 个并发,库存设 10,最后查数据库sold_count应该正好是 10,订单表里待支付订单也是 10 条。

ab -n 100 -c 50 -p order.json -T application/json \ http://localhost:8080/api/order/create

如果sold_count超过 10,回去检查 SQL 里的total_count - sold_count > 0条件是不是漏了,或者事务隔离级别被改成了READ UNCOMMITTED。

5. 避坑与排查:那些让我熬夜的报错和脏数据

5.1 余票扣成负数

现象:压测后sold_count大于total_count,余票显示负数。原因:扣减 SQL 只写了sold_count = sold_count + 1,没加total_count - sold_count > 0条件,或者条件写在 Java 里先查后改,并发下查到的余票是旧值。解决:把判断放进 UPDATE 的 WHERE 子句,让数据库行锁保证原子性。Java 层只根据affected行数判断成功与否。

5.2 重复订单

现象:同一用户同一车次出现两条待支付订单。原因:幂等 key 只用了userId,没拼车次和日期,或者 Redis 锁过期时间太短,请求还没处理完锁就释放了。解决:幂等 key 拼全业务维度,锁过期时间设成业务处理时间的 3 倍以上,并在finally里用 Lua 脚本判断锁归属再删除,避免误删别人的锁。

5.3 退票后库存没回来

现象:用户退票成功,但查票时余票没增加。原因:退票逻辑只改了订单状态,忘了调rollbackStock,或者回滚和状态更新不在同一事务。解决:在OrderService.cancelOrder方法上加@Transactional,先回滚库存再更新状态,两步都成功才提交。

5.4 定时任务重复执行

现象:超时订单被回滚了两次库存,余票多出来。原因:多实例部署时每个实例都跑定时任务,同一个订单被两个实例同时捞到。解决:定时任务加 Redis 分布式锁,或者用数据库的SELECT ... FOR UPDATE锁住待处理订单,处理完再释放。

5.5 时间字段差 8 小时

现象:刚创建的订单查出来创建时间是 8 小时前,超时任务立刻把它取消了。原因:JDBC URL 没配serverTimezone,或者实体类用了java.util.Date而数据库是datetime。解决:URL 加serverTimezone=Asia/Shanghai,实体统一用LocalDateTime,MyBatis 的jdbcType配TIMESTAMP。

6. 二次开发改造:从单机到能扛住实训并发的三个切入点

这套源码跑通之后,真正有价值的是知道往哪改。我带人改这个项目时,一般按“先加监控、再换锁、最后拆服务”的顺序推。

第一个切入点是把 Redis 预扣减加进来。现在的方案靠数据库行锁,并发到几百就会看到stockMapper.deductStock的响应时间飙升。改造思路是在 Redis 里用 Lua 脚本先扣减,扣成功再异步落库。Lua 脚本保证“查余票 + 扣减”原子执行:

-- KEYS[1]: stock key, ARGV[1]: 扣减数量 local stock = tonumber(redis.call('get', KEYS[1]) or '-1') if stock < tonumber(ARGV[1]) then return -1 end redis.call('decrby', KEYS[1], ARGV[1]) return stock - tonumber(ARGV[1])

Java 侧调redisTemplate.execute(script, keys, args),返回 -1 就是余票不足。落库用消息队列异步做,数据库只做最终一致性。这个改造能把下单接口的 P99 从几百毫秒压到几十毫秒。

第二个切入点是订单状态机显式化。现在状态流转散落在各个 service 方法里,加一个“改签”功能就要动好几处。我一般会抽一个OrderStateMachine类,用枚举定义合法流转,比如PENDING -> PAID -> TICKETED,非法流转直接抛异常。这样新增状态时只改枚举和转移表,业务代码不用动。

第三个切入点是把查票和下单拆成两个服务。查票是读多写少,下单是写多读少,两者对数据库的压力模型完全不同。拆开之后查票服务可以挂多个只读副本,下单服务单独限流。拆的边界就是TrainController和OrderController,中间用 HTTP 或 RPC 调,别一上来就上微服务全家桶,先把接口边界划清楚。

验证改造效果的方法很直接:改之前用ab压 200 并发记录 TPS 和错误率,改之后再压一次,对比sold_count是否精确等于库存数。我自己的习惯是每次动并发相关代码,都强制走一遍“压测 → 查库存 → 查订单 → 对账”四步,少一步都不敢提交。从那以后我每次改扣减逻辑,都会先把total_count - sold_count > 0这行 SQL 单独拎出来在 MySQL 里跑十遍并发测试,确认行锁行为符合预期再往上写业务。希望这套拆解能帮你少走点弯路,源码拿下去先把压测跑通,再谈改造。

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

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

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

立即咨询