☰
Java学生火车票订票系统:区间余票并发扣减与防超卖实战
2026/11/6 6:46:25 网站建设 项目流程

简介:这是一套面向计算机与软件工程专业学生、JavaWeb初学者及课程设计开发者的学生火车票订票系统完整源码,基于JavaEE技术栈实现,可用于课程设计、毕业设计参考和Web综合实践。压缩包共318个文件、约24.27MB,含72个jar依赖、38个java源文件与38个class、33个jsp页面,以及15个js、10个css和图片等静态资源,并附1个sql数据库脚本,覆盖后端逻辑、前端页面与数据初始化。系统包含后台列车与城市站点维护,支持列车类型、出车到达时间及出发、途经、终点城市管理;前台提供车次查询、选座、信息填写与下单流程,并体现寒暑假半价和团购优惠。项目按MVC分层组织,便于理解JavaEE请求处理、数据库访问与页面渲染,也能作为二次开发基础。已有3253人学习,适合需要完整项目结构、功能模块参考与部署调试思路的读者。

1. 抢票秒空这件事,倒推出来的技术清单

把「Java 学生火车票订票系统」填进选题表时,多数人第一反应是五张表加一套增删改查:用户、车次、订单,页面点得动、查得出车次就算跑通。真按这个路子交上去,答辩现场只要被追问一句「同一趟车 200 张票、300 个人同时下单,你凭什么保证不卖超」,整套东西就站不住了。订票系统的价值不在页面数量,而在余票这个数字始终经得起并发推敲,学生票资质校验是否经得起伪造,订单状态在支付、改签、退票之间来回跳转时是否还能对得上账。下面这份方案从数据模型一路写到验收脚本,适合要交毕设或课设的学生照着搭,也适合想拿一套可跑骨架改成生产雏形的开发者。

2. 总体架构与技术选型:四层结构与三张核心表的职责边界

2.1 技术栈定版:为什么不去追新

学生项目的技术选型只有一条原则:能在自己的机器上稳定跑起来,出了问题搜得到答案。追最新版本看着体面,装依赖那一下午就能把人耗光。我一般会按下面这套组合定版,全班统一,避免「你 JDK 17 我 JDK 8」这种低级混乱。

层次选型选它的理由
运行时JDK 8 或 17,二选一并写进 README两者生态都成熟,团队内不统一才是灾难
框架Spring Boot 2.7 这一代自动装配省掉大量 XML,拦截器、事务、定时任务开箱可用
持久层MyBatis-Plus单表 CRUD 不用写,复杂余票 SQL 又能手写,比全 ORM 可控
数据库MySQL 8行锁、事务、索引是余票扣减的基础,必须真库不能 H2
缓存Redis车次查询缓存、幂等令牌、订单延迟队列,三个用途一套组件
前端Vue 3 + Element Plus 或 Thymeleaf 二选一主攻后端就用模板,想写简历就上前后端分离
认证JWT + 拦截器无状态,压测时不依赖会话,少一类干扰
构建Maven依赖冲突排查比 Gradle 直观

不用消息中间件是刻意的决定。订单超时释放用 Redis 的有序集合加一个定时任务就够,多引一个 Kafka 进来,答辩时说不清它到底解决了什么,反而扣分。

2.2 四层结构与七个模块的职责划分

项目按controller → service → mapper → domain四层切,外加一个common放统一返回体、全局异常处理和登录拦截器。分层不是形式主义,它的实际作用是把「事务边界」圈在 service 层——controller 只做参数校验和组装,mapper 只写 SQL,任何跨表的库存扣减和订单写入都发生在 service 的一个方法里,回滚范围才说得清楚。

业务模块按下面七块分,每块对应一到两个包,写论文的章节划分时可以直接照抄:

  • 用户与认证:注册登录、JWT 签发、角色(学生、管理员)
  • 学生资质:学号、优惠区间、优惠次数、审核状态
  • 车次与站点:车次维护、经停站序列、发到时间
  • 库存与余票:区间段库存初始化、余票查询、扣减与回滚
  • 订单:下单、支付回调、超时关单、订单查询
  • 改签退票:规则校验、差额结算、库存回转
  • 后台管理:车次发布、订单统计、资质审核

其中「库存与余票」是整份方案的重心,其余模块都围着它转。把这块抽出来单独设计,后面的代码才有地方落。

2.3 数据模型:车次、区间库存、订单三张表的字段与索引

订单表存的是「一张票卖给了谁」,区间库存表存的是「每一段还剩多少」。这两张表的关系决定了整套系统能不能算对账。先看库存表和订单表的核心结构。

-- 区间库存:一趟车按经停站切成 N-1 段,每段每席别一行 CREATE TABLE seat_inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL COMMENT '车次ID', travel_date DATE NOT NULL COMMENT '乘车日期,按发车日归属', seat_type TINYINT NOT NULL COMMENT '1二等座 2一等座 3硬座 4卧铺', segment_no INT NOT NULL COMMENT '段序号,从1开始', total INT NOT NULL COMMENT '该段定员', sold INT NOT NULL DEFAULT 0 COMMENT '已售', version INT NOT NULL DEFAULT 0 COMMENT '版本号,配合缓存与覆盖写', UNIQUE KEY uk_train_date_seat_seg (train_id, travel_date, seat_type, segment_no) ) 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, travel_date DATE NOT NULL, seat_type TINYINT NOT NULL, from_seq INT NOT NULL COMMENT '上车站序号', to_seq INT NOT NULL COMMENT '下车站序号', passenger_id BIGINT NOT NULL COMMENT '乘车人', amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1待支付 2已支付 3已取消 4已退票 5已改签', expire_time DATETIME NOT NULL COMMENT '支付截止时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_dup (user_id, train_id, travel_date, from_seq, to_seq, passenger_id, status), KEY idx_expire (status, expire_time) ) COMMENT '订单表';

两处索引值得单独说。uk_train_date_seat_seg保证库存行唯一,避免初始化脚本跑两遍造出重复段;uk_dup是防重复下单的最后一道保险,配合应用层的幂等令牌使用,数据库这一层兜底。idx_expire是给超时关单的定时任务用的,没有它,扫表关单在几万单规模下会拖垮数据库。

提示:uk_dup里带上了status字段,是因为用户退票后要允许重新下单同一行程;如果只按行程做唯一键,退完票就再也买不了这张票。

2.4 配置基线:时区、连接池与初始化数据

配置里最容易翻车的不是连接池大小,而是时区。数据库、JVM、连接串三处时区不一致,跨天车次的travel_date会集体偏移一天,而且这种错在本地测试时往往发现不了。

spring: datasource: url: jdbc:mysql://127.0.0.1:3306/ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false hikari: maximum-pool-size: 20 # 压测时不要超过数据库 max_connections 的一半 connection-timeout: 3000 jackson: time-zone: Asia/Shanghai # 序列化统一时区,避免前端显示错位

初始化数据用一段脚本写死:三条线路、每条线路三到五个经停站、每趟车按maxSeq - 1生成区间段。这一步必须脚本化,手工插数据十有八九会漏段,而漏段的直接后果就是某段余票永远查不出来,表现为「明明有票却下不了单」。这部分初始化代码我建议单独放在resources/db/init.sql,跑测试前一键重置。

3. 核心业务实现:区间余票计算与并发扣减怎么写

3.1 区间座位复用:把一张票拆成若干段

新手最容易把「一趟车 200 个座位」理解成「只能卖 200 张票」。实际上一趟 A-B-C-D 的车,座位在 A 到 B 之间被占用,B 到 C 之间又可以被别人买走。这就是区间复用,也是「全程有票、中间某段没票」现象的来源,答辩时能主动讲清这一点,印象分很不一样。

做法是把线路按经停站切段:四个站切成 1、2、3 三段。一张 A→C 的票占段 1 和段 2,一张 C→D 的票占段 3,两者完全不冲突。查询 A→D 的余票,就是取段 1、2、3 三者剩余量的最小值。

/** * 查询指定区间余票:区间内所有段的剩余量取最小值 * @param fromSeq 上车站序号,1 开始 * @param toSeq 下车站序号,需大于 fromSeq */ public int remain(Long trainId, LocalDate date, int seatType, int fromSeq, int toSeq) { List<SeatInventory> segs = inventoryMapper.listSegments(trainId, date, seatType, fromSeq, toSeq); // 段数对不上说明初始化不全,直接判无票,不要用剩余量去猜 if (segs.size() != toSeq - fromSeq) { return 0; } return segs.stream() .mapToInt(s -> s.getTotal() - s.getSold()) .min() .orElse(0); }

参数上toSeq是开区间,A→C 对应段 1、2,所以 SQL 条件写成segment_no >= fromSeq AND segment_no < toSeq。这个边界条件写反过一次的人,会在这上面卡一整晚,候车室里补票都没这么难受。

3.2 余票预占:条件更新与按段序加锁

扣库存的核心只有一句话:不要「先查再改」。先select出剩余量,Java 里判断够不够,再update写回去,中间那几百毫秒足够另一个线程把票买走,超卖就是这么来的。

正确的写法是把判断压进where条件,让数据库的行锁来保证原子性:

-- 只有剩余量足够时才更新,影响行数为 0 即代表该段已无票 UPDATE seat_inventory SET sold = sold + #{count}, version = version + 1 WHERE train_id = #{trainId} AND travel_date = #{travelDate} AND seat_type = #{seatType} AND segment_no = #{segmentNo} AND total - sold >= #{count};

服务层对区间内的每一段依次执行,任意一段返回 0 就抛异常,整个方法回滚:

@Transactional(rollbackFor = Exception.class) public void lockSeats(Long trainId, LocalDate date, int seatType, int fromSeq, int toSeq, int count) { // 严格按 segment_no 升序扣减,多事务交叉时锁顺序一致,避免死锁 for (int seg = fromSeq; seg < toSeq; seg++) { int affected = inventoryMapper.tryLock(trainId, date, seatType, seg, count); if (affected == 0) { throw new NoSeatException("段" + seg + "余票不足"); } } }

关于version字段,得说清楚它到底管什么。上面这条 UPDATE 的原子性完全由 InnoDB 的行锁保证,total - sold >= count这个条件在加锁读的语义下不会失效,所以单看防超卖,version是多余的。它的真正用途在缓存侧:如果打算把余票放进 Redis 做一层本地判断,那就必须用版本号感知「我读到的余票是否已被别人改过」,否则缓存和库会对不上。学生项目一般不做这层优化,那就在注释里写明它当前未被使用,比留着让人误会强。

死锁这件事值得多说一句。两个事务同时买 A→D 和 B→E,涉及段 1、2、3 和 2、3、4,如果一方从段 1 加到段 3、另一方从段 4 减到段 2,就成了经典的交叉等待。统一升序遍历,问题自然消失。

3.3 订单超时释放:状态机加延迟队列

下单成功后座位被占住,用户不付款,这些座位必须还回去,否则一天下来余票全被未支付订单吃掉。做法是下单时给订单写一个expire_time(通常 15 分钟),同时把单号塞进 Redis 有序集合,score 就是到期时间戳。

// 下单成功后投递延迟任务,score 为毫秒时间戳 String key = "order:delay"; redisTemplate.opsForZSet().add(key, orderNo, System.currentTimeMillis() + 15 * 60 * 1000L);

定时任务每秒捞一批到期的:

@Scheduled(fixedDelay = 1000) public void releaseExpired() { long now = System.currentTimeMillis(); // 每次取 100 条,避免一次拉太多把内存打满 Set<String> due = redisTemplate.opsForZSet().rangeByScore("order:delay", 0, now, 0, 100); if (due == null || due.isEmpty()) return; for (String orderNo : due) { // CAS 更新:只有仍是待支付状态才关单,防止与支付回调抢同一张单 int changed = orderMapper.cancelIfUnpaid(orderNo); if (changed == 1) { inventoryService.releaseByOrder(orderNo); // 按原区间逐段回滚 } redisTemplate.opsForZSet().remove("order:delay", orderNo); } }

cancelIfUnpaid对应的 SQL 是UPDATE ticket_order SET status = 3 WHERE order_no = ? AND status = 1。这个AND status = 1是整个超时释放机制里最关键的一笔——如果用户恰好在第 15 分钟付款成功,支付回调把状态改成了 2,那么关单的影响行数就是 0,库存不会被错误回滚。少了这个条件,就会出现「付了钱票没了」的经典事故。

3.4 学生资质与优惠票价:把折扣写进配置表

学生票的坑不在算法,在规则。优惠区间、每年次数、席别折扣三样东西都是会变的,硬编码在if-else里,改一次配置就要重新编译打包。我一般会单独建一张折扣配置表,按席别存折扣率,代码只负责取配置和乘算。

/** * 计算实付票价 * @param fullPrice 公布票价(全程价按里程比例折算到本区间后的价格) * @param seatType 席别 * @param isStudent 是否命中学生票资质 */ public BigDecimal calcPrice(BigDecimal fullPrice, int seatType, boolean isStudent) { if (!isStudent) { return fullPrice.setScale(2, RoundingMode.HALF_UP); } // discount_config 表按 seatType 查折扣,例如二等座 0.75、硬座 0.50 BigDecimal rate = discountConfigMapper.getRate(seatType); return fullPrice.multiply(rate).setScale(2, RoundingMode.HALF_UP); }

资质校验要卡三件事:乘车区间是否落在学校与家庭所在地之间、当年优惠次数是否还有余额(一年四次单程,用一次减一次)、资质审核状态是否为通过。这三项任意一项不过,就退回普通票价并提示原因,不要静默按学生价出票——这类「多收了钱没提示」的问题在验收时最容易被挑出来。

4. 学生端功能落地:查询缓存、幂等下单与状态机

4.1 车次查询:缓存 60 秒,余票不进缓存

车次和经停站是典型的读多写少数据,一天也改不了几次,放 Redis 缓存 60 秒很划算。但余票绝对不能缓存,或者只允许缓存极短时间(比如 2 秒),否则用户看到有票、点进去没票,体验比直接查库还差。

public List<TrainVO> search(String from, String to, LocalDate date) { String key = "train:search:" + from + ":" + to + ":" + date; String cached = redisTemplate.opsForValue().get(key); if (cached != null) { return JSON.parseArray(cached, TrainVO.class); } // 查车次 + 经停站,再逐趟算余票;余票实时查库,不写入缓存 List<TrainVO> list = trainMapper.searchByStation(from, to, date); list.forEach(t -> t.setRemain(inventoryService.remain( t.getTrainId(), date, t.getSeatType(), t.getFromSeq(), t.getToSeq()))); redisTemplate.opsForValue().set(key, JSON.toJSONString(list), 60, TimeUnit.SECONDS); return list; }

缓存键里必须带日期,否则明天查的车次会命中今天的缓存,这种 bug 的排查成本极高——数据看着是对的,就是日期不对。

4.2 下单接口的幂等设计:令牌加唯一索引双保险

用户手抖双击、网络超时重发、前端重复提交,三种情况都会让同一个订单进来两次。只靠前端按钮置灰挡不住,服务端必须自己认账。

做法是下单前先申请令牌,令牌存 Redis 并设置 5 分钟过期,下单时携带,服务端用delete的返回值判断是否首次消费:

// 1. 申请令牌:GET /api/order/token String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set("order:token:" + userId + ":" + token, "1", 5, TimeUnit.MINUTES); // 2. 下单时校验并消费令牌 Boolean first = redisTemplate.delete("order:token:" + userId + ":" + token); if (Boolean.FALSE.equals(first)) { throw new BizException("请求已处理,请勿重复提交"); }

delete是原子的,返回值非空代表这次真的删掉了,也就是首次消费。第二道保险是 2.3 节建的那个uk_dup唯一索引,即使令牌被绕过(比如压测脚本直接打接口不带 token),数据库也会在插入时抛DuplicateKeyException,捕获后转成友好提示即可。

4.3 支付回调与订单状态机

支付回调有两个特点:可能重复到达,可能与关单任务同时发生。所以回调处理必须满足幂等,且必须用状态作为前置条件。

public String handlePayCallback(String orderNo, String tradeNo) { // 只有待支付的单才允许流转到已支付,重复回调影响行数为 0 int changed = orderMapper.payIfWaiting(orderNo); if (changed == 0) { // 已支付、已取消、已退票的单,直接返回成功让支付方停止重试 return "success"; } // 记录支付流水,作为对账依据 payLogMapper.insert(new PayLog(orderNo, tradeNo, new BigDecimal("0.00"), new Date())); // 从延迟队列摘掉,避免被关单任务误伤 redisTemplate.opsForZSet().remove("order:delay", orderNo); return "success"; }

状态流转只有这几条合法路径:待支付 → 已支付 → 已退票,待支付 → 已取消(超时或主动取消),已支付 → 已改签。其他跳转一律拒绝。把这张图贴在代码注释里,后面加需求时不容易走岔。

4.4 改签与退票:退旧票和占新座必须在同一个事务里

改签的本质是「释放在原区间占用的座位,再在新区间占一次座」,两步之间任何一步失败都要整体回滚,否则会出现旧票退了、新票没占上的空档,用户刷新一下发现自己无座。

@Transactional(rollbackFor = Exception.class) public void change(Long orderId, Long newTrainId, LocalDate newDate, int newSeatType) { TicketOrder old = orderMapper.selectById(orderId); if (old.getStatus() != 2) throw new BizException("仅已支付订单可改签"); // 先占新座,成功后再释放旧座;顺序反过来一旦占座失败,用户就两头落空 inventoryService.lockSeats(newTrainId, newDate, newSeatType, old.getFromSeq(), old.getToSeq(), 1); inventoryService.releaseByOrder(old.getOrderNo()); orderMapper.markChanged(orderId); orderMapper.insert(buildNewOrder(old, newTrainId, newDate, newSeatType)); }

先占新、后放旧,是为了让失败时的状态偏向「用户还留着旧票」而不是「两头空」。差额结算按新票价减旧票价,正数补款、负数走退款流程,这部分金额计算建议单独写单元测试,比在页面上点十遍可靠。

5. 避坑与排查:卖超、重复单、时间对不上

5.1 余票类问题:数字为什么总是不对

现象一:压测结束后sold大于total,卖超了。原因几乎总是先查后改。有人写了select拿到剩余量,Java 判断remain >= 1再update set sold = sold + 1,两个线程同时通过判断,各加一次。解决办法是把判断塞进where,用 3.2 节那条 SQL,并检查affected是否为 0。改完之后仍然超卖,就去查是不是有地方绕过了 service 直接调 mapper。

现象二:某段余票永远是 0,但车次明明有票。原因通常是初始化脚本漏建段,或者段序号从 0 开始、查询从 1 开始,差一位。表现是remain方法里segs.size() != toSeq - fromSeq一直成立,直接返回 0。排查方式是直接select * from seat_inventory where train_id = ?看段号是否连续,缺哪段补哪段,别在 Java 里做兼容。

现象三:退票之后余票越退越多,超过定员。这是重复回滚。支付回调重试、关单任务和退款流程各回滚了一次,sold被减了多次。根治办法是建一张库存流水表,每笔扣减和回滚都写一条唯一流水(以order_no + segment_no + 类型做唯一键),回滚前先插流水,插入失败就说明已经回滚过。这一步比在代码里加if判断靠谱得多。

5.2 订单类问题:重复单和卡死的单

现象:同一乘客同一车次出现两条待支付订单。幂等令牌没生效,或者令牌的 key 里没带用户维度,不同用户之间互相顶掉。检查 key 是否包含userId,以及是否在事务提交后才删除令牌——如果先删令牌再落库,落库失败时令牌已经没了,用户重试反而能下第二单。

现象:订单状态一直停在待支付,座位也不释放。多数是延迟队列没消费。先看 Redis 里order:delay有没有积压,再看定时任务是否因为异常被静默吞掉。Spring 的@Scheduled方法抛异常不会自动停,但会一直失败,日志里应该有痕迹。加一行log.error在 catch 里,比什么都强。

现象:用户付了钱,订单还是待支付。回调地址配错、内网穿透失效、或者回调处理里抛了异常返回了非 success。排查顺序是先看支付方的回调记录,再看应用日志有没有收到请求,最后确认payIfWaiting的 SQL 条件是不是写成了status = 2(写成这样就永远更新不到)。这类问题建议在后台加一个「主动查单」按钮,手工触发一次状态同步,作为应急手段。

5.3 时间与环境类问题:时区和跨天车次

现象:晚上 23:50 发车的车次,第二天查不到了。travel_date的归属没统一。约定按发车日归属,所有写入和查询都走同一个工具方法,不要一处用LocalDate.now()一处用数据库CURDATE()。跨天车次的到达时间允许落在次日,展示时格式化成「次日 06:20」,别让它看起来像倒着开。

现象:本地跑得好好的,换台机器数据全错位 8 小时。JVM 时区、MySQL 连接串serverTimezone、容器基础镜像三处不一致。统一在启动参数里加-Duser.timezone=Asia/Shanghai,连接串里写明时区,Dockerfile 里设置TZ环境变量。

现象:压测时 QPS 怎么都上不去,CPU 却不高。先怀疑测试脚本本身。用同一批用户 ID 循环打接口,行锁全撞在一个库存行上,测出来的不是系统能力,是锁等待。压测数据要准备至少几百个不同用户和多个车次,脚本里做随机分布。这一点没做好,写进论文的性能数据自己都不敢信。

6. 验收与答辩:用一组并发数据证明系统站得住

验收不用面面俱到,抓四个能拿出证据的点就够。每一项都要能在现场复现,光说「我做了」没有说服力。

验收项判定标准现场证据
不超卖200 张定员,500 并发下单,成功数不超过 200下单前后各查一次seat_inventory的sold
不重单同一用户同一乘客连点 10 次,只产生 1 条有效订单订单表按uk_dup维度分组统计
超时释放下单不支付,15 分钟后余票回到初始值观察order:delay被消费、订单状态变 3
退改一致退票后余票加回,改签后旧区间释放新区间扣减逐段核对库存流水

并发验证不用上专业压测平台,一个 shell 脚本加ab或者wrk就够,关键是数据要真实。

# 准备 500 个已登录用户的 token,写入 tokens.txt,每行一个 # -n 总请求数 -c 并发数,POST 下单接口,token 从文件轮询注入 ab -n 500 -c 100 -p order.json -T application/json \ -H "Authorization: Bearer $(shuf -n 1 tokens.txt)" \ http://127.0.0.1:8080/api/order/create

跑完立刻执行select segment_no, total, sold from seat_inventory where train_id = 1,如果每一段的sold都小于等于total,这一项就算过了。论文里的性能章节直接贴这组前后对比,比任何形容词都管用。

答辩时我建议按「正常下单 → 并发抢票 → 超时不付 → 退票回滚」这个顺序演示,每一步都先切到数据库窗口让评委看到数字变化。顺序很重要,先讲并发再讲功能,评委会觉得你在回避业务完整性;先走通业务再上并发,节奏就顺了。有个同学演示时把日志窗口开着,答辩老师问他为什么有几条DuplicateKeyException,他当场解释这是唯一索引在兜底,反而是加分项——把异常讲清楚,比藏起来强。

从那以后我每次带这类项目,都强制走一遍「压测前后截图 + 库存流水对账」这套流程,哪怕时间再紧也不省。翻车的次数多了就知道,能自证的系统才叫系统。希望帮到你。

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

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

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

立即咨询