JAVA学生火车票订票系统:高并发余票扣减与订单状态机设计

发布时间:2026/10/29 22:42:18
JAVA学生火车票订票系统:高并发余票扣减与订单状态机设计 简介这是一套面向计算机专业学生与JavaWeb初学者、进阶者的学生火车票订票系统完整项目源码基于JAVAEE技术栈搭建涵盖后台管理与前台购票两条主线重点解决学生群体寒暑假半价优惠、集体团购等特殊购票需求。项目采用Servlet、JSP、JSTL配合Spring、Spring MVC及Hibernate/MyBatis等框架组织业务逻辑并附数据库脚本用于初始化车次、订单与用户数据适合作为课程设计、毕业设计或企业级Web开发练手参考。压缩包共318个文件以jar依赖包、java源码与class编译文件、jsp页面为主辅以gif/jpg图片、js与css静态资源及sql脚本整体约24.27MB目录结构完整便于按模块研读。目前已有3250人学习下载。读者可从中获得后台车次与城市信息维护、学生优惠与团购逻辑、前端搜索选座下单支付流程的完整实现思路以及MVC分层设计与数据库表结构参考便于对照复现与二次扩展。1. 从一张排队截图说起这套订票系统到底要解决什么有人在课程群里发了一张截图春运抢票页面转圈转到超时刷新后余票从 12 张变成 0。这个场景几乎每年都在重演也正好是「订票系统」这类课程设计最值得做的切入点——它不是增删改查的堆砌而是一个天然带并发、带状态流转、带数据一致性的业务系统。这套 JAVA 学生火车票订票系统面向两类人一类是要交毕设或课设的学生需要一套结构完整、能跑通、能讲清楚设计取舍的方案另一类是要快速搭出票务原型验证业务的开发者。它要解决的核心问题有三个车次与余票怎么建模、下单时怎么防止超卖、订单从锁定到出票的状态怎么闭环。下面按需求、设计、实现、避坑、验收的顺序把整套方案拆开讲。2. 需求与数据模型先把车次、余票、订单三张表立住做订票系统最容易翻车的地方不是代码写不出来而是表结构一开始就没想清楚。我见过不少同学上来就建一张ticket表把车次、座位、乘客、订单全塞进去写到第三天发现余票扣不动、退票对不上账只能推倒重来。所以这一章先把需求边界和数据模型定死后面所有模块都围绕它展开。2.1 功能边界哪些必须做哪些先砍掉课程设计和毕设最大的误区是贪多。一个能讲清楚的订票系统核心功能其实就五块车次查询、余票展示、下单锁座、订单管理、退票改签。这五块构成一个完整闭环答辩时每一块都能对应到具体的设计点。至于积分商城、推荐算法、实名核验对接这类属于加分项第一版不要碰否则主线都跑不通。我一般会把需求分成两级来写。一级是必须交付的用户注册登录、按出发站到达站和日期查车次、查看某车次各席别余票、选座下单生成订单、订单列表与详情、退票并回补余票。二级是可选增强改签、候补、支付模拟、后台车次维护。把一级做扎实二级挑一两个做方案就足够完整了。这里的关键判断是——余票的准确性比功能数量重要得多一个能正确扣减余票的系统比十个花哨功能更能体现工程能力。角色上通常分两种乘客和管理员。乘客侧走查询、下单、退票管理员侧走车次录入、席别配置、订单查看。权限用简单的角色字段加拦截器就能控制不必上完整的权限框架课程设计阶段够用且好讲。2.2 数据表设计三张主表加两张关联表数据模型是这套方案的地基。核心思路是把「车次」「席别库存」「订单」拆开库存单独成表这样扣减余票时锁的粒度小、并发冲突少。下面是我一般会用的建表结构字段做了精简保留关键约束。-- 车次表一条记录代表一趟车 CREATE TABLE train ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(16) NOT NULL COMMENT 车次号如 G1234, depart_station VARCHAR(32) NOT NULL COMMENT 出发站, arrive_station VARCHAR(32) NOT NULL COMMENT 到达站, depart_time DATETIME NOT NULL COMMENT 发车时间, arrive_time DATETIME NOT NULL COMMENT 到达时间, UNIQUE KEY uk_train_no_time (train_no, depart_time) ) COMMENT 车次信息; -- 席别库存表车次 席别 唯一余票在这里扣 CREATE TABLE seat_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL, seat_type VARCHAR(16) NOT NULL COMMENT 席别二等座/一等座/商务座, price DECIMAL(10,2) NOT NULL, total INT NOT NULL COMMENT 总座位数, available INT NOT NULL COMMENT 当前余票, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_train_seat (train_id, seat_type), CONSTRAINT ck_available CHECK (available 0) ) COMMENT 席别库存; -- 订单表状态机在这里体现 CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL, train_id BIGINT NOT NULL, seat_type VARCHAR(16) NOT NULL, passenger VARCHAR(32) NOT NULL COMMENT 乘车人, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退票, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id) ) COMMENT 订单主表;这段建表有三处设计意图值得说清楚。第一seat_stock上的CHECK (available 0)是最后一道防线即使应用层逻辑写错数据库也不会让余票变成负数这是血泪经验换来的兜底。第二version字段是为乐观锁准备的后面扣减余票会用到。第三订单状态用数字枚举而不是字符串查询和索引都更省状态含义写在注释里答辩时能直接指着讲。除了这三张主表通常还需要user表存账号密码和角色以及一张order_passenger关联表支持一单多人的场景。如果第一版只做单人下单关联表可以先省掉但表结构里预留passenger字段后续扩展不用改主表。2.3 状态流转订单不是一条记录是一台状态机订单状态是很多同学讲不清楚的地方。下单、支付、取消、退票每一步都对应余票的增减如果状态定义混乱退票时余票回补就会重复或遗漏。我一般把状态收敛成四个待支付、已支付、已取消、已退票流转规则固定下来。待支付是下单锁座后的初始态此时余票已经扣减但还没真正卖出已支付是支付成功余票保持扣减已取消是超时未支付或用户主动取消余票回补已退票是已支付订单退款余票同样回补。关键约束是只有待支付能转已取消只有已支付能转已退票其余跳转一律拒绝。把这条规则写进服务层比事后查数据对账省太多事。3. 技术选型与总体架构Spring Boot 三层结构怎么切选型这件事课程设计阶段最忌讳追新。我见过有人为了「显得高级」上微服务结果注册中心、网关、链路追踪配了一周业务代码一行没写。这套订票系统的体量单体应用完全够用把分层切干净比拆服务有价值得多。3.1 技术栈够用、好讲、能跑通后端用 Spring Boot 打底它把配置、依赖注入、Web 容器都封装好了启动一个main方法就能跑省下的时间可以花在业务逻辑上。持久层用 MyBatis-Plus单表 CRUD 几乎不用写 SQL复杂查询再手写 XML比纯 JPA 更贴近国内课程教学的讲法。数据库选 MySQL事务和行锁支持成熟扣减余票的并发控制离不开它。前端可以是简单的 Thymeleaf 服务端渲染也可以是 Vue 加 Axios 前后端分离前者上手快、答辩演示稳后者更贴近实际项目按自己的时间选。缓存这块如果要做余票的高并发查询可以引入 Redis 存车次余票快照但要注意缓存和数据库的一致性第一版建议先不加把数据库层的并发控制做扎实反而更好讲。消息队列同理订单超时取消用定时任务扫表就能实现不必上 MQ。选型的标准不是先进而是每一层你都能说清楚为什么用它答辩时被追问「为什么不用 XX」能答上来就是加分。3.2 分层结构Controller、Service、Mapper 各管什么三层结构听起来老套但订票系统里每一层的职责边界恰恰是容易糊掉的地方。我的切法是Controller 只做参数校验和响应封装不写业务判断Service 承载全部业务规则包括余票扣减、状态流转、事务边界Mapper 只管数据访问不掺业务逻辑。Service public class OrderService { Autowired private SeatStockMapper seatStockMapper; Autowired private TicketOrderMapper orderMapper; // 下单扣库存 建订单必须在同一事务里 Transactional(rollbackFor Exception.class) public String createOrder(Long userId, Long trainId, String seatType, String passenger) { // 1. 乐观锁扣减余票影响行数为 0 说明被抢光了 int affected seatStockMapper.deductAvailable(trainId, seatType); if (affected 0) { throw new BizException(余票不足请选择其他席别); } // 2. 查库存拿价格组装订单 SeatStock stock seatStockMapper.selectByTrainAndType(trainId, seatType); TicketOrder order new TicketOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTrainId(trainId); order.setSeatType(seatType); order.setPassenger(passenger); order.setAmount(stock.getPrice()); order.setStatus(0); // 待支付 orderMapper.insert(order); return order.getOrderNo(); } }这段代码的关键在Transactional和deductAvailable的配合。事务保证「扣库存」和「建订单」要么都成功要么都回滚不会出现库存扣了订单没建的情况。deductAvailable对应的 SQL 用乐观锁实现下面单独讲。参数上trainId和seatType定位到唯一一条库存记录userId和passenger落到订单上generateOrderNo通常用时间戳加随机数保证业务单号唯一且可读。3.3 并发控制乐观锁扣余票别用先查后改余票扣减是整个系统最容易出问题的地方。新手最常见的写法是先select查余票判断大于 0再update减一。这个写法在并发下必然超卖——两个线程同时查到余票 1都判断通过都去减结果变成 -1。正确做法是把判断和扣减合并成一条原子 SQL。!-- SeatStockMapper.xml乐观锁扣减available 0 才扣 -- update iddeductAvailable UPDATE seat_stock SET available available - 1, version version 1 WHERE train_id #{trainId} AND seat_type #{seatType} AND available 0 /update这条 SQL 把「余票大于 0」作为更新条件数据库在执行时对目标行加锁判断和扣减在同一个原子操作里完成返回的影响行数就是扣减结果1 表示成功0 表示余票已空。相比先查后改它少了一次查询往返也彻底堵住了超卖窗口。version字段在这里其实不是必须的因为available 0已经起到了条件更新的作用但保留它便于后续做更复杂的版本控制也方便排查问题时看扣减次数。如果并发量再高可以在这个基础上加一层 Redis 预扣但课程设计阶段一条带条件的原子更新足够撑住答辩演示的并发场景。我一般会建议同学写一个简单的压测脚本用 JMeter 或自己写多线程模拟 100 个请求抢 10 张票验证最终余票正好是 0 而不是负数这个演示在答辩时非常有说服力。4. 核心模块实现查询、下单、退票三条主链路架构定完落到具体模块。订票系统的业务链路其实就三条查得到、下得成、退得回。每条链路都有它的实现要点和容易忽略的细节这一章逐条拆开给出可复用的代码骨架。4.1 车次查询组合条件与分页别写成一锅粥查询模块看起来简单但条件一多就容易写成十几个if拼 SQL。出发站、到达站、日期是必填席别、时间段是可选这种动态条件用 MyBatis 的if标签拼最自然但要注意防止 SQL 注入和全表扫描。public PageTrainVO searchTrains(TrainQuery query) { LambdaQueryWrapperTrain wrapper new LambdaQueryWrapper(); wrapper.eq(Train::getDepartStation, query.getDepartStation()) .eq(Train::getArriveStation, query.getArriveStation()) .ge(Train::getDepartTime, query.getDate().atStartOfDay()) .lt(Train::getDepartTime, query.getDate().plusDays(1).atStartOfDay()) .orderByAsc(Train::getDepartTime); // 分页由 MyBatis-Plus 拦截器处理这里只传页码和条数 return trainMapper.selectPage(new Page(query.getPageNo(), query.getPageSize()), wrapper); }这段查询用日期区间而不是DATE(depart_time) ?原因是后者会让索引失效数据量一大就慢得明显。ge和lt组合成左闭右开区间正好覆盖一整天。分页交给 MyBatis-Plus 的分页插件SQL 里自动加LIMIT不用手写。返回的TrainVO里除了车次信息通常还要带上各席别的最低余票和最低价这需要一次关联查询或者二次查询聚合我一般用一条带GROUP BY的 SQL 一次拿全减少往返。查询结果里余票的展示有个细节如果某席别余票为 0前端应该置灰而不是隐藏让用户知道这趟车有这个席别但卖完了体验上更清楚。这个判断放在 VO 组装时做别丢给前端算。4.2 下单锁座事务边界和超时释放下单是三条链路里最需要小心的一条。前面OrderService的骨架已经给了扣库存加建订单的主干这里补两个关键点事务边界和超时释放。事务边界要卡在「扣库存」到「建订单」之间中间不要夹远程调用或耗时操作否则事务持有锁的时间变长并发能力直线下降。如果下单后要发短信通知一定放到事务提交之后用TransactionSynchronization的afterCommit回调或者干脆异步处理。这个坑我踩过——早期把日志和通知写进事务里压测时锁等待直接飙高。超时释放解决的是「下单了但一直不支付座位被占着」的问题。做法是给待支付订单设一个过期时间比如 15 分钟用定时任务每分钟扫一次超时订单把状态改成已取消并回补余票。// 定时任务每分钟扫描超时未支付订单并释放座位 Scheduled(cron 0 * * * * ?) public void releaseTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); ListTicketOrder timeoutOrders orderMapper.selectTimeoutPending(deadline); for (TicketOrder order : timeoutOrders) { // 状态机校验只有待支付才能取消防止重复回补 int updated orderMapper.cancelIfPending(order.getId()); if (updated 1) { seatStockMapper.increaseAvailable(order.getTrainId(), order.getSeatType()); } } }这里cancelIfPending是一条带status 0条件的更新只有真正把状态从待支付改成已取消的那次才回补余票避免定时任务和用户手动取消并发时重复加库存。这个「条件更新 判断影响行数」的模式和扣库存是同一个思路整个系统里凡是涉及状态变更和库存增减的地方都应该这么写。4.3 退票回补状态校验比回补本身更重要退票的逻辑一句话能说完校验订单是已支付状态改成已退票余票加一。但真正容易出错的是校验环节。用户可能重复点退票也可能对一张已取消的订单发起退票如果校验不严余票就会被多加。Transactional(rollbackFor Exception.class) public void refund(String orderNo, Long userId) { TicketOrder order orderMapper.selectByOrderNo(orderNo); if (order null || !order.getUserId().equals(userId)) { throw new BizException(订单不存在或无权操作); } // 条件更新只有已支付(1)才能退成已退票(3) int updated orderMapper.refundIfPaid(orderNo); if (updated 0) { throw new BizException(当前订单状态不支持退票); } seatStockMapper.increaseAvailable(order.getTrainId(), order.getSeatType()); }refundIfPaid的 SQL 是UPDATE ticket_order SET status 3 WHERE order_no ? AND status 1影响行数为 1 才继续回补库存。这样即使两个退票请求同时到达也只有一个能成功另一个拿到 0 直接报错余票不会被加两次。退票和下单共用同一套「条件更新 影响行数判断」的范式整个系统的并发安全就建立在这个统一模式上讲的时候也容易串起来。5. 避坑与排查这几处翻车点我几乎每次都遇到方案写得再顺落地时该踩的坑一个不少。这一章挑五个订票系统里最高频的问题按「现象、原因、解决」的结构写都是实际项目里反复出现的提前知道能省不少调试时间。5.1 余票出现负数现象是压测或演示时数据库里某条库存记录的available变成 -1 甚至更低。原因基本是先查后改的写法在并发下失效两个请求都通过了余票判断。解决办法是把判断和扣减合并成一条带available 0条件的原子更新同时给字段加CHECK (available 0)约束兜底。改完之后一定要用多线程压测验证别只看单次请求的结果。5.2 订单状态乱跳现象是订单从待支付直接变成已退票或者已取消的订单又被退了一次。原因是状态变更没有统一入口各处代码随手update status。解决办法是把所有状态变更收敛到 Service 层的几个方法里每个方法用条件更新限定前置状态比如退票只认status 1。状态机规则写进代码注释和文档评审时对着看。5.3 定时任务重复回补库存现象是超时订单被取消后余票加多了。原因是定时任务多实例部署或者任务执行时间重叠同一张订单被处理两次。解决办法是取消操作本身用条件更新保证幂等只有真正改成功的那次才回补库存如果多实例部署给定时任务加分布式锁或者用数据库行锁把扫描和处理串起来。单机课程设计一般不会遇到多实例但这个幂等思路要养成习惯。5.4 查询慢到超时现象是车次查询在数据量上万后明显变慢。原因通常是日期条件写成了DATE(depart_time) ?导致索引失效或者出发站到达站字段没建索引。解决办法是把日期条件改成区间比较给(depart_station, arrive_station, depart_time)建联合索引注意字段顺序要匹配查询条件的区分度。改完用EXPLAIN看执行计划确认走了索引再收工。5.5 事务里夹了远程调用现象是下单接口在并发稍高时响应变慢数据库连接池被占满。原因是事务方法里调了短信、日志、第三方接口这类耗时操作事务持有连接的时间被拉长。解决办法是把非数据库操作移出事务放到提交后的回调或异步线程里执行。判断标准很简单事务方法里只留数据库操作其他一律往外挪。6. 验收与答辩用一张检查表把方案讲圆到了验收环节很多同学的问题是「功能都能点但讲不出设计」。答辩老师真正想听的不是你用了多少技术而是你能不能说明白每个设计决策背后的取舍。我一般会准备一张验收检查表把功能、并发、异常、数据一致性四类逐项过一遍既能自查也能当答辩提纲。检查项验收标准演示方式车次查询组合条件返回正确结果分页正常输入出发到达站和日期展示结果与分页余票扣减并发下不超卖余票不为负多线程脚本抢票展示最终余票为 0订单状态四种状态流转符合规则非法跳转被拒演示下单、支付、退票、重复退票被拦截超时释放待支付订单超时后自动取消并回补改短超时时间等待定时任务执行后查余票退票幂等重复退票只成功一次连续点两次退票第二次提示状态不支持这张表的价值在于每一项都对应一个可以现场演示的动作而不是空口说「我做了并发控制」。答辩时按表走先讲需求边界再讲表结构和状态机然后现场跑并发抢票和重复退票两个演示最后说清楚哪些是必做、哪些是增强。老师追问「为什么用乐观锁不用悲观锁」就答订票场景读多写少乐观锁冲突概率低、不加长事务配合条件更新已经能保证正确性悲观锁会拖慢查询。这种回答有取舍、有场景比背概念强得多。还有一个容易被忽略的点把关键日志打出来。下单、扣库存、状态变更这几处日志里带上订单号、车次、席别、影响行数出问题时能顺着日志还原现场。我吃过没有日志的亏一个余票对不上的问题查了一下午最后发现是某次手动改库没走程序。从那以后我每次做涉及库存和状态的项目都强制在扣减和状态变更处打一行带影响行数的日志验收前先翻一遍日志确认链路完整。希望这套结构和检查表能帮到你把方案交得踏实一点。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询