先说清楚,虚拟单体仓库不是把代码仓库合并,也不是搞一层复杂的代码镜像;它要解决的事情其实很朴素:让原本分散在多个仓库里的 .NET 项目,在某个约定的时刻,能被当作一个整体来分析、构建和发布。我们团队从去年开始,为了这件事专门搭了一套同步机制,真实感受就是:难点不在"多仓库",而在"同步"这两个字。
这套机制的起因很现实。后端系统由十几个 .NET 服务组成,分布在七八个 Git 仓库里,服务之间通过 NuGet 包和内部接口互相依赖。平时单仓内部开发还好,一旦遇到跨仓库的公共变更,比如改了基础库的接口又要在两个服务里配合调整,仓库与仓库之间的协调成本立刻涨几倍。我们也认真评估过直接合并成真正的单体仓库,但考虑到组织结构、权限边界和迁移成本,最后放弃了。于是"虚拟单体仓库"成了最折中的路线:保留仓库边界,但在需要整体验证和整体发布的时候,让它们表现得像一个仓库。
这篇文章就是当时那段落地经历的复盘。如果你也在维护多个 .NET 仓库,依赖关系开始变乱,每次发布都要协调一堆 tag 和包版本,那这套思路应该对你有用。
1. 虚拟单体仓库:我们想要解决的问题到底是什么
1.1 多仓库并存的真实开发场景
先还原一下当时的局面。团队按业务域拆分了仓库,比如有订单服务、商品服务、会员服务,还有几个底层基础库,像统一配置、数据访问、消息总线。每个仓库有自己的解决方案文件,自己发版,自己在 CI 上跑测试。表面看挺干净,但跨仓库协作的时候,问题藏不住。
最典型的一幕:基础库改了接口签名,下游的三个服务都要跟着改。这个改动需要跨四个仓库完成,代码本身不难,难的是提交顺序和验证节奏。你不可能在基础库仓库存一个中间态,让下游服务同时拿到并验证。更麻烦的是,下游服务之间也有依赖,一个服务改了消息协议,另一个服务就得同步调整消费逻辑。这种改动如果没法在同一个时间点统一验证,发布就变成一场接力赛,任何一个环节落下都会把问题带到线上。
.NET 的工程结构让这问题更具体。我们用解决方案文件管理项目引用,多仓库状态下,本地开发通常会开两个解决方案:一个打开基础库源码,一个打开服务源码,再用"添加现有项目"的方式手动把跨仓库项目引用进来。这套操作不是不能做,但很脆。同事 A 机器上配好了路径引用,提交到 CI 后路径不存在,构建直接挂;同事 B 用了最新版 NuGet 包,同事 C 还在用本地项目引用,两个人看到的编译结果不一样。
1.2 "单体仓库"为什么诱人,我们为什么没动
真正的单体仓库能根治刚才那一堆问题。所有代码在一个仓库里,跨仓库提交变成了"一个提交里改多个项目";构建、测试、发布全在一个管道里闭环;人人都能看见公共接口改动的完整影响面。我们当时也认真动过合并的念头。
但实际评估完,发现几条硬伤。第一,仓库合并会丢失很多历史细节,git log对不上号,代码溯源基本报废。第二,仓库一旦合并,权限粒度很难收敛,本来某业务域只能看自己的代码,现在整个大仓库一放开,管控成本明显上升。第三,构建链路会变成一个巨型作业,十几个服务全量构建一次的时间逼近半小时,而且互相阻塞。最要命的是,团队并没有一个强势的组织机制去强制统一分支规范,合进来的仓库各有各的节奏,合并容易,协调难。
这个评估结果不是"单仓不好",而是"迁移成本在当下不值得"。我们需要的不是物理上的单仓,而是逻辑上的整体视图。这个视图必须满足三件事:跨仓库改动能同点验证,公共依赖变更能立刻看到下游影响,发布时能锁定一组互相匹配的源码版本。
1.3 虚拟单体仓库对我们的定义
经过几轮讨论,我们把目标收敛成一个非常具体的定义:仓库仍然是多个,但有一个"虚拟单体视图",由一份清单文件描述,同步工具按清单把所有仓库的指定版本一次性拉齐到统一工作区,生成包含所有项目的解决方案,然后在这个统一工作区里完成整体构建和验证。样板间里,每个仓库保留自己的 Git 历史、自己的权限、自己的 CI,但在"虚拟单体"这个层面上,它们是一起被检出、一起被构建、一起被发布的一个整体。
"同步"这个词,在我们语境里有明确含义。它不是实时双向镜像源码,而是指:在任何一个版本发布时刻,我们能够以某个版本号作为唯一凭证,把所有参与的仓库锁定到相互匹配的提交,使它们整体处于可编译、可运行的状态。谁先改、谁后改,都不重要,重要的是最终那个快照对得上。
2. 开始做同步前,需要定死的四个边界
2.1 哪些仓库进入同步圈
不是所有仓库都得进虚拟单体仓库。我们一开始犯过贪心的毛病,恨不得把所有 .NET 项目都塞进来,后来发现纯内部工具库、原型项目、实验性服务进同步圈只会增加噪音。它们依赖面窄,发布节奏独立,强行纳入统一同步反而拖慢迭代。
我们最终定的准入规则有三条:第一,该仓库被至少两个其他仓库依赖;第二,该仓库与虚拟单体的发布节奏一致,也就是说它跟着同一套版本号一起发;第三,该仓库的构建产物必须在整体构建中出现。满足这三条才纳入。主动依赖别人但从来不被人依赖的服务,比如边缘的定时任务,我们允许它作为"消费方"单独拉取包,不进入同步圈。这个取舍非常重要,它决定了同步清单的规模不会无限膨胀。
2.2 依赖方向:构建顺序与禁止循环依赖
每个进入同步圈的仓库之间必须有清晰的依赖方向。我们的做法是把仓库分成两层:底层是基础库,它们不依赖任何业务服务;上层是业务服务,可以依赖底层基础库,也可以互相依赖,但禁止出现 A 依赖 B、B 依赖 A 这种循环。
这条规则听着像废话,实际执行起来没那么容易。曾经有一段时间,订单服务引用了会员服务的一个接口,而会员服务又引用了订单服务的事件定义,两个仓库互相引用 NuGet 包。单独构建都正常,但把版本放在一起整体构建时,就出现严重的循环依赖,dotnet restore阶段直接报错。我们当时的处理方法是:把互相引用的公共类型下沉到基础库,而不是让两个服务直接互相引用。下沉一次之后,依赖图立刻清爽,也为同步脚本的构建顺序提供了明确依据。
2.3 版本号:仓库解耦与整体对齐的平衡
这是整个方案里最需要动脑筋的部分。如果每个仓库各自发版本号,整体快照就很难描述:你发布的时候得说清楚用的是基础库 3.2 还是 3.4,订单服务 1.8 还是 2.0,一堆版本号组合起来才能描述一个完整系统。反过来,如果强制所有仓库共用一个版本号,又会让仓库间的迭代变得僵化,一个服务修个紧急 bug,整组仓库全都要跟着 bump 版本。
我们最后选了折中方案:仓库内部仍然保留自己的语义化版本号,但进入同步圈时,统一打一个同步版本标记。比如基础库自己发 3.2.1,订单服务自己发 1.8.0,但在虚拟单体的发布记录里,它们都挂上同一个同步版本号2025.06.1,表示这是一个彼此匹配的快照。同步版本号只作用于同步圈,不影响各仓库内部的正常发包逻辑。这样既保住了仓库的独立性,又让整体发布有一个一线到底的版本锚点。
2.4 分支与标签:同步的触发凭证
同步机制必须回答一个基础问题:拉齐哪些代码?我们的选择是,同步永远发生在发布分支上,而不是开发分支上。仓库日常开发可以在任意特性分支进行,合并到 main 后触发仓库自己的 CI,这个 CI 只做该仓库的独立验证。只有进入发布流程时,同步机制才会读取所有仓库的发布标签,按标签拉齐代码。
标签规范也是一开始就定死的:同步版本号必须反映在标签里,格式统一为sync/2025.06.1,每个仓库的该标签都指向它在该同步版本中应使用的提交。这样设计带来的好处是,无论谁来操作同步,只要知道同步版本号,就能完全复现当时的源码状态,不会出现"我拉的是最新代码,但最新代码和当初发布的不一样"这种问题。
| 边界项 | 选型 | 原因 |
|---|---|---|
| 进入同步圈的仓库范围 | 被多仓依赖或随整体一起发布 | 避免噪音和发布节奏冲突 |
| 依赖方向 | 分层,禁止循环依赖 | 保证构建顺序可计算、可重复 |
| 版本策略 | 仓库独立版本号 + 同步版本标记 | 兼顾仓库灵活性与整体可描述性 |
| 触发凭证 | 同步标签 sync/{版本号} | 发布状态可复现,便于回溯 |
3. 同步机制搭建:从多仓库拉齐到整体构建
3.1 一份清单文件:虚拟仓库的"大脑"
核心的同步依据是一份清单,我用 JSON 维护,放在一个独立的同步引擎仓库里。它描述三件事:同步圈里有哪些仓库、每个仓库从哪个远程地址拉取、某个同步版本对应哪些标签或提交。清单本身不参与构建,只作为操作指令存在。
清单结构大致是这样的:
{ "syncVersion": "2025.06.1", "workspace": "unified-tree", "repos": [ { "name": "core-framework", "remote": "git://internal.platform/core-framework.git", "ref": "refs/tags/sync/2025.06.1", "projects": ["src/CoreFramework/CoreFramework.csproj"] }, { "name": "order-service", "remote": "git://internal.platform/order-service.git", "ref": "refs/tags/sync/2025.06.1", "projects": ["src/OrderService/OrderService.csproj"] } ] }这份清单的价值在于把"虚拟单体"的状态显性化了。任何人打开清单,都能看到当前同步版本包含哪些仓库、各自指向哪个标签。同步操作本质上就是对这份清单的执行,而不是靠某个工程师的记忆和经验。
3.2 拉齐代码:先锁提交,再补依赖
有了清单,同步脚本就变得直接。我在本地和 CI 上用的是同一套 PowerShell 脚本,核心逻辑是先处理清单里的每个仓库:检查远程是否存在、本地是否已有缓存、目标标签是否可检出。全部查验通过后,再统一 checkout 到指定 ref,而不是一个仓库一个仓库地检出和构建。
这一步有个容易被忽视的细节:拉取时绝对不能顺便更新到远程最新。必须严格按照清单里的 ref 检出,哪怕远程 main 已经比标签新了十几个提交,也不能碰。否则就违背了"锁定快照"的初衷。我们用git checkout --detach直接进入游离状态,只认提交,不认分支,杜绝了"不小心多了个提交"的情况。
依赖关系在拉齐之后自然浮现。所有仓库都进入同一个工作区目录,生成统一的解决方案后执行dotnet restore,项目引用会直接解析到工作区里的本地项目,而不是去 NuGet 拉旧包。这一步的意义很大:跨仓库改动能够在整体构建中被直接验证。
3.3 生成解决方案并启动整体构建
在统一工作区里生成解决方案这一步,一开始是手工维护 .sln,后来改成了脚本生成。因为仓库数量固定在十几个,项目路径如果写死,改动任何一个仓库目录结构都要回头改 .sln,维护负担太重。用dotnet sln命令动态生成就灵活很多:
dotnet new sln -n UnifiedView dotnet sln UnifiedView.sln add unified-tree/*/src/*/*.csproj dotnet restore UnifiedView.sln --no-cache dotnet build UnifiedView.sln --no-restore--no-cache和--no-restore这两个参数,我们是在踩过坑之后才加上的。没有它们的时候,CI 偶尔会复用旧的缓存,导致明明本地工作区里已经是新代码,构建却引用了旧编译产物。这个坑后续会详细讲。
整体构建的实际价值,是在公共接口变更时体现出来的。以前改一个基础库接口,下游服务的适配要等各自仓库发布新版本才能验证;现在直接在统一工作区里改完所有代码,一个构建全部覆盖。构建产物会按仓库分别输出,测试也可以按需独立运行,但对于快速反馈跨仓库破坏性变更来说,已经是一台"兜底安全网"。
3.4 触发流程与失败处理
同步操作本身是显式触发的,不是自动的。每个进入发布周期的仓库合并代码并打上 sync 标签后,发布负责人运行同步脚本,拉齐统一工作区,执行整体构建。构建通过后才进入后续自动发布流水线。
失败时的策略也值得说。整体构建一旦失败,我们默认不自动回滚任何仓库的标签,而是直接在统一工作区里定位失败项目。因为同步版本是整体锁定的,如果某个仓库的提交有问题,发布负责人会联系该仓库的开发修复,修复后必须重新打一个sync/xxxx新标签,再重新拉齐。这个规则避免了"某个仓库偷偷往前推一个修复,其他仓库还在旧状态"的错位。
4. 上线后踩到的三个坑:同步链路如何被我们补强
4.1 坑一:同步脚本重复触发,Git 历史出现"镜像提交"
第一个事故来得很快。最初版本的双向同步脚本里,我为了让"整体状态"也能反馈回各仓库,写了一段推送逻辑:统一工作区里如果有跨仓库的构建修复,脚本会自动 commit 并推送到对应仓库的同步分支。听起来很贴心,结果某次一个仓库的提交被脚本推送后,触发了该仓库自己的 CI,而 CI 的完成回调又触发了同步脚本,同步脚本再次把同样内容的 commit 推到另一个仓库,另一个仓库的 CI 又触发……虽然触发了循环检测的阀值,但已经生成了好几个空转的镜像提交,Git 历史里出现了一堆内容相同的 commit,非常难看。
这件事让我彻底放弃了"自动回写"的思路。同步这个动作应该是只读的:从各仓库拉取指定版本到统一工作区,构建、验证、产出物,但绝不把统一工作区里的改动自动推送回原仓库。跨仓库修复还是走人工提交,回到各自仓库重新打标签。同步机制要做的是忠实地反映现状,而不是试图掩盖人工协调的缺失。后来我们把脚本里的写操作全部移除,只保留拉取和校验,这套系统才变得稳定。
4.2 坑二:NuGet 缓存让跨仓库依赖停留在旧版本
第二个坑更隐蔽,也非常 .NET 特色。当时整体构建命令里没有加--no-cache,某天基础库改了一个重要接口,订单服务的代码也已经同步适配了,但整体构建跑完,订单服务引用的却还是基础库的旧版程序集。构建不报错,测试却诡异失败,排错排了半天才发现是 NuGet 在本地全局包目录里缓存了旧版本。
原因很简单:统一工作区里项目引用已经从源码解析了,但某些间接依赖路径仍然走了 NuGet。因为解决方案里项目个数多,缓存命中时不会强制刷新,依旧使用本地包缓存里的旧版本。解决办法很粗暴也很有效:人和 CI 都执行dotnet restore --no-cache,并在构建前清理指定本地缓存目录。干净构建带来的可重复性,远比几分钟的缓存加速重要。后来的规则就是:统一工作区的构建不接受任何增量缓存,性能损失由依赖图优化来补偿。
4.3 坑三:标签与提交漂移,发布拿不到正确代码
第三个坑出在发布环节,事故原因同样反直觉。某次发布已经验证通过,进入打包阶段后,有人发现订单服务的某个紧急修复没有包含在正式产物里。当时订单服务的sync/2025.06.1标签指向的是一个旧提交,而我们验证时用的是订单服务仓库的 main 最新代码。也就是说,验证代码和发布代码根本不是同一个东西。
问题出在流程缺口:我在清单里指定了ref,但最初脚本允许ref为空时默认拉取 main。这个"默认"看似贴心,实则危险。一旦有人没在清单里写明标签,脚本就会静默拉 main,构建通过后发布负责人根本注意不到标签和提交已经漂移。修复方案是把脚本改成强校验:清单里的每个仓库必须显式指定非空标签,不存在任何默认分支回退逻辑;拉齐之后还会校验收到的提交 SHA 是否与远端标签指向一致,不一致直接中止。从那以后,再没出现过"验证的是 A 代码,发布的是 B 代码"的情况。
| 故障 | 现象 | 根因 | 修复方式 |
|---|---|---|---|
| 镜像提交 | Git 历史出现一列相同内容提交 | 同步脚本自动回写触发循环触发 | 同步只读,禁止自动推送 |
| 旧版本引用 | 构建通过但依赖仍为旧程序集 | NuGet 本地缓存未强制刷新 | restore 增加 --no-cache |
| 标签漂移 | 验证代码与发布代码不一致 | ref 为空时默认拉取 main | 强校验非空标签,核对提交 SHA |
5. 如果你也想做虚拟单体仓库,我的五点建议
5.1 先小范围验证,再全量铺开
我建议第一步不要直接同步所有仓库。选一条清晰的依赖链,比如一个基础库加两个服务,先跑通同步、整体构建、发布验证的闭环。小范围的好处是出了问题能快速定位到某个仓库,不至于在十多个仓库之间排查。我们就是用一条核心交付链验证了两周之后,才把范围扩大的。
5.2 同步脚本必须幂等,输出要可复现
这是整套机制的底线。同步脚本要在任意时间重复执行,且每次得到相同结果。我踩过的教训是,一开始用了大量的增量判断,提前判断某个仓库是否已存在本地缓存,结果出现"这次同步了、下次又没同步"的奇怪状态。改成每次都完整校验清单、每次都强制 checkout 到指定提交后,行为才稳定。宁可慢一点,也不要不可复现。
5.3 用统一前缀约束分支与标签
发布分支、同步标签、走后端流水线时的版本号,全部使用统一前缀。我们的规范是sync/前缀的标签专门给虚拟单体同步使用,普通仓库自己的功能标签用各自语义化版本。前缀一旦混乱,同步脚本里的匹配逻辑就会失效,甚至可能把业务版本误认成同步版本,导致拉取错误代码。建议在一开始就把命名收敛好。
5.4 别忽视本地开发视图的同步
虚拟单体同步不能只在 CI 上做。我们可以让统一工作区里的开发环境也有"发布快照"的概念:本地开发通常在共享主分支上进行,配合虚拟单体的清单可以快速把相关仓库都 checkout 到同一个主线状态,再生成本地解决方案。这样一来,跨仓库开发不必手工处理项目引用,只需一条命令把工作区恢复成同步快照即可。日常开发效率的提升,反而比 CI 上的收益更明显。
5.5 时刻准备推翻"虚拟"方案
这句话比听起来实际得多。虚拟单体仓库不是一个历史终局,它是在"仓库拆分"和"代码统一"之间寻求平衡的产物。当同步成本开始超过合并成本,比如仓库提交频繁、整体验证需求极高、依赖图复杂度趋于一体的阶段,我会毫不犹豫重新评估真正的单体仓库迁移。我们当前的团队规模和维护节奏仍然适合虚拟单体,但方案本身不要神化,它是工具,不是信仰。
最后再说一点个人体会。这套机制跑顺之后,最大的变化不是构建次数多了,而是跨仓库改动不再靠"微信群对齐"了。任何一次公共接口变更,只要能通过整体构建和配套测试,团队就敢继续往下走。同步工具不解决问题本身,它只是把问题变成一个可以被验证、被回溯、被检查的状态。虚拟单体仓库的下一篇文章,我会重点写统一工作区里的版本对齐和产物产出,那也是我们踩得最深的一部分。如果你也在折腾类似的东西,欢迎按照这套思路先搭一个最小闭环试试。