Spring Boot学生票订票系统:余票扣减、订单状态机与防超卖

发布时间:2026/11/2 19:10:35
Spring Boot学生票订票系统:余票扣减、订单状态机与防超卖

简介:这是一套面向Java Web课程设计、毕业设计与入门级项目实训的学生火车票订票系统源码,适合需要理解JAVAEE企业级应用完整开发链路的开发者。项目围绕学生购票场景,包含后台列车与城市信息维护、寒暑假半价优惠、团购购票以及前台车次查询、选座、填写信息与支付流程,采用Servlet/JSP与MVC分层组织业务,并配有数据库脚本用于初始化表结构,便于对照学习请求处理、页面渲染和订单数据管理。压缩包共318个文件,以jar依赖、java源码、class字节码、jsp页面为主,辅以js、css、图片资源和sql脚本,整体约24.27MB,目录中保留较完整的工程配置与分层结构。已有3253人学习,可作为课程设计参考,帮助梳理功能模块划分、后台管理逻辑与优惠规则实现,也能为二次开发和界面调整提供基础样本。

1. 学生票订票的痛点,不在“选座”而在“对账”

每年放假前两周,学生火车票订票系统这类需求就会被翻出来:辅导员手里一张 Excel,谁几号走、坐哪趟车、学生证优惠区间还剩几次,全靠人肉核对。学生改一次签,表就得重排一次,最后还容易撞车。这套 Java 版学生火车票订票系统把车次查询、学生资质校验、余票、下单、订单状态、退改签和后台车次维护串成一条能跑通的链路,学生端自己查自己订,后台只看数据和异常单。它适合两类人:一类是课程设计或毕设要交东西的,另一类是想练 Spring Boot 事务、并发扣减、状态机这套组合拳的开发者。技术栈是 Spring Boot + MyBatis + MySQL,前端 Vue3 + Element Plus,仓库里通常也留了 Thymeleaf 单页版本,跑不动前后端分离可以直接切。

2. 技术栈选型与工程目录:从 pom 到分包怎么摆

拿到这类订票系统源码,我第一件事不是点启动,而是先看 pom 和目录。因为订票系统的坑八成不在业务逻辑,而在依赖版本打架、配置没对齐、分层摆错导致事务失效。这一章先把“这堆文件是什么关系”讲清楚,后面读代码才不迷路。

2.1 后端技术栈与依赖取舍

常见的组合是 Spring Boot 2.x + MyBatis + MySQL 8 + Lombok,前端 Vue3 + Element Plus + axios,构建用 Maven。有些版本会带 Redis,用来做余票缓存或者下单令牌;如果只是想先把项目跑起来看效果,可以先不启 Redis,改成纯数据库扣减,等链路跑通再补。

层次选型在这类项目里的实际作用
Web 层Spring MVC提供车次查询、下单、订单列表等 REST 接口
业务层Spring Service +@Transactional下单、退票的事务边界都在这一层
持久层MyBatis(XML 写 SQL)余票扣减这类 SQL 需要手写条件更新,ORM 自动生成反而不好控
数据库MySQL车次、余票、订单、学生资质四类核心数据
定时任务Spring Task超时未支付订单自动取消、余票回滚
前端Vue3 + Element Plus学生端查询下单、管理端车次维护

为什么持久层选 MyBatis 而不是全自动 ORM?因为订票系统最关键的一句 SQL 是“余票大于 0 才扣减”,这种带条件的原子更新必须自己写,自动生成的 update 很容易写成先查再改,一并发就超卖。pom 里除了 starter,通常还需要这几块:

<dependencies> <!-- Web 与参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- MyBatis 与 MySQL 驱动 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 简化 getter/setter,实体类多的时候很省事 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

这里有个小取舍:Lombok 能省掉大量样板代码,但如果你的 IDE 没装插件,编译会报“找不到 getter”,新手第一反应往往是项目坏了。我一般会先确认 IDE 的 Annotation Processing 有没有打开,再怀疑代码。数据库驱动用mysql-connector-j而不是老的mysql-connector-java,是因为新版 MySQL 8 的驱动坐标已经换过,混用会出现驱动类找不到的报错。

2.2 目录结构与分层职责

这类项目的包结构基本是同构的,看懂一次,换个项目还是这套:

ticket-system ├── src/main/java/com/demo/ticket │ ├── TicketApplication.java # 启动类,@EnableScheduling 挂在这里 │ ├── common/ # Result 统一返回、BizException、常量 │ ├── config/ # CORS、MyBatis、拦截器配置 │ ├── controller/ # 只做参数接收与返回封装,不写业务 │ ├── service/ # 事务边界、优惠计算、状态流转 │ │ └── impl/ │ ├── mapper/ # 接口 + XML,一句 SQL 一个方法 │ ├── entity/ # 与表一一对应的实体 │ ├── dto/ # 入参,带 @NotNull 等校验注解 │ ├── vo/ # 出参,车次列表这种聚合结果 │ └── task/ # 订单超时取消、余票日切任务 ├── src/main/resources │ ├── application.yml │ ├── mapper/ # 与 mapper 接口同名的 XML │ └── sql/ # schema.sql、data.sql └── web/ # 前端 Vue 工程

分层最容易翻车的是“Controller 里写业务”。比如有人图省事,在 Controller 里先查余票再调 Mapper 扣减,这段代码没有事务包裹,中间抛异常就是余票扣了单没生成。血泪经验是:凡是涉及两张表以上写操作的,一律沉到 Service,并且方法上明确@Transactional(rollbackFor = Exception.class)。另外entity和vo别混用,车次列表页要展示“历时、余票、最低价”,这些字段分散在两张表里,直接拿实体返回,前端要么多请求一次,要么字段名对不上。

2.3 配置文件与启动类

配置里最需要盯的是数据源、MyBatis 的 XML 扫描路径、时区三件事:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/ticket_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.demo.ticket.entity configuration: map-underscore-to-camel-case: true # train_no -> trainNo log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 调试期打开,能看到真实 SQL ticket: order-timeout-minutes: 15 # 未支付订单保留时长,定时任务读这个值

serverTimezone必须和 JVM 时区一致,否则会出现“前端选的是今天,后端查出来是昨天”的玄学问题,后面排查章节还会提。map-underscore-to-camel-case打开之后,数据库的train_no才能自动映射到实体的trainNo,不开就得在 XML 里一条条写resultMap,纯体力活。

启动类本身很短,但注解别漏:

@SpringBootApplication @MapperScan("com.demo.ticket.mapper") // 不扫的话 Mapper 注入会报找不到 Bean @EnableScheduling // 订单超时取消任务靠它生效 public class TicketApplication { public static void main(String[] args) { SpringApplication.run(TicketApplication.class, args); } }

@EnableScheduling是最容易被漏掉的一个——任务类写得好好的,@Scheduled也标了,就是不执行,很多人会去怀疑 cron 表达式写错了。实际上启动类没开开关,任务压根没注册。CORS 配置则通常放在config包里,用WebMvcConfigurer的addCorsMappings放行前端开发端口,前后端分离的项目不配这一条,浏览器控制台会直接红一片。

3. 核心链路拆解:一次学生下单从查询到落库

把项目跑起来只是第一步,真正要看懂的是“学生点了确认下单之后,服务器上发生了什么”。这条链路串起了查询、库存扣减、订单生成、状态流转四件事,也是面试和答辩最容易被追问的部分。

3.1 车次查询与余票拼装

查询接口的入参是出发站、到达站、日期,出参是一个聚合了车次信息和余票的 VO:

@RestController @RequestMapping("/api/train") public class TrainController { @Resource private TrainService trainService; /** * @param from 出发站编码,例如 STA_A * @param to 到达站编码,例如 STA_B * @param date 乘车日期,格式 yyyy-MM-dd,由前端日期控件传入 */ @GetMapping("/search") public Result<List<TrainVO>> search(@RequestParam String from, @RequestParam String to, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { // 日期不允许查过去,这里做一次兜底,避免前端传错日期 if (date.isBefore(LocalDate.now())) { return Result.fail("只能查询今天及以后的车次"); } return Result.ok(trainService.searchByStation(from, to, date)); } }

Controller 只做参数兜底和结果包装,真正的拼装在 Service。对应 XML 里的一句联表查询:

<select id="searchByStation" resultType="com.demo.ticket.vo.TrainVO"> SELECT t.id AS trainId, t.train_no AS trainNo, t.start_time AS startTime, t.end_time AS endTime, t.duration_min AS durationMin, s.remain_seat AS remainSeat, s.price_hard AS priceHard, s.price_second AS priceSecond FROM train t JOIN train_stock s ON s.train_id = t.id AND s.stock_date = #{date} WHERE t.from_station_code = #{from} AND t.to_station_code = #{to} AND t.status = 1 <!-- 1=正常开行,0=停运 --> ORDER BY t.start_time ASC </select>

这段 SQL 的关键在train_stock这张按“车次 + 日期”拆分的余票表。如果直接把余票放train表上,同一趟车不同日期就没法区分,第二天要重置库存还得靠定时任务全表刷一遍,很容易出错。拆表之后,每天的库存是独立一行,日切只需要提前生成接下来 30 天的库存记录。duration_min存分钟数而不是直接存“3小时25分”,是为了前端好排序、好算,展示时再格式化成“3:25”这种样式。

3.2 下单主链路与余票扣减

下单是整个系统里唯一必须保证“要么全成功、要么全失败”的地方:

@Service public class OrderServiceImpl implements OrderService { @Resource private TrainStockMapper stockMapper; @Resource private TicketOrderMapper orderMapper; @Resource private StudentMapper studentMapper; @Override @Transactional(rollbackFor = Exception.class) public Long createOrder(Long studentId, Long trainId, LocalDate date, String passengerName, String idCard) { // 1. 学生资质校验:没认证、优惠次数用完都不允许买学生票 Student student = studentMapper.selectById(studentId); if (student == null || student.getAuthStatus() != 1) { throw new BizException("学生资质未通过认证,无法购买学生票"); } // 2. 条件更新扣库存,remain_seat >= 1 是防超卖的核心 int rows = stockMapper.decrStock(trainId, date); if (rows == 0) { throw new BizException("余票不足,请选择其他车次"); } // 3. 生成订单,状态从“待支付”开始 TicketOrder order = new TicketOrder(); order.setOrderNo(OrderNoGenerator.next(trainId)); order.setStudentId(studentId); order.setTrainId(trainId); order.setTravelDate(date); order.setPassengerName(passengerName); order.setIdCard(idCard); order.setStatus(OrderStatus.UNPAID.getCode()); order.setExpireTime(LocalDateTime.now().plusMinutes(15)); orderMapper.insert(order); return order.getId(); } }

参数说明一下:studentId从登录态里取,不要信前端传的值,否则谁都能拿别人的资质下单;date必须是LocalDate而不是字符串,避免格式不统一;expireTime在插入时就写死,定时任务直接按这个字段扫,比“下单时间 + 15 分钟”现算要稳,后面改超时时长也不用动 SQL。

扣库存的那条 update 长这样:

<update id="decrStock"> UPDATE train_stock SET remain_seat = remain_seat - 1 WHERE train_id = #{trainId} AND stock_date = #{date} AND remain_seat >= 1 </update>

注意这里没有“先 select 查余票,再 update 扣减”。两条语句之间哪怕只隔几毫秒,两个请求同时读到剩 1 张,就都会往下走,最后卖出去 2 张。条件更新的本质是把判断和修改压进一条 SQL,由数据库的行锁保证原子性,返回影响行数为 0 就代表没抢到。这是这类订票系统最值钱的一行代码,也是最容易被写错的一行。

3.3 订单状态机与超时释放

订单状态别用一堆 if-else 硬编码,先定义清楚有哪几个状态、允许怎么流转:

状态码含义可流转到
0待支付1 已支付、2 已取消
1已支付3 已退票、4 已完成
2已取消终态
3已退票终态
4已完成终态

超时释放的定时任务每隔一分钟扫一次过期订单:

@Component public class OrderTimeoutTask { @Resource private TicketOrderMapper orderMapper; @Resource private TrainStockMapper stockMapper; /** 每分钟执行一次,扫出“待支付且已过期”的订单 */ @Scheduled(cron = "0 * * * * ?") public void cancelExpiredOrders() { List<TicketOrder> expired = orderMapper.selectExpired(OrderStatus.UNPAID.getCode(), LocalDateTime.now(), 200); for (TicketOrder order : expired) { // 先改状态,改成功了才回滚库存,避免重复回滚 int rows = orderMapper.cancelIfUnpaid(order.getId(), OrderStatus.UNPAID.getCode()); if (rows > 0) { stockMapper.incrStock(order.getTrainId(), order.getTravelDate()); } } } }

这里的cancelIfUnpaid也必须是带状态条件的 update(WHERE id = ? AND status = 0),因为用户可能刚好在这一秒付款成功。如果定时任务先查出来再无条件改状态,就会把已支付的订单改成已取消,库存还多回滚一张,这种问题在对账时非常难查。selectExpired加了limit 200是防止积压时一次性拉太多,把内存和数据库连接打满,剩下的下一分钟继续处理。

4. 数据模型与关键 SQL:几张表撑起整个订票流程

订票系统的表不多,但每张表的字段设计都直接决定后面好不好改。学生票这个场景还多一层“资质”概念,优惠区间、剩余次数、认证状态都得有地方放,不然后面做退票、改签时会很别扭。

4.1 五张核心表与索引设计

表名作用关键字段
student学生账号与资质school_code、student_no、auth_status、discount_left
train车次基础信息train_no、from_station_code、to_station_code、status
train_stock按日期拆分的余票与票价train_id、stock_date、remain_seat
ticket_order订单主表order_no、student_id、status、expire_time
passenger乘车人(可代买)order_id、name、id_card、seat_type

建表时几个字段的细节值得说:

CREATE TABLE `train_stock` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `train_id` BIGINT NOT NULL COMMENT '车次ID', `stock_date` DATE NOT NULL COMMENT '乘车日期', `remain_seat` INT NOT NULL DEFAULT 0 COMMENT '剩余座位数', `price_hard` DECIMAL(10,2) NOT NULL COMMENT '硬座公布票价', `price_second` DECIMAL(10,2) NOT NULL COMMENT '二等座公布票价', PRIMARY KEY (`id`), UNIQUE KEY `uk_train_date` (`train_id`, `stock_date`), KEY `idx_date_remain` (`stock_date`, `remain_seat`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_train_date这个唯一索引是防重复生成库存的保险丝,定时任务多实例跑或者重跑时,插入会直接报唯一键冲突,而不是悄悄多出一行。idx_date_remain用于后台“今天还有哪些车次有余票”这类统计查询。金额字段一律DECIMAL,别用DOUBLE,学生票要打折,浮点数算出来的 0.1 + 0.2 那种尾差最后会体现在对账表上,财务看了会沉默。

订单表上则要有一个uk_order_no唯一索引,订单号生成器再好也可能撞,靠数据库兜底最省心。

4.2 关键 SQL:余票回滚与分页查询

除了扣减,另外两条高频 SQL 是库存回滚和个人订单分页:

<!-- 退票、超时取消都走这一条,注意是加回去 --> <update id="incrStock"> UPDATE train_stock SET remain_seat = remain_seat + 1 WHERE train_id = #{trainId} AND stock_date = #{date} </update> <!-- 学生端“我的订单”,按状态筛选 + 分页 --> <select id="pageByStudent" resultType="com.demo.ticket.vo.OrderVO"> SELECT o.id, o.order_no, o.status, o.travel_date, t.train_no, t.start_time, t.end_time, p.passenger_name, p.seat_type FROM ticket_order o JOIN train t ON t.id = o.train_id JOIN passenger p ON p.order_id = o.id WHERE o.student_id = #{studentId} <if test="status != null"> AND o.status = #{status} </if> ORDER BY o.create_time DESC LIMIT #{offset}, #{size} </select>

<if>这种动态 SQL 是 MyBatis 的常规用法,状态为空就查全部,不为空就按状态过滤。分页用LIMIT offset, size在数据量小的校园场景够用,数据量上来再换 PageHelper 或者游标。要注意offset是算出来的(pageNum - 1) * pageSize,在 Service 里算好再传,别让前端传偏移量,否则翻页参数被篡改就能扫全表。

4.3 学生资质校验与优惠价计算

学生票的价格不是简单地“打八折”,通常和席别、乘车区间都有关系。这类项目里比较稳妥的做法是把规则集中在一个方法里,别散落在各个 Service:

public BigDecimal calcPrice(BigDecimal publishPrice, String seatType, Student student, String fromCode, String toCode) { // 1. 资质异常直接按全价 if (student == null || student.getAuthStatus() != 1) { return publishPrice; } // 2. 优惠次数用完也按全价 if (student.getDiscountLeft() == null || student.getDiscountLeft() <= 0) { return publishPrice; } // 3. 乘车区间必须落在学生证登记的区间内,这里用站点编码前缀做粗校验 if (!student.getFromCode().equals(fromCode) || !student.getToCode().equals(toCode)) { return publishPrice; } // 4. 按席别给折扣,二等座和硬座各一档,其他席别不打折 BigDecimal rate; if ("SECOND".equals(seatType)) { rate = new BigDecimal("0.75"); } else if ("HARD".equals(seatType)) { rate = new BigDecimal("0.50"); } else { return publishPrice; } // 保留两位小数,四舍五入 return publishPrice.multiply(rate).setScale(2, RoundingMode.HALF_UP); }

参数里塞Student实体而不是散装字段,是为了后面加“研究生不享受”“每学年四次”这类规则时不用改方法签名。区间校验这里用了简单的起终点相等,实际业务里学生可能中途上车,那就要引入经由站表做区间包含判断,属于二次开发可以补的点。优惠次数在支付成功后扣减,在退票成功后加回,这两个动作必须和订单状态变更放在同一个事务里,否则会出现“票退了次数没回来”的客诉。

5. 跑起来与常见问题排查:本地起服务最容易翻车的几个点

源码下载下来跑不起来,九成不是代码写错,而是环境和配置没对齐。这一章按“先跑通再排错”的顺序来,遇到报错可以直接对照下面的现象找。

5.1 从零跑通的顺序

第一步先建库导数据,别急着改代码:

# 建库,字符集一定要 utf8mb4,否则学生姓名里的生僻字会变问号 mysql -uroot -p -e "CREATE DATABASE ticket_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 先结构后数据,顺序反了外键会报错 mysql -uroot -p ticket_db < src/main/resources/sql/schema.sql mysql -uroot -p ticket_db < src/main/resources/sql/data.sql # 确认库存数据生成了 mysql -uroot -p ticket_db -e "SELECT train_id, stock_date, remain_seat FROM train_stock LIMIT 5;"

第二步改application.yml里的账号密码,然后启动后端。看到 Tomcat 端口和Started TicketApplication就说明后端通了。第三步起前端:

cd web npm install npm run dev

前端启动后如果页面空白,先看浏览器 Network 里接口是不是 404,404 多半是代理没配或者后端端口不是 8080;如果是 CORS 报错,回到config包确认跨域配置有没有放行开发端口。前后端都起来之后,用data.sql里预置的测试账号登录,走一遍“查询—下单—支付—退票”,链路通了再开始改代码。

5.2 五条踩坑记录

第一条:查询当天的车次,列表永远是空的。现象是数据库里明明有数据,接口返回空数组。原因是 JDBC 连接串里的serverTimezone和本机时区不一致,LocalDate.now()算出来是今天,传到数据库被当成昨天。解决是把连接串固定成serverTimezone=Asia/Shanghai,同时确认 MySQL 服务端的time_zone也一致,用SELECT NOW();对比一下本机时间就知道差多少。

第二条:下单成功,但余票数字没变。现象是订单表多了记录,train_stock的remain_seat纹丝不动。原因通常是扣减方法写在了同一个类的另一个方法里,@Transactional自调用不走代理,事务没生效;另一种是 XML 里 update 的WHERE条件写错,比如stock_date = #{date}传了LocalDateTime,类型对不上导致匹配 0 行,但代码没判断返回值。解决是养成“扣减后必须检查影响行数”的习惯,同时把事务方法放在独立的 Service 里由外部调用。

第三条:定时任务到了点不执行。现象是待支付订单堆了一堆,expire_time早过了也没人取消。先查启动类有没有@EnableScheduling,再查任务类有没有被@Component扫到,最后看 cron 表达式——0 * * * * ?是每分钟第 0 秒执行,写成* * * * * ?就变成每秒执行,日志刷屏但功能看着“正常”。还有一个隐蔽原因:任务方法抛了异常被吞掉,Spring Task 默认单线程,一次异常可能影响后续调度,建议在方法体内自己 try-catch 并打日志。

第四条:学生资质认证后仍然显示“未认证”。现象是后台点了审核通过,学生端刷新还是未认证。原因一般是状态字段类型不一致,数据库存的是1,实体映射成了Boolean,或者前端判断写的是=== '1'而接口返回的是数字。解决是把状态字段统一成Integer并在常量类里定义,前端也按数字比较,别在这里玩类型体操。

第五条:中文站名在页面上显示成问号。现象是后台录入的站点名在接口返回里正常,页面上是乱码。检查顺序是:数据库表字符集、连接串的characterEncoding、响应头的Content-Type。最常见的是建库时图快用了默认字符集,后面改表也救不回已经存进去的数据,只能重导。我一般建库第一步就把utf8mb4写死在脚本里,省得后面返工。

6. 二次开发改造思路:从能跑到能演示

课程设计交上去和真正拿得出手,差的往往就是两三个功能点。这一章说几个改造成本低、但演示效果明显的方向,以及改完之后怎么验证自己没改坏。

6.1 三个高性价比的改造方向

方向一:候补队列。余票为 0 时,与其只提示“无票”,不如让学生排进候补队列。实现上可以用一张waitlist表,字段是train_id、stock_date、student_id、create_time,退票回滚库存成功时触发一次“按创建时间取队首、尝试直接生成订单”的逻辑。这个功能在答辩时很好讲,因为它天然带出了“库存回滚后要通知别人”这个业务闭环。

方向二:接口幂等。用户手抖连点两次确认下单,现在会生成两笔订单、扣两次库存。稳妥的做法是下单前先拿一个令牌:进入确认页时调/api/order/token拿一个 UUID 存进 Redis 或本地缓存,提交时带上,Service 里用SETNX抢占,抢不到直接返回“请勿重复提交”。没有 Redis 也可以用数据库唯一键模拟。

// 简化版:用订单表的唯一键兜底,同一个学生同一车次同一天只能有一笔待支付订单 int exist = orderMapper.countUnpaid(studentId, trainId, date); if (exist > 0) { throw new BizException("你已有一笔待支付订单,请先完成支付或取消"); }

方向三:并发压测与自证。改完扣减逻辑,别只用鼠标点两下就宣称没问题。写个简单的并发脚本,用固定线程数同时打同一个车次的下单接口,最后核对“订单数 + 剩余库存 = 初始库存”这个等式。对不上就说明还有超卖或者漏回滚,等式本身比任何口头保证都可靠。

改造点改动位置验证方式
候补队列新增waitlist表 + 退票回调退一张票,看候补第一人是否自动成单
接口幂等下单接口前置令牌校验并发提交同一令牌,只成功一次
库存对账定时任务统计订单数 + 余票 = 初始票数恒成立

6.2 验证方法与一个实际教训

每改完一处,我习惯按“单点 → 链路 → 并发”三层验证。单点就是直接调接口看返回字段对不对;链路是走完整流程,查询、下单、支付、退票各来一遍,看库存和优惠次数有没有同步;并发则是用脚本压同一个车次,压完立刻查对账等式。前两层能挡住八成低级错误,第三层专门抓那种“单测全绿、一上线就超卖”的问题。

有次我图快,改完退票逻辑只点了两下页面,看着库存加回去了就提交了。结果压测时发现,同一笔订单连续点两次退票,库存会加两次——退票接口没做状态校验,第一次退完状态已经是“已退票”,第二次还能进来。从那以后我每次动订单状态相关的代码,都强制走一遍“同一操作连点两次 + 并发两次”的验证,多花五分钟,能省掉一晚上的排查。这套学生火车票订票系统的源码结构不算复杂,把扣减、状态机、对账这三块吃透,剩下的都是体力活,希望帮到你。

本文还有配套的精品资源,点击获取