资讯动态

火车票订票系统余票模型、并发扣减与源码开题实践

发布时间:2026/10/27 11:10:49 来源:尧图企业网站定制
火车票订票系统这个选题我在毕业设计和课程设计的场景里见得太多了。帮人看源码、改开题、陪着调试接口前后加起来少说也有十几份。绝大多数交上来的东西长一个样照着网上某套模板改个名字查票就是select * from train下单就是update stock stock - 1一到答辩问“两个人同时买最后一张票会怎样”当场卡壳。这篇文章我想把这件事说透。火车票订票系统看起来是个CRUD堆料项目实际上它的复杂度全藏在“余票”这两个字里——车次是固定的、座位是共享的、乘客的行程是区段重叠的这三点凑在一起就注定它不是一个简单的库存扣减。而源码和开题报告这两个交付物恰恰是评审最先翻、也最能看出功力的部分。开题写得空后面代码再花哨也先输一半。下面我按“思路拆解—模型设计—核心实现—部署联调—问题排查”的顺序,把我自己踩过的坑和反复验证过的做法讲一遍。适合正在做这个选题的同学也适合想拿它当练手项目、补一补并发与状态机基础的朋友。代码给的是 Java 技术栈换 Python 或 Node 思路完全一样模型和锁的策略才是重点。1. 项目整体设计与技术选型拆解动手写代码之前有四件事必须先定下来否则写到一半推倒重来是常态。这四件事分别是业务边界、余票模型、并发策略、交付物清单。很多人一上来就建表写接口写到订单模块发现余票逻辑不支持区段只能删库重来。我见过最惨的一个例子是把“余票”做成了车次级的一个整数写到退票功能时才发现根本没法计算——A站到B站退了一张C站到D站的人能不能买从数据上压根看不出来。1.1 先划清业务边界做哪些坚决不做哪些订票系统的完整业务链条很长车次管理、站点管理、余票查询、选座下单、订单支付、出票、退票、改签、检票、报表。一个学期或者几个月的周期全做完是不现实的堆上去只会每个模块都是半成品。我通常建议把范围收敛到下面这条主线车次与经停站管理后台录入支持多站经停按出发站、到达站、日期查询余票与票价选座下单、生成订单、模拟支付订单查询、取消订单、退票简单的用户与乘客管理改签、检票、报表这些可以作为扩展点写在开题的“后续工作”里反而是加分项——说明你知道系统完整的形态长什么样只是主动做了裁剪。评审最反感的是“什么都写了什么都点不动”。注意开题报告里的“研究内容”和最终源码的功能列表必须对得上。我见过开题写了八个模块、答辩演示只有三个能跑的情况问一句“发票管理在哪”就直接崩盘。1.2 技术选型为什么这套组合最稳妥选型的原则只有一条选你调试得动的。订票系统的难点在并发和状态流转不在框架炫技。下面这套是我用过最省心的组合资料多、报错好查、答辩时也容易讲清楚。层次选型选择的理由后端框架Spring Boot 2.7 / 3.x自动装配省掉大量 XML 配置拦截器、事务、定时任务开箱可用持久层MyBatis-Plus单表 CRUD 自动生成复杂查询还能手写 SQL不至于被 ORM 绑架数据库MySQL 8.0事务、行锁、唯一索引齐全InnoDB 支持间隙锁做区间冲突检测很顺手缓存Redis余票预扣、分布式锁、订单超时倒计时都靠它属于性价比最高的中间件前端Vue 3 Element Plus表格、表单组件现成管理端界面半天能搭出来鉴权JWT 拦截器比 Session 省事前后端分离不折腾有人会问为什么不直接上微服务。我的看法很直接一个订票系统的课设或毕业设计拆成服务只会让部署和联调时间翻倍收益接近于零。单体应用加上清晰的分层已经足够把并发、事务、状态机这些真正的技术点讲明白。1.3 开题报告怎么写才不像模板开题报告最容易写空的三块是研究背景、国内外现状、技术路线。我的经验是把“背景”落到具体场景上——节假日集中出行时段某条线路的车票在开售瞬间被大量请求冲击系统需要在这种压力下保证不超卖、不重复出票。这么一写后面的技术方案就有了对应关系不超卖对应并发控制不重复出票对应幂等设计。技术路线部分不要罗列框架名词而要写成“问题—方案”的对应句式。比如“针对余票在并发下单场景下可能超卖的问题采用数据库乐观锁配合 Redis 预扣减的双层校验方案”。这一句话的信息量比“本系统采用 Spring Boot 框架开发”这种空话强十倍。开题里的进度安排也值得认真排。我一般按六段排需求与建模、数据库与接口骨架、核心业务查票与下单、辅助业务订单与退票、前后端联调、测试与文档。每段留出缓冲别排成理论上的满负荷实际执行一定会延期。2. 数据模型设计余票到底该怎么存这一节是整个项目的分水岭。模型设计对了后面的并发控制和业务逻辑都会顺畅模型设计错了后面全是补丁。2.1 核心表结构拆解与字段取舍订票系统的表不多但每张都有讲究。我通常按下面的结构组织字段只留必要的避免答辩时被问“这个字段从来没用过是干嘛的”。-- 车次表 CREATE TABLE train ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(16) NOT NULL COMMENT 车次号, train_type TINYINT NOT NULL COMMENT 车型1高铁 2动车 3普快, start_station VARCHAR(32) NOT NULL, end_station VARCHAR(32) NOT NULL, start_time TIME NOT NULL, total_seats INT NOT NULL COMMENT 定员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0停运, UNIQUE KEY uk_train_no (train_no) ); -- 经停站表这是区间计算的基础 CREATE TABLE train_stop ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL, station_name VARCHAR(32) NOT NULL, stop_order INT NOT NULL COMMENT 第几站从1开始, arrive_time TIME NULL, depart_time TIME NULL, distance_km INT NOT NULL DEFAULT 0 COMMENT 距始发站里程用于算票价, KEY idx_train_order (train_id, stop_order) ); -- 区段余票表核心中的核心 CREATE TABLE seat_inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL, travel_date DATE NOT NULL, seat_type TINYINT NOT NULL COMMENT 1二等座 2一等座 3商务座, from_order INT NOT NULL COMMENT 上车站序号, to_order INT NOT NULL COMMENT 下车站序号, total INT NOT NULL, sold INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_seg (train_id, travel_date, seat_type, from_order, to_order) );订单表的关键在于状态字段和冗余字段。状态用枚举值表达别用字符串查询和索引都更友好乘车人姓名、车次号这些在订单里冗余一份是为了查询订单列表时不用频繁关联多张表。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, from_order INT NOT NULL, to_order INT NOT NULL, seat_type TINYINT NOT NULL, seat_no VARCHAR(8) NULL COMMENT 具体座位号支付后分配, passenger_name VARCHAR(32) NOT NULL, id_card VARCHAR(32) NOT NULL, amount DECIMAL(10,2) NOT NULL, 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_user_status (user_id, status) );提示order_no上的唯一索引是幂等设计的最后一道保险。前端因为网络抖动重复提交、用户连点两次下单都会落到这个唯一键上插入失败直接返回“订单已存在”比在业务层判断要可靠得多。2.2 余票存储的三种方案对比这是必须讲清楚的一段也是答辩最爱问的地方。常见做法有三种方案存法优点致命问题车次级每趟车每类座位一个总数卖一张减一实现最简单不支持区段复用A-B 段卖完B-C 段的座位也白白浪费区段级按上车站序号到下车站序号的组合存精确支持区间复用组合数量是站点数的平方级需要控制站点规模座位级每个物理座位一张记录记录占用的区段最精确能直接选座数据量大插拔判断复杂不适合小体量项目我推荐区段级。它能讲清楚“区间复用”这个订票系统最有意思的业务点复杂度和代码量又控制得住。代价是查询余票时要做一次判断用户选的上车站序号f和下车站序号t需要找出所有满足from_order f 且 to_order t的库存记录取其中的最小值作为可售数量。这里有个容易忽略的细节如果站点规模是 10 站组合数是 45如果是 30 站组合数就到 435 了每趟车每天就要插这么多行。实际项目里要把站点数量限制在合理范围或者在录入车次时只对相邻区段建库存查询时再累加。我一般选前者简单直接也够用。2.3 索引设计与查询性能的取舍查票是最高频的接口索引没设计好几百条数据就能明显卡顿。核心查询是这样的给定出发站、到达站、日期找到所有可能的车次。我通常会在train_stop上建(station_name)的索引先查到包含出发站和到达站的车次集合再取交集最后用train_id travel_date去seat_inventory里查库存。这条链路里uk_seg那个联合唯一索引已经覆盖了大部分查询条件不需要额外加索引。一个实际的经验不要在 SQL 里写复杂的子查询去做区间重叠判断把逻辑挪到 Java 层用集合运算处理。数据库负责取数据业务逻辑负责算职责分清楚代码可读性和性能都会好一些。我试过把区间冲突判断写成一条嵌套三层的 SQL跑得慢不说答辩时自己都讲不明白。3. 核心业务链路从查票到出票的完整实现模型定好之后剩下的是把业务流程串起来。这一段我会把关键代码贴出来重点讲清楚每一步“为什么这么写”。3.1 查票接口区间复用的实际计算用户输入出发站、到达站、日期后端要返回车次、时间、历时、各席别余票和票价。核心是两步先确定车次集合再算每趟车的余票。public ListTrainVO search(String fromStation, String toStation, LocalDate date) { // 1. 找到同时包含两站的车次 ListLong fromTrainIds stopMapper.selectTrainIdsByStation(fromStation); ListLong toTrainIds stopMapper.selectTrainIdsByStation(toStation); fromTrainIds.retainAll(toTrainIds); if (fromTrainIds.isEmpty()) return Collections.emptyList(); // 2. 逐车次算余票 ListTrainVO result new ArrayList(); for (Long trainId : fromTrainIds) { ListTrainStop stops stopMapper.selectByTrainIdOrderByStopOrder(trainId); int fromOrder findOrder(stops, fromStation); int toOrder findOrder(stops, toStation); if (fromOrder 0 || toOrder 0 || fromOrder toOrder) continue; TrainVO vo buildTrainVO(trainId, stops, fromOrder, toOrder, date); if (vo.getRemainSeats() 0) result.add(vo); } return result; }余票计算本身按前面说的规则取所有覆盖区间的库存记录里的最小值// 查询所有 from_order 上车站 且 to_order 下车站 的库存行 ListSeatInventory rows inventoryMapper.selectCovering( trainId, date, seatType, fromOrder, toOrder); // 每行剩余 total - sold取最小值即为该区间可售 int remain rows.stream() .mapToInt(r - r.getTotal() - r.getSold()) .min() .orElse(0);这段代码值得多解释一句。为什么取最小值假设一列车经过 A、B、C 三站A-B 段的车票卖了 80 张B-C 段卖了 20 张那么 A-C 全程的票就只剩下 20 张能卖因为每一张全程票都会同时占用 A-B 和 B-C 两个区段的座位。这个“最小可用”的逻辑就是区间复用的数学表达答辩时把这句话讲出来比背十句设计模式管用。票价的计算同理按里程分段累加。distance_km字段就是为此准备的BigDecimal price seatPriceRule(distance(stops, toOrder) - distance(stops, fromOrder), trainType, seatType);高铁和普快的每公里基准价不同二等座和商务座还要乘不同系数。这些系数不用真的去对标某个真实平台自己定一套合理比例写在开题里说明这是模拟数据即可。3.2 并发扣减三层防线怎么搭这是全项目最容易翻车的地方。假设某区段只剩 1 张票两个请求同时进来不加控制就会双双扣减成功而sold变成了 2超卖。我通常搭三道防线从外到内分别是Redis 预扣、数据库乐观锁、唯一索引兜底。第一层Redis 里维护一个以trainId:date:seatType:from:to为键的计数器。下单前先执行一段 Lua 脚本判断剩余是否大于 0是则减 1否则直接拦截。Lua 在 Redis 里是原子执行的这一步能挡掉绝大部分并发流量。-- KEYS[1] 库存键, ARGV[1] 扣减数量 local stock tonumber(redis.call(GET, KEYS[1]) or -1) if stock tonumber(ARGV[1]) then return 0 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1第二层数据库乐观锁。真正落库时带上 version 和余量条件int affected inventoryMapper.deduct( trainId, date, seatType, fromOrder, toOrder, 1); // update seat_inventory // set sold sold 1, version version 1 // where train_id ? and travel_date ? and seat_type ? // and from_order ? and to_order ? and version #{version} // and total - sold 1返回 0 说明被人抢先此时要把 Redis 里预扣的数量补回去同时给用户返回“票已售罄请刷新重试”。第三层ticket_order表的uk_order_no唯一索引兜住重复提交和重试。注意Redis 预扣和数据库扣减之间不是事务关系代码里必须用 try-catch 包住数据库操作失败时补偿 Redis。这个补偿逻辑一定要写我见过不少项目写完预扣就忘了回滚压测跑两轮 Redis 里的库存就变成负数了。有人会问为什么不用select ... for update悲观锁。行锁确实能保证正确性但订票场景下热点区段的并发非常集中一个事务持锁期间要经过订单生成、乘车人校验等多个步骤锁持有时间被拉长很容易出现锁等待超时。乐观锁加库存条件的写法冲突时快速失败整体吞吐更好。3.3 订单状态机与超时取消订单的状态流转必须显式定义否则代码里会散落大量if (status 1)的判断改一处漏一处。我习惯画一张小的状态表放在注释里当前状态可执行动作目标状态触发条件待支付(0)支付已支付(1)支付回调成功待支付(0)取消已取消(2)用户主动取消待支付(0)超时取消已取消(2)超过 expire_time已支付(1)退票已退票(3)用户申请且未过退票时限所有状态变更都走同一个入口方法内部先校验当前状态是否允许该动作再做更新Transactional public void transit(String orderNo, OrderStatus from, OrderStatus to) { int n orderMapper.updateStatus(orderNo, from.getCode(), to.getCode()); if (n 0) { throw new BizException(订单状态已变更请刷新后重试); } }update ... where order_no ? and status ?这个条件本身就是一个乐观锁天然防住并发重复操作。比如用户点“取消”的同时系统在跑超时任务两边都以为自己是第一个但只有一个能更新成功。超时取消的实现小体量项目用定时任务扫描就够每分钟扫一次状态为待支付且expire_time now()的订单批量取消并回补库存。数据库里给(status, expire_time)建联合索引扫描开销很小。别一上来就上延迟队列运维成本和调试难度都会上一个台阶除非你的项目确实要做压测演示。3.4 支付与退票的模拟实现支付这块没必要接真实渠道做一个“模拟支付”接口就行但流程要完整否则演示时显得空。我的做法是下单成功后返回订单号和金额前端跳到一个模拟收银台页面点击“确认支付”后调用后端回调接口回调里做三件事——校验订单状态、更新为已支付、分配实际座位号并写入。座位号的分配是一个容易忽略的点。因为库存是按区段记的没有记录具体是哪个座位所以支付成功后才去挑一个在该区间内没被占用的座位。简单做法是在 Redis 里用一个集合理记录每个区段已占的座位号取差集选一个最小的。这个实现虽然朴素但逻辑清楚答辩时也讲得明白。退票就是把整条链路反向执行一次状态更新为已退票、seat_inventory.sold减 1、Redis 库存加回、释放座位号。要注意幂等同一张订单重复点退票不能退两次靠状态条件更新防住。4. 前后端联调与部署上线的实操细节代码写完只是开始能不能跑起来、能不能演示取决于这一段的功夫。我带过的几个项目里时间超支最多的往往就是这里。4.1 接口约定与统一返回体前后端分离最怕的是接口来回改。动手写业务代码之前先把接口文档定下来哪怕是手写的 Markdown 表格。约定好 URL 命名、请求方法、参数格式、返回结构后面基本不会有大的返工。统一返回体建议这样定前端只需要处理一种结构{ code: 0, msg: success, data: {} }业务错误用非 0 的 code 表达HTTP 状态码保持 200前端拦器统一处理。这么做的好处是前端不用区分“网络错误”和“业务失败”代码简洁很多。参数校验失败时msg 里要写清楚是哪一项不对别只返回一个“参数错误”调起来能气死人。跨域是另一个高频坑。开发阶段前后端分端口跑必须配全局跨域上线打包到同一个端口部署后又要把配置清掉否则可能出现重复响应头。我的习惯是开发环境配好CrossOrigin或者全局配置类打包上线前用 Nginx 统一代理前端直接请求相对路径。4.2 部署方式与演示环境准备部署方案按复杂度从低到高本地 jar 直跑、Docker Compose 一键起、云服务器 Nginx。对这个体量的项目我推荐 Docker Compose三个服务MySQL、Redis、应用一个文件搞定换台机器也能快速复现。version: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: demo123 MYSQL_DATABASE: train_ticket ports: [3306:3306] volumes: [./sql:/docker-entrypoint-initdb.d] redis: image: redis:7 ports: [6379:6379] app: build: . depends_on: [mysql, redis] ports: [8080:8080]./sql目录里放建表脚本和初始化数据MySQL 容器第一次启动时会自动执行省掉手工导库的麻烦。这一步对答辩演示特别重要——万一临场换机器一条命令就能把环境拉起来。演示数据一定要提前造好。车次至少准备 5 条覆盖不同车型经停站每条 4 到 6 个订单表里预置几条不同状态的记录这样演示订单列表时不会全空。库存数据别填太满留一两个区段接近售罄方便现场演示“抢最后一张票”的场景。5. 常见问题排查与避坑实录下面这些是我在实际调试和看别人代码时反复遇到的高频问题整理成速查表遇到直接对照。5.1 典型问题速查表现象大概率原因排查与解决两个人同时下单库存变负数只做了 Redis 预扣没做数据库条件更新补上total - sold 1的 where 条件冲突返回 0 时回滚 Redis订单列表重复出现同一条前端重复提交或回调重试依赖uk_order_no唯一索引 状态条件更新查票接口偶发返回空retainAll的集合类型是 List性能差且去重逻辑有问题换成HashSet或Set.retainAll支付后座位号是 null分配座位时 Redis 集合未初始化首次使用前SADD全量座位号或用EXISTS判断后初始化超时任务把已支付订单取消了更新时没带状态条件update ... where status 0影响行数为 0 就跳过退票后库存显示异常只改了数据库Redis 没同步以数据库为准退票后删掉对应 Redis 键下次查询重建日期跨天时间计算错乱用了java.util.Date做时间运算统一换LocalDateTime/LocalDate时区显式指定中文乱码数据库字符集不是 utf8mb4建库建表统一指定连接串加characterEncodingutf8前三行几乎每个项目都会遇到尤其是第一条。我记得有一次帮人看代码Redis 里扣得干干净净数据库里sold字段却纹丝不动原因是那个deduct方法的 where 条件里写成了total - sold 1剩最后一张的时候永远扣不动用户看到的是“有票但下不了单”。这种边界条件不写测试根本发现不了。5.2 几个不写文档但一定要知道的经验关于余票缓存的重建策略我的做法是读多写少就用缓存写操作一律删缓存。查票接口先读 Redis没有就查库回填设置一个较短的过期时间比如 60 秒下单、退票、取消这些写操作更新数据库后直接把对应的库存键删掉让下次读请求重建。别在写操作里去更新缓存值并发下很容易出现新旧值交错删缓存是最省心的。关于开题和源码的一致性我建议把源码的接口列表导出来跟开题里的功能清单逐条对照打勾。评审翻开源码发现表结构和开题里画的不一样是很掉分的事。同理数据库设计那一章的 E-R 图要和实际的建表脚本完全对应字段名、类型、主外键都别对不上。关于代码结构分层要干净Controller 只做参数校验和结果封装Service 放业务逻辑和事务Mapper 只做数据访问。我见过不少人把并发控制、价格计算全塞在 Controller 里一个方法五六百行自己回头改都找不到地方。分层的价值不在于“规范”而在于出问题时能快速定位到是哪一层的锅。还有一点日志一定要打。下单流程里把订单号、车次、区段、扣减结果都打成 INFO 日志退票和取消打 WARN。真出现库存对不上的时候翻日志比断点调试快十倍。这个习惯在课设阶段就该养成成本极低收益极高。至于答辩时的高频追问我总结出几个必被问到的并发下单怎么防止超卖、区间复用是怎么算的、订单超时怎么实现、Redis 和数据库不一致怎么办。这四个问题如果能用自己的话讲清楚并且能指向源码里的具体某几行基本就稳了。反过来如果只会说“用了 Redis 所以很快”那大概率会被继续追问到答不上来。最后分享一个我觉得挺有意思的扩展方向把余票计算做成独立的服务对外提供一个“给定车次和区段返回可售数量”的接口前端和后台管理都调它。这样区间复用的逻辑只维护一份测试也只需要针对这一个接口写。我当时做这个拆分的时候原本散落在三个类里的重复计算一下就被收敛掉了代码量少了将近三分之一。这种重构带来的收益比多加十个功能页面实在得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑