三年前,单体仓库(monorepos)是一种与谷歌和 Facebook 相关的小众策略。到2026年,它们已成为任何管理两个以上互联服务的团队的默认架构。工具链已经成熟,痛点得到了解决,而替代方案——特别是多仓库设置——在现代开发工作流程的压力下开始显现其缺陷。
我在2025年底将一个包含14个服务的SaaS平台从独立仓库迁移到了Turborepo单体仓库。本文分享了我学到的经验、当前生态系统的情况,以及为什么单体仓库运动不再只是炒作。
无人谈论的多仓库问题
多仓库架构在理论上听起来很清晰。每个服务都有自己的仓库、自己的CI/CD流水线、自己的版本控制。独立。自主。自由。
在实践中,实际情况是这样的:
- 依赖漂移:服务A使用shared-utils@2.1.0。服务B停留在shared-utils@1.8.3,因为没有人有时间升级。服务C完全分叉了这个库,因为升级破坏了某些功能。
- 跨领域变更需要N个拉取请求:重命名API架构中的字段意味着需要在6个仓库中打开PR,协调合并,并希望没有人乱序部署。
- 工具链不一致:每个仓库的ESLint配置略有不同,Docker设置略有不同,CI流水线也略有不同。新开发者需要花第一周时间来理解这些差异。
- 代码审查碎片化:代码审查者因为变更分散在多个仓库中而失去上下文。如果不查看仓库B中相应的PR,仓库A中的PR就毫无意义。
真正的成本不在于其中任何单一问题,而在于它们的复合效应。每次跨领域变更都变成了一个项目,而不是一次提交。
工具链发生了什么变化
2020年的单体仓库需要大量自定义工具。你需要Lerna(实际上已被放弃)、用于构建编排的自定义脚本,以及能够处理选择性构建的CI系统。这足够痛苦,以至于许多团队尝试后都退却了。
2026年的格局根本不同:
Turborepo
Turborepo在2022年被Vercel收购,已成为JavaScript/TypeScript单体仓库的标准。仅其远程缓存就节省了大量CI时间。典型配置如下所示:
// turbo.json
{
"globalDependencies": ["**/.env.*local"],
"globalEnv": ["NODE_ENV"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
},
"lint": {},
"deploy": {
"dependsOn": ["build", "test", "lint"],
"cache": false
}
}
}
关键创新在于依赖图。Turborepo 理解,如果你更改了 shared-ui 包,它需要重新构建 web-app 和 admin-dashboard,但不需要重新构建 api-server。这种选择性执行使得 monorepo CI 成为可能。
Nx
Nx 采用了更加明确的方法。虽然 Turborepo 专注于作为构建编排器,但 Nx 提供了生成器、插件和完整的开发工作流。对于较大的团队(50+ 开发者),Nx 通常是更好的选择,因为它强制执行一致性:
# 使用适当的边界生成新库
npx nx g @nx/js:library shared-validators --directory=libs/shared/validators --tags="scope:shared,type:util"
# 在 .eslintrc 中强制执行模块边界
{
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{ "sourceTag": "scope:web", "onlyDependOnLibsWithTags": ["scope:shared"] },
{ "sourceTag": "scope:api", "onlyDependOnLibsWithTags": ["scope:shared"] }
]
}
]
}
模块边界强制执行的功能被低估了。它防止了 monorepo 变成一个混乱不堪的依赖关系网,其中所有内容都相互依赖。
Bazel 和 Buck2
对于多语言 monorepo(混合使用 Go、Rust、TypeScript、Python),Bazel 仍然是黄金标准。Buck2,Meta 对 Buck 的重写,在 2025-2026 年凭借更好的用户体验获得了关注。这些工具对大多数团队来说过于复杂,但当您真正需要跨多种语言构建具有密封可复现性的项目时,它们是必不可少的。
真正有效的架构
在迁移了多个项目并与完成相同工作的团队咨询后,以下是始终运行良好的 monorepo 结构:
monorepo/
├── apps/
│ ├── web/ # Next.js 前端
│ ├── api/ # Express/Fastify 后端
│ ├── admin/ # 管理面板
│ └── worker/ # 后台任务处理器
├── packages/
│ ├── config-eslint/ # 共享 ESLint 配置
│ ├── config-typescript/ # 共享 tsconfig
│ ├── shared-types/ # 跨应用共享的 TypeScript 类型
│ ├── shared-utils/ # 纯工具函数
│ ├── ui/ # 共享 UI 组件库
│ └── database/ # Prisma 模式和客户端
├── tools/
│ ├── scripts/ # 构建和部署脚本
│ └── generators/ # 代码生成器
├── turbo.json
├── package.json
└── pnpm-workspace.yaml
防止 monorepo 混乱的关键规则:
- 应用从不从其他应用导入。 只从包中导入。
- 包声明显式依赖关系。 不依赖于提升的 node_modules。
- 共享类型是契约。
shared-types包是 API 契约、数据库模型和共享接口的单一事实来源。 - 一个 CI 流程,选择性执行。 不要为每个应用维护单独的 CI 配置。
实际性能数据
以下是我之前提到的迁移中的实际测量数据:
| 指标 | 多仓库 | 单仓库 (Turborepo) |
|---|---|---|
| 完整 CI 运行(所有服务) | 47 分钟 | 12 分钟(缓存) |
| 跨领域类型变更 | 6 个 PR,约 2 小时 | 1 个 PR,约 20 分钟 |
| 新开发者入职 | 3 天 | 1 天 |
| 依赖更新(共享库) | 6 个 PR,1-2 天 | 1 个 PR,即时 |
| CI 成本(每月) | $840 | $310 |
CI 成本的减少几乎完全来自远程缓存。当构建工件已存在于缓存中时,Turborepo 会完全跳过构建。在一个 12 人的团队中,第一个月后缓存命中率稳定在 78% 左右。
Git 性能问题
最常见的反对意见是:”Git 在大型仓库中会不会变慢?” 在 2026 年,这个问题在很大程度上已经得到解决:
- Git 稀疏检出 让开发者只克隆他们需要的目录。前端开发者不需要每个后端服务的完整历史。
- Git 内置的文件系统监视器 (
fsmonitor) 大大加快了大型仓库上的git status速度。 - Scalar(来自微软,现在是 Git 的一部分)支持部分克隆和后台维护。
# 启用稀疏检出以实现更快的克隆
git clone --filter=blob:none --sparse https://github.com/org/monorepo.git
cd monorepo
git sparse-checkout set apps/web packages/ui packages/shared-types
# 启用文件系统监视器以加快 git status 速度
git config core.fsmonitor true
git config core.untrackedcache true
对于小于 50GB 的仓库(这涵盖了绝大多数单仓库),在这些设置启用的情况下,Git 性能不是一个实际问题。
单仓库世界中的 CI/CD
CI 策略是大多数单体仓库迁移的绊脚石。简单的方法——每次推送都运行所有内容——违背了初衷。以下是在 GitHub Actions 中有效的方法:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.filter.outputs.changes }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
web:
- 'apps/web/**'
- 'packages/ui/**'
- 'packages/shared-types/**'
api:
- 'apps/api/**'
- 'packages/database/**'
- 'packages/shared-types/**'
build:
needs: changes
runs-on: ubuntu-latest
strategy:
matrix:
package: ${{ fromJSON(needs.changes.outputs.packages) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build test lint --filter=${{ matrix.package }}
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
这种方法结合了基于路径的过滤(只对变更区域运行作业)和 Turborepo 的构建缓存(跳过已构建的包)。结果是 CI 的扩展性与仓库大小呈对数关系,而非线性关系。
何时单体仓库不合适
单体仓库并非普遍适用。在以下情况下,它们并不合适:
- 团队没有共享代码。 如果您的服务确实没有任何共享——没有类型、没有工具、没有配置——那么单体仓库只会增加复杂性而没有任何好处。
- 存在监管边界。 某些合规框架要求代码库严格分离。单体仓库可以通过适当的访问控制来满足这一要求,但这会增加开销。
- 您的团队只有一个人。 一个拥有两个服务的独立开发者不需要构建编排。保持简单。
- 技术栈差异巨大。 React 前端和 Rust 后端如果没有共享的工件,放在同一个仓库中没有好处,除非您使用 Bazel。
迁移策略:渐进式方法
不要一次性迁移所有内容。有效的方法是:
- 第一周: 创建包含共享配置包(ESLint、TypeScript、Prettier)的单体仓库。迁移一个低风险的服务。
- 第二至三周: 将共享类型和工具提取为包。迁移第二个与第一个共享代码的服务。
- 第四至六周: 一次迁移一个剩余的服务。将旧仓库设为只读模式,直到团队有信心为止。
- 第七至八周: 设置远程缓存,优化 CI,归档旧仓库。
关键洞见:你的单体仓库在第一天并不需要包含所有内容。从共享代码最多的服务开始,然后逐步扩展。
展望未来
单体仓库的趋势并未减缓。像 pnpm 这样的包管理器已经使工作区管理变得轻而易举。CI 提供商正在添加原生的单体仓库支持。即使是 Rust 的 Cargo 工作区也遵循相同的模式。
如果你仍在管理 5 个以上共享代码的仓库,2026 年是整合它们的一年。工具已经准备就绪,模式已经得到验证,生产力提升也是实实在在的。如果你在 JavaScript 生态系统中,可以从 Turborepo 开始;如果你需要更多结构,可以选择 Nx;如果你使用多种语言,则可以选择 Bazel。多仓库的代价已经不再值得付出。
