简介:这是一套基于Java的12306转移仓库设计与源码实现方案,面向Java开发者、系统架构师以及对铁路票务系统感兴趣的中高级技术人员,重点解决12306转移仓库管理流程的优化与自动化问题,可适用于购票流程模拟、接口对接、自动化脚本开发等场景。资源共包含86个文件,以Python脚本、PNG/JPEG图像、Markdown与文本说明、HTML5页面、Docker部署配置、Git配置及镜像构建文件为主,压缩包约59.05MB,覆盖代码实现、界面设计、文档说明与容器化部署等环节。从内容预览可见,项目涵盖登录、查询余票、提交订单、验证码识别等核心模块,并配有自定义异常类、Docker编排文件、多语言工具脚本,以及针对不同环境的镜像构建配置;各文件按模块分目录组织,便于读者按需查阅。已有240人学习下载,适合希望掌握Java与Python混合开发、深入理解交易系统设计与容器化部署实践的开发者参考。
1. 先说清楚:12306转移仓库,拆开看其实是三件事
做 Java 后端的人,几乎都拿 12306 当过练手对象:高并发抢票、余票扣减、订单超时取消,随便挑一个都是 Java 面试题里翻来覆去问的点。但“12306转移仓库”这个标题真正要解决的,不是怎么抢票,而是怎么把这样一套源码项目安全地搬到新的代码仓库里,同时把 Maven 依赖仓库的源也一起迁好。很多人以为转移仓库就是 clone 一下再 push,结果历史提交丢了、tag 对不上、依赖下载不下来,代码拿过来编译直接翻车。这篇笔记就按“选型 → 源码实现 → 执行迁移 → 排错 → 验证”一条线讲,适合要把老项目迁到新 Git 平台、同时想理清 Java 多模块构建的开发者。这里没有黑匣子,每一步都可以照着敲。
2. 转移仓库的选型与前置设计:先定 Git 迁移方式,再定 Maven 镜像
2.1 Git 仓库迁移的三种方式:mirror、bundle 与 SVN 转换
转移仓库的第一步不是急着 push,而是先想清楚旧仓库是什么形态、新仓库放在哪、历史提交要不要完整保留。常见做法有三种,适用范围差别很大,我一般会先画一张对比表再决定:
| 迁移方式 | 适用场景 | 是否保留全部分支与 tag | 对旧仓库的要求 |
|---|---|---|---|
git clone --mirror+git push --mirror | Git 仓库迁到另一个 Git 仓库 | 完整保留 | 需要能直接访问旧仓库 |
git bundle | 旧仓库不能直接访问、只能传文件 | 完整保留 | 需要先在旧环境生成 bundle 文件 |
git svn clone转换 | 旧仓库是 SVN | 只转主干与分支,不保留 SVN 部分元数据 | 需要本机装 SVN 命令行工具或小乌龟客户端 |
最推荐的是 mirror 方案。git clone --mirror拉下来的是一个裸仓库,里面不包含工作区文件,但包含全部 refs:分支、tag、HEAD、远程跟踪分支,一个都不少。之后执行git push --mirror,会把这一整套 refs 按原样推到新仓库。
有一点必须提醒:--mirror的语义是“让对端变成我这个仓库的镜像”,所以 push 时如果对端仓库里已经存在本地没有的引用,会被直接删掉。也就是说,目标仓库务必先建一个空的,否则后果很严重。
如果旧仓库是 SVN,情况会复杂一些。很多人还在用 tortoisesvn 管理老代码,这种仓库没法直接镜像到 Git。常见做法是在本机先用git svn clone把 SVN 的历史拉成 Git 仓库,再做一次 mirror 推送。但要注意,SVN 的目录结构(trunk、branches、tags)在转换后会不会被正确识别成 Git 分支与 tag,取决于--trunk、--branches、--tags三个参数给得对不对,转换出来的历史往往还需要手工整理一轮。
另外,如果项目里还带着 Docker 镜像仓库的依赖,那属于另一条线。Git 仓库转移解决的是源码和构建脚本,Docker 镜像仓库需要单独做迁移或同步,不要混在一个方案里谈,否则构建时拉镜像一样会失败。
2.2 Maven 镜像仓库:中央仓库、阿里云与私服的优先级
源码仓库转移完了,紧接着就是依赖仓库。Maven 的依赖解析顺序是:本地仓库~/.m2/repository优先,找不到再去远程仓库拉;而镜像(mirror)的作用是拦截对远程仓库的请求并转发到指定地址。很多人以为settings.xml里写一个镜像地址就行,实际上一套可用的配置要同时考虑私服和公共镜像两条通路。
以最常见的 Java 项目为例,我一般会在settings.xml里配置多个镜像,按顺序排列:
<mirrors> <mirror> <id>aliyun-public</id> <name>Aliyun Maven Mirror</name> <url>https://maven.aliyun.com/repository/public</url> <mirrorOf>*</mirrorOf> </mirror> <mirror> <id>internal-nexus</id> <name>Internal Nexus Repository</name> <url>http://nexus.internal.example.com/repository/maven-public/</url> <mirrorOf>external:*</mirrorOf> </mirror> </mirrors>这段配置里两个参数要特别注意。第一个是mirrorOf的取值:*表示所有请求都走这个镜像,包括对私服的请求;external:*表示只镜像外部的远程仓库(即 pom 中声明的中央仓库等),对localhost、文件系统或内网地址的请求不拦截。第二个是镜像的摆放顺序,Maven 匹配 mirror 时按第一个匹配到的配置生效,所以通常把公共镜像放在前面兜底,把内网私服放在后面做精细化分流。
如果你的团队有 Nexus 私服,迁移完仓库后要记得去 Nexus 的网页后台检查仓库组(Repository Group)是否还指向旧地址。所谓“Maven 仓库网页版入口”,指的就是 Nexus 或 Artifactory 的管理后台。这里有个很容易被忽略的点:仓库组本身是一个聚合地址,如果组里某个 proxy 仓库还指向旧的中央仓库地址,构建时虽然表面上走的是私服,实际拉依赖还是会落到旧地址上。
2.3 源码实现方案为什么必须先定依赖边界
在动手写代码之前,要先想清楚一个问题:这个仓库转移之后,是不是任何一台机器 clone 下来都能构建出同样的结果?如果做不到,那就算历史记录搬得再完整,项目也是“能看不能用”。
Java 项目里最影响构建可复现性的就是依赖版本漂移。单模块项目还好,多模块项目一旦某个子模块依赖了父模块里没有显式管理的版本,构建结果就很难收敛。常见的做法是在 parent pom 中用dependencyManagement统一锁版本:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.7.18</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>这段配置的逻辑是:spring-boot-dependencies这个 BOM(Bill of Materials)本身是一个 pom 文件,import它的作用是把里面锁定的所有依赖版本引入当前工程,但不会直接给当前工程添加依赖。之后子模块里声明 Spring Boot 相关依赖时,就可以不写 version,由父 pom 统一兜底。
这一点和转移仓库的关系非常直接:转移仓库之后,如果碰到“本地能编译、CI 上编译失败”这种玄学问题,十有八九是依赖版本没锁住。提前把依赖边界定好,等于给迁移后的项目上了一道保险。
3. 源码实现方案:多模块工程与余票扣减的 Java 核心代码
3.1 多模块拆分:api、core、web 三层各管各的事
12306 这类模拟高并发购票的项目,最常见的落地方式是 Spring Boot 多模块工程。我一般会拆成三个 module,而不是把所有代码堆在一个工程里:
12306-transfer/ ├── pom.xml ├── 12306-api/ │ ├── src/main/java/com/transfer/api/ │ │ ├── dto/ │ │ └── facade/ ├── 12306-core/ │ ├── src/main/java/com/transfer/core/ │ │ ├── service/ │ │ ├── repository/ │ │ └── model/ └── 12306-web/ └── src/main/java/com/transfer/web/ └── controller/这么拆有三个实际好处。第一,core模块不依赖 Web 容器,迁移后可以先单独执行mvn -pl 12306-core install验证核心逻辑,不用启动整个应用,排错范围小很多。第二,api模块专门放对外接口的 DTO 和 facade,这样后续如果要做 RPC 化,可以直接把这个模块抽成独立的 SDK 仓库。第三,从源码仓库转移的角度看,多模块工程在迁移后更容易做增量构建——CI 只需要重新构建发生变更的模块,而不是每次全量编译。
这里顺带提一个很多人踩过的坑:有人喜欢把“源码”和“笔记”放在同一个仓库里,Markdown 文档直接堆在 src 目录旁边。如果笔记里有截图、有录屏,仓库体积会迅速膨胀,后面做 mirror 迁移时会非常痛苦。所以工程目录里尽量只放源码和必要文档,大文件交给专门的文件服务。
3.2 余票扣减的核心实现:Redis Lua 原子扣减 + 数据库落单
既然说“源码实现方案”,核心业务代码必须能落地。12306 这类系统里最有代表性的就是余票扣减:不能超卖,也不能因为并发高就把数据库打崩。常见的可靠做法是 Redis 预扣 + 数据库落单,扣减动作要保证原子性。
@Service public class TicketService { private static final String STOCK_KEY_PREFIX = "stock:train:"; @Autowired private StringRedisTemplate redisTemplate; @Autowired private OrderMapper orderMapper; public boolean tryDeductStock(String trainNo, String travelDate, int quantity) { String key = STOCK_KEY_PREFIX + trainNo + ":" + travelDate; // Lua 脚本在 Redis 服务端原子执行,避免并发扣减超卖 String lua = "local stock = tonumber(redis.call('get', KEYS[1]) or '0') " + "if stock < tonumber(ARGV[1]) then return -1 end " + "redis.call('decrby', KEYS[1], ARGV[1]) " + "return stock - tonumber(ARGV[1])"; Long left = redisTemplate.execute( new DefaultRedisScript<>(lua, Long.class), Collections.singletonList(key), String.valueOf(quantity) ); if (left == null || left < 0) { return false; } // 预扣成功后再落订单;订单表与库存表在同一个数据库事务里写 Order order = new Order(); order.setTrainNo(trainNo); order.setTravelDate(travelDate); order.setQuantity(quantity); order.setStatus(OrderStatus.CREATED); orderMapper.insert(order); return true; } }这段代码的逻辑链路是:先拼出车次的余票 key,再执行 Lua 脚本完成“读余票、比较、扣减”三步原子操作。脚本返回 -1 代表余票不足,返回非负值代表扣减成功。只有扣减成功后才往订单表插入一条待支付订单。
有几个参数需要在实际项目中认真调。第一,STOCK_KEY_PREFIX建议按业务维度拆分,比如按车次加日期,粒度越细,并发争抢同一个 key 的概率越低。第二,Redis 脚本中的KEYS[1]和ARGV[1]不能混淆,前者是 key 的列表,后者是扣减数量,这里如果把位置写反,脚本会直接报错。第三,DefaultRedisScript的泛型要和返回值类型一致,如果 Redis 里存的是字符串数字,返回类型写成Long没问题,但如果是其他结构,反序列化时会抛异常。
还要注意一点:Redis 预扣成功但数据库落单失败的情况是真实存在的,比如订单表插入时字段超长、数据库连接池满了。这时候不能假装没发生,常见的做法是发一条 MQ 消息做补偿,定时任务扫描“已扣减但未落单”的 key 回滚库存。这一块在源码里一定要预留接口,否则迁移上线后迟早要出事。
3.3 pom.xml 里与仓库迁移直接相关的三个参数
写完了业务代码,还要保证这段代码在新仓库里能被顺利构建出来。多模块工程的 parent pom 里有三个参数,是转移仓库时最常出问题的位置。
第一个是<relativePath>。它决定当前子模块去哪找父 pom,默认值是../pom.xml。如果迁移仓库时调整了目录层级——比如原来所有模块平铺在根目录,后来被挪进modules/文件夹——relativePath没跟着改,构建时 Maven 会报 “parent.relativePath” 相关错误。我一般会在 parent pom 所在目录保持不变的前提下,显式写明relativePath:
<parent> <groupId>com.transfer</groupId> <artifactId>12306-parent</artifactId> <version>1.0.0</version> <relativePath>../pom.xml</relativePath> </parent>第二个是编译器的release参数。在 JDK 9 之后,maven.compiler.source和maven.compiler.target只能决定源码语法兼容级别,但会把编译过程绑定到当前 JDK 的 API 上。用maven.compiler.release才能同时约束语法和 API。转移仓库后如果 CI 机器上的 JDK 版本和本地不一致,这个参数能避免很多莫名其妙的编译失败。
第三个是构建产物的finalName,它决定打出来的 jar 包叫什么名字。如果 CI 脚本里写死了旧仓库的产物名,而新仓库改过artifactId,那部署脚本会直接找不到文件。这不是 Git 层面的问题,但迁移仓库时很容易连带踩中。
4. 执行转移仓库操作:从导出、建仓到推送与首次构建
4.1 从旧仓库导出完整镜像:用 clone --mirror 拿到全部引用
操作阶段的第一步,是在旧仓库所在的机器上执行镜像克隆。假设旧仓库地址是git@old-git.example.com:team/12306-transfer.git:
git clone --mirror git@old-git.example.com:team/12306-transfer.git cd 12306-transfer.git执行完之后,当前目录会生成一个名为12306-transfer.git的裸仓库。它没有工作区,但有完整的引用列表和对象数据库。用git branch -a可以看到所有远程分支,用git tag -l可以看到全部 tag。
这里要特别强调--mirror和--bare的差别:--bare只是不要工作区,但 refs 依然和普通仓库一样是局部视图;--mirror是把远程仓库的 refs 完整映射下来,并且之后的 fetch 行为也会被配置成镜像模式。做仓库迁移时,选 mirror 才能保证不漏分支、不漏 tag。
如果旧仓库体积很大,这一步需要点耐心。对象数据库里有大量历史提交,网络差的时候clone --mirror跑到一半超时也见过。事前可以用git count-objects -vH看一下size-pack的值,提前判断要传多少数据量,免得执行到一半才发现根本拉不完。
4.2 创建新远程仓库并推送:push --mirror 覆盖式推送
旧仓库镜像准备好了,接下来到 Gitee、GitLab 或 Gogs 上新建一个空的远程仓库。以 Gitee 为例,创建时不要勾选“初始化仓库”(不要生成 README 或 .gitignore),因为push --mirror要求目标端是一个纯净的空仓库。
然后回到镜像仓库目录执行:
git push --mirror git@gitee.example.com:team/12306-transfer.git这一条命令会把本地镜像里的所有 refs 推到目标端。与普通git push不同,--mirror会对目标端的引用做对齐:目标端有而本地没有的引用会被删除,本地有的引用会全部推送。所以它本质上是一次覆盖式同步,不能用于向已有代码的仓库合并历史。
如果你用的是 Gogs 或 Gitea,它们的页面上有“迁移外部仓库”功能,可以直接填入旧仓库地址做服务端迁移。但这个功能的限制是:旧仓库必须能被新仓库所在的服务器网络访问到,而且只能迁移 Git 仓库本身,分支、tag 之外的 Webhook 设置、部署密钥这些都要手工重配。所以我的习惯是:能用 mirror 操作的,就不依赖页面功能。
4.3 本地工作区切换关联新远程
远程仓库推送完,原来的本地工作仓库也要换指向,否则开发者继续往旧地址提交,等于白迁移。进入到原本的工作目录:
cd 12306-transfer git remote -v git remote set-url origin git@gitee.example.com:team/12306-transfer.git git fetch --prune git branch -vv第一句git remote -v是迁移前的确认,看看当前 origin 指向哪里。之后set-url把 origin 改成新地址。fetch --prune会拉取新远程的所有引用,并清理本地已经不存在的远程跟踪分支。最后git branch -vv用来确认每个本地分支跟踪的是不是新远程的对应分支。
这一步看起来简单,但很关键。很多人只改了远端地址,忘了fetch --prune,结果本地还留着旧远程的跟踪分支信息,后面 push 的时候会出现“远端不存在的分支”这种提示。全部改完之后,最好再执行一次git status,确认本地没有意外处于 detached HEAD 状态。
4.4 更新 Maven 镜像配置并进入首次构建
代码仓库切完了,最后补上依赖仓库这一环。先编辑~/.m2/settings.xml,把 mirror 地址从旧私服切换到新私服或公共镜像。如果项目里有多个同事,可以让每个人各自更新本机配置;如果仓库里已经有.mvn目录,也可以在项目级配置里做覆盖,避免每台机器都要手工改。
然后执行首次构建:
mvn clean install -DskipTests -U这里-U表示强制更新快照版本依赖。如果之前旧仓库里缓存过本地~/.m2/repository,不加-U可能一直用旧缓存,构建结果不能真实反映新仓库的配置是否生效。首次构建建议不要 skip tests,至少把核心模块的单元测试跑一遍,确认迁移后的代码在新仓库环境下真的能编译、能测。
5. 转移仓库常见问题排查:五个高频翻车点与修复办法
5.1 推送后 tag 少了一半:问题出在没走 mirror
现象:新仓库代码看起来是完整的,但 tag 数量明显不对,一些发布版本的 tag 根本找不到。
原因:绝大多数人做“转移仓库”时,用的是git clone加git push的方式。默认的git clone虽然会拉取 commit 对象,但 tag 并不会全部拉下来,只有当前分支可达的 tag 才会被带出;而git push默认也不推送 tag。两条默认行为叠在一起,tag 就漏了大半。
解决:如果还没推,回到第 4.1 节的 mirror 方案重新走一遍。如果已经推完了,只能增量修复:
git fetch --tags origin git push origin --tagsfetch --tags把远程仓库的 tag 全量拉到本地,push --tags再全部推到新仓库。这个办法能补救,但遇到同名 tag 被不同 commit 指向的情况,直接 push 会被拒绝,得手工判断以哪个为准。
5.2 Maven 依赖下载还是去旧仓库:mirrorOf 配置没生效
现象:新仓库的代码已经能正常 clone,但执行mvn clean install时,构建日志里显示的下载地址还是旧仓库的 URL,甚至直接报连接超时。
原因:最常见的两种。一是settings.xml里 mirror 的mirrorOf写得太窄,比如写成了central,而项目 pom 里声明的是aliyun或自定义 id,请求落不到镜像上。二是 pom.xml 里直接声明了<repositories>,里面写死了旧仓库地址,Maven 对直连仓库的请求优先级高于 mirror 的匹配。
解决:先用mvn help:effective-settings看当前生效的 settings 配置,确认 mirror 到底匹配了哪些仓库 id;再查项目 pom 里有没有写死<url>。修复方式是把 pom 中的旧仓库地址改成新私服地址,或者把mirrorOf放宽到external:*。现在很多团队已经在做多源仓库接口配置,让构建机在多个镜像源之间自动切换,但前提是先把生效配置查清楚,否则写再多镜像也是空转。
5.3 仓库里硬编码旧地址:CI 与本地同时翻车
现象:代码迁移完成,本地也能构建,但 CI 上跑着跑着开始访问旧仓库地址,执行到一半失败。
原因:源码仓库里不只有 Java 代码,还有.gitmodules、Jenkinsfile、GitLab CI 的.gitlab-ci.yml、部署脚本等。很多项目在早期部署时把仓库地址直接写死在脚本里,迁移后这些脚本没有跟着改。
解决:在仓库根目录执行一次全局搜索,把旧域名全部替换成新地址:
grep -r "old-git.example.com" . --exclude-dir=.git重点排查.gitmodules里的子模块地址和 CI 脚本里的 clone 地址。如果有 git submodule,改完地址后还要执行git submodule sync,让子模块的 remote 配置重新同步。这一步不做,后续所有基于子模块的构建都会在暗处翻车。
5.4 大文件对象导致 clone 卡死:历史包袱拖垮迁移
现象:git clone --mirror执行到一半就卡住,或者推送到新仓库时超时;仓库体积极大,但代码量其实很少。
原因:仓库历史里混进了大二进制文件,比如 jar 包、安装包、录屏、测试数据。Git 对文本文件很友好,但对二进制文件毫无优化,一个 50MB 的文件进历史,后续所有 clone 都要把这个对象完整拉一遍。
解决:先定位大对象:
git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | sort -k3 -n -r | head -20找到大文件后,用git filter-repo清理历史:
git filter-repo --path-glob '*.zip' --invert-paths清理之后再重新做 mirror 推送。这里必须提醒:filter-repo会重写历史,所有基于旧历史的本地克隆在清理后都不能直接 push,必须重新 clone。除非你确认这些大文件可以永久放弃,否则不要在迁移主干仓库时贸然执行。
5.5 本地与远程历史分叉:push 直接被拒绝
现象:本地工作区已经切换到新远程,执行git push origin main时报错,提示non-fast-forward或拒绝合并无关历史。
原因:新仓库不是空仓库,创建时勾选了“初始化仓库”,生成了 README 或 LICENSE 提交。而本地仓库是从旧仓库 clone 来的,拥有完全独立的历史。两边都有 commit,Git 认为这是两段无关历史,所以拒绝直接推送。
解决:如果新仓库里只是初始化的 README,最简单可靠的办法是删除新仓库重新创建,保证它是一个空仓库,然后重新走 mirror 推送。如果新仓库已经有人提交了代码,就要谨慎处理:在新仓库确认没有有效内容之前,不要用--allow-unrelated-histories强行合并,那只会把两段不相干的历史搅在一起,后面的排查成本更高。
6. 验证与自动化:用 git fsck、CI 与依赖锁把转移后的仓库钉住
6.1 用 git fsck 给仓库做一次完整体检
推送完成后别急着宣布结束。先在新仓库的镜像目录里跑一次完整性检查:
git fsck --full --strict git count-objects -vHgit fsck --full --strict会检查对象数据库里的每一个 commit、tree、blob 是否完整、是否有关联缺失;git count-objects -vH可以看到size-pack的值。如果新仓库的size-pack比旧仓库小很多,很可能 clone 时只拿到了部分历史,也就是变成了浅克隆。确认这两条都正常,才能说历史记录真的搬全了。
6.2 在 CI 里加一条可复现构建检查
仓库迁移完,最怕的是“本地能编译,CI 不能编译”。这通常不是代码问题,而是 CI 环境没有拿到同样的依赖配置。常见的做法是在 CI 配置里加一个干净构建步骤,以 Gitea Actions 为例:
build: stage: build script: - mvn clean verify cache: paths: - ~/.m2/repository这段配置里cache指定了 Maven 本地仓库的缓存目录,目的是让多轮构建之间不重复下载依赖,但第一次构建一定是从镜像源重新拉取。CI 机器上不要复用开发者的~/.m2目录,否则缓存里残留的旧仓库依赖会让构建结果失真。
6.3 把 Maven Wrapper 和依赖版本一起锁进仓库
最后一条是我自己的习惯,也是踩过坑之后的教训:迁移仓库时,一定要把mvnw和.mvn/wrapper/maven-wrapper.properties一起提交进去。Maven Wrapper 会把 Maven 版本钉死在文件里,任何机器 clone 下来都能用同一套 Maven 构建,避免“我本地 3.9 能过、CI 3.6 报错”这类问题。
我印象很深的一次迁移:push 完以为万事大吉,结果第二天同事在新仓库 pull 下来编译直接失败,原因是他的机器没有装指定版本的 Maven,而项目里恰好没有提交 mvnw。后来我把 Maven Wrapper 补进去,类似的问题再没出现过。现在我做任何仓库迁移,最后一步永远是找一台干净的机器git clone,然后跑一遍mvn clean verify,跑通了才算真正完成。希望这条习惯对你也有用。
本文还有配套的精品资源,点击获取