Language:Chinese VersionEnglish Version

三年前,单体仓库(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-appadmin-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 混乱的关键规则:

  1. 应用从不从其他应用导入。 只从包中导入。
  2. 包声明显式依赖关系。 不依赖于提升的 node_modules。
  3. 共享类型是契约。 shared-types 包是 API 契约、数据库模型和共享接口的单一事实来源。
  4. 一个 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。

迁移策略:渐进式方法

不要一次性迁移所有内容。有效的方法是:

  1. 第一周: 创建包含共享配置包(ESLint、TypeScript、Prettier)的单体仓库。迁移一个低风险的服务。
  2. 第二至三周: 将共享类型和工具提取为包。迁移第二个与第一个共享代码的服务。
  3. 第四至六周: 一次迁移一个剩余的服务。将旧仓库设为只读模式,直到团队有信心为止。
  4. 第七至八周: 设置远程缓存,优化 CI,归档旧仓库。

关键洞见:你的单体仓库在第一天并不需要包含所有内容。从共享代码最多的服务开始,然后逐步扩展。

展望未来

单体仓库的趋势并未减缓。像 pnpm 这样的包管理器已经使工作区管理变得轻而易举。CI 提供商正在添加原生的单体仓库支持。即使是 Rust 的 Cargo 工作区也遵循相同的模式。

如果你仍在管理 5 个以上共享代码的仓库,2026 年是整合它们的一年。工具已经准备就绪,模式已经得到验证,生产力提升也是实实在在的。如果你在 JavaScript 生态系统中,可以从 Turborepo 开始;如果你需要更多结构,可以选择 Nx;如果你使用多种语言,则可以选择 Bazel。多仓库的代价已经不再值得付出。

By

Leave a Reply

Your email address will not be published. Required fields are marked *

You missed