简介这是一套面向计算机专业学生与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)车次编号如 G1024seat_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.xMySQL 驱动用 8.0.33Redis 用 Lettuce 默认客户端。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency提示如果你的 JDK 是 17Spring Boot 2.7.x 可以正常跑但如果升到 Spring Boot 3.xMyBatis Starter 要换 3.0.x且javax.*全部要改成jakarta.*新手不建议一上来就动这个。3. 核心链路拆解从查票到下单余票扣减到底怎么防超卖3.1 车次查询与缓存策略查票是读多写少的典型场景每次请求都打 MySQL 没必要。项目里用 Redis 缓存车次余票key 设计成train:stock:{trainNo}:{date}value 存一个 Hashfield 是座位类型value 是剩余数量。public ListTrainVO search(String from, String to, String date) { String cacheKey train:list: from : to : date; // 先查 Redis命中直接返回 ListTrainVO cached (ListTrainVO) redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return cached; } // 未命中查库回写缓存过期时间 5 分钟 ListTrainVO 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 分钟前创建且仍未支付的订单 ListOrder 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.ymlspring: datasource: url: jdbc:mysql://localhost:3306/train_ticket?useSSLfalseserverTimezoneAsia/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?fromBJPtoSHHdate2025-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 加serverTimezoneAsia/Shanghai实体统一用LocalDateTimeMyBatis 的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 里跑十遍并发测试确认行锁行为符合预期再往上写业务。希望这套拆解能帮你少走点弯路源码拿下去先把压测跑通再谈改造。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?