☰
Java学生火车票订票系统:并发余票扣减与订单状态机实战
2026/11/2 11:26:55 网站建设 项目流程

简介:这是一套面向高校学生、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_timeuk_train_no 唯一
t_seat_stock席别余票,一行一单元train_id、travel_date、seat_type、price、stock、versionuk(train_id,travel_date,seat_type)
t_order订单主表order_no、user_id、total_amount、status、expire_timeuk_order_no、idx(status,expire_time)
t_order_item订单明细,一个乘客一行order_id、stock_id、passenger_name、seat_no、amountidx_order_id
t_student学生资质与优惠区间user_id、school_no、from_mileage、to_mileage、verify_statusuk_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_time2 已取消归还余票
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.504 次折后价不得低于 0.01 元
硬卧0.754 次仅按硬座部分打折的业务按配置项处理
二等座0.754 次默认席别
一等座1.000 次不参与学生优惠

计算逻辑拆成独立服务,下单和试算接口共用同一份代码,避免「列表页显示 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 并发 + 超卖核对 + 关单核对」这三步,哪怕只是把>改成>=。因为这个位置出错从来不是报错,而是安安静静地多卖出去几张票,等到对账时才发现。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询