一个需求动了前端、BFF、订单、公共模型库——仓库拆了六个,于是开六个 PR,合并要排序,联调等最后一环。结论往往是"合回去"。
这个结论对一半。合仓消掉六个 PR,同时把权限、发布节奏、构建时间、体积绑到一根绳上。阈值不是"PR 超过几个",而是这四件事里最先到底的那件。
1. 两边的痛不在同一时间尺度
拆仓的痛是每天可见的,合仓的痛是延迟发作的。所以直觉永远偏向"合"。
| 成本项 | 多仓 | 单仓 | 谁先发作 |
|---|---|---|---|
| 跨模块改动 | 每仓一个 PR,人工排序 | 一次提交 | 多仓:即刻 |
| 依赖对齐 | 需显式发布中间版本 | 源码直连,编译期校验 | 多仓:即刻 |
| 构建耗时 | 只跑本仓 | 随仓增长 | 单仓:半年后 |
| 权限 | 按仓硬隔离 | CODEOWNERS 软约束 |
单仓:审计时 |
| 独立发版 | 天然独立 | 需切分发布单元 | 单仓:首次热修 |
| 体积 | 各自小 | 单调增长 | 单仓:两年后 |
2. 六维对比:只有一维不可补偿
| 维度 | 单仓 | 多仓 | 可补偿性 |
|---|---|---|---|
| 依赖管理 | 源码依赖,天然一致 | 版本号 + 发布 | 可:BOM、version catalog |
| 原子变更 | 一次提交即完整状态 | 只能缩短中间态 | 不可补偿 |
| CI 成本 | 需增量与缓存 | 只跑本仓 | 可:路径过滤、远程缓存 |
| 权限 | 软约束 | 硬隔离 | 可:CODEOWNERS + 环境审批 |
| 版本发布 | 需切分单元 | 天然独立 | 可:发布流水线、变更集 |
| 工具链 | 一套版本 | 各自漂移 | 可,但漂移是反向的痛 |
判据只有一条:改动是否经常需要"跨模块同时正确"。若一次变更的正确性依赖 A、B 两份语义同时生效,只有单仓(或源码级联编)能满足,其余五维都能花钱补。若模块本就"发一次、各跑各的、靠契约通信",原子性用不上,你只是在为用不上的能力付账。
3. 多仓如何逼近原子变更
手段一 临时分支指向:B 的依赖写成 branch-SNAPSHOT
→ 不可重现、缓存互相污染
手段二 先发布后引用:A 发版,B 再升依赖
→ 中间态期间线上跑"旧 A + 新 B"这种从未验证的组合
手段三 源码内联:git submodule 固定 commit / Gradle includeBuild
→ 指针要人肉同步;复合构建只解决编译,不解决发布
三种都能编译通过,都留下中间态窗口:窗口内任何提交组合都可能从未被整体验证。单仓把窗口压到零,多仓只能压短——能力差异,不是工程量差异。
判据:跨模块改动的平均中间态时长若超过一个发布周期,多仓已在常态化跑未验证组合。此时要么合仓,要么把两个模块的发布绑成一次。
4. 拆仓库与拆服务不是一回事
| 要解决的问题 | 该动的边界 | 动错会怎样 |
|---|---|---|
| 跨模块改,PR 太多 | 仓库边界 | 拆服务:多一次网络调用,问题还在 |
| 团队互相阻塞发布 | 仓库 + 发布单元 | 只拆服务:发布仍耦合 |
| 模块耦合深,改一处崩一片 | 代码/服务边界 | 只拆仓:耦合原封不动 |
| 合规要求生产变更隔离 | 仓库 + 分支保护 | 只拆服务:同权限,审批虚设 |
| 构建超阈值 | 构建图与增量策略 | 拆仓:症状缓解,两年复发 |
口诀:仓库边界决定原子性,服务边界决定失败域。 用一边解另一边的问题,得到的是成本。
反直觉的是:单仓不妨碍拆服务(一个仓可有十个部署单元),两个仓也可能是同一服务的两个模块。先定拆服务、后定拆仓;顺序反了就是用仓库形态偿还架构债,债还在。
5. 单仓的三种规模形态
| 形态 | 边界 | 工具支持 | 升级信号 |
|---|---|---|---|
| 单应用单模块 | 一个部署产物 | 单工程即可 | 出现第二个产物 |
| 多模块单应用 | 多模块共打包 | 父 pom + dependencyManagement |
某模块被别的仓引用 |
| 多项目单仓 | 多部署单元 + 共享库 | version catalog、任务图、路径过滤 |
团队 >2、构建超阈值 |
repo/
├── settings.gradle.kts # 决定"哪些参与构建"
├── gradle/libs.versions.toml # 版本单一事实源
├── build-logic/ # 构建约定插件
├── platform/ # 只发 BOM,不发代码
├── libs/common/ # 共享库
├── services/order/ # 独立部署单元
└── .github/CODEOWNERS
升第三形态的判据不是模块数,而是是否出现"独立部署 + 独立发布节奏"。某模块一旦单独交付客户,成本就从代码组织转到流水线。
6. CI 成本是非线性的
线性假设"仓库大一倍、构建慢一倍"不成立:全量慢一倍,触发频率与重试次数同时上升,乘起来超线性。
| 变更类型 | 全量 | 路径过滤 + 远程缓存 |
|---|---|---|
| 改某服务业务代码 | 全仓编译 + 全仓测试 | 只编该服务及下游 |
| 改共享库一个方法 | 全仓 | 所有依赖方重编(无法回避) |
| 只改文档 | 全仓 | 跳过编译,仅跑 lint |
| 改构建脚本 | 全仓 | 缓存失效,退化为全量 |
后两行是关键:增量在"改共享库"和"改构建逻辑"上必然退化,而这两类恰是最危险的变更。增量需要三件同时到位:路径到模块的映射、模块到产物的缓存键、脚本变更的失效规则。
rules:
- changes: ["services/order/**", "libs/common/**"] # 共享库触发所有依赖方
when: on_success
- changes: ["docs/**", "**/*.md"]
when: manual
取舍:写宽等于没做增量,写窄会漏构建、把问题推到线上。窄规则的风险静默,宽规则的代价可见——有争议时选宽。
7. 权限与版本模型
CODEOWNERS 是软约束:只决定谁被要求评审,不决定谁能写。合规看的是"能否证明变更经授权人批准",CODEOWNERS + 必需评审 + 分支保护三者齐备才成立;缺一项,单仓就弱于多仓。
# .github/CODEOWNERS
/services/order/ @team-order
/libs/common/ @team-platform @team-order @team-billing
代价:多仓付在建仓时,单仓付在每次提权时。团队少时单仓划算,外部协作多或有硬合规多仓更便宜。共享路径必须多 owner,否则共享库变某个团队的独裁。
统一版本几乎不产生内部组合(心智负担低、回滚即整体),代价是一个补丁迫使全仓发版;独立版本影响面小,代价是 N 个模块的 M 种组合,需要 BOM 与调解规则兜住。
依赖地狱的因果链:独立版本 → 版本空间多维化 → 组合未被整体验证 → 冲突在集成或运行期暴露。不是有人乱升版本,是维度变了。折中是分层:平台层统一,业务独立,服务之间走契约。
8. 体积、迁移与判据
体积趋势:代码与历史对象线性增长,克隆随之变慢;混入大二进制会跳变。
du -sh .git
git count-objects -vH # size-pack 与对象数
git rev-list --objects --all | wc -l # 历史对象总量级
信号是".git 明显大于工作区,克隆进入新人第一天的等待"。先做 partial clone 与归档再谈拆仓——直接拆会把一个大历史切成几个都不小的历史。
迁移成本集中在五处:历史(无法真正合并,只能归档或断代)、CI(触发、缓存键、并行度、制品路径全变)、密钥(作用域从仓级变宽,最危险,等于权限静默扩张)、发布(单元从"仓"变"仓内路径")、习惯(分支名、评审人全改)。共同误判是"以为只改个路径"。
| 条件 | 倾向 | 判据 |
|---|---|---|
| 团队 >2 且发布无关 | 拆 | 发布耦合已成日常阻塞 |
| 硬合规、生产变更需隔离 | 拆 | 审计要求仓级隔离 |
| 构建超阈值且增量已失效 | 先查增量 | 多数"构建慢"是缓存或测试不可并行 |
| 体积进入克隆瓶颈 | 先瘦身 | 归档、LFS、partial clone |
| 三五个强耦合小团队 | 不拆 | 跨仓 PR 成本远大于构建成本 |
| 发布必须同步 | 不拆 | 拆了也要绑发布,白拆 |
9. 混合形态与度量
混合形态分三层:平台层(构建约定、BOM、公共库)单仓;业务层(多服务共享源码)同仓;交付层(需单独交付)独立仓。代价是两套 CI 两套规范,硬规矩是平台层变化必须被所有层以同一版本消费,否则退化成"多仓 + 一个没人跟的平台仓"。
| 指标 | 采集 | 报警 |
|---|---|---|
| 平均 PR 跨度 | 一个需求涉及的仓/模块数 | 持续 >3 且同批模块 → 该合 |
| 构建 P90 | 按变更类型分组 | 单模块变更接近全量 → 增量失效 |
| 依赖更新延迟 | 发版到下游升级的中位天数 | 超一个发布周期 → 漂移 |
| 中间态时长 | 多 PR 需求首末合并之差 | 超一个发布周期 → 原子性缺口 |
前两个是合仓的收益,后两个是成本;放一起看比任何原则都好用。
10. 排错:五种误判
| 现象 | 真实原因 | 处置 |
|---|---|---|
| 公共库改了,下游没编就上线 | 依赖 SNAPSHOT 或未升版本 | 加"公共库变更须构建全依赖方"门禁 |
| 合仓后构建不可接受 | 共享库或脚本变更使增量退化 | 先修缓存键与路径映射 |
| 拆仓后仍要等别的团队 | 发布耦合在流水线 | 拆发布单元,非拆仓 |
| 单仓里 A 改了 B 的代码 | CODEOWNERS 未覆盖或未启用必需评审 |
补 owner + 分支保护 |
| 合仓后审计不过 | 密钥作用域变大 | 环境级审批 + 分环境凭证 |
11. 边界:形态选择本就不重要的场景
| 场景 | 说明 |
|---|---|
| 两三人团队 | 沟通成本低于任何形态差异 |
| 完全独立的多个产品 | 无原子变更需求,同仓只是物理共处 |
| 平台工程能力强 | CI 与权限被工具吸收 |
| 代码量几千行 | 成本尚未出现 |
仓库形态是"原子变更需求"与"治理成本"之间的一个解,不是正确答案。两侧量级都小时,选择价值接近零。
12. 自测
- 六维里哪一维不可补偿,另外五维为何可补偿?
- 三种模拟手段各自留下什么中间态窗口?
- "一个需求六个 PR"能否作为合仓理由?
- 统一版本下"一个补丁迫使全仓发版",如何折中?
- 体积信号看工作区还是
.git,用什么命令判量级? - 合仓时最易被忽略、会静默扩张权限的成本项是哪一项?