☰
基于Spring Boot的学生火车票订票系统:余票并发扣减与订单状态机实战
2026/10/8 16:36:13 网站建设 项目流程

简介:这是一套面向高校计算机相关专业学生的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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 席别余票表:余票扣减发生在这张表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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 Result<OrderVO> 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 SQL:

<update id="deductStock"> 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() { List<Order> 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 加serverTimezone=Asia/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」时,有具体的量化依据,而不是空谈。

我自己做这类项目最大的习惯是:每写一个业务规则,先在文档里用一句话写清楚「什么条件下允许、什么条件下拒绝」,再去写代码。规则和代码对齐了,论文自然就写出来了,答辩也不怕问。希望帮到你。

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

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

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

立即咨询