
我到现在还对那个周五记忆犹新。上线前最后一刻后端接口加了一个必传参数前端仓库的 PR 已经合了但 SDK 仓库的新版本还没发出来联调环境里一片红灯。当时我们的代码分成十几个仓库每次这种跨仓库改动都像在走钢丝——所有相关方必须在同一时间点完成合并、发版、部署只要有一个环节慢了整体就卡住。后来我们才下定决心往 Monorepo 迁移。经过几个月的折腾我对 Monorepo 的认识也从最开始的把所有仓库合并成一个变成了一种需要重新审视代码组织、依赖管理和协作方式的方法论。这篇文章想把自己梳理过的东西整理一遍给正在被多仓库问题困扰的人一个参考。文章不会只讲概念定义还会把为什么迁、迁了之后解决了什么、又引入了哪些新麻烦、工具链怎么选这些实操问题一并说清楚。1. 先从仓库形态说起Monorepo 到底在说哪件事1.1 单仓库与多仓库两种基础的代码组织形态要理解 Monorepo得先退一步看清楚仓库这个词在工程体系里扮演的角色。仓库是代码存储的容器也往往决定了你从哪拉分支、在哪跑流水线、在哪做代码评审。绝大多数团队的组织方式可以归成两类多仓库Multi-repo / Poly-repo每个项目、每个服务、每个基础库各自独立建仓仓库之间通过依赖包管理器npm、Maven、Go Modules 等相互引用。单仓库Monorepo多个项目、多个服务、多个包的代码放在同一个版本控制仓库里管理通过目录结构和工作区workspace机制在逻辑上隔离。注意逻辑上隔离这个词。Monorepo 不是说把所有代码乱七八糟塞进一棵目录树而是在同一个 Git 仓库里划分出清晰边界。比如 my-monorepo/packages/ui、my-monorepo/services/api-gateway、my-monorepo/apps/admin-web 各自拥有独立的 package.json / 构建配置 / 测试脚本彼此之间像同一个仓库里的多个独立项目一样协作。我第一次给团队画这张关系图的时候用了很多版本我们的前端后台三个仓库、React 组件库一个仓库、Node 中间层一个仓库……绕了半天其实就是多仓库形态。后来统一到 monorepo 之后只需要说一句代码在 monorepo 的 apps 和 packages 目录下就够了。1.2 一个常见的误区代码多不等于 Monorepo很多人把 Monorepo 等同于巨大的仓库这是最常见的误解。仓库大不大不是关键关键在于仓库里是否有多个独立版本、独立生命周期的组件或应用并且这些组件和应用共享同一个仓库的版本控制历史。一个单体应用的代码虽然也可能上百万行但它只有一个部署单元、一个构建入口那是 Monolith不是 Monorepo。Monorepo 里的多个项目应该各自可以独立构建、独立发布只是在源码依赖关系上可以互相引用、统一编排。判断标准其实很简单如果你只想改其中一个子项目能不能只跑那个子项目的测试、只构建那个子项目的产物、只发布那个子项目的版本如果可以那这就是 Monorepo如果所有代码必须一起构建、一起发布那无论仓库多大都只是单体应用。我见过有人把整个公司的业务代码全塞进一个仓库构建脚本里写死一串串顺序打包命令改一个前端页面要触发全量后端编译——这不是 Monorepo这是把多个项目强行合仓属于移植了形制但没掌握方法。这类情况往往比原来的多仓库更痛苦。1.3 大仓库不是一夜形成的从单体应用到微服务化的路径观察一个典型团队的仓库演进路径往往是这样的最开始一个单体应用一个仓库后来业务复杂了把模块拆成独立服务、独立包于是每个服务一个仓库再后来基础库抽出来、前端项目拆开仓库数量越来越多直到跨仓库改动变得难以协调又开始琢磨要不要把部分仓库合并回去。这条路走下来你会发现多仓库的很多问题不是一开始就有的而是协作规模和依赖关系复杂到一定程度之后才爆发的。Monorepo 并不是要回到单体而是想在多项目、多服务、多包的基础上恢复一定程度的统一治理能力。理解了这条演进路径就不会把 Monorepo 当成一种激进的重构而会把它看成一种对失控状态的纠偏。2. 为什么那么多团队在往 Monorepo 迁直接痛点比情怀更诚实2.1 跨仓库改动的原子性问题一次上线需要多个仓库同时发版我说说真实的多仓库协作场景。前后端分离、中间有协议仓库或接口定义仓库的时候一个需求往往要拆成三个改动前端仓库改调用方、SDK 仓库改接口实现、协议仓库改 schema。在 Git 层面它们彼此独立天然没有一起提交的概念。于是你只能靠人工约定来协调先合协议仓库再合 SDK 仓库再发布新版本最后等前端升完依赖再合前端代码。任何一个环节提交晚了、发布失败了、或者 reviewer 提了个大改动整条链路的节奏全部打乱。上线窗口一过改动只能延到第二天一天的工作白等。我在那家创业公司里最怕的就是周五下午四点跨仓库改动基本等于向加班宣告投降。因为留给协调的时间窗口只有不到两小时而涉及的仓库可能分布在两个语言生态里每个仓库的 CI 都得跑完一轮。这种原子性问题是 Monorepo 最直接的解决点一个需求的代码改动可以同时出现在同一个 PR 里评审、测试、合并、发布都围着一次改动来进行。回滚也一样git revert 可以把跨包改动一次性全部撤销不存在前端回滚了但服务端新版本还在线上的中间态。2.2 依赖发版的版本地狱从 compile 到 release 的连锁反应多仓库还有一个让人头大的问题——内部包的版本管理与发布。假设你维护一个公共组件库在十几个业务仓库里被引用。今天组件库修了一个 bug你需要发布一个 patch 版本然后每个业务仓库都要手动升一下依赖、跑一遍测试、各自发布。稍微多几层依赖版本地狱就出现了。公共库 A 依赖公共库 B业务仓库依赖 A。B 发了个新版本A 需要升级依赖并重新发版业务仓库又需要升级 A 才能拿到修复。这一条链路上的每一个仓库都要有人动手任何一个步骤被遗忘就会造成线上环境的版本漂移——同一个服务在不同环境里跑着不同版本的公共库。Monorepo 里面这种内部依赖通常用 workspace 协议直接指向源码目录而不是指向一个已发布的版本号。开发时你改了公共库的代码引用它的业务项目立刻就能感知到构建和发布阶段再按需绑定版本。这相当于把编译期依赖和发布期依赖解耦日常开发不再被版本号节奏卡住。2.3 门禁、CI 配置和多仓库权限维护带来的团队摩擦多仓库看起来每个仓库相对独立、权限隔离但实际上有大量隐性维护成本。每个仓库都要各自配置 CI 流水线、分支保护规则、质量门禁、依赖扫描任务某个公共的 Shell 脚本或者 Code Review 规范更新了你需要遍历所有仓库去同步。权限问题也很有意思。多仓库时代团队一般按仓库划分权限前端团队只能碰前端仓库后端团队只能碰后端仓库。听起来很规范但遇到后端接口需要微调一下返回结构、前端需要跟着改这类事务跨仓库的权限申请流程经常要走上半天。最后大家变成了互相信任的绕过者——找后端同事帮忙改一下或者临时开个权限权限模型形同虚设。从治理角度看多仓库是边界清晰但流动困难Monorepo 是流动方便但边界需要显式重新定义。后者不是天然更优只是对于协作频度高、共用基础库多的团队来说它把摩擦点从仓库边界转移到了代码所有者机制上后者处理起来更灵活。3. Monorepo 的核心收益不是集中管理这么简单3.1 原子提交与跨包重构需求变更的天然最小单元前面提到的原子提交通常被看作 Monorepo 的杀手锏但它带来的连带收益经常被低估——跨包重构能力。在一个仓库里你可以放心地做一个跨多个包的 API 重命名因为 IDE 的全局引用分析可以覆盖所有相关代码一次 PR 就能完成改动、测试与评审。多仓库时代我做过一次公共 SDK 的破坏性升级前后花了两周。第一周在 SDK 仓库改代码、发内部版本第二周辗转于六个业务仓库分别升级依赖、适配新接口、各自跑测试。中间还遇到一个业务仓库的负责人请假我只好远程找他们团队成员帮忙对方还要临时熟悉我改的东西。这种重构的隐性成本大得惊人。而在 Monorepo 里做同样的重构我会直接全局搜索所有调用点一次性修改、一次性提 PRreviewer 能看到完整的变更上下文。代码评审的质量也会高很多——因为评审的是一件事的完整逻辑而不是把同一件事拆成三份互不关联的 PR 去猜来猜去。3.2 依赖统一解析与构建缓存摆脱重复安装带来的确定性多仓库里经常出现同一个依赖各自仓库装了一份版本还不一样的情况。A 仓库用了 lodash 4.xB 仓库还在用 lodash 3.x两个仓库的服务相互调用时因为某个对象深层结构不同产生了诡异的线上问题。这类问题查起来极其痛苦因为看起来是业务逻辑问题实际是依赖版本漂移。Monorepo 的 workspace 机制能把大部分依赖提升到仓库根目录统一安装、统一解析。所有子项目共享同一份锁文件同一个依赖在仓库里只有一个解析结果从源头避免了同一依赖多版本并存的脏状态。这里说的不是管得更好而是依赖关系变得确定——同样的代码在 CI 和本地还原出来的依赖树完全一致很多在我机器上是好的问题就消失了。构建缓存是另一个容易感知的收益。多仓库时代每个仓库的 CI 都是从零安装依赖、从零构建公共逻辑没法跨仓库复用。Monorepo 配合现代构建编排工具后可以做到只有变更过的包及其下游包才重新构建其余直接命中缓存。仓库看起来很大但一次改动触发的实际计算量反而可能比多仓库时代还小。3.3 全局可见性与团队协作从信息孤岛到统一代码评审代码仓库不仅是存储工具它还是团队信息可见性的边界。多仓库模式下每个仓库的代码像一座孤岛业务团队之间很难互相了解到对方正在改什么、底层库变成什么样了。很多重复造轮子的项目之所以发生就是因为大家互相看不到各自的实现。Monorepo 之后整个组织的代码都在一棵目录树里做技术调研时 grep 一遍就能知道已有方案。新人 onboarding 也轻松很多不用逐个 clone 十几个仓库、各自安装依赖、折腾环境问题clone 一个仓库再按文档引导打开对应包就行。我记得以前带新人最怕的就是环境搭建十几个仓库每个都有不同的 Node 版本要求、不同的环境变量模板。迁到 Monorepo 后环境统一了新同事第一天就能把开发环境跑起来这种体验差异特别真实。4. 天上不会掉馅饼Monorepo 的隐性成本和适用边界4.1 代码量本身不是问题工具链和扩展性才是问题很多团队对 Monorepo 望而却步的第一原因是仓库会不会太大、Git 会不会卡死。实际上 Git 仓库的性能主要跟文件数量、二进制大文件、历史深度相关跟代码行数没有线性关系。常见的文本代码仓库几万到几十万个文件Git 都还能应付。真正的挑战在工具链。单仓库规模变大后全量跑测试和构建会慢到不可接受必须在构建编排层面做增量计算所有包的依赖需要统一解析引入一个坏依赖可能影响整个仓库IDE 索引大仓库也考验机器性能需要做好忽略目录和按需加载。所以 Monorepo 迁移不是把仓库搬一起就完事而是必须配套工具链改造。我甚至建议先把构建缓存和增量测试跑通再动手合并仓库。顺序反了的话迁移后的前两周会让人非常怀疑人生。4.2 权限模型与分支策略单仓库里的团队隔离怎么做多仓库给每个团队天然的仓库级权限边界这一点 Monorepo 做不到。Git 的权限模型是仓库级的你不能在同一个 Git 仓库里给不同目录设置不同的 push 权限。很多团队在这一点上纠结了很久。业界的通行做法是靠 CODEOWNERS 机制在仓库根目录定义一个文件声明哪些路径由哪个团队负责合并请求时只要改了对应路径就必须获得该团队成员的 review 通过才能合并。这确实能约束谁能批准合并但它约束不了谁能看代码——如果公司有严格的代码保密域隔离需求Monorepo 反而不合适。分支策略上也需要注意。多仓库时代每个仓库各自维护长期分支很常见但在 Monorepo 里多个团队共享主干分支长期存在的功能分支一多合并冲突和管理成本会迅速上升。我自己更推荐 trunk-based 或者至少是短生命周期分支策略配合 feature flag 来控制功能上线。这不光是偏好问题而是 Monorepo 的场景下长分支的代价会被放大到让人无法忽视。4.3 什么时候不适合 Monorepo判断边界与退路结合自己的经验我觉得这几种场景暂时不太适合一窝蜂上 Monorepo组织没有任何共享基础库各团队代码之间几乎没有相互引用。合并后只能收获麻烦拿不到协作收益。有严格的安全或合规隔离要求某些模块的源代码可见性必须限制在小范围人员内。Monorepo 的全员可见基因会直接戳破这个需求。携带超大二进制资产的项目比如游戏客户端、大型离线模型Git 仓库体积会迅速膨胀到不可维护。这类项目即使合并也应该把二进制资产放到 Git LFS 或独立的对象存储里。团队没有专职或兼职的工程效能人员。Monorepo 的工具链踩坑是持续的没有人投入持续维护迁移后的体验可能比迁移前更差。我给团队的判断标准就一句话如果你们平时有大量跨仓库 PR且这些 PR 合并节奏对不齐Monorepo 值得认真考虑如果你们各仓库之间像陌生邻居一样很少串门那就别凑热闹了。另外也要留好退路——合并历史时用 git filter-repo 之类的工具保留原提交记录后续万一要拆仓也不会丢失历史脉络。5. 支撑 Monorepo 运转的工具链概念落到实践的关键5.1 包管理器与 workspace 机制npm/yarn/pnpm 的选择Monorepo 的底层基础是包管理器的 workspace 能力。npm 7、Yarn 1.x 的 workspace 模式、Yarn Berry、pnpm 都支持在根目录统一管理多个子包。几个关键函数根目录的配置文件声明仓库里有哪些子包glob 模式或精确路径。安装依赖时把子包之间相互依赖的版本链接成工作区引用而不是去 registry 下载。子包内部的 node_modules 会做符号链接symlink或提升hoist保证共享依赖只装一份。我现在的默认选择是 pnpm原因在于它用符号链接模拟 node_modules 的方式更严格。pnpm 默认不会把所有依赖都提升到根目录子包只能用自己在 package.json 里显式声明的依赖这天然避免了 JS 生态里幽灵依赖问题——那种依赖没声明但靠提升侥幸能用的写法在 pnpm 严格模式下直接被拦死。# pnpm-workspace.yaml 示例 packages: - apps/* - packages/*5.2 增量构建与本地缓存的效率来源Monorepo 变大后效率的胜负手在于能不能不重跑没变过的东西。这有两个层次任务级别的增量通过输入文件的 hash 来判断某个构建、测试、lint 任务是否已经跑过跑过且输入没变就直接复用产物。依赖图级别的增量根据子包之间的 import 关系生成依赖图只有一个子包的输入变化时只重跑该子包及其下游包的任务。这两个层次的增量分别对应两类工具任务运行器Task Runner和构建系统Build System。前者的代表是 Turborepo、Nx 的任务编排部分后者的重武器是 Bazel、Buck 这类带远程缓存的构建系统。以 Turborepo 为例它通过 turbo.json 声明任务的输入输出文件范围每个任务跑完会生成一份缓存。下次任务触发时对比相同的输入集合命中缓存就直接从本地或远程缓存里拉结果不重新执行。{ tasks: { build: { dependsOn: [^build], outputs: [dist/**] }, test: { dependsOn: [^build], outputs: [coverage/**] } } }5.3 Bazel/Turborepo/Nx 等构建编排工具的定位差异工具选型经常是团队纠结最多的地方。我简单梳理一下主流工具的不同位Lerna老牌 Monorepo 工具链早期主要解决版本发布和跨包执行脚本的问题生态成熟但任务编排和缓存能力相对薄弱。现在很多团队已经把 Lerna 和 pnpm workspace 结合使用Lerna 管发布pnpm 管依赖链接。Turborepo强调最快构建无限缓存配置简单接入成本低适合中小规模仓库和 JS/TS 生态。缺点是任务模型没有 Bazel 那么严格跨语言支持一般。Nx功能比 Turborepo 更重内置了项目图Project Graph和影响范围affected分析对 Angular/React/Node 生态支持好也支持插件生态适合想长期演进的中大型团队。Bazel谷歌的开源构建系统功能极其强大支持多种语言、远程构建、sandbox 隔离、分布式缓存但学习曲线非常陡峭需要专门的工程效能投入。适合超大规模仓库和多语言组织。选择时要坦诚面对团队规模和维护能力。我们当时团队只有十来个人选了 Turborepo 因为配置工作量小几天就能跑起来。如果一开始就奔着 Bazel 去估计迁移还没完成人先被复杂度劝退了。工具学习成本增量缓存发布能力适用规模npm/yarn/pnpm workspace低无/弱弱小型Lerna 包管理器中无/弱强中小型Turborepo中低强弱中小型Nx中强中中大型Bazel高极强中超大型、多语言5.4 CI 侧的按需触发只有变化过的包才跑测试Monorepo 的 CI 设计跟多仓库有本质不同。多仓库的 CI 就是这个仓库一变就全量跑Monorepo 如果再这么玩一次公共库改动会触发几十个下游项目全量构建和测试CI 队列直接爆炸。要解决这个问题CI 需要做三件事变更检测拿到本次提交相对目标分支改动过的文件路径映射到对应的子包。下游传播通过依赖图计算哪些包受到这次变更的影响包括直接改的包和间接依赖它的包。定向执行只对这些受影响包执行构建、测试、lint其他包直接跳过或者复用缓存。这套逻辑在 Turborepo 里叫 affected 任务在 Nx 里叫 affected projects在 Bazel 世界里靠目标图自动完成。按需触发的边界条件是依赖关系要准确所以构建编排工具里的依赖图管理是 Monorepo CI 的心脏值得多花时间维护。6. 最后一个容易被忽视的维度Monorepo 对人和流程的影响6.1 从仓库权限到代码所有者的习惯转变迁移 Monorepo 之后最大的不习惯往往不是技术而是协作方式的改变。以前在各自仓库里自己是毫无疑问的主人现在大家住进了同一个大院隐私和特权都减少了。一个前端工程师可以直接搜到后端某段逻辑的源码虽然不是他负责的领域但他会忍不住评价两句——这种跨领域的信息流动是好事但也需要引导。团队应该尽早建立 CODEOWNERS 机制明确模块归谁所有、变更必须经过谁评审。我见过没有 Owner 机制的 Monorepo最终走向是什么都可以改、什么责任都不清线上出问题后互相看对方代码像看陌生人一样。因此迁移 Monorepo 的时间线里最好把 CODEOWNERS 的梳理和确立放在同一步骤里一起做。6.2 Git 历史管理与大仓克隆体验基础设施要跟上Monorepo 的克隆体验不能拿裸 Git 的原生表现来要求。几十 GB 的仓库直接 clone 会让人崩溃需要一系列手段配合浅克隆shallow clone只有需要完整历史的 CI 和本地开发者才拉全量历史日常开发可以只拉最新提交。稀疏检出sparse checkout只 checkout 自己关心的目录配合 filter blob 实现按需文件加载。部分克隆partial cloneGit 2.x 支持延迟获取 blob用到哪个文件再补拉。Git LFS二进制资产放 LFS避免仓库无限膨胀。基础设施升级是 Monorepo 落地的前置条件。团队里如果有人用机械硬盘加老旧笔记本克隆一个几 GB 的仓库可能要等十几分钟这种体验会让全员对迁移产生敌意。所以迁移前我会先让基础设施团队确认 Git 服务端配置和开发者本地环境能扛住新形态。6.3 迁移 Monorepo 不是一次性的搬运而是一种持续演进还有一个观念要纠正Monorepo 迁移不是周末搬完代码、周一大家就用新仓库的事件而是一个需要持续投入的组织演进。合并当天只是起点后续还要持续调整包边界、优化构建缓存命中率、完善 CI 策略、制定版本发布规范。我们迁移完成后的头两个月几乎每周都在调构建配置某次发现测试任务的缓存命中率只有 40%排查后发现是因为测试文件里把绝对路径写进了快照某次发现公共包引入了跟业务包冲突的全局样式被迫在包边界上加 lint 规则。这些坑不深入使用根本发现不了做的时候会觉得琐碎但每解决一个仓库的健康度就上一个台阶。另外也不是所有东西都必须永远待在 Monorepo 里。当某个包规模过大、团队边界足够清晰、独立演进的需求远大于协作需求时把它拆出去也没问题。Monorepo 不是一个宗教般的铁律而是一种动态平衡的仓库组织方式。我在实际落地过程中感受最深的一点是Monorepo 的价值高度依赖团队的自律程度。它把物理隔离变成了逻辑隔离这要求每个人都更自觉地在包边界、依赖声明、CODEOWNERS 评审等细节上遵守约定。没有这些约定一个整洁的 Monorepo 会退化成一潭乱麻。所以每个准备上车的团队都应该先认真回答一个问题你们需要的到底是一棵更整齐的代码树还是一整套协作规则答案若只是前者那这次迁移大概率会在半年后变成新的负担。