ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java实现12306转移仓库:表结构设计与并发一致性实践

Java实现12306转移仓库:表结构设计与并发一致性实践 简介基于Java的12306转移仓库设计与源码实现方案面向需要了解铁路售票系统后端架构及多语言协同开发的Java开发者与架构师。方案以Java为主结合Python与Shell脚本覆盖数据处理、自动化任务与系统部署等环节旨在优化12306转移仓库管理流程并提升高峰期处理稳定性。压缩包共86个文件约59.05MB包含60个Python脚本、5个PNG图像、4个文本文件、3个Markdown文档、Docker部署配置及模型文件等可支撑从源码阅读到环境搭建的完整实践。已有240人学习浏览。读者可获得完整的源码目录、接口调用逻辑、多线程任务处理示例、Docker容器化部署思路以及针对登录、查询、订单提交等核心场景的辅助脚本适合作为中高级Java项目设计与源码分析的参考资料。1. 先搞清“转移仓库”在 12306 体系里管哪一段数据在客票系统的代码里“转移”不像支付或者订单那样被单独描述它是一次“从一个客票实体变化到另一个客票实体”的过程。转移仓库Transfer Repository就是这个过程的数据落点。改签、变更到站、因列车运行图调整需要将旅客从原列车调整到其他列车这些业务在表面上操作的对象是订单和车票但在底层都会产生一条“转移记录”原票是谁、新票是谁、转移类型是什么、当前处于哪个处理阶段。如果没有一个单独的数据结构来沉淀这些过程订单表和车票表就会被塞入大量中间状态的字段而且这些字段只在转移生命周期内有效日常的售票和检票查询又不需要它们最终的结果是两个核心大表都被无意义放大。转移仓库要解决的就是把这种“临时但重要”的数据独立出来让订单模块、票库模块、运力调整模块都能以统一接口去读写同时保留完整的操作痕迹便于对账与排障。这篇文章把“基于 Java 的 12306 转移仓库”作为一个可落地工程来拆解先从业务边界和表结构下手再落到 Spring Boot MyBatis 的仓储层实现随后处理并发与一致性最后给出一套可本地验证的检查清单。适合正在设计客票系统、交易系统或者任何需要把“变更过程”抽出为独立仓储的团队参考。2. 转移仓库的领域模型与表结构先定实体边界再写代码在写任何 Java 类之前先回答两个问题转移记录里最细的粒度是什么以及转移记录和订单、车票之间是引用关系还是包含关系。错误选择会在后续并发控制时付出代价。2.1 业务场景决定转移记录的四种状态转移业务主要有三个来源改签、变更到站、运行调整。它们的共性在于都涉及一张原始车票和一张目标车票区别在于发起方不同——改签和变更到站由旅客发起运行调整由系统内部调度发起。另一个容易被忽略的场景是“转移失败后的回滚”例如新票已生成但支付失败需要把原票释放回可售库存。转移记录的状态可以归为四类待处理已锁定原票但新票未生成、已完成原票注销且新票生效、已失败新票生成失败原票解锁、已回滚原票恢复、新票作废。这个状态划分直接决定表结构里 status 字段的取值范围也决定仓储接口暴露哪些方法。2.2 表结构设计transfer_order 与 transfer_item 的粒度选择采用两层结构transfer_order 记录一次转移行为的整体信息transfer_item 记录转移涉及的具体票面信息。为什么需要两层因为一次改签可能存在“多个乘车人合并转移”或者“一张原票拆成两张新票”的场景单靠一个 order 表无法表达一对多关系。-- 转移主表一次转移行为的唯一入口 CREATE TABLE transfer_order ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, transfer_no VARCHAR(32) NOT NULL COMMENT 对外转移编号用于幂等, old_order_id BIGINT UNSIGNED NOT NULL COMMENT 原订单ID, new_order_id BIGINT UNSIGNED DEFAULT NULL COMMENT 新订单ID生成后回填, transfer_type TINYINT NOT NULL COMMENT 1-改签 2-变更到站 3-运行调整, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待处理 1-已完成 2-已失败 3-已回滚, operator VARCHAR(64) NOT NULL COMMENT 操作人/调用方标识, source_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, UNIQUE KEY uk_transfer_no (transfer_no), KEY idx_old_order (old_order_id), KEY idx_status_time (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT转移主表; -- 转移明细表一条原票对应一张新票 CREATE TABLE transfer_item ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, transfer_id BIGINT UNSIGNED NOT NULL COMMENT 关联 transfer_order.id, old_ticket_id BIGINT UNSIGNED NOT NULL COMMENT 原车票ID, new_ticket_id BIGINT UNSIGNED DEFAULT NULL COMMENT 新车票ID, passenger_id BIGINT UNSIGNED NOT NULL COMMENT 乘客ID, from_station_code VARCHAR(8) NOT NULL, to_station_code VARCHAR(8) NOT NULL, old_price DECIMAL(10,2) NOT NULL, new_price DECIMAL(10,2) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-处理中 1-完成 2-失败, UNIQUE KEY uk_transfer_ticket (transfer_id, old_ticket_id), KEY idx_old_ticket (old_ticket_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT转移明细表;几个关键点transfer_no 的独立唯一索引是幂等控制的基础后面在并发章节会细化old_order_id 上不建唯一索引是因为一次订单可以分多次转移例如先改签一部分乘客剩余部分后续再处理status 和 create_time 的联合索引支撑“扫描超时未完成转移记录”的后台任务这个需求在线上非常常见。明细表用transfer_id, old_ticket_id做唯一约束避免同一次转移里同一张原票被重复处理。2.3 为什么不在订单主表直接加字段给 order 表加一个 transfer_status 或者 new_order_id 看起来更简单但它会让订单表承载两种职责一种是订单本身的交易状态待支付、已支付、已退票另一种是转移过程状态。这两种状态的更新频率和查询模式完全不同。订单状态是高频读、低频写转移过程是低频读、低中频写但要求严格顺序。耦合在一起后任何转移流程的改动都要小心不破坏订单状态机这在多人维护的代码库里几乎必然导致回归问题。另一个理由是数据生命周期不同。订单数据要长期保留转移过程数据只需要保留一定周期供对账和排查定期归档即可。独立出 transfer_order 之后归档策略可以单独制定例如只保留最近 90 天的活跃转移记录。3. 源码实现Java 仓储层把转移过程落库领域模型定了接下来是 Java 侧的仓储实现。这里采用 Spring Boot 3 MyBatis 的组合用接口定义仓储契约用 XML 管理 SQL用 Service 层控制事务边界。3.1 仓储接口定义CRUD 之外还需要什么TransferRepository 接口不仅包含 save/findById还包含状态流转和幂等查询这两个特殊方法。状态流转方法通过 update ... where status ? 的方式实现乐观状态机防止两个线程同时把同一条记录从“待处理”推进到“已完成”。public interface TransferRepository { // 按业务幂等号查询调用方用 transferNo 做去重 TransferOrder findByTransferNo(String transferNo); // 创建转移主记录要求 transfer_no 唯一重复插入会抛 DuplicateKeyException long createOrder(TransferOrder order); // 创建明细记录 int createItem(TransferItem item); // 状态推进仅当当前状态为 expectedStatus 时才允许更新 int updateStatus(long id, int expectedStatus, int targetStatus); // 查询指定时间窗口内未完成的转移记录 ListTransferOrder listPendingOrders(LocalDateTime startTime, LocalDateTime endTime, int limit); // 回填新订单号用于新票生成成功之后 int bindNewOrderId(long id, long newOrderId); }核心是 updateStatus 方法的语义它把乐观锁从“版本号”变成了“状态值”。直接执行 update transfer_order set status #{targetStatus} where id #{id} and status #{expectedStatus}如果返回行数为 0说明状态已经被其他线程改变Service 层需要重新加载并决定是否重试。相比 version 字段这种方式的优点是状态本身就是语义化版本排查日志时直接能看到从哪个状态变到哪个状态。3.2 MyBatis 映射文件中的动态 SQL 与状态推进Mapper XML 中需要特别注意两点插入时的幂等冲突处理以及状态推进条件的动态拼装。insert idcreateOrder parameterTypeTransferOrder useGeneratedKeystrue keyPropertyid INSERT INTO transfer_order ( transfer_no, old_order_id, transfer_type, status, operator, create_time, update_time ) VALUES ( #{transferNo}, #{oldOrderId}, #{transferType}, #{status}, #{operator}, NOW(), NOW() ) /insert update idupdateStatus UPDATE transfer_order SET status #{targetStatus}, update_time NOW() WHERE id #{id} AND status #{expectedStatus} /update select idlistPendingOrders resultTypeTransferOrder SELECT id, transfer_no, old_order_id, transfer_type, status FROM transfer_order WHERE status #{targetStatus} AND create_time BETWEEN #{startTime} AND #{endTime} ORDER BY create_time ASC LIMIT #{limit} /select插入使用 useGeneratedKeys 是为了在 Service 层拿到自增主键随后插入明细表时用这个主键作为 transfer_id 的外键。这里需要提醒一点不要依赖 MySQL 的 ON DUPLICATE KEY 来做幂等因为 transfer_no 冲突时它会把原来的记录更新掉这与“重复提交应该被忽略而不是被覆盖”的业务语义不一致。正确做法是让唯一索引兜底捕获 DuplicateKeyException 后在 Service 层走“已存在则返回旧记录”的路径。这个差异在测试环境很难暴露但线上高并发重放时会造成数据错乱。3.3 调用链设计从订单模块发起转移到仓储落库完整的调用链是OrderController 接收改签请求先调用 TransferApplicationServiceService 校验乘客和票面信息后第一个动作是“锁原票”——在车票表执行 update ticket set status 2 where id ? and status 1返回行数为 1 表示锁定成功。锁票成功后创建转移主记录和明细记录此时 status 为 0。随后进入第二步“生成新票”这一步可能发生在另一个事务甚至另一个服务节点上所以转移记录必须持久化而不是放在内存里否则进程重启后原票被锁但新票流程不知道从哪继续。新票生成成功后回填 new_order_id再调用 updateStatus 把主表状态推进为 1。任一步骤失败都走补偿逻辑解锁原票并将转移状态置为 2。Service RequiredArgsConstructor public class TransferServiceImpl implements TransferService { private final TransferRepository transferRepository; private final TicketRepository ticketRepository; Transactional(rollbackFor Exception.class) public TransferResult execute(TransferCommand command) { // 幂等判断相同 transferNo 的请求直接返回已存在结果 TransferOrder existing transferRepository.findByTransferNo(command.getTransferNo()); if (existing ! null) { return TransferResult.of(existing.getTransferNo(), existing.getStatus()); } // 创建转移主记录与明细状态默认待处理 TransferOrder order TransferOrder.fromCommand(command); transferRepository.createOrder(order); for (TicketLockItem item : command.getTicketItems()) { transferRepository.createItem(TransferItem.fromCommand(order.getId(), item)); } // 锁定原票防止同一张票被并发转移 boolean locked ticketRepository.lockTickets(command.getTicketIds()); if (!locked) { transferRepository.updateStatus(order.getId(), 0, 2); throw new TicketLockException(原票锁定失败); } return TransferResult.pending(order.getTransferNo()); } }细节值得展开。第一幂等判断放在事务之外做一次事务之内依赖唯一索引兜底两层防护避免“查询-插入”之间的竞态。第二原票锁定和转移记录创建必须在同一个本地事务中这样至少保证“锁了票就一定有转移记录”后续恢复线程可以基于待处理记录继续推进。第三远程调用如库存预扣、支付确认应放在事务外或事务边界之后避免长事务拖死连接池。4. 并发与一致性转移仓库最容易踩的三个坑4.1 幂等控制重复提交通常发生在网关重试12306 这类系统的请求会经过多层网关超时后客户端重试是常态。如果转移接口不能正确处理重复请求原票会被同一请求锁两次——第一次锁定成功后第二次锁票会失败进而把第一次的转移记录标记为失败造成业务方看到“改签失败”但原票实际已被锁。解决方案就是前面提到的 transfer_no 唯一索引加上 Service 层的先查后插。4.2 数据一致性锁票、建单、生成新票的操作顺序不能乱一致性问题的核心是“先有转移记录再锁票还是先锁票再建记录”。如果先锁票再建记录锁票成功后进程崩溃原票被锁但没有转移记录恢复线程无从发现。如果先建记录再锁票又可能出现转移记录存在但票根本没锁上的状态让定时任务误以为需要解锁。把这两个操作放在同一个本地事务里事务提交成功后原票和转移记录同时可见。4.3 高并发压测下的写热点分表与缓存选型压测时容易暴露的问题是 transfer_order 表按照 create_time 顺序写入单表在千万级别后写入性能下降明显。常见思路是按 transfer_no 的哈希或者按时间分表。按日分表对转移场景比较合适因为改签请求通常在某次购票后几天内完成跨月转移占比很低。查询时先根据 transfer_no 中的日期段路由到对应物理表避免全表扫描。Redis 缓存主要放在“待处理计数”和“最近一次转移状态”上不缓存全量转移记录因为明细数据对一致性要求高缓存击穿后的回源代价远大于直接查库。下面给出一个典型的分表路由实现public class TransferShardSelector { // transferNo 格式yyyyMMdd 6 位随机序列例如 20250115123001 public static String tableName(String transferNo) { String day transferNo.substring(0, 8); return transfer_order_ day; // 直接映射到日表 } }日表带来的一个明显收益是待处理转移记录的扫描任务可以只扫今天和昨天两张表而不是全表扫描。代价是跨日转移的生成新票步骤如果隔天完成新票回填时需要再次路由定位到原表这个信息通过 transfer_id 已经能定位。把 transfer_id 设计为包含分表键的号段例如前 8 位是日期后续位数是当日自增序列这样通过 transfer_id 也能反查出表名。5. 验证与调优本地复现转移流程的排查清单最后给出一套可以在本地环境完整跑通并验证转移仓库正确性的方法。主要分三层单元测试层验证状态机集成测试层验证幂等和并发线上观察层通过监控指标判断是否存在问题。Test void transferStatusFlowShouldFollowExpectedSequence() { // 创建转移记录后状态必须是待处理 TransferOrder order createPendingOrder(); assertEquals(0, order.getStatus()); // 状态推进待处理 - 已完成 int affected transferRepository.updateStatus(order.getId(), 0, 1); assertEquals(1, affected); // 重复推进同一状态必须失败 int again transferRepository.updateStatus(order.getId(), 0, 1); assertEquals(0, again); // 从已完成无法回退到已失败 int rollback transferRepository.updateStatus(order.getId(), 1, 2); assertEquals(0, rollback); }集成测试建议用一个支持事务回滚的测试配置每次用例结束后回滚数据避免测试数据污染。并发的幂等测试可以开 20 个线程同时提交同一个 transferNo断言最终只有一条 transfer_order 记录且状态符合预期。这个用例能同时验证唯一索引和 Service 层去重逻辑是否衔接得当。线上观察阶段关注三个指标指标采集方式合理范围超出后的判断转移成功率日志按 status 聚合99.9% 以上查看失败集中在哪个 transfer_type待处理记录堆积数定时任务计数长时间低于 100持续上涨则扫描任务异常状态推进冲突率updateStatus 返回 0 行次数低于 0.1%并发冲突频繁暴露接口重试率问题一个值得单独调优的点是 updateStatus 冲突后的重试策略。直接重试会放大冲突应使用指数退避第一次重试间隔 50ms之后每次翻倍最多重试 4 次。另外扫描“待处理超时”任务不要把 SQL 写成 update transfer_order set status 3 where status 0 and create_time ?这个语句在高峰期会锁住大量行。应该先 select 出一批 id再逐个或按小批次更新每批不超过 200 条避免长事务和锁等待。本地复现时还可以做一次故障演练在生成新票步骤启动一个延迟约 3 秒的模拟接口然后在执行转移的同时手动 kill 掉应用进程重启后观察 transfer_order 中是否残留“待处理”记录以及扫描任务能否从原票锁定状态继续推进。这个演练能同时验证持久化设计、恢复链路和状态机语义三者是否配合正确。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进