尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

火车票订票系统从零搭建:余票扣减与订单状态闭环实战

火车票订票系统从零搭建:余票扣减与订单状态闭环实战 简介火车票订票系统.zip是一份面向C初学者的完整课程设计资源实现了查询、增加、打印、订票、修改、保存及删除车次和订票信息等核心功能。其中增加车次时会去重判断订票需校验到达城市与余票订票成功后总票数同步变化能帮助学习者掌握文件读写、数据校验和菜单驱动的控制台程序结构并理解业务逻辑的组织方式。压缩包内共70个文件既有源代码文件也有解决方案、项目配置等工程文件还有编译后的可执行文件与中间文件以及用于保存车次和订票记录的数据文本文件目录结构清晰整体体积约21.78MB。目前已有1319人浏览学习适合作为C大作业、期末项目或文件操作练手参考。资源附带调试生成的Debug与Test目录读者可对照工程配置快速运行并结合源码理解从界面交互到数据落盘的完整流程。1. 火车票订票系统.zip从零搭一套能跑通的订票后端到底要写哪些代码火车票订票系统.zip这个名字在毕设、课程设计和求职项目里出现频率很高。很多同学下载下来发现里面是一个 Java Web 项目或 Python 后端源码附带 SQL 脚本和部署说明但一跑就报错。作为做过几套这类系统的一线开发者我直接告诉你结论这类 zip 项目本质上是一套「车次管理 余票扣减 订单生成」的 CRUD 业务系统核心难点不在增删改查而在余票扣减的一致性和订单状态的闭环。这篇文章会带你拆解一个典型订票系统的完整落地路径技术选型怎么定、数据库表怎么设计、订单和余票的并发问题怎么解决、前端联调要注意什么以及最关键的——你自己下载的 zip 包为什么跑不起来问题多半出在哪。无论你是拿它交作业还是想改造成生产可用的服务这篇都能给你一条可复现的路线。2. 先搞清这套系统的真实结构订票流程、角色边界与表设计2.1 订票系统到底有哪些角色和状态一套完整的火车票订票系统通常包含三个角色游客查票、注册用户订票、支付、退票、管理员维护车次和价格。业务流程可以压缩成一句话用户选车次 → 提交订单 → 锁定余票 → 支付成功 → 出票如果超时未支付或主动退票余票回补。听起来简单但订单状态至少要有这几种待支付、已支付、已出票、已取消、已退票。我见过很多新手项目只设计了已支付/未支付两个状态结果退票功能完全没法做。订单状态建议用整型数字表示不要用字符串存中文否则后续统计和状态流转判断都很痛苦。2.2 数据库表设计五张表能覆盖 90% 的功能常见的做法是建五张核心表用户表、车次表、车次座位表也叫余票表、订单表、订单明细表。车次和车次座位表分离的原因很简单——一趟车有硬座、硬卧、软卧、二等座等多个席别每个席别的票数和价格都不一样拆开才能支持「按席别查余票」「按席别锁票」。表结构的关键字段我直接给出来按这个建表基本不会翻车CREATE TABLE train ( id BIGINT AUTO_INCREMENT PRIMARY KEY, train_no VARCHAR(20) NOT NULL COMMENT 车次编号, origin VARCHAR(50) NOT NULL COMMENT 出发站, destination VARCHAR(50) NOT NULL COMMENT 到达站, depart_time DATETIME NOT NULL, arrive_time DATETIME NOT NULL, UNIQUE KEY uk_train_no (train_no) ); CREATE TABLE train_seat ( id BIGINT AUTO_INCREMENT PRIMARY KEY, train_id BIGINT NOT NULL, seat_type VARCHAR(20) NOT NULL COMMENT 席别一等座/二等座/硬卧, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL COMMENT 当前余票, version INT DEFAULT 0 COMMENT 乐观锁版本号 );这里的stock字段就是余票扣减的战场后面讲并发控制时全指它。你下载的 zip 包里如果表设计比这个还简单比如只有一个train表一个order表那余票扣减基本就是UPDATE train SET stock stock - 1这种写法并发一高一定出超卖。2.3 为什么我把「车次表」和「座位表」拆开而不是合并很多毕设项目会把车次、票价、余票全塞进一张表里。这种做法在小数据量下没问题但一旦要加「席别」维度比如同一趟车同时卖了二等座和一等座一张表就得拆成两行记录来存而且每行都冗余了车次信息。拆表之后的设计干净很多车次表管「这趟车从哪到哪」座位表管「这趟车每个席别卖多少钱、还剩多少票」。从开发角度来说拆表也有实际好处。管理员调整某趟车的一等座价格时只改train_seat表里对应的行不需要碰车次信息订票下单时也只需要锁train_seat的行不需要锁整趟车并发粒度更细。这些经验看起来是常识但自己踩过坑才懂。3. 搭建订票后端最小闭环注册登录、车次查询与下单接口3.1 技术栈怎么选我推荐 Spring Boot MyBatis-Plus MySQL市面上的火车票订票系统 zip 包八成是 Java 写的剩下两成是 Python Flask 或 Django。如果你要自己从零搭我推荐 Spring Boot 2.7 MyBatis-Plus MySQL 8.0 这套组合。理由很简单Spring Boot 生态对新手最友好spring-boot-starter-web一个依赖就把接口服务拉起来了MyBatis-Plus 有QueryWrapper写单表查询比原生 MyBatis 少一半代码MySQL 则是这类业务最稳妥的存储选型。Java 版本用 JDK 8 就行没必要追新。很多 zip 项目跑不起来就是 JDK 版本不对——代码用 JDK 8 写的你拿 JDK 17 编译javax和jakarta命名空间直接冲突。这个细节我后面单开一节讲现在先记住先确认项目的 JDK 版本再动手。我的建议是将下载下来的 zip 解压后先看三个文件pom.xmlMaven 依赖、application.yml配置、数据库脚本.sql初始化数据。注意如果压缩包带了密码解压时会提示输入密码这类 zip 密码移除的诉求很常见用 360 压缩或 7-Zip 尝试无密码解压即可实在不行才考虑密码移除工具。3.2 车次查询接口写一个真正可用的搜索逻辑车次查询是最基础也最容易被做废的接口。很多项目只能做到「按车次号精确查」但用户的实际诉求是「从 A 站到 B 站有哪些车」。为了做到能用的程度查询接口至少要支持出发站、到达站、出发日期三个条件组合。GetMapping(/api/train/search) public ResultListTrainVO search(RequestParam String from, RequestParam String to, RequestParam String date) { LocalDate targetDate LocalDate.parse(date); // 查出符合条件的车次再关联座位表和价格 ListTrain trains trainMapper.selectList(new QueryWrapperTrain() .eq(origin, from) .eq(destination, to)); if (trains.isEmpty()) { return Result.ok(Collections.emptyList()); } ListLong trainIds trains.stream().map(Train::getId).collect(Collectors.toList()); ListTrainSeat seats trainSeatMapper.selectList(new QueryWrapperTrainSeat() .in(train_id, trainIds)); // 组装 VO返回车次号、出发到达时间、各席别余票与价格 return Result.ok(buildTrainVO(trains, seats, targetDate)); }这里有个常见的坑很多人只查了车次表不查座位表导致前端拿到结果后发现没有任何席别信息和价格。我在联调时就遇到过一次前端同学拿着接口文档对照发现返回结构里没有seatList字段还以为是后端没部署新代码。buildTrainVO这一步一定要把每个车次的trainSeat列表按席别组装好否则接口对前端不可用。再提醒一句日期参数date建议用字符串2025-06-01传入后端用LocalDate.parse解析而不是让前端传时间戳。时间戳在跨时区时会有偏移字符串传参最直观也方便前端调试。3.3 下单接口锁余票 生成订单一个事务搞定下单是整个系统的心脏也是面试官最爱问的地方。我给出一个能直接用的实现逻辑先查座位表判断余票是否充足再更新余票数最后插入订单记录。注意查询和更新要在同一个事务里并且更新语句要带stock 0条件防止超卖。Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, Long trainId, String seatType) { // 1. 查出对应席别的座位记录 TrainSeat seat trainSeatMapper.selectOne(new QueryWrapperTrainSeat() .eq(train_id, trainId) .eq(seat_type, seatType)); if (seat null || seat.getStock() 0) { throw new BizException(该席别余票不足); } // 2. 扣减余票这里利用 update 返回受影响行数判断是否抢到票 int updated trainSeatMapper.updateStock(seat.getId()); if (updated 0) { throw new BizException(手慢了余票已被抢完); } // 3. 创建订单状态为待支付 Order order new Order(); order.setUserId(userId); order.setTrainId(trainId); order.setSeatType(seatType); order.setPrice(seat.getPrice()); order.setStatus(0); // 0待支付 orderMapper.insert(order); return order; }对应地Mapper 里的更新 SQL 这样写UPDATE train_seat SET stock stock - 1, version version 1 WHERE id #{id} AND stock 0updateStock返回 0 表示影响行数为 0也就是 stock 已经被别的请求扣成 0 了这时候直接对外报「余票不足」。这段逻辑看起来简单但它同时解决了两个问题一是通过stock 0条件避免了扣成负数二是利用受影响行数判断并发竞争的结果。比你「先 select 再 update」的常规写法安全得多也比写死synchronized的本地锁方案更贴近生产环境。4. 余票扣减与订单状态流转并发一致性怎么保证4.1 三种方案对比悲观锁、乐观锁、Redis 原子扣减余票扣减是订票系统最核心的技术点。你下载的 zip 项目如果源码里用的是「先查询余票数再在 Java 代码里减 1最后 update」这种写法那并发一高必炸。常见的正确做法有三种按实现成本从低到高排列方案一数据库乐观锁。在train_seat表加一个version字段更新时同时校验版本号。实现成本最低适合毕设和中小流量场景这也是我上面代码用的方案。方案二数据库悲观锁。查询时用SELECT ... FOR UPDATE锁行直到事务结束才释放。写起来比乐观锁多一行 SQL但锁等待会让接口变慢高并发下容易堆积数据库连接。方案三Redis 原子扣减。把余票预加载到 Redis用DECR命令扣减再异步同步回数据库。性能最高但要处理 Redis 和数据库的一致性架构复杂度明显上升。对你现在这个阶段我建议用方案一。原因很实在火车票订票系统的并发根本没到电商秒杀的量级用乐观锁已经能把超卖问题挡死而且代码可读性强面试官一眼能看懂你在干什么这比炫技重要得多。4.2 超时未支付怎么办定时任务 状态检查用户下单后不一定马上支付很多 zip 项目直接不管这个事导致订单永远停在「待支付」余票被白白占用。正确做法是加一个定时任务扫描超过 15 分钟仍未支付的订单自动取消并回补余票。Component public class OrderTimeoutTask { Scheduled(fixedDelay 60 * 1000) // 每分钟执行一次 public void cancelTimeoutOrders() { // 查出超过 15 分钟且状态为待支付的订单 ListOrder orders orderMapper.selectList(new QueryWrapperOrder() .eq(status, 0) .lt(create_time, LocalDateTime.now().minusMinutes(15))); for (Order order : orders) { // 先把订单状态改成已取消 int updated orderMapper.cancelOrder(order.getId(), 0, 2); if (updated 0) { continue; // 状态已被其他请求修改跳过 } // 回补余票同样使用条件更新防止重复回补 trainSeatMapper.increaseStock(order.getTrainId(), order.getSeatType()); } } }这里有两个细节值得注意。第一cancelOrder的 SQL 要带状态条件WHERE id ? AND status 0防止用户刚好在支付成功的一瞬间又被定时任务取消这属于状态机流转里最常见的竞态。第二回补余票的increaseStock也必须封装成原子 SQL不要在 Java 里先查后加否则同样会出现并发问题。4.3 退票流程和下单对称但要注意原路返回退票的逻辑是下单的镜像操作订单状态从「已出票」变为「已退票」余票加回去。但这里有一个新手很容易忽略的地方——订单状态不是「已支付」就能退必须是「已出票」。因为中间还有一层出票操作模拟很多项目跳过了这个环节导致支付成功后的订单没有状态流转到已出票退票接口无从判断。我个人的习惯是在支付成功的回调逻辑里同步把订单状态从0待支付改成1已支付再立即改成2已出票。这样订单状态机就是完整的五态流转后续写统计报表也好写面试被问到也好解释。5. 把接口串起来前端页面、联调顺序与常见设计缺陷5.1 前端技术选型Vue 3 Element Plus 还是微信小程序如果你下载的 zip 包里带的是一整套微信小程序源码工程类似railay那种原生开发框架的小程序项目那说明交付方选择了小程序作为前端载体。现在做订票系统前端主流就两条路一条是 Vue 3 Element Plus 做管理后台另一条是微信小程序做用户端。我的建议是用户端用微信小程序更讨喜管理端用 Vue 后台。原因在于微信小程序天然适合「查票、订票、支付」这类低频但刚需的场景而且不需要处理浏览器兼容性而管理员维护车次、价格、查看订单直接上 Element Plus 的表格组件和表单组件开发效率最高。5.2 管理后台必备的四个功能模块管理后台别做花哨的把四个核心模块做扎实就够了车次管理增删改查车次及席别票价、订单管理查看所有订单、手动取消异常订单、余票监控按车次查看当前各席别余票、数据统计简单的售票数量汇总。其中车次管理最容易出问题的是编辑车次时没有联动检查未支付订单。举个例子管理员把一趟车的二等座从 100 张改成 50 张但如果此时数据库里还有 60 个未支付订单占着二等座直接改库存就会导致数据不一致。我一般在编辑余票前加一个校验当前该席别的stock 未支付订单数必须大于等于修改后的库存数否则直接拦截并提示。5.3 联调时最容易卡住的三个点后端接口写完前端页面也渲染出来了联调时通常会有三个卡点。第一个是跨域问题Vue 开发服务器跑在localhost:5173后端接口在localhost:8080不配 CORS 的话浏览器直接拦截。第二个是时间格式化问题后端返回的LocalDateTime序列化后是一长串数组或 ISO 字符串小程序端解析困难。第三个是字段命名问题后端习惯用train_no前端习惯trainNo不统一的话字段对不上。我的解法分别是后端加一个全局 CORS 配置类在application.yml里配置 Jackson 的时间格式化格式统一使用驼峰命名并配置 MyBatis-Plus 的地图下划线转驼峰。这三件套做完联调会顺畅很多不用来回改代码。6. 避坑记录这类 zip 项目最常见的五个翻车现场6.1 解压后缺少关键文件启动直接报错现象把压缩包解压到本地后用 IDEA 打开发现缺src/main/resources目录或.mvn相关配置项目结构看起来「瘸腿」。原因网上下载的 zip 压缩包很多是交付方从自己电脑直接压缩的可能勾选了「排除系统文件」或忽略了部分目录。更常见的是压缩包本身是加密的你用了「zip密码移除」之类的工具强行去掉了密码但移除过程损坏了文件索引。解决解压后第一件事不是打开 IDEA而是先看压缩包内文件列表。用 7-Zip 打开 zip 查看有没有pom.xml和application.yml如果没有说明压缩包本身就不完整直接换一个资源。文件都齐但启动报错则优先检查pom.xml里的依赖是否有红叉。6.2 JDK 版本不匹配编译阶段就挂在 javax 命名空间现象项目导入后大量javax.annotation找不到或报package javax.servlet does not exist。原因代码是 JDK 8 时代的写法但你装了 JDK 11 或 17。JDK 9 之后 JavaEE 相关包从javax迁移到了jakarta老代码在 JDK 8 以外的版本编译不过。解决在 IDEA 的Project Structure里把 Project SDK 和 Module SDK 都改成 1.8同时在pom.xml的properties里确认java.version1.8/java.version。定 version 之前先看两处配置是否一致因为 IDEA 的 SDK 设置偶尔会覆盖 Maven 的编译版本。6.3 MySQL 连接串和驱动不匹配数据库操作全报错现象项目能启动但一调接口就报Access denied或Unknown database。原因application.yml里的数据库配置还是交付方的本地配置用户名密码或库名对不上你的环境。另一个雷点是 MySQL 8.0 使用com.mysql.cj.jdbc.Driver而老项目里写的是com.mysql.jdbc.Driver驱动类名直接失效。解决改动application.yml里的 datasource 为你的本地配置并且核对pom.xml里 mysql-connector-java 的版本。高版本 MySQL 驱动会自动识别时区但如果你是 MySQL 5.7驱动版本别硬上 8.0.x会有通信协议兼容问题。6.4 余票扣减写成「先查后改」并发测试一打就超卖现象用 Jmeter 或 Postman 并发发 20 个下单请求一趟余票只有 5 张的车次居然订出去了 10 单。原因下单代码是这种写法——select stock得到 5判断 5 0然后update train_seat set stock 4 where id ?。两个请求同时通过select判断都能走到update但最终库存只减了一次。解决按我前面第 3 章的做法把更新 SQL 改成带AND stock 0的条件更新并通过update返回的行数判断是否抢票成功。改完再压一次超卖问题一定会消失。6.5 定时任务重复执行或时间不准订单被误取消或永不取消现象下了单准备去支付结果过了 5 分钟订单被自动取消了或者等了 30 分钟订单还挂着。原因服务器系统时间和数据库时间不一致定时任务按服务器本地时间扫描查出来的待支付订单时间全对不上。另一种情况是部署了多个实例每个实例都跑同一个定时任务同一个订单被多个实例扫描到产生了重复取消。解决统一时间来源。定时任务里不要用LocalDateTime.now()改为查询数据库时间SELECT NOW()保证和订单的create_time是同一时钟。另外给取消订单的 SQL 加上状态条件status 0这样就算多个实例同时扫描也只有一个实例能更新成功天然的幂等保护。7. 打包交付前需要的验证用最少的时间确系统能跑、能演示、能答辩项目写完别急着打包成 zip 发给别人先做一遍自测清单。我的建议是准备一个「演示脚本」按角色走一遍核心链路注册新用户 → 搜索车次 → 下单 → 支付 → 查看订单 → 退票 → 管理员登录改价 → 再搜索确认价格已变。这一套走完系统的主干功能就没问题了。技术验证上我习惯做两次并发冒烟测试。第一次是下单并发测试用 Jmeter 并发 50 个线程抢一趟余票 10 张的车结束后查数据库确认订单数等于 10、余票数等于 0。第二次是超时取消测试把定时任务的扫描间隔临时改成 10 秒把订单超时时间改成 1 分钟人工制造一笔待支付订单等 1 分钟后确认订单被取消、余票回补。这两次测试通过并发这块基本就把住了。打包交付时建议把系统构建成一个可以直接运行的 jar 包用mvn clean package -DskipTests打出来然后配一个简洁的启动脚本java -jar train-ticket-system.jar一行起服务。zip 包里放三样东西就够可执行 jar 包、初始化 SQL 脚本、一个 README 文件写明 JDK 版本和 MySQL 配置要求不要塞一堆没用的文档和截图交付体验会好得多。我在帮别人排查这类 zip 项目时最常做的事就是先看 README 或 SQL 脚本里的建表语句从中判断这个系统的水平。表结构设计得有version字段、订单状态是整型枚举而不是字符串说明作者是懂并发和状态设计的反之看到订单价格和余票都放在车次表里心里基本就有数了。所以我建议你在交付前也回头审视一遍自己的表结构这会直接影响项目给人的第一印象。希望这些经验在你的开发过程中能帮到你少走点我当年走过的弯路。本文还有配套的精品资源点击获取
返回列表