火车票订票系统这个词在毕业设计和课程设计的选题池里出现频率高得离谱,高到不少人一看标题就默认"这不就是个带登录的增删改查练习"。我前前后后帮人看过十几份同类开题报告和配套源码,说实话,真正把核心逻辑做对的不到三成——大部分项目能跑起来,但一谈到"同一个座位在 A 站到 B 站被买走之后,B 站到 C 站还能不能卖"这种问题就卡壳。这篇就围绕火车票订票系统(源码+开题)这个完整交付物,把需求拆解、表结构设计、余票扣减、订单超时释放、开题报告写法、源码组织和答辩追问这几块串起来讲清楚,适合正在做同类毕设的同学,也适合想搞明白"区间库存"这类模型怎么落地的开发者。代码部分我按 Java + Spring Boot + MySQL 这条最常见的路线来写,思路换成其他语言一样成立。
1. 先把"订票"这件事拆开:它和普通增删改查差在哪
1.1 一个座位在时间轴上是分段的,不是一个整数库存
大部分人对"库存"的直觉是电商那套:商品表里放一个stock字段,下单就减一,付不了款就加回来。订票系统最坑的地方在于,这个直觉是错的。一趟车从甲站出发,经停乙站、丙站,终到丁站,中间有 3 个区段。二等座总共 100 个位置,当一个乘客买了"甲站到乙站"的票,他占用的只是第 1 个区段,第 2、3 个区段的这 100 个位置对他来说依然是空的,完全可以在乙站卖给下一位乘客。
这就意味着库存的最小单位不是"车次 + 席别",而是"车次 + 席别 + 区段"。一趟 10 个站的车次,如果有 3 种席别,光是库存记录就是 9 段 × 3 种 = 27 条,再乘上未来 30 天的发车日期,就是 810 条。你把这个数字想明白,后面表结构怎么设计、扣减怎么写、查询怎么优化,基本就有方向了。
我在看别人源码时最常发现的问题,就是把库存直接建成train_id + date + seat_type + remaining,然后发现"甲到乙卖完了,乙到丁明明还有位置却卖不出去",最后只能在代码里打补丁,越补越乱。所以动手写第一行代码之前,先把"分段"这个概念在脑子里立住。
1.2 三个绕不开的核心矛盾,决定了你后面所有的取舍
第一个矛盾是库存粒度与查询性能。粒度越细(精确到每个区段),余票计算越准,但每次查余票要聚合多个区段;粒度越粗(整趟车一个数字),查询飞快,但会拒掉本该卖出去的票。我建议的做法是细粒度存、查询时用一条聚合 SQL 搞定,别在应用层循环查库。
第二个矛盾是座位占用与订单生命周期。用户点了"提交订单"但没付款,这个座位到底算不算被占?算,就得有个"锁座"的中间状态;不算,用户付款时可能发现票没了。业界通行的做法是下单即锁座,给 15 到 30 分钟支付窗口,超时自动释放。这个窗口怎么实现,是第 5 章要展开的。
第三个矛盾是数据一致性与响应速度。高并发下你想用缓存扛流量,但缓存和数据库的余票数字一旦不同步,就会出现"显示有票、下单失败"或者更糟的"超卖"。毕设场景下并发量不大,我的建议是先把数据库层面的正确性做扎实,缓存作为可选项后加,别一上来就上 Redis 分布式锁,容易把自己绕进去。
2. 技术选型的取舍:够用、能跑、答辩讲得清
2.1 后端框架和持久层为什么选这一套
后端我用的是 Spring Boot 3.x 加 MyBatis,数据库 MySQL 8.0。选 MyBatis 而不是 JPA 的理由很实在:这套系统里最关键的几条 SQL——余票聚合查询、带条件的原子扣减、多区段批量更新——都需要手写,而且要精确控制执行顺序和影响行数。JPA 的自动生成 SQL 在这种场景下反而碍事,你得为了一个UPDATE ... WHERE remaining >= 1去写原生查询,绕了一圈又回到手写 SQL。
MySQL 版本要选 8.0 以上,原因有两个:一是窗口函数在统计报表模块里好用,二是 8.0 的 InnoDB 在默认隔离级别下对UPDATE ... WHERE的行锁行为和 5.7 有细微差别,用新版本能少踩一些老坑。存储引擎必须是 InnoDB,MyISAM 没有事务和行锁,用在扣减库存上是灾难。
提示:建表时统一用
utf8mb4字符集,站名里出现生僻字的情况不少,utf8三字节存不下会直接报错,这个问题在联调阶段才暴露会非常浪费时间。
依赖管理上,除了 Spring Boot Starter Web、MyBatis Starter、MySQL Driver,还需要spring-boot-starter-validation做参数校验,lombok减少样板代码(如果答辩要求展示代码,谨慎使用,有些评审不喜欢看到满屏@Data)。定时任务用 Spring 自带的@Scheduled就够了,毕设规模下引 Quartz 属于杀鸡用牛刀。
2.2 缓存要不要上,什么时候上
这个问题我被问过很多次。我的答案是:第一版坚决不上。理由是这样——余票扣减的正确性依赖数据库的行锁和事务,你一旦在前面加了缓存,就要处理缓存与数据库的一致性、缓存击穿、缓存雪崩、缓存与扣减操作的原子性,每一个都是独立的坑。毕设的评分点在于"业务逻辑完整、技术点讲得清",不在于"上了多少中间件"。
真要加,加到什么位置比较安全?加到查询侧而不是扣减侧。也就是余票查询接口可以缓存,但缓存 key 要带上"车次 + 日期 + 出发站序 + 到达站序",过期时间设短一点,30 秒到 1 分钟。下单扣减走数据库,扣减成功后主动删掉相关 key。这样最坏情况是用户看到 1 分钟前的余票数据,下单时被数据库拦住,体验上有轻微落差但绝不会超卖。
如果你确实想在答辩里体现技术广度,可以用 Redis 做一个"预扣减"层:请求进来先在 Redis 里用 Lua 脚本原子扣减,扣成功再异步落库。这套方案能讲出花来,但落地复杂度高,异步落库失败的回滚逻辑很难写对。除非你已经把纯数据库方案跑通并且有余力,否则不要碰。
2.3 前端渲染方式与环境清单
前端两条路:服务端渲染(Thymeleaf)和前后端分离(Vue + Element Plus)。做毕设我偏向 Thymeleaf,理由很功利——少一个工程、少一次跨域配置、少一套打包部署流程,答辩演示时启动一个 jar 就行,不容易在演示现场翻车。缺点是页面交互比较"朴素",如果评审对界面美观度有要求,那就上 Vue。
无论选哪条路,环境版本一定要在开题报告里写死,别写"JDK 8 以上"这种模糊表述,装环境的时候你自己会感谢当初写清楚的那个决定。
| 组件 | 推荐版本 | 选这个版本的原因 |
|---|---|---|
| JDK | 17 | Spring Boot 3.x 最低要求,长期支持版本 |
| Spring Boot | 3.2.x | 与 JDK 17 匹配,文档和示例最多 |
| MySQL | 8.0.33 以上 | 窗口函数可用,InnoDB 行为稳定 |
| MyBatis Starter | 3.0.3 | 与 Boot 3.x 适配的版本,注意别用 2.x |
| Node(若用 Vue) | 18 LTS | 与主流构建工具链兼容 |
| Maven | 3.8 以上 | 低于 3.6 下载依赖容易出证书问题 |
3. 数据库表结构:把一条铁路线拆成可计算的区间
3.1 车次、经停站与运行时刻的建模细节
核心是两张表:train(车次)和train_stop(经停站)。train存车次号、始发站、终到站、席别配置这些静态信息;train_stop存这个车次经过的每一站,字段包括train_id、station_name、stop_order(站序,从 1 开始递增)、arrive_time、depart_time、mileage。
站序stop_order是整套系统里最重要的一个字段,因为它决定了"区间"怎么表示。甲站到丙站的票,本质上是占用了stop_order从 1 到 3 之间的两个区段(1-2 和 2-3)。所有余票计算、扣减、退票,都基于这个整数做加减,不涉及任何字符串比较,性能好也不容易错。
这里有个必须提前想到的坑:跨天时间。一趟车 23:40 从甲站发车,次日 06:20 到乙站,数据库中arrive_time只存06:20:00,你直接拿它减发车时间会得到负数。解决办法是在train_stop里加一个day_offset字段,表示相对始发日过了几天,计算时先把时间换算成"相对始发时刻的分钟数":
-- 计算某区段运行时长(分钟),day_offset 处理跨天 SELECT (t2.day_offset * 1440 + TIME_TO_SEC(t2.arrive_time) / 60) - (t1.day_offset * 1440 + TIME_TO_SEC(t1.depart_time) / 60) AS duration_min FROM train_stop t1 JOIN train_stop t2 ON t2.train_id = t1.train_id AND t2.stop_order = t1.stop_order + 1 WHERE t1.train_id = ? AND t1.stop_order = ?;这个day_offset字段看起来不起眼,但没有它,跨天车次的到达时间、行程时长、退票规则全都会算错,而且错得很隐蔽——短途车次测试时根本发现不了。
3.2 座位库存落表的两种方案,我为什么选第二种
方案一叫"实时计算法":不存库存表,每次查余票时去订单表里聚合,统计每个区段被占用了多少。优点是数据永远不会不一致,缺点是无法处理"座位号"这个维度,而且订单表一大查询就慢。
方案二叫"区段库存法":单独建一张seat_stock表,主键是(train_id, travel_date, seat_type, segment_index),segment_index就是区段编号(1 表示第 1 站到第 2 站,依次类推),字段remaining表示这个区段还剩多少。下单时把乘客跨越的所有区段同时扣减,退票时同时加回。
我选方案二,因为它是唯一能同时支持"余票查询快"和"座位号分配"的结构。具体怎么扣减,第 4 章详细讲。这里先把表结构列清楚:
CREATE TABLE seat_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL, travel_date DATE NOT NULL, seat_type VARCHAR(16) NOT NULL COMMENT '二等座/一等座/商务座', segment_index INT NOT NULL COMMENT '区段序号,从1开始', total_capacity INT NOT NULL COMMENT '该区段总座位数', remaining INT NOT NULL COMMENT '该区段剩余可售', version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_stock (train_id, travel_date, seat_type, segment_index) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;初始化的时机也要想好:不是提前把 30 天的数据全铺出来,而是按需初始化——用户第一次查询某个车次某天的余票时,如果库存记录不存在,就用INSERT ... ON DUPLICATE KEY UPDATE的方式批量插入并忽略冲突。这样既避免了定时任务铺数据,又不会因为并发插入报主键冲突。
3.3 订单状态机与座位锁的关系
订单表ticket_order里我最看重两个字段:status和expire_time。状态机的设计必须收敛,别搞出七八个状态互相跳转:
| 状态值 | 含义 | 可跳转到 |
|---|---|---|
| 0 | 待支付 | 1(已支付)、2(已取消) |
| 1 | 已支付 | 3(已退票)、4(已完成) |
| 2 | 已取消 | 无(终态) |
| 3 | 已退票 | 无(终态) |
| 4 | 已完成 | 无(终态) |
状态机收敛的好处是,所有状态变更都能写成带条件的 UPDATE:
UPDATE ticket_order SET status = 2, cancel_reason = '超时未支付' WHERE order_no = ? AND status = 0;这条语句的影响行数如果是 1,说明本次操作真的改变了状态,可以继续去释放座位;如果是 0,说明订单已经被别的线程改过了,直接返回。这个"用影响行数做幂等判断"的技巧,是整套系统里复用率最高的一个模式,订单超时释放、支付回调、退票处理三处全都用得上。
另外订单表要存冗余字段:train_no、travel_date、from_stop_order、to_stop_order、seat_type。别只存一个train_id就完事,因为车次信息可能被修改,订单必须能独立还原出当时买的是什么票——这是"订单快照"的思路,做电商的同学应该很熟悉。
4. 余票扣减:超卖是怎么发生的,又该怎么堵住
4.1 一段能稳定复现超卖的代码
先说错误的写法,因为很多人第一版就是这么写的:
// 错误示范:典型的读-改-写,并发下必然超卖 public boolean deductWrong(Long trainId, LocalDate date, String seatType, int fromIdx, int toIdx) { for (int seg = fromIdx; seg < toIdx; seg++) { Integer remain = stockMapper.selectRemaining(trainId, date, seatType, seg); if (remain == null || remain <= 0) { return false; // 中途失败,前面已扣的区段没回滚,脏数据 } stockMapper.updateRemaining(trainId, date, seatType, seg, remain - 1); } return true; }这段代码有两个致命问题。第一,selectRemaining和updateRemaining之间有间隙,两个线程可能同时读到remaining = 1,然后各自把它更新成 0,最终卖出 2 张票,这就是超卖。第二,循环中途某区段没票时直接返回,前面已经扣掉的区段没有回滚,造成"库存凭空减少"的幽灵问题。第二个问题在测试阶段特别难发现,因为它依赖于"用户恰好买了跨多区段的票而且中途某段售罄"这个条件。
我在本地 4 核 8G 的机器上,用 JMeter 起 100 个并发线程去打这个接口,同一区段 10 个座位卖出了 13 张票,超卖率大概在 30% 这个量级。你把这段代码贴进开题报告的问题分析部分,会显得很有说服力。
4.2 三种防超卖方案,我在毕设里怎么选
第一种是悲观锁,SELECT ... FOR UPDATE。扣减时先把涉及的所有区段行锁住,判断余量,再更新,最后提交。正确性没问题,缺点是并发性能差,而且多行锁按不同顺序获取时容易死锁。毕设场景可以用,但要配合"按 segment_index 升序加锁"的规则。
第二种是条件更新(CAS),也是我最推荐的方案。核心是把"判断"和"扣减"合并成一条 SQL,让数据库在行锁保护下原子完成:
UPDATE seat_stock SET remaining = remaining - 1, version = version + 1 WHERE train_id = #{trainId} AND travel_date = #{date} AND seat_type = #{seatType} AND segment_index = #{segIdx} AND remaining > 0;影响行数为 1 表示这一区段扣减成功,为 0 表示已售罄。多区段扣减时把这些 UPDATE 放在同一个事务里,任意一条返回 0 就抛异常回滚。这里有个关键细节:循环更新时必须按segment_index升序执行,否则两个订单分别买"1 到 3"和"2 到 4"时,一个先锁 1、2 再抢 3,另一个先锁 2、3 再抢 4,形成一个交叉等待,MySQL 会直接抛出死锁异常。死锁不影响正确性(事务会回滚),但会污染日志、降低成功率,答辩时被问到"为什么日志里有 Deadlock"就尴尬了。
第三种是Redis 预扣减 + 异步落库。性能最好,复杂度也最高。Lua 脚本能把多区段扣减做成原子操作,但异步落库失败后的补偿逻辑需要消息队列和本地消息表,毕设体量下属于过度设计。
三种方案的对比我整理成表格,方便你根据自己项目的评价标准挑:
| 方案 | 并发安全 | 实现复杂度 | 吞吐量级 | 适用场景 |
|---|---|---|---|---|
| 悲观锁 FOR UPDATE | 安全 | 中,要处理死锁顺序 | 低,百级 QPS | 并发压力小、逻辑清晰的场景 |
| 条件更新 CAS | 安全 | 低,一条 SQL | 中,千级 QPS | 毕设首选,性价比最高 |
| Redis 预扣减 | 安全(需补偿) | 高,要处理落库失败 | 高,万级 QPS | 真实高并发生产环境 |
4.3 乐观锁重试次数怎么估,别凭感觉填
如果用version字段做乐观锁而不是直接条件更新,就要考虑重试次数。重试次数不能拍脑袋,得算。假设冲突概率为 p,重试 n 次后失败概率是 p 的 n 次方。100 并发、热点车次的情况下,单次冲突概率经验值在 0.3 到 0.5 之间,重试 3 次失败概率大概是 0.125 到 0.027。放到 100 次请求里就是 12 次左右失败,用户体验很差。所以要么重试 5 次以上,要么干脆放弃乐观锁改用条件更新。
我更倾向于不用 version 字段做库存扣减,原因很简单:条件更新里的remaining > 0本身就是一个天然的业务版本号,加了 version 反而多一层不必要的循环。version 字段适合用在"读取-计算-写回"这种业务逻辑复杂的场景,比如座位号的分配。
还有个小经验:扣减失败时不要立即重试,加个 20 到 50 毫秒的随机退避,能显著降低重复冲突。这个技巧在高并发下效果明显,原理跟网络协议里的退避算法是一个思路。
5. 订单超时未支付:座位怎么干净地回到池子里
5.1 定时扫表方案:实现最简单,坑也最多
最直观的做法是开一个@Scheduled任务,每 30 秒扫一次订单表,把status = 0且expire_time < now()的订单捞出来,逐条取消并释放座位。
@Scheduled(fixedDelay = 30_000) public void releaseExpiredOrders() { List<TicketOrder> expired = orderMapper.selectExpired(200); for (TicketOrder order : expired) { try { cancelAndRelease(order); } catch (Exception e) { log.error("释放超时订单失败, orderNo={}", order.getOrderNo(), e); } } } @Transactional(rollbackFor = Exception.class) public void cancelAndRelease(TicketOrder order) { int affected = orderMapper.cancelIfPending(order.getOrderNo()); if (affected == 0) { return; // 已被其他线程或支付流程处理,直接跳过 } stockService.release(order); }这个方案能跑通,但也有三个明显的毛病,讲清楚了反而是加分项。一是时间精度受限于扫描间隔,用户可能在 15 分 29 秒时付款,此时座位已经被 15 分 30 秒的那次扫描释放掉了,导致付款成功但没座。解决办法是把释放时间点往后留一点缓冲,比如订单过期时间是 15 分钟,实际释放时间用 16 分钟。二是扫表压力,订单量大了以后WHERE status = 0 AND expire_time < ?会全表扫,必须在这两个字段上建联合索引。三是单机单线程处理慢,可以在 SQL 里加LIMIT分批,但别开多线程,容易和支付回调撞车。
5.2 延迟触发而不是轮询扫描,以及那个必须做对的幂等
更优雅的做法是下单时就注册一个延迟任务,到点触发。能用的载体有几种:消息队列的死信队列、Redis 的有序集合、时间轮。毕设里我最推荐Redis 的 ZSet,理由是依赖已有、实现简单、精度能到秒级。
思路是这样的:下单成功后,把订单号作为 member,expire_time的时间戳作为 score,塞进一个叫order:delay的 ZSet。另起一个轻量的轮询任务,每 5 秒用ZRANGEBYSCORE order:delay 0 now LIMIT 0 100捞出一批到点的订单号,逐个处理,处理完ZREM掉。
这里有个极易踩的坑:ZREM的返回值必须用来做幂等判断。如果只是ZRANGEBYSCORE捞出订单就处理,多实例部署时同一个订单会被处理多次,虽然前面cancelIfPending的affected == 0能拦住,但多绕一圈没必要。正确做法是先ZREM,返回 1 才继续往下走,返回 0 说明别的进程已经拿走了。
public void poll() { Set<String> orderNos = redisTemplate.opsForZSet() .rangeByScore(DELAY_KEY, 0, System.currentTimeMillis(), 0, 100); for (String orderNo : orderNos) { Long removed = redisTemplate.opsForZSet().remove(DELAY_KEY, orderNo); if (removed != null && removed > 0) { orderService.cancelAndReleaseByNo(orderNo); } } }释放座位本身也必须幂等。释放逻辑是给seat_stock的若干区段remaining + 1,如果重复执行,座位数就会凭空变多,最终导致超卖——这是个很隐蔽的 bug,因为它的表现是"系统里能卖出的票比实际座位多"。所以释放前必须先确认订单状态是从 0 变成 2 成功了,用第 3 章讲的影响行数判断来保证。
注意:退票也走释放座位的逻辑,如果超时释放和退票同时发生,两者都会执行
remaining + 1,座位就被加了两次。所以释放接口必须挂在"订单状态变更成功"之后,用状态机做闸门,而不是让两个入口各自去加库存。
6. 开题报告的写法:让评审一眼看出你想清楚了
6.1 研究内容和技术路线要落到"我具体要解决什么问题"
开题报告最常见的失败写法是堆砌宏大叙事:"本系统采用先进的微服务架构,具有高并发、高可用、可扩展等特性,能够为用户提供便捷的购票体验"。评审看了这段话,不会觉得你有水平,只会觉得你在凑字数。真正有效的写法是把问题具体化。
比如"研究内容"这一节,你可以这么写:本课题重点解决三个问题——一是同一座位在不同区段的复用问题,通过区段库存模型实现分段售卖;二是高并发下的超卖问题,通过数据库条件更新保证扣减原子性;三是未支付订单的座位回收问题,通过延迟触发加状态机幂等实现自动释放。这三个问题都是真实存在的、可验证的,写出来就比空话强十倍。
"技术路线"要画出分层结构并说明每层职责。典型的四层是:表现层负责页面渲染与表单校验,控制层负责参数接收和响应封装,业务层承载订票、退票、释放等核心逻辑,持久层用 MyBatis 对接 MySQL。每一层配一句"为什么这么分"——比如"业务层独立出来,是因为订票和退票要复用同一套库存操作,放在控制层会导致逻辑重复"。
6.2 功能模块图和进度安排:两个最容易露馅的地方
功能模块图很多人直接画成一个方框下面挂七八个子方框,这不算错但太糙。稍微用心一点的做法是按角色划分:乘客端和管理端分开,乘客端下面挂"车次查询、余票查询、下单支付、订单管理、退票改签",管理端下面挂"车次维护、站点维护、库存初始化、订单监控"。这样一眼就能看出你想过系统的使用场景,而不是把功能列成一堆。
进度安排千万别写成"第 1-4 周完成需求分析,第 5-8 周完成编码,第 9-12 周完成测试"这种粗线条。评审问一句"第 3 周你具体做了什么"就露馅了。合理的写法是把关键里程碑标出来:第 2 周完成数据库表结构评审,第 4 周跑通第一个查询接口,第 6 周完成下单主流程并验证不超卖,第 8 周完成超时释放,第 10 周补管理端和联调,第 12 周写论文。里程碑要和可验证的产出挂钩,不是和时间段挂钩。
提示:如果指导老师允许,进度安排里塞一两条"风险预案",比如"若区段库存模型实现超期,降级为整趟车统一库存,牺牲部分售票能力保证主流程可交付"。这种表述会显得你懂工程,比"按时完成"四个字有价值得多。
6.3 创新点怎么写才不显得在吹牛
本科生项目谈"创新"其实是个尴尬的要求,硬造创新点反而扣分。我的建议是把创新点写成"工程改进点",落在具体的实现选择上,比如:针对传统实现中查询余票需要遍历所有订单表的问题,采用区段库存表预存余量,使余票查询从聚合扫描降为单行读取;针对订单超时释放可能重复执行的问题,引入基于状态机影响行数的幂等机制。这类表述有对比、有手段、有效果,评审挑不出毛病。
7. 源码组织与关键代码走读
7.1 目录结构:让看代码的人三分钟找到入口
包结构按职责切,不要按技术层切得太碎。我的组织方式是controller、service、service.impl、mapper、entity、dto、vo、config、util、task。这里有几个容易混淆的目录要说清楚:entity放和数据库表一一对应的对象,dto放接收前端参数的对象,vo放返回给前端的对象。三者分开的意义在于,前端传的参数和后端返回的字段往往不一致,混用 entity 会导致"接口返回了不该暴露的字段"这种安全问题,虽然毕设不查得很严,但分开写是专业习惯。
配置类里至少要有一个WebConfig(跨域、拦截器注册)、一个MybatisConfig(如果用了分页插件或自定义类型处理器)、一个GlobalExceptionHandler(统一异常响应)。全局异常处理器特别值得写,把BusinessException、参数校验异常、系统异常分别映射成不同返回码,前端处理起来会轻松很多。
7.2 从点击"提交订单"到库存扣减成功的完整链路
这条链路值得完整走一遍,因为它是整个项目的核心,答辩必问。我按顺序拆成六步:
第一步,参数校验。校验车次、日期、上车站序、下车站序的合法性,重点是fromStopOrder < toStopOrder,不然会出现"从第 3 站买到第 1 站"这种负区间。
第二步,查库存。对fromIdx到toIdx - 1的所有区段取remaining的最小值,这个最小值就是本次能买的最大票数。注意是取最小值而不是总和,因为乘客要占满整段。
SELECT MIN(remaining) AS min_remain FROM seat_stock WHERE train_id = #{trainId} AND travel_date = #{date} AND seat_type = #{seatType} AND segment_index >= #{fromIdx} AND segment_index < #{toIdx};第三步,扣库存。在事务里按segment_index升序逐段执行条件更新,任一返回 0 就抛异常回滚。这一步和第二步之间存在时间窗口,所以第二步查出来的余量只用来快速拦截,真正的判定在第三步。
第四步,生成订单。订单号建议用"日期 + 车次 ID + 随机数"的结构,别用自增 ID 直接对外暴露。
第五步,注册延迟任务,往 ZSet 里塞订单号和过期时间戳。
第六步,返回订单详情,前端跳转支付页面。
这六步里第一步和第五步经常被省略,但它们恰恰是"看起来能跑"和"真的健壮"的分界线。
7.3 自己写测试用例,比等人来问强
至少设计四组用例:单区段购买(甲到乙)、跨区段购买(甲到丙)、边界售罄(某区段只剩 1 张时两个请求同时打进来)、超时释放(下单后等过期时间过去,检查库存是否回补)。第二组用例能验证"分段售卖"是否真的生效——买完甲到乙之后,乙到丙应该还能买。第四组用例要连数据库直接看remaining变化,别只看接口返回。
并发测试用CountDownLatch起 50 个线程同时打同一个区段,断言卖出的订单数不超过座位数。这个断言要写成硬编码的,跑十次十次通过,才能证明扣减逻辑是对的。
8. 答辩现场最容易被追问的几个问题
"你这个系统支持多少并发?"——别说"支持十万并发"这种话,实事求是地讲"本地压测 100 并发下无超卖,瓶颈在数据库行锁,理论上可以靠加机器和分库分表扩展,但当前实现的定位是正确性优先"。这个回答比吹牛安全得多。
"如果一个订单买了 3 个区段,中间某段被扣成功了但后面失败怎么办?"——答案是事务回滚,所有区段要么全扣要么全不扣。追问"那 MySQL 的隔离级别是什么",答可重复读(RR),并说明条件更新靠的是行锁而不是隔离级别,这句话能体现你真的理解了原理,而不是背概念。
"为什么不用消息队列做超时释放?"——可以讲清楚取舍:引入 MQ 会增加部署复杂度和运维成本,在单机毕设场景下 Redis ZSet 已经能覆盖需求,同时保留了对 MQ 方案的调研和对比分析。承认取舍比强行上技术更打动人。
"余票查询为什么不用缓存?"——把第 2.2 节那套"查询侧可缓存、扣减侧不缓存、缓存 key 带区段维度"的逻辑讲一遍,顺便说明缓存一致性风险,基本就够了。
最后一个常被忽视的追问是"你怎么保证系统里卖出的票数量不超过座位数"。这时候别只讲代码,讲约束:唯一的扣减入口是带remaining > 0条件的 UPDATE,库存不会变成负数;所有释放操作都有状态机做闸门,不会重复加;建表时给remaining加上CHECK (remaining >= 0)约束作为兜底(MySQL 8.0.16 以上版本才真正生效,低版本会被忽略,这个细节说出来会加分)。
我个人的体会是,这类系统的难点从来不在写接口,而在把"一个座位可以卖给不同区段的人"这件事想透。想透了,表结构自然就出来了,扣减逻辑也不会写错。真正的坑往往藏在跨天时间、死锁顺序、释放幂等这些边角上,而恰恰是这些边角,决定了你的项目是"能演示"还是"能讲清楚"。