简介:这是一套面向高校学生、Java Web初学者及课程设计开发者的JAVA学生火车票订票系统源码包,围绕学生购票场景提供后台管理与前台订票流程,涵盖车次维护、学生优惠、团购下单和订单处理等模块,可用于课程设计、毕业设计参考或JavaEE入门实战。压缩包共318个文件、约24.27MB,主体由72个jar依赖、38个java源文件与38个class文件、33个jsp页面构成,并配有sql数据库脚本、js/css及gif/jpg等页面素材,便于还原项目结构、梳理MVC分层与数据库表关系。该资源已有3253人学习,适合按模块阅读后台车次管理、优惠票价计算、团购订单和前端购票交互的实现思路,也可作为调试排错与功能扩展的参考底稿。
1. 学生票订票系统拆开看:真正难的不是查车次,是扣余票那一秒
拿到 JAVA学生火车票订票系统 这类项目包,多数人的第一反应是把首页跑起来,看到车次能查、按钮能点、页面能跳,就觉得活儿干完了。真跑过一次带并发的东西才会明白,订票系统里查询是最不值钱的部分:车次表加两个索引,分页查询随便写都能跑通。能把人熬到半夜的是「同一趟车最后一张票,两个学生同时点确认」这一秒——余票扣成负数、订单生成两笔、支付回调和关单任务互相打架,这些问题在单人点鼠标时一个都暴露不出来。
这套系统要落的事情很具体:学生按学号绑定优惠区间,票价按席别系数打折,余票扣减不能超卖,订单超时未支付要自己关掉并还票,退票要能回滚库存。技术栈是 Java 后端的常规组合,难度不在框架,而在数据库那几条 UPDATE 的写法上。适合拿它当课设/毕设底座的人,也适合想完整练一次「库存 + 状态机 + 事务边界」链路的后端新手——把第 3 章的扣减逻辑吃透,比多写十个 CRUD 页面值钱。
2. 技术栈与表结构:五张核心表撑起一次下单
2.1 为什么选 Spring Boot + MyBatis,而不是 JSP 直连 JDBC
课设项目最常见的写法是 JSP + Servlet + 手写 JDBC,问题不在于老,而在于三件事会同时失控:SQL 散落在几十个 Servlet 里改一处要全局搜;事务靠setAutoCommit(false)手写,漏一次commit就锁住一行;连接用完忘记close(),压测到几十并发就报连接不可用。这套订票系统的下单链路至少要跨四张表写入,手写事务出错概率太高。
Spring Boot 解决的是启动和装配成本,MyBatis 解决的是「SQL 要看得见」这件事。订票业务里最关键的那几条语句——条件扣减、关单、余票归还——必须能手写、能手改、能贴到数据库客户端里单独跑验证,全自动 ORM 在这类场景反而碍事。依赖最小集如下:
<!-- 只列关键依赖,版本交给 parent 管理 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <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> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>连接串和 MyBatis 配置是整个项目最容易埋雷的地方,直接给一份能用的:
spring: datasource: # serverTimezone 必须显式指定,否则 DATETIME 读写会出现 8 小时偏差 url: jdbc:mysql://127.0.0.1:3306/train_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: ticket password: ticket_dev hikari: maximum-pool-size: 20 # 与 Tomcat 最大线程数保持同一量级 connection-timeout: 3000 # 拿不到连接 3 秒就失败,不要无限等 max-lifetime: 1800000 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # create_time -> createTime default-statement-timeout: 5 # 单条 SQL 5 秒超时,防拖死连接池serverTimezone不写会直接导致第 5 章第 1 条那个「订单时间差 8 小时」的坑;default-statement-timeout是后悔药,某条 SQL 突然走了全表扫描时,至少不会把 20 个连接全占死。maximum-pool-size不要照着网上抄成 100,连接数超过数据库max_connections之后报的错和连接池耗尽长得一模一样,排查时很容易跑偏。
2.2 五张核心表:库存粒度决定锁粒度
表设计里唯一需要动脑子的是余票表。有人的做法是在车次表上挂一个stock字段,一趟车所有席别、所有日期共用一列,结果是同一天不同席别互相锁,并发一上来全堵在同一个行锁上。正确做法是把库存拆到「车次 + 乘车日期 + 席别」这一粒度,一行对应一个可扣减单元,锁的范围就收到最小。
| 表名 | 作用 | 关键字段 | 索引与约束 |
|---|---|---|---|
| t_train | 车次基础信息 | train_no、from_station、to_station、depart_time | uk_train_no 唯一 |
| t_seat_stock | 席别余票,一行一单元 | train_id、travel_date、seat_type、price、stock、version | uk(train_id,travel_date,seat_type) |
| t_order | 订单主表 | order_no、user_id、total_amount、status、expire_time | uk_order_no、idx(status,expire_time) |
| t_order_item | 订单明细,一个乘客一行 | order_id、stock_id、passenger_name、seat_no、amount | idx_order_id |
| t_student | 学生资质与优惠区间 | user_id、school_no、from_mileage、to_mileage、verify_status | uk_user_id |
建表时有两处细节值得单独说。余票表的唯一键不只是防重,它同时是「同一车次同一天同席别只能有一行」的业务约束,插入重复数据会直接报错而不是悄悄多出一行库存。订单表的idx(status, expire_time)是给关单任务用的,没有它,定时任务每分钟都要全表扫一遍订单表。
CREATE TABLE t_seat_stock ( 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二等座 4一等座', price DECIMAL(10,2) NOT NULL COMMENT '全价,学生价在下单时计算', stock INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_train_date_seat (train_id, travel_date, seat_type), KEY idx_date_type (travel_date, seat_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no CHAR(20) NOT NULL, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3已退票', expire_time DATETIME NOT NULL COMMENT '创建时间+15分钟', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_status_expire (status, expire_time), KEY idx_user_create (user_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;price存全价而不是折后价,理由是优惠规则会变,学生资质状态也会变(比如当年次数用尽),把折扣计算留在下单那一刻,历史订单的金额才不会被后来的规则改动污染。version字段在纯条件更新写法里其实用不上,留着是为了后面做乐观锁对比实验,第 3 章会讲两种写法的差别。
2.3 学生资质校验放哪一层:三个位置,代价完全不同
学生票和普通票的差别全在资质上,所以校验位置的选择直接决定这套系统好不好维护。放注册时校验,用户在注册阶段就得填一堆区间信息,转化极差,而且资质是会过期的;放支付前校验,用户已经填完乘客、选完座位,最后一步被拒,体验最差;放下单入口校验,失败得最早,代价最小。
常见做法是在下单接口入口做一次拦截,同时在支付回调里再查一次verify_status,防止「下单时还有效、支付时已被撤销」这种窗口期。校验逻辑里最容易写错的是区间判断——很多实现直接拿站名字符串比大小,遇到「A 站」和「A 东站」这种包含关系就判错。
@Service public class StudentQualifyService { @Autowired private StudentMapper studentMapper; @Autowired private StationMapper stationMapper; /** * 校验学生资质是否覆盖本次行程 * @param userId 用户ID * @param fromCode 出发站编码 * @param toCode 目标站编码 */ public void check(Long userId, String fromCode, String toCode) { Student s = studentMapper.selectByUserId(userId); if (s == null || s.getVerifyStatus() != 1) { throw new BizException("学生资质未通过审核,无法购买学生票"); } // 用里程数比较,而不是站名或站编码的字符串大小 int fromMileage = stationMapper.selectMileage(fromCode); int toMileage = stationMapper.selectMileage(toCode); if (toMileage <= fromMileage) { throw new BizException("行程方向不合法"); } if (fromMileage < s.getFromMileage() || toMileage > s.getToMileage()) { throw new BizException("本次行程超出学生优惠区间"); } } }参数说明:fromMileage/toMileage来自站点表里的累计里程字段,是整数比较,天然处理了站名包含关系;verifyStatus用 1 表示审核通过,0 未提交、2 已驳回,三态比布尔值好扩展。这个方法只读不写,不加事务,避免把只读查询也塞进事务里去占连接。
3. 余票扣减与订单状态机:并发下不超卖的三种写法
3.1 「先查余票再扣」为什么必然翻车
先把错误写法摆出来,因为九成课设项目就是这么写的:
// 反例:查询与扣减是两条独立 SQL,中间存在窗口期 public void buy(Long stockId) { SeatStock stock = stockMapper.selectById(stockId); if (stock.getStock() <= 0) { throw new BizException("余票不足"); } // 覆盖式更新:把整个 stock 值写回去 stockMapper.updateStock(stockId, stock.getStock() - 1); }两个线程同时进来,都读到stock = 1,都通过判断,都写回0,最后卖出去两张票、库存显示 0。更隐蔽的是覆盖式更新的连带影响:线程 A 扣完之后线程 B 拿着自己读到的旧值写回,把 A 的扣减直接冲掉,库存反而变多了。这类 bug 在本地单人测试里一次都不会出现,压测时才会以「余票对不上」的形式冒出来。
| 方案 | 适用规模 | 代价 | 结论 |
|---|---|---|---|
| synchronized 加锁 | 单机单 JVM | 集群部署直接失效,锁住整个方法 | 只在本地演示用 |
| 数据库条件更新 | 中小规模,单库 | 依赖行锁,热点行有排队 | 这套系统的默认方案 |
| Redis 预扣 + 异步落库 | 大规模秒杀式场景 | 一致性要靠补偿,复杂度翻倍 | 有余力再上,不是课设必需 |
选第二种的理由很实在:订票系统的并发峰值和电商秒杀不是一个量级,单条 SQL 的条件更新足以扛住,而且失败时状态是确定的,不需要写对账补偿。
3.2 条件更新 + 唯一索引:把判断和扣减压进一条 SQL
核心思路是把「够不够扣」的判断交给数据库在同一条语句里完成,Java 层只看影响行数。MyBatis 的 mapper 就三行:
<!-- 影响行数为 0 代表余票不足,业务层据此抛异常回滚 --> <update id="deductStock"> UPDATE t_seat_stock SET stock = stock - #{num}, version = version + 1 WHERE id = #{id} AND stock >= #{num} </update>对应的服务层是整个项目最需要读懂的一段:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateDTO dto, Long userId) { // 1. 扣减余票:原子操作,返回 0 说明库存不够 int rows = stockMapper.deductStock(dto.getStockId(), dto.getPassengers().size()); if (rows == 0) { throw new BizException("余票不足,请刷新后重试"); } // 2. 写订单主表,先落主表拿到自增 ID Order order = new Order(); order.setOrderNo(snowflake.nextIdStr()); order.setUserId(userId); order.setStatus(OrderStatus.UNPAID.getCode()); order.setExpireTime(LocalDateTime.now().plusMinutes(15)); order.setTotalAmount(priceService.sum(dto)); orderMapper.insert(order); // 3. 写明细:uk_order_stock 唯一索引兜底同一订单重复占同一库存 for (Passenger p : dto.getPassengers()) { OrderItem item = buildItem(order.getId(), dto.getStockId(), p); orderItemMapper.insert(item); } return toVO(order); }几个参数和写法值得解释。stock >= #{num}是这条语句的灵魂,它让数据库在加锁读的那一刻就判断完,不会出现「读到 1、扣 2 变成 -1」;rollbackFor = Exception.class是为了让受检异常也触发回滚,默认配置只回滚运行时异常;expire_time在插入订单时就算好,而不是关单时用create_time现算,避免后期改规则导致历史订单被误关。
为什么不用SELECT ... FOR UPDATE?行锁必须放在事务里,一旦事务里有别的事——调远程接口、发消息、拼装复杂对象——锁的持有时间就从几毫秒涨到几百毫秒,热点车次的同一行会排起长队,比超卖更难查。条件更新只在 UPDATE 执行的那一瞬间持锁,窗口期短得多。
3.3 订单状态机:四个状态和一条关单 SQL
状态流转必须先定死,否则后面每个接口都在猜「这个订单现在算什么状态」。
| 当前状态 | 触发动作 | 目标状态 | 附带操作 |
|---|---|---|---|
| 0 待支付 | 支付回调成功 | 1 已支付 | 记录支付流水号 |
| 0 待支付 | 超过 expire_time | 2 已取消 | 归还余票 |
| 0 待支付 | 用户主动取消 | 2 已取消 | 归还余票 |
| 1 已支付 | 用户申请退票 | 3 已退票 | 归还余票,记录退款单 |
关单任务用分批 SQL 而不是一次全量更新,理由是长事务会阻塞其他写入:
-- 定时任务每分钟执行一次,一次最多处理 500 单 UPDATE t_order SET status = 2 WHERE status = 0 AND expire_time < NOW() LIMIT 500;这里有个顺序问题必须讲清楚:先改状态、再还库存,还是反过来?正确做法是先用UPDATE ... WHERE status = 0抢到状态变更权,影响行数为 1 的订单才去还库存。因为数据库条件更新本身是原子的,同一笔订单只会有一个执行者拿到 0 行受影响以外的结果,天然幂等;如果先还库存再改状态,中间崩溃就会导致「库存还了、订单还是待支付」,下一次任务再还一遍,库存凭空多出来。
归还库存同样走单条语句:
<update id="returnStock"> UPDATE t_seat_stock SET stock = stock + #{num} WHERE id = #{id} </update>对账时把「已取消订单数 × 每单人数」和「库存增加总量」对一遍,差值为 0 才算这条链路闭环。
4. 学生票打折与车次查询:折扣表、里程区间和一条下单链路
4.1 折扣别硬编码:规则进表,价格在下单时算
学生票的折扣不是一乘了之,不同席别系数不同,还有年度次数上限。硬编码在 Java 里的后果是每年规则一调整就得改代码重新发版,所以做成一张规则表:t_discount_rule(seat_type, rate, year_limit, max_amount)。
| 席别 | 折扣系数 | 年度上限 | 备注 |
|---|---|---|---|
| 硬座 | 0.50 | 4 次 | 折后价不得低于 0.01 元 |
| 硬卧 | 0.75 | 4 次 | 仅按硬座部分打折的业务按配置项处理 |
| 二等座 | 0.75 | 4 次 | 默认席别 |
| 一等座 | 1.00 | 0 次 | 不参与学生优惠 |
计算逻辑拆成独立服务,下单和试算接口共用同一份代码,避免「列表页显示 60、下单变成 80」这种客诉:
public BigDecimal calcStudentPrice(BigDecimal fullPrice, int seatType, Student student) { DiscountRule rule = ruleMapper.selectBySeatType(seatType); if (rule == null || rule.getRate().compareTo(BigDecimal.ONE) >= 0) { return fullPrice; // 无优惠政策 } if (student.getUsedTimes() >= rule.getYearLimit()) { return fullPrice; // 年度次数已用尽 } BigDecimal price = fullPrice.multiply(rule.getRate()) .setScale(2, RoundingMode.HALF_UP); // 兜底:折后价不允许低于最低票价,也不允许高于全价 BigDecimal min = new BigDecimal("0.01"); return price.max(min).min(fullPrice); }RoundingMode.HALF_UP必须显式写,setScale(2)不指定模式会抛异常;max(min).min(fullPrice)这一串看着啰嗦,但它挡住了两个真实故障:规则表配错成 1.2 倍时卖出比全价还贵的票,以及折扣后出现 0 元订单。
4.2 车次查询:索引对了,GROUP BY 才不会拖后腿
列表页要展示「最低票价」和「总余票」,天然要聚合,而聚合很容易把索引废掉。索引按查询条件的最左前缀建:
CREATE INDEX idx_from_to_time ON t_train (from_station, to_station, depart_time);查询语句:
SELECT t.id, t.train_no, t.depart_time, t.arrive_time, MIN(s.price) AS min_price, SUM(s.stock) AS total_stock FROM t_train t JOIN t_seat_stock s ON s.train_id = t.id AND s.travel_date = #{travelDate} WHERE t.from_station = #{fromCode} AND t.to_station = #{toCode} AND t.depart_time BETWEEN #{start} AND #{end} GROUP BY t.id, t.train_no, t.depart_time, t.arrive_time ORDER BY t.depart_time LIMIT #{offset}, #{size};GROUP BY后面的字段要写全,只写t.id在开了ONLY_FULL_GROUP_BY的实例上直接报错;LIMIT深分页到几万条之后会明显变慢,页面上限制最多翻 20 页是最省事的做法。余票展示允许有几十秒的延迟,但下单必须回源数据库,这一点第 5 章会单独踩一次。
4.3 下单接口的完整链路:幂等键放请求头
从 Controller 到 Service 的链路要短,校验全放在 Service 里,Controller 只做参数绑定和幂等键透传:
@PostMapping("/api/orders") public Result<OrderVO> create(@RequestBody @Valid OrderCreateDTO dto, @RequestHeader("Idempotency-Key") String idemKey, @RequestAttribute("userId") Long userId) { // 幂等键由前端在进入确认页时生成,重复提交返回同一笔订单 return Result.ok(orderService.createOrder(dto, userId, idemKey)); }Service 内部的执行顺序是有讲究的:先查幂等键(命中直接返回上次结果)→ 校验学生资质 → 计算金额 → 扣库存 → 写订单 → 写幂等记录。顺序反过来会出问题:先算金额再校验资质,白白浪费一次计算;先写幂等记录再扣库存,扣减失败后幂等记录残留,用户重试会被直接拒绝,只能等键过期——这个坑我吃过一次,表现是「用户一直提示重复提交,但订单列表里什么都没有」。
OrderCreateDTO上的@Valid要配@NotNull、@Size(max = 5)之类约束,一个订单最多五个乘客这条业务规则写在注解里比写在 if 里更难被绕过。
5. 避坑与排查:上线前我踩过的五个坑
5.1 本地跑得好好的,部署后订单时间差 8 小时
现象:本地expire_time显示 15 分钟后,部署到服务器后订单刚创建就被关单任务判定过期,日志里时间是未来时间。
原因:连接串没写serverTimezone,驱动按 UTC 解释DATETIME;容器基础镜像时区是 UTC,而代码里LocalDateTime.now()拿的是 JVM 默认时区,两边差 8 小时。
解决:连接串统一serverTimezone=Asia/Shanghai,启动参数加-Duser.timezone=Asia/Shanghai,字段类型统一用DATETIME不用TIMESTAMP(后者会随会话时区转换)。改完写一条断言型单测:插入一条expire_time = now + 15min的订单,立刻查关单 SQL,必须返回 0 行。
5.2 列表页显示还有 3 张票,点进去提示余票不足
现象:车次列表显示余票 3,进详情页下单直接报「余票不足,请刷新后重试」,刷新列表还是 3。
原因:列表页的total_stock走了 60 秒本地缓存,缓存只在查询时写入、扣减后没有失效,展示值和真实库存脱节。
解决:缓存只用于列表展示,下单永远走数据库条件更新;扣减成功后主动删掉对应 key;缓存 TTL 压到 10 秒以内。更进一步的做法是把余票展示改成「有票 / 无票」两态,从根上消除用户对具体数字的预期落差。
5.3 压测到 150 并发,连接池打满,所有接口一起超时
现象:单接口压测正常,混合场景压测时 Hikari 报Connection is not available, request timed out,连不涉及下单的查询接口也超时。
原因:下单事务里调了一次短信通知接口,超时时间 3 秒,热点车次的行锁被持有 3 秒以上,后续请求全在等锁、等连接,连接池 20 个连接瞬间耗尽。
解决:事务内只做数据库操作,通知类动作放到事务提交之后。用TransactionSynchronizationManager.registerSynchronization的afterCommit回调,或者直接发一条本地消息表记录,由独立线程池消费。改完之后同一行锁的持有时间从 3000ms 降到 5ms 以内。
5.4 跨站行程被判「超出优惠区间」
现象:学生资质区间登记的是 A 站到 C 站,买 A 站到 B 站(B 在 A、C 之间)被系统拒绝。
原因:区间判断用了站名字符串比较,"A站" > "B站"这类比较结果和实际里程顺序无关;换成站编码比较同样不对,编码是按录入顺序分配的。
解决:站点表加mileage累计里程字段,判断改成整数区间包含关系(见 2.3 的代码)。补一条覆盖用例:区间 A–C,分别测 A–B、B–C、A–C、C–A 四种行程,前三种通过、最后一种拒绝。
5.5 网络抖动重试,一个学生生成两笔订单
现象:弱网环境下用户连点两次提交,订单列表里出现两笔内容完全相同、状态都是待支付的订单,两笔都扣了库存。
原因:接口无幂等设计,前端按钮也没做置灰;数据库层只有order_no唯一,而两次请求生成的订单号不同,唯一索引拦不住。
解决:三层一起上。前端进入确认页时生成一个 UUID 放进Idempotency-Key请求头,提交期间按钮禁用;服务端把键写入t_idempotent表并加唯一索引,命中直接返回上次结果;数据库层再给t_order_item加uk_order_stock(order_id, stock_id)兜底同一订单重复占位。
5.6 排查顺序:先看行锁等待,再看慢查询
超时类问题不要一上来就翻业务代码,按这个顺序走一遍通常十分钟内能定位:
-- 1. 当前是否有行锁等待,waiting 的那一方就是被堵住的请求 SELECT * FROM performance_schema.data_lock_waits; -- 2. 看最近一次死锁的完整现场,包含两条互相等待的 SQL SHOW ENGINE INNODB_STATUS;# 3. 慢查询按总耗时排序,取前 20 条看有没有缺索引的 UPDATE mysqldumpslow -s t /var/log/mysql/slow.log | head -20如果data_lock_waits里长期有记录,基本可以确定事务里混进了非数据库操作;如果慢查询里出现全表扫描的 UPDATE,多半是WHERE条件没走到唯一索引上。这两类问题在压测阶段暴露得最充分,等上线后被用户撞见,排查成本要高一个数量级。
6. 压测验收:用 200 并发抢同一趟车,盯住三个指标
功能跑通不等于能交付。这套系统我验收时只做一件事:把 200 个线程全部压到同一个stock_id上,跑 3 分钟,然后看三个数。
准备阶段把库存改成 50,压测脚本用 JMeter 起 200 线程、Ramp-up 5 秒,请求体里的stockId全部写死成同一个值,Idempotency-Key每个线程独立生成——这一点很关键,如果所有线程共用一个幂等键,测的就不是并发扣减而是幂等命中率了。
跑完立刻执行这条校验 SQL,超卖数必须是 0:
-- 成功售出的票数不能超过初始库存 SELECT s.id, s.stock AS remain_stock, COUNT(i.id) AS sold_count, 50 - s.stock AS should_sold FROM t_seat_stock s LEFT JOIN t_order_item i ON i.stock_id = s.id LEFT JOIN t_order o ON o.id = i.order_id AND o.status IN (0, 1) WHERE s.id = #{stockId} GROUP BY s.id;remain_stock必须等于50 - sold_count,差一行就说明扣减逻辑有洞。三个验收指标我给的是:
| 指标 | 阈值 | 不达标时先看哪里 |
|---|---|---|
| 超卖条数 | 0 | 扣减 SQL 是否带stock >= num条件 |
| 下单接口 P95 | < 800ms | 事务内是否有非数据库操作,行锁持有时间 |
| 订单成功率 | > 99% | 连接池大小、default-statement-timeout是否过短 |
| 关单延迟 | < 60s | 定时任务周期、idx(status, expire_time)是否命中 |
还有一个容易被忽略的验证点:压测结束后等 16 分钟,让所有订单过期,再跑一次库存核对。如果这时remain_stock没有回到 50,问题一定出在关单还库存那段——要么状态 CAS 没写对,要么还库存的num取错了人数。
这套系统的完整源码、建表脚本、压测脚本和这份核对 SQL 都在包里,照着第 2 章的表结构和第 3 章的扣减语句跑一遍,本地就能复现全部场景;想省事的话,直接拿压测脚本当冒烟测试用,改完任何一行扣减逻辑都跑一次。
从那以后我每次动库存相关的代码,不管改动多小,都会强制走一遍「200 并发 + 超卖核对 + 关单核对」这三步,哪怕只是把>改成>=。因为这个位置出错从来不是报错,而是安安静静地多卖出去几张票,等到对账时才发现。希望帮到你。
本文还有配套的精品资源,点击获取