12306 转移仓库实战:Java 高并发库存分层与位图设计
简介这是一套面向Java开发者与软件工程学习者的12306转移仓库设计与源码实现方案聚焦铁路售票平台中仓库管理流程的优化适合具备一定Java基础、希望研究高并发业务系统与容器化部署的中高级读者参考。资源包共86个文件约59.05MB以60个Python脚本为核心辅以PNG与JPEG图像、Markdown与txt文档、HTML5页面、Dockerfile与docker-compose等容器化配置以及Shell脚本和各类清单文件覆盖代码实现、界面素材、说明文档与部署配置多个层面。项目以Java为主开发语言结合Python与Shell完成自动化处理与系统管理并借助Docker保证环境一致性。源码方案将设计理念落地为可运行系统读者可从中了解转移仓库的业务逻辑组织、异常体系划分、接口调用与容器化部署思路目录结构清晰便于按模块检索与二次开发。目前已有240人学习下载。1. 从一张票的库存说起12306 转移仓库到底在转什么很多人第一次听到「12306 转移仓库」会以为是爬虫或者抢票脚本的变体其实它解决的是一个更底层的问题票务库存在查询、下单、支付、退票这几个环节里怎么在多个库之间安全地搬来搬去既不超卖也不把已经锁定的座位弄丢。举个具体场景一趟高铁放票后某一段区间的余票被反复查询热点数据全压在同一个库上写操作又集中在少数几张表这时候就需要把「查询用的库存」和「扣减用的库存」拆开用转移仓库的思路做数据分层。这套方案适合有 Java 基础、做过 Spring Boot 项目、想理解高并发库存设计的工程师也适合正在准备 java 面试题、想拿一个真实场景练手的人。它不承诺帮你抢到票而是让你把库存转移这件事的代码写对、写稳。2. 转移仓库的库存模型与 Java 分层设计2.1 为什么不能只靠一张余票表硬扛最常见的做法是一张ticket_stock表字段就是车次、区间、座位类型、余票数查询和扣减都打这张表。单机低并发时没问题一旦查询量上来行锁竞争会让响应时间肉眼可见地变长。血泪经验是很多人以为加个索引就能解决结果发现扣减语句update ... set stock stock - 1 where stock 0在热点行上依然排队。转移仓库的核心思路是把库存拆成两层一层是「展示库存」负责扛查询允许短暂不一致另一层是「扣减库存」负责最终一致性所有写操作必须落在这里。两层之间通过转移动作同步转移的粒度可以是车次加区间也可以是座位段。这样查询走缓存或只读库扣减走主库压力就被分开了。选型上Java 侧我一般用 Spring Boot 加 MyBatis-Plus缓存用 Redis消息用 RocketMQ 或 RabbitMQ 做异步转移。数据库还是 MySQL因为票务数据需要事务和持久化。这里不引入太重的分布式事务框架用本地消息表加定时补偿就够了复杂度可控。2.2 库存表结构与转移状态机先看表结构这是整个方案的地基。下面这段 SQL 可以直接在 MySQL 8 里执行注意segment_bitmap字段它用位图表示这行库存覆盖了哪些区间段避免为每个小区间建一行。CREATE TABLE ticket_stock ( id bigint NOT NULL AUTO_INCREMENT, train_no varchar(16) NOT NULL COMMENT 车次, travel_date date NOT NULL COMMENT 乘车日期, seat_type tinyint NOT NULL COMMENT 座位类型 1二等 2一等, segment_bitmap bigint NOT NULL COMMENT 区间位图第n位为1表示覆盖第n段, total_stock int NOT NULL DEFAULT 0 COMMENT 总库存, available_stock int NOT NULL DEFAULT 0 COMMENT 可售库存, locked_stock int NOT NULL DEFAULT 0 COMMENT 锁定库存, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_train_seat_seg (train_no,travel_date,seat_type,segment_bitmap) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT票务库存主表;逻辑说明available_stock是真正对外可卖的locked_stock是下单未支付占用的total_stock等于两者之和加上已售。version用于乐观锁扣减时带上版本号失败就重试。segment_bitmap是关键设计比如一趟车有 5 个站就有 4 个区间段位图0b0011表示覆盖前两段。这样一行数据能表达一个区间组合查询时用位运算匹配。参数上total_stock初始化时按车厢座位数乘以区间重叠系数来设不要直接填座位总数否则会超卖。available_stock初始等于total_stock。version每次更新加一。2.3 转移动作的 Java 实现转移分两种正向转移是查询库从扣减库拉取最新库存反向转移是扣减库把释放的库存推回查询库。下面这段 Java 代码演示正向转移的核心逻辑用 MyBatis-Plus 的LambdaUpdateWrapper做乐观锁扣减。Service public class StockTransferService { Autowired private TicketStockMapper stockMapper; Autowired private RedisTemplateString, Integer redisTemplate; // 正向转移从扣减库读取最新库存写入查询缓存 public void transferToQueryCache(String trainNo, LocalDate date, int seatType) { // 1. 从主库查当前可用库存 TicketStock stock stockMapper.selectOne( new LambdaQueryWrapperTicketStock() .eq(TicketStock::getTrainNo, trainNo) .eq(TicketStock::getTravelDate, date) .eq(TicketStock::getSeatType, seatType) ); if (stock null) { return; } // 2. 写入 Rediskey 按车次日期座位类型拼 String cacheKey String.format(stock:%s:%s:%d, trainNo, date, seatType); redisTemplate.opsForValue().set(cacheKey, stock.getAvailableStock(), 30, TimeUnit.SECONDS); } // 扣减库存带乐观锁重试 public boolean deductStock(Long stockId, int count, int expectedVersion) { int retry 3; while (retry-- 0) { TicketStock stock stockMapper.selectById(stockId); if (stock null || stock.getAvailableStock() count) { return false; } // 乐观锁更新available 减 countlocked 加 countversion 加一 int rows stockMapper.update(null, new LambdaUpdateWrapperTicketStock() .setSql(available_stock available_stock - count) .setSql(locked_stock locked_stock count) .setSql(version version 1) .eq(TicketStock::getId, stockId) .eq(TicketStock::getVersion, stock.getVersion()) .ge(TicketStock::getAvailableStock, count) ); if (rows 0) { return true; } // 版本冲突短暂等待后重试 try { Thread.sleep(20); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } return false; } }逻辑说明transferToQueryCache是读扩散把主库库存刷到 Redis过期时间设 30 秒避免缓存长期不一致。deductStock是写收敛所有扣减都走主库用version做乐观锁ge条件保证库存不会被扣成负数。重试三次是经验值再多会拖长响应。参数上count是本次购买张数expectedVersion其实在循环里重新查了所以传参可以忽略但保留是为了兼容外部调用。Thread.sleep(20)是退避生产环境建议换成随机退避避免重试风暴。2.4 查询侧怎么用位图匹配区间查询余票时用户选的是出发站和到达站需要转成位图再去匹配。下面这段代码把站序转成位图然后查库存。public int queryAvailable(String trainNo, LocalDate date, int seatType, int fromSeq, int toSeq) { // 位图从 fromSeq 到 toSeq-1 位为 1 long mask 0L; for (int i fromSeq; i toSeq; i) { mask | (1L i); } // 查询覆盖该位图且可用库存大于 0 的记录 ListTicketStock list stockMapper.selectList( new LambdaQueryWrapperTicketStock() .eq(TicketStock::getTrainNo, trainNo) .eq(TicketStock::getTravelDate, date) .eq(TicketStock::getSeatType, seatType) .apply((segment_bitmap {0}) {0}, mask) .gt(TicketStock::getAvailableStock, 0) ); return list.stream().mapToInt(TicketStock::getAvailableStock).min().orElse(0); }逻辑说明mask是用户区间的位图SQL 里用segment_bitmap mask mask保证这行库存完整覆盖用户区间。取min是因为一行库存可能覆盖多个区间组合实际可售数受最短板限制。参数上fromSeq和toSeq是站序从 0 开始。位图用long最多支持 64 个区间段一般车次够用超过就要换BigInteger。3. 用 Spring Boot 把转移仓库跑起来的最小步骤3.1 环境与依赖配置先把工程搭起来。JDK 用 17Spring Boot 3.2MyBatis-Plus 3.5.5Redis 7MySQL 8。pom.xml里关键依赖如下。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencyapplication.yml里配好数据源和 Redis连接池用 HikariCP最大连接数设 20因为扣减是短事务不需要太大。spring: datasource: url: jdbc:mysql://127.0.0.1:3306/ticket?useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password hikari: maximum-pool-size: 20 data: redis: host: 127.0.0.1 port: 63793.2 初始化库存与压测验证库存初始化不要手工 insert写个接口按车次和日期批量生成。下面这段代码按站序生成位图组合每个组合一行。public void initStock(String trainNo, LocalDate date, int seatType, int stationCount, int totalSeats) { // 枚举所有区间组合这里简化为每个连续段一行 for (int i 0; i stationCount - 1; i) { for (int j i 1; j stationCount; j) { long mask 0L; for (int k i; k j; k) { mask | (1L k); } TicketStock stock new TicketStock(); stock.setTrainNo(trainNo); stock.setTravelDate(date); stock.setSeatType(seatType); stock.setSegmentBitmap(mask); stock.setTotalStock(totalSeats); stock.setAvailableStock(totalSeats); stock.setLockedStock(0); stock.setVersion(0); stockMapper.insert(stock); } } }逻辑说明双重循环枚举所有起止站组合每个组合一行。totalSeats是车厢座位数实际要按区间重叠打折这里为了演示直接填满。压测用 JMeter 或 wrk并发 200 打扣减接口观察version冲突率和响应时间。如果冲突率超过 30%说明热点太集中要把库存行拆得更细比如按座位段再分片。3.3 异步转移与定时补偿正向转移可以同步做但反向转移释放库存建议异步。用本地消息表记录待转移事件定时任务扫描并执行。Scheduled(fixedDelay 5000) public void compensateTransfer() { ListTransferEvent events eventMapper.selectList( new LambdaQueryWrapperTransferEvent() .eq(TransferEvent::getStatus, 0) .last(limit 100) ); for (TransferEvent event : events) { try { stockTransferService.transferToQueryCache( event.getTrainNo(), event.getTravelDate(), event.getSeatType()); event.setStatus(1); eventMapper.updateById(event); } catch (Exception e) { // 记录失败下次重试 log.error(transfer failed, eventId{}, event.getId(), e); } } }逻辑说明每 5 秒扫一次未完成事件成功就置状态为 1失败留到下一轮。limit 100防止一次拉太多。参数上fixedDelay根据业务容忍延迟调整票务场景 5 秒可以接受。事件表要加索引在status上否则扫描会慢。4. 避坑与排查转移仓库最容易翻车的 4 个点4.1 现象库存扣成负数日志里 version 冲突频繁原因乐观锁重试次数不够或者扣减条件里漏了available_stock count。高并发时多个线程同时读到相同 version只有一个能更新成功其余重试如果重试次数少就返回失败但失败后没有回滚已锁库存。解决扣减 SQL 必须带ge(available_stock, count)重试次数设 3 到 5 次并在失败时记录详细日志。另外检查事务边界扣减和锁库存要在同一个事务里。4.2 现象查询缓存和主库库存不一致用户看到有票下单却失败原因正向转移是定时刷的缓存过期时间设太长或者转移任务积压。用户查的是旧缓存下单走主库发现没票。解决缓存过期时间缩短到 10 到 30 秒转移任务加监控积压超过 1000 条就告警。下单前做一次主库校验不要只信缓存。4.3 现象位图匹配查不到库存明明有票却返回 0原因位图方向搞反了或者站序从 1 开始但代码按 0 算。比如用户从第 1 站到第 3 站mask 应该是0b011如果写成0b110就匹配不到。解决统一站序从 0 开始写单元测试覆盖各种起止组合。查询 SQL 里用(segment_bitmap mask) mask不要用! 0否则会匹配到部分覆盖的行。4.4 现象转移任务重复执行库存被加回两次原因本地消息表没有做幂等定时任务扫描时同一条事件被多个实例处理或者任务执行成功但状态更新失败下一轮又执行一次。解决事件表加唯一键比如event_id执行前先插入去重。状态更新用乐观锁update ... set status 1 where id ? and status 0影响行数为 0 就跳过。5. 进阶用分片键把转移仓库的写压力再降一半前面讲的方案在单库单表下能扛住中等并发但如果车次多、日期跨度大ticket_stock表会迅速膨胀扣减的热点行依然集中。这时候要做分片分片键选train_no加travel_date的组合用 ShardingSphere 或自己写路由。分片后每个库的库存行独立转移任务也要按分片维度拆开避免跨库事务。具体做法是先按travel_date分库比如一个月一个库再按train_no哈希分表比如 16 张表。路由规则写在配置里MyBatis-Plus 的分页插件要关掉因为跨库分页代价高。转移任务改成按分片扫描每个分片一个线程线程数等于分片数用线程池控制。验证分片是否生效可以看慢查询日志如果扣减语句的执行时间稳定在 5 毫秒以内说明热点被分散了。另一个技巧是给available_stock加一个「预扣减」字段下单时先扣预扣减支付成功再转成正式扣减这样能把支付回调的写压力也错开。我自己的习惯是每次改完分片规则先用 100 并发跑 10 分钟看version冲突率和 GC 频率两个指标都平稳了才上生产。这套东西没有后悔药分片键选错后面迁移成本极高所以前期宁可多花半天做压测。希望帮到你。本文还有配套的精品资源点击获取