ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

火车票订票系统这个选题,我在毕业设计和课程设计的场景里见得太多了。帮人看源码、改开题、陪着调试接口,前后加起来少说也有十几份。绝大多数交上来的东西长一个样:照着网上某套模板改个名字,查票就是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 List<TrainVO> search(String fromStation, String toStation, LocalDate date) { // 1. 找到同时包含两站的车次 List<Long> fromTrainIds = stopMapper.selectTrainIdsByStation(fromStation); List<Long> toTrainIds = stopMapper.selectTrainIdsByStation(toStation); fromTrainIds.retainAll(toTrainIds); if (fromTrainIds.isEmpty()) return Collections.emptyList(); // 2. 逐车次算余票 List<TrainVO> result = new ArrayList<>(); for (Long trainId : fromTrainIds) { List<TrainStop> 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 >= 下车站 的库存行 List<SeatInventory> 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建库建表统一指定,连接串加characterEncoding=utf8

前三行几乎每个项目都会遇到,尤其是第一条。我记得有一次帮人看代码,Redis 里扣得干干净净,数据库里sold字段却纹丝不动,原因是那个deduct方法的 where 条件里写成了total - sold > 1,剩最后一张的时候永远扣不动,用户看到的是“有票但下不了单”。这种边界条件不写测试根本发现不了。

5.2 几个不写文档但一定要知道的经验

关于余票缓存的重建策略,我的做法是读多写少就用缓存,写操作一律删缓存。查票接口先读 Redis,没有就查库回填,设置一个较短的过期时间(比如 60 秒);下单、退票、取消这些写操作,更新数据库后直接把对应的库存键删掉,让下次读请求重建。别在写操作里去更新缓存值,并发下很容易出现新旧值交错,删缓存是最省心的。

关于开题和源码的一致性,我建议把源码的接口列表导出来,跟开题里的功能清单逐条对照打勾。评审翻开源码发现表结构和开题里画的不一样,是很掉分的事。同理,数据库设计那一章的 E-R 图,要和实际的建表脚本完全对应,字段名、类型、主外键都别对不上。

关于代码结构,分层要干净:Controller 只做参数校验和结果封装,Service 放业务逻辑和事务,Mapper 只做数据访问。我见过不少人把并发控制、价格计算全塞在 Controller 里,一个方法五六百行,自己回头改都找不到地方。分层的价值不在于“规范”,而在于出问题时能快速定位到是哪一层的锅。

还有一点,日志一定要打。下单流程里把订单号、车次、区段、扣减结果都打成 INFO 日志,退票和取消打 WARN。真出现库存对不上的时候,翻日志比断点调试快十倍。这个习惯在课设阶段就该养成,成本极低,收益极高。

至于答辩时的高频追问,我总结出几个必被问到的:并发下单怎么防止超卖、区间复用是怎么算的、订单超时怎么实现、Redis 和数据库不一致怎么办。这四个问题如果能用自己的话讲清楚,并且能指向源码里的具体某几行,基本就稳了。反过来,如果只会说“用了 Redis 所以很快”,那大概率会被继续追问到答不上来。

最后分享一个我觉得挺有意思的扩展方向:把余票计算做成独立的服务,对外提供一个“给定车次和区段,返回可售数量”的接口,前端和后台管理都调它。这样区间复用的逻辑只维护一份,测试也只需要针对这一个接口写。我当时做这个拆分的时候,原本散落在三个类里的重复计算一下就被收敛掉了,代码量少了将近三分之一。这种重构带来的收益,比多加十个功能页面实在得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进