51.4 单体仓库 vs 多仓库
一句话
这不是“代码放几个仓库”的形式问题,而是两笔成本的交换:单仓把协调成本内部化(原子改动、统一版本、一次 CI),多仓把独立性外部化(独立发布、独立权限),代价是跨仓改动的版本对齐——决定因素是一次典型改动要同时动几个仓库。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “monorepo = 单体应用” | 仓库边界 ≠ 部署边界。一个 monorepo 里可以有 200 个独立部署的服务;反过来,把同一个服务拆进 5 个仓库(“多仓单体”)是最差的组合:既没有独立发布,又多了版本对齐 |
| “monorepo 一定要上 Bazel” | Bazel 解决的是“增量构建 + 影响分析 + 远程缓存”。Nx、Turborepo、pnpm workspaces、go.work、Maven/Gradle 多模块都能做到其中大部分;先有影响分析,再谈工具 |
| “多仓才是微服务” | 微服务是部署与边界概念:服务能否独立发布、独立伸缩、独立故障。仓库数只是代码组织方式,两者正交 |
| “单仓没法做权限隔离” | 目录级 ACL 与 CODEOWNERS 能解决“谁能改、谁能批”;解决不了的是物理隔离(涉密代码不能与普通代码同库)——这是合规问题,不是工程问题 |
一张表看全:八个数得清的维度
| 维度 | 单仓(monorepo) | 多仓(polyrepo) |
|---|---|---|
| 一次改动的原子性 | 一个 PR 改完所有调用方,CI 一次过 | 需要 N 个 PR 按顺序合,中间态必然存在“版本不匹配” |
| 依赖版本 | 一份锁文件,永远没有版本冲突 | 每个仓库各自锁版本,跨仓升级要逐个提 PR |
| CI 影响分析 | 必须做,否则每次全量 | 天然只跑本仓;跨仓影响靠人工判断 |
| 权限与可见性 | 目录级(CODEOWNERS/ACL),做不到物理隔离 | 仓库级,天然支持“只给某人某仓的读权限” |
| 克隆与工具开销 | 随历史线性增长,需要部分克隆/稀疏检出 | 每个仓都小,克隆快 |
| 独立发布 | 可以(按目录打 tag),但发布流水线要支持“只发某个包” | 天然独立;这也是它最大的优势 |
| 故障/权限隔离 | 一个仓被删/被投毒,影响全公司代码 | 爆炸半径限于单仓 |
| 跨团队协作 | 改别人的目录要 CODEOWNERS 审批,但看得到全貌 | 看不到别人代码,接口靠文档/契约 |
🔑 一句话判据:典型改动的“跨仓数”。如果你的日常改动经常要同时改 3 个以上仓库,单仓的收益(原子性、统一版本)远大于它的成本;如果每个仓库都能独立演进、消费者固定,多仓更省心。
决策表:你的情况选什么
| 你的情况 | 选什么 | 理由 |
|---|---|---|
| 单一产品,共用发布节奏,5–50 人 | 单仓 | 原子改动 + 统一版本锁文件,收益最大、工具最省 |
| 多产品线,客户/合规要求代码分区 | 多仓 | 物理隔离是合规要求,工程手段绕不过去 |
| 典型改动横跨 3 个以上仓库 | 合并成单仓(或至少共享依赖仓) | 跨仓版本对齐的成本已经超过单仓的 CI 成本 |
| 单仓克隆 > 5GB 或全量构建 > 30 分钟 | 先上影响分析 + 远程缓存,再考虑拆 | 拆仓只是把成本转移给开发者,不是消灭它 |
| 对外发布的 SDK / 开源库 | 每库一仓 | 发布边界与仓库边界对齐最省事 |
| 只有一个服务、一个团队 | 无所谓,跟习惯走 | 两种都谈不上成本 |
实测:单仓的命门是“影响分析”
单仓最常被抱怨的是“改一行要跑全量 CI”。这个成本能不能压下去,取决于反向依赖闭包算得准不准——改了包 A,所有(直接或间接)依赖 A 的包都要测。
下面这个实验用合成仓库量了一遍:120 个包、400 次提交,每次提交改 1–3 个包,包之间的依赖是随机 DAG(seed 固定,可复现)。分析器读 git diff-tree 找直接改动,再沿反向依赖求闭包:
package main
import (
"fmt"
"os"
"os/exec"
"regexp"
"sort"
"strings"
)
var (
impRe = regexp.MustCompile(`"example\.com/mono/(services/svc\d{3})"`)
pkgRe = regexp.MustCompile(`services/svc(\d{3})`)
)
func git(args ...string) string {
out, err := exec.Command("git", args...).Output()
if err != nil {
panic(err)
}
return string(out)
}
func main() {
// ① 建反向依赖图:被依赖的包 → 依赖它的包
const n = 120
rev := map[string][]string{}
for i := 0; i < n; i++ {
p := fmt.Sprintf("services/svc%03d", i)
f, err := os.ReadFile(p + "/svc.go")
if err != nil {
panic(err)
}
for _, m := range impRe.FindAllStringSubmatch(string(f), -1) {
rev[m[1]] = append(rev[m[1]], p) // m[1] 被 p 依赖
}
}
// ② 每个提交直接改了哪些包,再求反向闭包
var directHist, closureHist []int
for _, c := range strings.Fields(git("rev-list", "--reverse", "HEAD")) {
direct := map[string]bool{}
for _, f := range strings.Fields(git("diff-tree", "--no-commit-id", "--name-only", "-r", c)) {
if m := pkgRe.FindStringSubmatch(f); m != nil {
direct["services/svc"+m[1]] = true
}
}
if len(direct) == 0 {
continue
}
seen := map[string]bool{}
var queue []string
for p := range direct {
seen[p] = true
queue = append(queue, p)
}
for len(queue) > 0 {
p := queue[0]
queue = queue[1:]
for _, up := range rev[p] {
if !seen[up] {
seen[up] = true
queue = append(queue, up)
}
}
}
directHist = append(directHist, len(direct))
closureHist = append(closureHist, len(seen))
}
pick := func(xs []int, q float64) int {
s := append([]int(nil), xs...)
sort.Ints(s)
return s[int(float64(len(s)-1)*q)]
}
fmt.Printf("提交数(含改动): %d · 包总数: %d\n", len(closureHist), n)
fmt.Printf("直接改动: p50=%d p90=%d max=%d\n", pick(directHist, .5), pick(directHist, .9), pick(directHist, 1))
fmt.Printf("受影响闭包: p50=%d p90=%d p99=%d max=%d 均值=%.2f\n",
pick(closureHist, .5), pick(closureHist, .9), pick(closureHist, .99), pick(closureHist, 1),
float64(sum(closureHist))/float64(len(closureHist)))
// ③ CI 成本:每包测试 20 秒 + 固定开销 60 秒
const per, fixed = 20.0, 60.0
all := float64(n)*per + fixed
median := float64(pick(closureHist, .5))*per + fixed
p90 := float64(pick(closureHist, .9))*per + fixed
fmt.Printf("\n全量测试: %.0f 秒 (%.1f 分钟)\n", all, all/60)
fmt.Printf("只测受影响(中位): %.0f 秒 (%.1f 分钟) → 省 %.0f%%\n", median, median/60, (1-median/all)*100)
fmt.Printf("只测受影响(p90): %.0f 秒 (%.1f 分钟) → 省 %.0f%%\n", p90, p90/60, (1-p90/all)*100)
}
func sum(xs []int) int {
s := 0
for _, x := range xs {
s += x
}
return s
}
输出(本机实测,在合成 monorepo 根目录运行):
提交数(含改动): 400 · 包总数: 120
直接改动: p50=1 p90=3 max=3
受影响闭包: p50=3 p90=25 p99=73 max=77 均值=10.05
全量测试: 2460 秒 (41.0 分钟)
只测受影响(中位): 120 秒 (2.0 分钟) → 省 95%
只测受影响(p90): 560 秒 (9.3 分钟) → 省 77%
三个结论,第二个是反直觉的:
- 影响分析的收益是真的:中位数只测 3 个包(2.5%),41 分钟变 2 分钟。
- 但平均收益会骗人:闭包分布的尾巴很长——p50 只有 3,p90 是 25(21%),p99 是 73(61%),均值 10。按“平均省 95%”做容量规划,会在发布高峰遇到“p90 的 PR 要跑 9 分钟、p99 的几乎全量”的排队。容量要按 p90 规划,超时阈值按 p99 兜底(用
[34.5 性能测试与负载模型](../../L3-质量属性/34-性能/34.5-性能测试与负载模型.md)的方法给 CI 队列建负载模型)。 - 闭包大小由依赖形状决定,不由代码量决定:被测的包如果处在依赖链底层(公共库、协议包、通用工具),它的闭包天然接近全量。这类包的血统就是“改一次全公司重测”——所以公共库的变更要按最高标准评审(见 51.2 代码评审),这是单仓里最贵的目录。
实测:部分克隆与稀疏检出,只对大仓库有意义
“单仓太大克隆慢”的标准解法是 --filter=blob:none(部分克隆,只取提交与树,按需拉 blob)+ git sparse-checkout(只检出需要的目录)。它们在小仓库上是负优化,本机拿本书仓库(309 个文件、659 KiB pack)量了一遍:
| 方式 | 耗时 | 体积(含 .git) | 结果 |
|---|---|---|---|
全量 git clone | 0.77 秒 | 9.7 MB | 正常 |
git clone --filter=blob:none(本地路径) | 23.58 秒 | 10 MB | 检出时按需回源取 blob,小仓库反而慢 30 倍 |
--filter=blob:none(file:// 模拟远程) | 24.68 秒 | 10 MB(.git 4.3 MB) | 省了 .git 的历史对象,工作区不变 |
git sparse-checkout set --cone v2/L5-工程效能 | — | 5.0 MB | 检出 51 个文件(全量 309),.git 不变 |
两条结论:
- 部分克隆省的是“历史里的 blob”,不省“当前工作区”。检出时每个文件都要 blob,按需拉取会让“文件多、历史短”的仓库变慢。它真正的收益场景是“历史很长、blob 很大”(如十年历史的 50GB 仓库)。
- 稀疏检出让 IDE/搜索只看到需要的目录,但
.git不变——对“克隆慢”没用,对“编辑器卡”有用。二者常被混为一谈。
💡 量级参考:Google 公开过其单仓的规模(Potvin & Levenberg,《Why Google Stores Billions of Lines of Code in a Single Repository》,2016):约 20 亿行、86 TB、每天 4.5 万次提交。这个量级才需要虚拟文件系统(VFS for Git / Scalar)这类“把工作区也虚拟化”的方案。绝大多数公司的单仓在 1–10 GB 量级,用普通 git + 影响分析就够了,直接照搬 Google 的工具链是过度工程。
单仓里必须做对的三件事
| 必须做 | 不做会怎样 | 怎么做 |
|---|---|---|
| 目录即边界 | 目录之间随便 import,单仓退化成大泥球 | 每个包一个目录,依赖方向可检查(Go 用 internal 与 import 规则,见 26.6 模块化与包设计) |
| 目录即属主(CODEOWNERS) | 改谁的东西都没人负责,评审变成路人投票 | 每个目录至少 1 名属主;公共目录(/libs、/proto)两人以上 |
| 影响分析进 CI | 每次全量,单仓的成本优势归零 | 反向闭包 + 只跑受影响目标的测试;工具见下 |
版本策略是第三个容易吵起来的问题,单仓不强制统一版本:
| 策略 | 做法 | 适合 | 代价 |
|---|---|---|---|
| 统一版本(release train) | 全仓一个版本号,一起发 | 单一产品、强一致依赖 | 一个包不 ready 就全仓等 |
| 独立版本 | 每个包各自打 tag(pkg-a/v1.2.0) | 多产品共用基础库 | 发布流水线要支持“只发某个包” |
| 混合 | 对外发布的包独立版本,内部应用统一版本 | 大多数公司 | 需要明确“哪些包对外承诺兼容” |
Go 的实现方式是 go.work(工作区里多个模块互相 replace,不发布也能联调);前端是 pnpm workspaces / Turborepo(--filter 只跑受影响包);Java 是 Maven/Gradle 多模块 + 版本对齐插件。共同点:版本可以独立,但依赖解析必须统一——一份锁文件是单仓最值钱的东西,别在单仓里造出“每个包各自锁版本”的多仓问题。
落地:指名 / 可抄 / 可验 / 可退
指名:构建与影响分析用 Bazel(强,但学习曲线陡)、Nx(JS/TS 生态,图分析成熟)、Turborepo(轻量,缓存 + --filter)、pnpm workspaces(Node 包管理)、go.work(Go 多模块)。仓库侧用 GitHub CODEOWNERS + 目录级权限;超大仓再用 Scalar / VFS for Git 或 --filter + sparse-checkout。
可抄:
# ① 部分克隆 + 稀疏检出(大仓库专用;小仓库别用,见上表实测)
git clone --filter=blob:none <repo-url> && cd <repo>
git sparse-checkout init --cone
git sparse-checkout set v2/L5-工程效能
# ② Go 多模块单仓:把两个模块放进同一个工作区,改 A 立即被 B 看到
go work init ./services/a ./services/b
# ③ 按目录指定属主(GitHub,文件放 .github/CODEOWNERS)
printf '/libs/ @org/platform\n/services/a/ @org/team-a\n* @org/backend\n' >> .github/CODEOWNERS
# ④ 只跑受影响包的测试(Node 生态,未验证:本机未装 pnpm 依赖)
pnpm -r --filter "...[origin/main]" test
可验(本机真实输出):两条断言——① 闭包分析必须给出“中位数 ≪ 包总数、p99 接近包总数”的长尾分布,如果你的分布不是长尾,说明依赖图没建对(多数包互不依赖,说明这个“单仓”其实是几个独立目录拼在一起,可以拆);② 稀疏检出后 find . -name '*.md' | wc -l 必须显著小于全量,而 .git 体积不变——否则你以为在省克隆时间,其实没有。
$ git sparse-checkout set --cone v2/L5-工程效能
$ find . -type f | wc -l
51 # 全量是 309 个文件
$ du -sh .git
4.3M # 与全量克隆相同:稀疏检出不动历史
可退:从单仓拆出去,顺序比决心重要。先拆发布与权限,再拆仓库:
| 步骤 | 做什么 | 判据 |
|---|---|---|
| 1 | 该子项目独立打 tag、独立发布(仍在单仓) | 能单独回滚,不牵连其他项目 |
| 2 | 目录级权限 + 独立 CI 触发路径 | 改它不触发全仓流水线 |
| 3 | 抽成独立仓库,保留 git filter-repo 的历史 | 克隆与构建不再被其他项目拖累 |
| 4 | 依赖改为版本化引用(发布制品 + 锁版本) | 不再需要 replace/workspace 指向本地 |
反方向(多仓合并)同理:先把发布合并,再合并仓库,最后合并 CI——任何一步都可以停在那里,不必一次做完。拆分的技术手段是 git filter-repo --path <dir>(保留历史)或 git subtree split(保留双向合并能力),两者都要在动手前 git bundle 备份。
反模式
| 反模式 | 后果 |
|---|---|
| 单仓但没有影响分析 | 每次全量 CI,41 分钟起步;开发者开始绕 CI 直推 |
| 单仓但目录随便 import | 依赖成环,改一处全仓编译失败,退化成大泥球 |
| 多仓但跨仓改动频繁 | N 个 PR 顺序合,中间态“版本不匹配”导致联调环境长期红 |
| 把同一个服务拆成 5 个仓库 | 既没有独立发布,又多了版本对齐——最差组合 |
小仓库照搬 --filter=blob:none | 检出按需回源,克隆慢 30 倍(本机实测) |
| 单仓里每个包各自锁版本 | 一份仓库里造出多仓的版本冲突,锁文件失去意义 |
拆仓时直接 cp -r 新仓库 | 历史丢失,git blame 断代,问题无法追溯到源头 |
| 用仓库数衡量“微服务化程度” | 组织被形式绑架:拆了仓库,发布仍然绑在一起 |
自检
Q1 团队要“微服务化”,第一步是把单体拆成 12 个仓库。这个动作哪里错了?
▸ 答案:把仓库边界当成了服务边界。微服务的核心是“独立部署 + 独立伸缩 + 独立故障 + 明确契约”,这些与仓库数无关。先拆仓库的后果:跨服务改动要开 12 个 PR,联调环境永远版本不匹配,而部署仍然是一起发(因为拆不动的部分还在共享数据库)——这就是“多仓单体”,成本翻倍、收益为零。正确顺序:先在单仓里把模块边界与依赖方向理清(见 27.1 单体架构的适用性),让每个模块能独立测试、独立发布,发布解耦之后再决定仓库怎么切。
Q2 实测里“只测受影响包”中位数省 95%,为什么不能拿这个数字去说服老板?
▸ 答案:因为分布是长尾的:p50=3 个包(2.5%),p90=25 个包(21%),p99=73 个包(61%),均值 10 个。95% 是中位数的收益,而 CI 容量要按高峰规划——发布前一天集中合入的 PR,往往正是 p90/p99 那一档(大重构、公共库升级)。用中位数做容量规划,结果就是“平时 2 分钟,发版日排队一小时”。正确说法:中位 2 分钟、p90 9 分钟、p99 接近全量,容量按 p90 配、超时按 p99 兜底。
Q3 单仓里“公共库”目录为什么应该享受最高级别的评审?
▸ 答案:因为它的反向闭包接近全仓。改一行 libs/common,所有依赖它的服务都要重测;一旦引入缺陷,爆炸半径是全公司而不是一个服务。这与 51.2 代码评审 的“PR 尺寸决定检出率”叠加:公共库的 PR 天然影响面大,所以更应该拆小、更应该双人评审、更应该要求兼容性测试。判断“哪些目录该严管”,看闭包大小,而不是看目录名字听起来重不重要。
Q4 有人提议“我们单仓太大,先上 Bazel 再说”。你怎么判断这个提议值不值?
▸ 答案:先量三个数:① 全量构建/测试时长;② 克隆与检出时长;③ 典型改动的受影响闭包占比。如果 ① 是 10 分钟、② 是 10 秒、③ 的中位数是个位数包,那 Bazel 解决的是不存在的问题,代价是几个月的迁移与全员学习成本。反过来,如果 ① 超过 30 分钟且无法靠缓存压下去、③ 的 p90 超过 30%,那“增量构建 + 影响分析 + 远程缓存”就是刚需,Bazel/Nx/Turborepo 都值得评估——先明确要买哪个能力,再选工具。
Q5 单仓里既有 Go 服务又有前端,版本该怎么管?
▸ 答案:依赖解析统一、发布版本独立。统一的是“一份锁文件/一个工作区”(Go 用 go.work,前端用 pnpm workspace),保证本地改动能被所有消费者立即看到,避免“改了库但服务还用旧版”的假绿灯;独立的是“发布版本”(pkg-a/v1.2.0、web/v3.1.0),因为发布节奏由各自的消费者决定。最忌讳两种极端:全仓一个版本号(一个包不 ready 全仓等),或每个包各自锁版本(单仓退化成多仓,冲突全回来)。