资讯详情

资讯详情

Java学生火车票订票系统:区间余票并发扣减与防超卖实战

简介这是一套面向计算机与软件工程专业学生、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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse 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) { ListSeatInventory 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 条避免一次拉太多把内存打满 SetString 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 ListTrainVO 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); } // 查车次 经停站再逐趟算余票余票实时查库不写入缓存 ListTrainVO 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.timezoneAsia/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他当场解释这是唯一索引在兜底反而是加分项——把异常讲清楚比藏起来强。从那以后我每次带这类项目都强制走一遍「压测前后截图 库存流水对账」这套流程哪怕时间再紧也不省。翻车的次数多了就知道能自证的系统才叫系统。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →