
简介面向Java开发者及对铁路票务系统技术实现感兴趣的工程技术人员这份方案围绕12306转移仓库管理流程的优化展开提供从系统设计到源码落地的完整参考实现。项目以Java为主要开发语言借助Python与Shell脚本处理自动化任务覆盖数据处理、接口交互、可视化展示及容器化部署等环节适合用于学习大型业务系统的模块划分与多语言协作实践。压缩包约59.05MB共86个文件以60个Python脚本、Markdown与文本说明、HTML页面及Docker编排文件为主辅以PNG/JPEG界面与流程图像便于对照代码查看页面效果和架构示意。方案中不仅包含核心业务逻辑实现还提供Dockerfile与docker-compose.yml可帮助读者快速搭建一致运行环境降低部署门槛。目前已有240人学习浏览对于希望掌握多语言融合开发、理解票务场景中高并发与数据处理思路的开发者是一份具有直接参考价值的项目资料。1. 一张票在两个仓库之间“搬家”才是 12306 抢票背后的真正瓶颈春运买过 Z 字头直达车的人都有印象放票瞬间全程票显示 0可刷两分钟再查某一段区间又冒出几张。这不是 12306 的缓存玄学而是票额在“全程可售仓”和“区间可售仓”之间做了转移。所谓基于 Java 的 12306 转移仓库设计就是把每个车次每个日期的座席额度拆成若干票额池再通过一套可 trace 的转移流程处理放票、退票、改签、区间复用和运能调拨。真正难的不是写一个 CRUD而是确保同一张席位在任意时刻只属于一个仓库、一个区间段且并发扣减时余票不为负。这篇文章会把仓库模型、转移状态机、并发扣减和排障逐一拆开每段都有可直接复用的表结构和 Java 代码。适合正在做售票类系统、库存拆分设计或者拿 Java 做课设却不想停留在增删改查的读者。2. 转移仓库的数据模型用“车次-日期-区间”把票额池切成可搬家的格子2.1 票不是库存可售区间才是先搞懂仓库里存的到底是啥很多 Java 新手做购票系统第一反应是建一张票表每个座席一条记录买到就 UPDATE status。这在小流量下没毛病但放到 12306 这个量级会导致两个问题一是同一趟车被拆成上百个区间后每一个区间的剩余量都要实时计算全表扫描不现实二是退票时“释放”的不只是一张票而是一段可售票额如果不把库存抽象出来退票逻辑会和购票逻辑绞成一团乱麻。所以转移仓库里存的不是“票”而是“可售区间 数量”。一趟车次从 A 到 D会按站点拆出 A-B、B-C、C-D、A-C、B-D、A-D 等若干区间每个区间都有独立的可售额度。一张全程票卖出去消耗的是 A-D 的额度乘客退掉这张票回到仓库里的是 A-D 的 1 个额度而不是某个座位的“闲忙状态”。这里想强调一个设计理念库存是“事务”不是“状态”。可售额度只在一次扣减和一次回补之间变化所有变动都要记录在案。当全程仓和区间仓之间有拆票、拼票需求时扣减一个仓、回补另一个仓必须发生在同一个事务里否则就会出现“全程票退回来两张短途票也同时存在”的超售现象。2.2 表结构设计三张表和一条转移日志先给出一套最小可落地的表结构不依赖任何中间件直接用 MySQL 就能跑。核心是三张表车次仓表、库存明细表、转移日志表。-- 票额仓库表一个车次日期方向 对应一个仓库 CREATE TABLE train_warehouse ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 仓库ID, train_code VARCHAR(32) NOT NULL COMMENT 车次如G1234, run_date DATE NOT NULL COMMENT 发车日期, direction TINYINT NOT NULL COMMENT 1-下行 2-上行, warehouse_no VARCHAR(64) NOT NULL COMMENT 仓库编号业务唯一, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-可售 2-冻结 3-停售, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_wh_no (warehouse_no), KEY idx_train_date (train_code, run_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT票额仓库表; -- 可售区间库存表拆到具体区间 CREATE TABLE warehouse_stock ( id BIGINT NOT NULL AUTO_INCREMENT, warehouse_id BIGINT NOT NULL COMMENT 所属仓库ID, from_station VARCHAR(64) NOT NULL COMMENT 始发站, to_station VARCHAR(64) NOT NULL COMMENT 终到站, stock_count INT NOT NULL DEFAULT 0 COMMENT 当前可售数量, sold_count INT NOT NULL DEFAULT 0 COMMENT 已售数量, version INT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_wh_interval (warehouse_id, from_station, to_station) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT区间库存表; -- 转移日志表每一次扣减、回补、调拨都写一条 CREATE TABLE warehouse_transfer_log ( id BIGINT NOT NULL AUTO_INCREMENT, transfer_no VARCHAR(64) NOT NULL COMMENT 转移单号幂等键, warehouse_id BIGINT NOT NULL, source_stock_id BIGINT DEFAULT NULL COMMENT 来源库存ID扣减方, target_stock_id BIGINT DEFAULT NULL COMMENT 目标库存ID回补方, change_type TINYINT NOT NULL COMMENT 1-放票 2-退票回库 3-区间复用 4-运能调拨 5-改签, change_count INT NOT NULL COMMENT 变动数量正数为增加负数为扣减, order_id VARCHAR(64) DEFAULT NULL COMMENT 关联订单号可空, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_transfer_no (transfer_no), KEY idx_warehouse (warehouse_id), KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存转移日志;对应的 Java 实体类不必全部贴出其中最关键的是WarehouseStock和TransferLog两个类。change_count字段我刻意设计成带符号正值回补、负值扣减这样对账时只需要按warehouse_id分组求和就能快速算出仓库余额是否和库存表一致。这套表结构的取舍在于把“仓库”和“库存明细”分开而不是直接在库存明细上打仓库标记是为了支持“冻结仓库”这个动作。比如某趟车因天气原因停运可以先把整个仓库置为停售而不必逐条修改所有区间等到复运或调拨时再恢复并且可以通过日志表回放整个操作序列。2.3 分库分表与仓库编号为什么按车次分而不是按用户分真实的高并发场景下单表很快扛不住。常见的分流方式是“按车次哈希分库”。以train_code末尾两位做分片键可以把同一车次的所有数据钉在同一个库内避免跨库查询。分片规则示例public String pickDataSource(String trainCode) { // 取车次数字部分的最后两位作为分片下标 String digits trainCode.replaceAll(\\D, ); int shard Integer.parseInt(digits.substring(digits.length() - 2)) % SHARD_COUNT; return ds_ shard; }这么选分片键的理由很直接一个用户可能买多趟车一次下单只涉及一趟车把同一趟车的数据放同一库事务天然闭环。如果按用户 ID 分片一趟热门车的库存就会散落在十几个库里一次扣减要跨库事务复杂度成倍上升。 电商按用户分片很常见但票务库存是典型的“热点集中型”数据必须按车次收敛。仓库编号我建议直接拼成${trainCode}_${runDate}_${direction}比如G1234_20250215_1。字符串略长但可读性好排查问题时拿车次和日期就能直接定位仓。不要用自增 ID 暴露给外部否则爬虫可以遍历出所有仓来窥探余票策略。3. 转移流程的核心实现退票回库、区间复用、运能调拨三种场景3.1 退票回库状态机从“已售”到“可售”要过三关退票不是把订单状态改成“已退”就结束了。落库的顺序错了会给后面留下大坑比如先回补库存再更新订单状态结果订单更新失败乘客收到退款却查不到退票记录票额已经回到池子里一次事故就这么产生。我一般把退票回库实现成一个三步状态机订单状态改为“退票中”此时对外不售回补库存写入转移日志订单状态改为“已退”释放座位记录。Transactional(rollbackFor Exception.class) public void refundTicket(RefundRequest request) { // 第一步锁定订单 Order order orderMapper.selectByOrderIdForUpdate(request.getOrderId()); if (order null || !order.canRefund()) { throw new BizException(ORDER_NOT_REFUNDABLE); } order.setStatus(OrderStatus.REFUNDING); orderMapper.updateById(order); // 第二步回补库存 WarehouseStock stock stockMapper.selectByWarehouseAndInterval( order.getWarehouseId(), order.getFromStation(), order.getToStation()); int updated stockMapper.increaseStockWithVersion(stock.getId(), 1, stock.getVersion()); if (updated 0) { throw new BizException(STOCK_UPDATE_CONFLICT); } // 第三步写转移日志transfer_no 做幂等 TransferLog log TransferLog.builder() .transferNo(request.getRefundNo()) .warehouseId(order.getWarehouseId()) .sourceStockId(stock.getId()) .changeType(ChangeType.REFUND) .changeCount(1) .orderId(order.getOrderId()) .build(); transferLogMapper.insert(log); order.setStatus(OrderStatus.REFUNDED); orderMapper.updateById(order); }这段代码的关键点是第一步先selectByOrderIdForUpdate把订单锁住防止退票请求被重复提交。increaseStockWithVersion是带乐观锁的更新虽然退票并发冲突概率低但拦一道没坏处。中途任何一步抛异常整个事务回滚不会出现“库存回补了但订单状态没更新”的中间态。 我在第二家公司踩过这个坑当时就是偷懒没加事务半夜收到告警余票比座位数还多。3.2 区间复用全程票断票时席位如何裂变转移区间复用是整个转移仓库里最聪明也是最容易写崩的功能。逻辑说穿了很简单一趟车 A 到 D全程票卖完了但 A 到 B 没人坐B 到 C 没人坐就把全程座位的“一段使用权”拆成两个区间卖。拆票过程在数据库层面就是两条库存线一增一减Transactional(rollbackFor Exception.class) public void splitFullTripIntoSegments(FullTripSplitRequest req) { // 全程仓扣减 1 Stock fullStock stockMapper.selectByWarehouseAndIntervalForUpdate( req.getWarehouseId(), req.getFullFrom(), req.getFullTo()); if (fullStock.getStockCount() 0) { throw new BizException(FULL_STOCK_EMPTY); } stockMapper.decreaseStock(req.getFullStockId(), 1); // 分段仓各回补 1 Stock segStock1 stockMapper.selectByWarehouseAndIntervalForUpdate( req.getWarehouseId(), req.getSegFrom(), req.getSegMid()); Stock segStock2 stockMapper.selectByWarehouseAndIntervalForUpdate( req.getWarehouseId(), req.getSegMid(), req.getSegTo()); stockMapper.increaseStock(segStock1.getId(), 1); stockMapper.increaseStock(segStock2.getId(), 1); // 写日志一条拆票记录对应多条 detail String transferNo genTransferNo(SPLIT, req.getTicketId()); transferLogMapper.insert(TransferLog.builder() .transferNo(transferNo).warehouseId(req.getWarehouseId()) .sourceStockId(fullStock.getId()).changeType(ChangeType.INTERVAL_REUSE) .changeCount(-1).build()); transferLogMapper.insert(TransferLog.builder() .transferNo(transferNo).warehouseId(req.getWarehouseId()) .targetStockId(segStock1.getId()).changeType(ChangeType.INTERVAL_REUSE) .changeCount(1).build()); transferLogMapper.insert(TransferLog.builder() .transferNo(transferNo).warehouseId(req.getWarehouseId()) .targetStockId(segStock2.getId()).changeType(ChangeType.INTERVAL_REUSE) .changeCount(1).build()); }注意这里我用selectByWarehouseAndIntervalForUpdate把两行库存都锁住后再做增减是为了防止两个拆票事务同时读到全程仓还有 1 张、各自都拆出短途票的情况。SELECT FOR UPDATE 配合事务隔离级别 READ_COMMITTED能保证同一时刻只有一个事务在拆同一个全程区间。区间复用不是每次拆票都成功的真正的生产系统里有一个前提所拆的座位必须没有被其他占用逻辑挂起。比如某张票已经进入改签流程座位状态是“已锁定”此时拆票要先确认座位没有被锁。这个校验放在库存操作之前也可以在座位表上再加一个status字段拆票时条件更新座位状态为“试营业”。3.3 运能调拨跨车次仓库的“调拨单”不能少运能调拨简单说就是某趟车客流少、另一趟车爆满把前者的部分席位额度划给后者。这比区间复用更重因为它跨仓库、跨车次可能还跨日期一旦出错影响面更大。 调拨流程建议走“预占 → 审批 → 生效 → 回滚”四段式不能像退票那样直接改库存。落库时的原则是调拨单是主记录两边仓库的库存变动都挂在调拨单下面。一张调拨单产生两条转移日志来源仓扣减、目标仓增加。手动调拨必须带过期时间比如未来 72 小时内有效过期自动回滚。Transactional(rollbackFor Exception.class) public void executeTransfer(TransferApply apply) { WarehouseStock source stockMapper.selectByWarehouseAndIntervalForUpdate( apply.getSourceWarehouseId(), apply.getFromStation(), apply.getToStation()); WarehouseStock target stockMapper.selectByWarehouseAndIntervalForUpdate( apply.getTargetWarehouseId(), apply.getFromStation(), apply.getToStation()); int sourceRemain source.getStockCount(); if (sourceRemain apply.getCount()) { throw new BizException(SOURCE_STOCK_NOT_ENOUGH); } stockMapper.decreaseStock(source.getId(), apply.getCount()); stockMapper.increaseStock(target.getId(), apply.getCount()); transferLogMapper.insert(TransferLog.builder() .transferNo(apply.getTransferNo()).warehouseId(source.getWarehouseId()) .sourceStockId(source.getId()).changeType(ChangeType.TRANSFER) .changeCount(-apply.getCount()).build()); transferLogMapper.insert(TransferLog.builder() .transferNo(apply.getTransferNo()).warehouseId(target.getWarehouseId()) .targetStockId(target.getId()).changeType(ChangeType.TRANSFER) .changeCount(apply.getCount()).build()); transferMapper.updateStatus(apply.getTransferNo(), TransferStatus.EFFECTIVE); }调拨最怕的是“调出去了目标仓没接收”。原因往往出现在目标仓的区间和来源仓不一致来源仓的 A-B 和 A-C 不是同一个区间系统没有配置对应关系。 所以在执行调拨前一定要校验两头区间编码一致或者维护一张“站段映射表”来做坐标转换。调拨的执行时间也要谨慎发车前一小时以内原则上不建议调拨。乘客已经买了票调拨出去的席位如果已销售就会发生已售席位被调走、乘客上车没座的严重事故。我见过一次线上问题就是在发车前四十分钟调拨导致十几个乘客改签被投诉到铁路部门。血的教训。4. 并发扣减与库存一致性给“转移”加上不超卖的保险丝4.1 悲观锁还是乐观锁先看你的抢票峰值长什么样刚到一家公司时团队争论了很久到底用悲观锁还是乐观锁最后两套都写过。结论是没有绝对的对错看热点集中度。12306 这种场景同一趟热门车次在放票瞬间会有几十万请求打到同一个仓库热点极度集中。此时用乐观锁每次 UPDATE 失败就重试会导致大量无效 SQL 和连接占用数据库 CPU 直接飙红。悲观锁SELECT FOR UPDATE的问题是锁持有时间可能过长但至少能保证队列化执行数据库不会被打瘫。 所以放票接口我倾向于先用悲观锁兜底再叠加 Redis 预扣减做流量削峰。如果是一般的余票展示、退票回库并发量没那么极端乐观锁就够用。4.2 Redis 预扣减 数据库兜底两级减库存的实现抢票的核心诉求是“手快有、手慢无”绝大多数请求到不了支付环节只是先锁个位置。因此常见做法是把库存热度最高的车次放进 Redis扣减先走内存再异步同步到 MySQL。 但内存扣减不可靠——Redis 一旦宕机库存数据会回滚到不一致状态。所以数据库里的库存才是最终真相Redis 只是挡在第一层的缓存。伪代码如下public boolean preDeductStock(String trainCode, String runDate, String interval, int count) { String key stock: trainCode : runDate : interval; Long remain redisTemplate.opsForValue().decrement(key, count); if (remain 0) { // 扣超了回补然后拒绝 redisTemplate.opsForValue().increment(key, count); return false; } // 异步发送消息真正落库 mqSender.sendDeductMessage(trainCode, runDate, interval, count); return true; }Redis 的decrement是原子的多线程抢票不会把值扣成负数。但这里有个细节remain 0判断之后立刻回补回补操作本身也可能并发极端情况下两个线程同时扣到 -1、同时回补数值就会漂移。更稳妥的做法是用 Lua 脚本包装“检查-扣减-回补”三段逻辑保证原子性。Lua 脚本示例local remain redis.call(GET, KEYS[1]) if remain false then return -2 end if tonumber(remain) tonumber(ARGV[1]) then redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 else return -1 end这个脚本在 Redis 里是单线程执行的不会被其他命令插入所以不存在检查后、扣减前被抢走库存的问题。落库的消息队列做最终一致数据库更新失败会有对账任务兜底每天的凌晨跑一次全量对账差多少补多少。4.3 用版本号做乐观锁的三次重试模板当并发量没到压垮数据库的程度乐观锁实现更轻。核心是 UPDATE 时带上版本号影响行数为 1 才算成功然后按重试次数循环。这里给一个三次重试模板。public boolean tryDeductWithRetry(Long stockId, int count, int maxRetry) { for (int i 0; i maxRetry; i) { WarehouseStock stock stockMapper.selectById(stockId); if (stock.getStockCount() count) { return false; } int rows stockMapper.deductWithVersion(stockId, count, stock.getVersion()); if (rows 1) { return true; } // 版本冲突睡一小段随机时间后重试避免同时重试造成新热点 Thread.sleep(ThreadLocalRandom.current().nextInt(10, 30)); } return false; }对应的 SQLUPDATE warehouse_stock SET stock_count stock_count - #{count}, version version 1 WHERE id #{stockId} AND version #{version} AND stock_count #{count}这个 SQL 的巧妙之处是把“检查余票”和“扣减”合并成一个原子操作stock_count #{count}作为条件天然防止超卖。 乐观锁重试最怕的是在峰值期所有失败请求同时重试形成新的流量尖峰所以重试前要加随机延时把重试请求打散到一个时间窗口里。5. 踩坑与排查仓库转移最常见的 5 个翻车现场5.1 余票对不上账库存表和日志表求和结果不一致现象 对账任务跑出来某车次在 MySQL 里的总库存和日志表的 sum(change_count) 差了十几张但线上没有任何报错。原因最可能是库存表被人工直接改过比如运维手动加库存没走转移日志也没记增补单。第二个可能是某次新增区间时初始库存直接 INSERT 进 stock 表而没有写一条“期初库存”的转移日志。第三个隐蔽原因扣减库存的 UPDATE 成功但插入转移日志失败因为 transfer_no 唯一键冲突事务回滚了可代码里吞了异常。解决 写一个每天凌晨的全量对账脚本以转移日志为流水账按仓库求和对比库存表。差数为正说明缺日志或有多扣差数为负说明有回补没记日志。监控到差异先冻结该仓库的售卖排查完毕后再恢复。加一条铁律严禁绕过 Service 层直接改库存表任何库存变动必须走同一个入口类。5.2 区间复用拆票后两张短途票同时卖掉但只有一个座位现象 乘客 A 买全程票后退票系统立刻把它拆成 A-B 和 B-C 两张短途票可 B 到 C 那段又被另一个订单卖出后面乘客上车发现座位重复了。原因 拆票逻辑里两个区间是分别扣减的拆完之后并没有“座位实例”级别的状态校验。也就是库存维度扣了 A-B 一个、回补 B-C 一个但座位表里同一个车座同时关联了两个未核销订单。解决 在座位表上加一个“占用令牌”。拆票时座位先从全程仓转移成一个可拆分的中间态再分别生成两个区间令牌只有拿着令牌的订单才能出票。如果拆完后没人下单令牌在 30 分钟后自动失效并回收到全程仓。这套设计等于把“库存转移”的原子性下沉到“座位占用”粒度成本高一些但对号入座的场景必须这么做。5.3 同一车次同一天开了两个仓一个卖爆一个卖不动现象 生产环境出现一个很诡异的线上 bug——同时存在两个仓库库存分散在两个仓里其中一个仓已经卖完另一个仓还剩 200 张用户看到的余票却只有几十张或 0。原因 仓库编号生成规则是${trainCode}_${runDate}_${direction}但上线时日期格式化用了LocalDate.now()而初始化数据用的字符串是yyyy-MM-dd两个格式在某个时区或连接环境里出现了不一致比如有的用了yyyyMMdd导致同一逻辑仓库被创建了两次。 换句话说仓库编号唯一键没有拦住肉眼看起来“相同”的仓。解决 仓库编号的生成统一收敛到同一个工具类并且把日期序列化格式固定为DateTimeFormatter.ISO_LOCAL_DATE不主动拼接。启动时加一个建仓检查如果同一个 train_code run_date direction 已经存在可售仓就直接复用不新建。5.4 调拨单生效期间目标仓的余票被直接扣减调拨数量放大了两倍现象 一次调拨从 A 车次调 50 张到 B 车次执行完对账发现 B 车次多出 50 张。原因 调拨单执行的是“目标仓增加 50”但 B 车次本身在发车前又做了一次“预留放票”把调拨进来的 50 张也当成了自己的初始库存覆盖在展示位上。说白了就是调拨生效和正常放票并发互相不知道对方操作了同一批额度。解决 调拨单生效必须写一个“库存锁定”标记目标仓在调拨到票之前先被置为“不可售”等调拨单状态变为 EFFECTIVE再解除锁定。解除锁定使用比较并交换命令确保只执行一次。对账脚本也要单独校验调拨单两侧数量一致不一致立即告警。5.5 Redis 预热库存和数据库真实库存不同步导致购物车看到有票支付时无票现象 用户把车票加入购物车结算时系统提示“余票不足”但 12306 页面明明还显示有票。客服也确认系统没有超卖。原因 Redis 里预热的库存值比 MySQL 大原因是预热任务只读了某几个区间而其他区间的扣减也影响了总池的余票。比如全程 A-D 的热点在 Redis 里但 A-B 的销售也在扣全程总库存导致 Redis 里预热的数字没有同步减少。解决 预热任务必须以“全仓总余票 所有区间购买消耗之和”的口径来计算不能只读单区间。另一种做法是把“热点区间”和“非热点区间”分开存Redis 只存热点区间非热点读库对账任务每 5 分钟把 MySQL 的真实余量回写 Redis并设置一个“软过期”时间超过 10 分钟没有心跳就自动失效强制回落数据库查询。6. 进阶用“预占-确认-释放”模式把压测做到不亏票到这里转移仓库的主体功能已经完整但还不够“抗造”。真正的生产环境里用户下单后不一定立刻支付可能锁定 10 分钟、30 分钟而且锁定期内座位不能被别人买走。这时库存不能一上来就“扣死”也不能完全不加保护。建议正解的方案是“预占-确认-释放”三态模型。预占阶段用户发起下单请求时调用预占接口把库存从可用状态改为“已预占”。 这一步在 Redis 里做原子扣减在 MySQL 里只插入一条预占记录不直接扣减库存表。确认阶段用户支付成功后将预占记录更新为“已确认”同时真正扣减 MySQL 库存表里的数量并写转移日志。 这样 MySQL 的扣减只发生在真实成交之后减少无谓的数据库压力。释放阶段预占超时或用户取消回补 Redis 和 MySQL 的库存。 回补必须是幂等的同一个预占单号不能重复回补。预占状态表设计可以很轻CREATE TABLE stock_pre_occupy ( id BIGINT NOT NULL AUTO_INCREMENT, occupy_no VARCHAR(64) NOT NULL COMMENT 预占单号幂等键, train_code VARCHAR(32) NOT NULL, run_date DATE NOT NULL, interval_key VARCHAR(64) NOT NULL COMMENT 区间标识如A-B, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-预占中 1-已确认 2-已释放, expire_time DATETIME NOT NULL COMMENT 过期时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(id), UNIQUE KEY uk_occupy_no(occupy_no), KEY idx_train_date_status(train_code, run_date, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存预占表;压测时使用这个模型可以模拟真实用户的操作序列预占 → 等待 → 确认或释放。 它的价值在于能把数据库的压力集中到“真实成交”那一刻而不是被几十万无效请求打满。 我习惯在压测脚本里加一个 5% 左右的“超时释放”场景专门验证幂等是否做对。 有一回压测就是没写释放逻辑导致预占表里堆了几十万条过期记录后续的所有查询全都变慢从那以后每次压测都会专门盯着释放率。另一个习惯是给转移日志表建一个按日的分区表线上只保留最近 90 天超过分区的数据转储到归档库。转移日志永远只增不改查询时再大的量也只会命中一个分区。 这样做的好处是对账永远有一条干净的流水线不用像业务表那样担心历史数据被 UPDATE 漂移。最后分享一个自己摸索出来的验收办法新版本发布前用“同一时间点、同一车次、同一天”做三组并发压测一组纯扣减、一组混合预占释放、一组带调拨单。 三组测试都跑完后用日志回放出的库存变动和数据库实际余票做全量比对差值为零才算过关。这套流程虽然笨但确实救过我两次上线事故希望帮到你。本文还有配套的精品资源点击获取