ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于Spring Boot的学生火车票订票系统:余票并发扣减与订单状态机实战

2026/10/8 16:36:32 拓冰建站 浏览量
基于Spring Boot的学生火车票订票系统:余票并发扣减与订单状态机实战 简介这是一套面向高校计算机相关专业学生的Java课程设计与毕业设计参考项目主题为学生火车票订票系统适合需要完成数据库课设、Java实训或项目开发练习的读者。系统围绕学生基本信息管理、订票信息维护、退票处理、信息统计查询以及操作员管理五大模块展开重点覆盖目的地与票价等业务字段采用JavaFX构建界面基于IDEA 2016.3与SQL Server 2014开发并配套Hibernate持久化配置。资源包共72个文件约8.87MB包含9个java源码、16个xml配置、15个jar依赖、7个properties配置以及png运行截图、pdf课设报告和md说明文档源码已通过测试可直接导入参考。目前已有42人学习下载。读者可据此快速理解订票系统的表结构设计、界面交互与业务逻辑实现并在此基础上进行功能延申与二次开发。1. 学生火车票订票系统从课程设计到能跑起来的 Java 工程每年毕业季总有一批同学在选题时盯上「学生火车票订票系统」。原因很直接业务场景清晰有车次、余票、订单、学生优惠这几个天然模块答辩时老师一听就懂写论文时也有足够的业务逻辑可以展开。但真正动手做的时候很多人会卡在同一个地方——网上找到的 Java 源码要么是纯控制台 demo要么是只有增删改查、没有余票扣减和并发处理的空壳跑起来连「同一趟车两个人同时下单」都模拟不了。这篇笔记面向三类人正在做毕业设计、需要一套能讲清楚架构的完整项目的同学做课程设计、想在一周内跑通核心链路的开发者以及想拿这个场景练 Spring Boot MyBatis 分层开发的 Java 新手。我会按「业务模型怎么建 → 余票并发怎么处理 → 订单状态怎么流转 → 学生优惠怎么校验 → 怎么排查线上问题」的顺序把一套可复现的实现路径讲透。标题里的「源码 项目文档 参考论文」不是三样孤立的东西它们对应的是同一套业务逻辑的三种表达代码是执行层文档是设计层论文是论证层。三者对齐项目才立得住。需要先明确一个边界这不是一个真实上线的 12306 级别系统而是一个教学向的、能体现核心技术难点的工程。它的价值不在于 QPS 多高而在于你能不能把「余票扣减的原子性」「订单超时释放」「学生资质校验」这几个点讲明白、写对。下面从数据模型开始拆。2. 车次、余票、订单先把数据模型和分层结构定死2.1 三张核心表怎么设计才不返工很多同学一上来就写 Controller结果写到订单模块发现余票字段没地方放回头改表结构连带 Service 和 Mapper 全部重写。血泪经验是先把表定死再写代码。这个系统最小可用的表结构是四张train车次、train_seat车次席别余票、order订单、student学生资质。-- 车次表一趟车的基础信息 CREATE TABLE train ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(16) NOT NULL COMMENT 车次号如 G1234, start_station VARCHAR(32) NOT NULL COMMENT 始发站, end_station VARCHAR(32) NOT NULL COMMENT 终点站, depart_time DATETIME NOT NULL COMMENT 发车时间, arrive_time DATETIME NOT NULL COMMENT 到达时间, UNIQUE KEY uk_train_no_depart (train_no, depart_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 席别余票表余票扣减发生在这张表 CREATE TABLE train_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL, seat_type VARCHAR(16) NOT NULL COMMENT 席别二等座/一等座/硬卧, price DECIMAL(10,2) NOT NULL COMMENT 全价, total_count INT NOT NULL COMMENT 总票数, stock INT NOT NULL COMMENT 当前余票, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_train_seat (train_id, seat_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;train_seat里我特意留了version字段这是后面处理并发扣减的关键先记住它。stock和total_count分开存是为了在文档和论文里能画出「库存水位」的图答辩时是个加分项。订单表要包含状态机字段不要只用一个status存 0/1否则超时释放和退款逻辑会写成一团乱麻。CREATE TABLE order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, student_id BIGINT NOT NULL, train_id BIGINT NOT NULL, seat_type VARCHAR(16) NOT NULL, amount DECIMAL(10,2) NOT NULL COMMENT 实付金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已出行, expire_time DATETIME NOT NULL COMMENT 支付截止时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_student (student_id), KEY idx_status_expire (status, expire_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_status_expire这个联合索引是给定时任务扫超时订单用的没有它订单量一上来扫描就是全表这是很多人文档里不会写、但实际会翻车的点。2.2 Spring Boot 分层Controller 里不要写业务选型上Spring Boot MyBatis-Plus 是这类课程设计最稳的组合。MyBatis-Plus 能省掉大量单表 CRUD 的 XML让你把精力放在余票扣减这种真正的难点上。分层按controller → service → mapper三层走DTO 和 Entity 分开。RestController RequestMapping(/api/order) public class OrderController { Autowired private OrderService orderService; PostMapping(/create) public ResultOrderVO create(RequestBody Valid CreateOrderDTO dto) { // Controller 只做参数接收和结果包装业务全部下沉到 Service OrderVO vo orderService.createOrder(dto.getStudentId(), dto.getTrainId(), dto.getSeatType()); return Result.ok(vo); } }逻辑说明Controller 层不出现任何if (stock 0)这类判断所有校验和扣减都在 Service。参数说明CreateOrderDTO用Valid做非空和格式校验studentId、trainId、seatType三个字段必填。这样分层的好处是论文里画架构图时层次清晰答辩老师问「你的业务逻辑在哪一层」你能直接指出来。2.3 用 MyBatis-Plus 生成建表 SQL 做文档对齐项目文档里通常要附一份完整的建表语句。如果你是用实体类反向维护文档可以用 MyBatis-Plus 的代码生成或建表工具从 Java 实体类生成对应的 SQL避免手写文档和实际表结构对不上。常见做法是维护一份schema.sql作为唯一事实来源实体类字段和它一一对应改表先改 SQL 再改实体。这样论文里的「数据库设计」章节直接引用这份文件即可不会出现文档写stock、代码里叫remain的尴尬。3. 余票扣减并发下单不超卖的三个层次3.1 为什么「先查再减」一定会超卖新手最常写的扣减逻辑是这样先select stock from train_seat where id ?判断stock 0再update train_seat set stock stock - 1。单线程测试永远通过一上并发就超卖。原因是查询和更新之间存在时间窗口两个线程都查到stock 1都判断通过都执行减一结果库存变成 -1。这个问题的本质是「检查」和「动作」不是原子的。解决思路有三个层次从弱到强数据库乐观锁、数据库悲观锁、Redis 预扣减。课程设计里我一般推荐乐观锁够用、好讲、代码量小。3.2 乐观锁扣减一条带 version 的 UPDATE乐观锁的核心是把「检查」塞进「更新」的 WHERE 条件里让数据库来保证原子性。Service public class SeatService { Autowired private TrainSeatMapper seatMapper; /** * 乐观锁扣减余票 * return 影响行数1 表示扣减成功0 表示余票不足或被并发抢占 */ public int deductStock(Long trainId, String seatType) { // 关键stock 0 和 version 匹配同时作为更新条件 return seatMapper.deductStock(trainId, seatType); } }对应的 Mapper SQLupdate iddeductStock UPDATE train_seat SET stock stock - 1, version version 1 WHERE train_id #{trainId} AND seat_type #{seatType} AND stock gt; 0 AND version #{version} /update逻辑说明stock 0保证不超卖version #{version}保证并发时只有一个线程能更新成功。参数说明trainId和seatType定位到具体席别version是查询时读到的版本号。调用方拿到返回的受影响行数等于 0 就说明扣减失败需要重试或直接返回「余票不足」。这里有个细节如果只用stock 0不加 version在 MySQL 的 InnoDB 行锁下其实也能防超卖因为 UPDATE 会对行加排他锁。但加上 version 的好处是语义更清晰论文里能讲「乐观锁 vs 悲观锁」的对比而且换成 Redis 方案时思路是连贯的。3.3 扣减失败的重试与降级乐观锁在高并发下会有大量失败。如果直接返回失败用户体验很差。常见做法是有限次重试。public OrderVO createOrder(Long studentId, Long trainId, String seatType) { int retry 3; while (retry-- 0) { TrainSeat seat seatMapper.selectByTrainAndType(trainId, seatType); if (seat null || seat.getStock() 0) { throw new BizException(余票不足); } int rows seatMapper.deductStock(trainId, seatType, seat.getVersion()); if (rows 1) { // 扣减成功创建订单 return orderMapper.insertOrder(studentId, trainId, seatType, seat.getPrice()); } // rows 0说明被其他线程抢先循环重试 } throw new BizException(当前购票人数较多请稍后重试); }逻辑说明每次重试都重新查询最新 version避免用旧版本号空转。参数说明retry 3是经验值太高会拖长响应时间太低在秒杀场景下失败率上升。降级策略是重试耗尽后返回友好提示而不是抛系统异常。注意重试次数不要设成无限循环否则在极端并发下会形成活锁线程一直空转。三次是个平衡点。3.4 订单超时释放把票还回去用户下单后没支付票不能一直占着。用定时任务扫status 0 且 expire_time now()的订单把状态改成已取消同时把余票加回去。Scheduled(fixedDelay 60000) // 每分钟扫一次 public void releaseExpiredOrders() { ListOrder expired orderMapper.selectExpired(0, new Date()); for (Order order : expired) { // 先改订单状态再回补库存顺序不能反 int updated orderMapper.cancelIfPending(order.getId()); if (updated 1) { seatMapper.increaseStock(order.getTrainId(), order.getSeatType()); } } }逻辑说明cancelIfPending带status 0条件保证只有待支付订单能被取消避免和用户支付操作冲突。参数说明fixedDelay 60000表示上一轮执行完 60 秒后再执行比fixedRate更适合这种可能耗时的批量任务。回补库存用stock stock 1不需要 version因为这是增加操作不会超卖。4. 学生优惠校验与订单状态机业务规则别写散4.1 学生资质怎么存、怎么校验学生票的核心规则是每年有固定次数的优惠额度且乘车区间要在学校所在地和家庭所在地之间。资质信息存在student表包含school、home_city、remain_times剩余优惠次数。public void validateStudentTicket(Long studentId, String startStation, String endStation) { Student student studentMapper.selectById(studentId); if (student null) { throw new BizException(学生资质不存在); } if (student.getRemainTimes() 0) { throw new BizException(本学年优惠次数已用完); } // 校验乘车区间始发或终到需匹配学校/家庭所在地 boolean match startStation.contains(student.getSchoolCity()) || endStation.contains(student.getHomeCity()); if (!match) { throw new BizException(乘车区间不符合学生票规定); } }逻辑说明校验放在创建订单之前不通过直接拦截。参数说明remainTimes在订单支付成功后扣减不是下单时扣避免下单未支付占用次数。区间匹配用contains是简化处理真实场景应该用城市编码表精确匹配课程设计里说明这个简化即可。4.2 订单状态机用枚举管住流转订单状态不要用魔法数字散落在代码里。定义一个枚举把合法流转写清楚。public enum OrderStatus { PENDING(0, 待支付), PAID(1, 已支付), CANCELED(2, 已取消), TRAVELED(3, 已出行); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public static boolean canTransfer(OrderStatus from, OrderStatus to) { if (from PENDING (to PAID || to CANCELED)) return true; if (from PAID to TRAVELED) return true; return false; } }逻辑说明canTransfer集中管理状态流转规则支付、取消、出行三个操作都调它校验。参数说明from是当前状态to是目标状态。这样论文里画状态机图时直接对应这个枚举逻辑和文档一致。4.3 支付回调的幂等处理支付回调可能重复推送必须做幂等。做法是在更新订单状态时带上原状态条件。public void handlePayCallback(String orderNo) { Order order orderMapper.selectByOrderNo(orderNo); if (order null) return; // 只有待支付订单才能被支付重复回调时 updated 0 int updated orderMapper.payIfPending(order.getId()); if (updated 1) { // 首次支付成功扣减学生优惠次数 studentMapper.decreaseTimes(order.getStudentId()); } }逻辑说明payIfPending的 SQL 带status 0条件重复回调时影响行数为 0不会重复扣减优惠次数。参数说明orderNo是业务订单号用它而不是主键 id 做回调参数避免暴露自增 id。5. 避坑与排查那些文档里不会写的翻车现场5.1 余票扣了但订单没创建现象并发测试时发现stock减少了但订单表里没有对应记录。原因扣减库存和创建订单不在同一个事务里扣减成功后创建订单抛异常库存没回滚。解决把两个操作放进同一个Transactional方法或者用「先创建订单再扣减、扣减失败则删订单」的补偿思路。我一般用前者简单直接。5.2 定时任务重复回补库存现象订单超时释放后余票数量比预期多。原因定时任务在多实例部署时同时执行同一个订单被两个实例各回补一次。解决cancelIfPending已经用状态条件做了幂等但如果两个实例同时读到待支付订单仍可能都执行成功。加一层分布式锁或者用数据库的SELECT ... FOR UPDATE锁住订单行再处理。5.3 学生优惠次数扣减时机错误现象用户下单未支付优惠次数就被扣了取消订单后次数没还回来。原因扣减逻辑写在了创建订单里。解决扣减放在支付成功回调里取消订单时如果已支付则回补次数未支付则不动。这个规则要在项目文档的「业务规则」章节写清楚答辩时是高频提问点。5.4 时间字段时区不一致现象本地测试正常部署到服务器后订单expire_time判断出错刚创建的订单立刻被判定超时。原因数据库连接 URL 没配时区JVM 时区和数据库时区不一致。解决JDBC URL 加serverTimezoneAsia/Shanghai实体类时间字段统一用LocalDateTime不要混用Date和LocalDateTime。5.5 车次查询没走索引现象车次列表接口在数据量到几万条后变慢。原因按start_station和end_station查询时没有联合索引。解决加KEY idx_route (start_station, end_station, depart_time)并且查询时避免在字段上做函数运算比如不要写DATE(depart_time) ?改成范围查询。6. 把项目讲成论文验证方法与一个压测技巧项目做完只是第一步毕业设计还要能讲清楚。这里给一个验证方法用 JMeter 或简单的多线程测试模拟 100 个线程同时抢同一趟车的 10 张票验证最终stock是否为 0、订单数是否为 10。这个测试结果直接放进论文的「系统测试」章节比任何文字描述都有说服力。Test public void testConcurrentDeduct() throws InterruptedException { int threads 100; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch latch new CountDownLatch(threads); AtomicInteger success new AtomicInteger(0); for (int i 0; i threads; i) { pool.submit(() - { try { orderService.createOrder(1L, 1L, 二等座); success.incrementAndGet(); } catch (Exception ignored) { // 余票不足的失败不计入成功 } finally { latch.countDown(); } }); } latch.await(); // 断言成功数等于初始库存且库存为 0 Assert.assertEquals(10, success.get()); }逻辑说明用CountDownLatch让所有线程同时开始最大化并发冲突。参数说明threads 100是模拟并发数初始库存设为 10。断言成功数等于库存说明没有超卖也没有少卖。这个测试跑通论文里的「并发控制」章节就有了实测数据支撑。一个进阶技巧如果你想在答辩时展示更强的说服力可以在扣减方法里加一行日志记录每次version冲突的次数。压测后统计冲突率如果冲突率过高说明重试次数需要调整或者该考虑 Redis 预扣减方案了。这个数据能让你在回答「为什么用乐观锁而不是 Redis」时有具体的量化依据而不是空谈。我自己做这类项目最大的习惯是每写一个业务规则先在文档里用一句话写清楚「什么条件下允许、什么条件下拒绝」再去写代码。规则和代码对齐了论文自然就写出来了答辩也不怕问。希望帮到你。本文还有配套的精品资源点击获取