如果你的团队有 QA 回归测试,或者多个大改动共享同一批页面、数据、布局,大概率做过这样的决策:手头有两个还没测完的功能,为了不让回归测试跑两遍,干脆把它们合到同一条分支上,整体测一次。
听起来很省。但这条分支往往越长越大,等到要合主干时,变成一场谁都不想 review 的灾难;还经常出现「改动 A 的 bug 卡住了改动 B 上线」这种连坐。
结论先放前面:
这是一个有名字、有成熟解法的工程权衡------批量大小(batch size)。把多个变更攒一批一起测,省下的是「重复回归测试成本」,赔进去的是「独立交付能力、风险隔离、可审查性」。而 feature flag、环境分支聚合、stacked branches 等实践,能让你「测一遍」的同时不赔这些。
下面从这个权衡的本质讲起,到为什么它几乎必然发生,再到四类更优实践和怎么选。
一、现象:一条越长越大的集成分支
典型的演化路径是这样的:
- 多个变更(变更 A、变更 B......)共享同一批页面、数据或布局,彼此无法独立验证。
- 每个变更单独走一遍回归 / QA / E2E,都要付一次完整的固定测试成本。
- 为了省这个成本,把它们攒成一批,合到同一条分支,整体测一次再发布。
这条分支会随着新需求不断收编而越长越大,逐渐变成一条长命的共享集成分支。它的特征很好认:存活好几天甚至几周、反复把主干合进来、好几个人同时往里提交、迟迟合不回主干。
二、本质:批量大小(batch size)权衡
这不是某个团队的「凑合」,而是一个被研究透了的经典命题。Donald Reinertsen 在《The Principles of Product Development Flow》里系统论述了批量大小与流动效率的关系:批越大,单位固定成本越低;但批越大,其它几乎所有维度都越差。
把「多个改动聚合到一条分支整体测」放进这个框架看:
| 维度 | 大批量(聚合一条分支整体测) |
|---|---|
| 固定测试成本 | 摊薄,只测一遍(这是你想要的收益) |
| 风险耦合 | 任一变更的 bug 卡住整批放行 |
| 周期时间 | 先完成的也得等最慢的一起走 |
| 失败定位 | 测出问题,难判断是哪个变更引入 |
| 独立交付 | 不能单独上线、单独回滚 |
| 可审查性 | 最终合主干时是一个巨型 diff |
一句话:你用「独立可交付性」换了「测试成本」。 在 deadline 加上回归昂贵的双重约束下,这往往是个局部最优解------但它的代价是隐藏的,不会在合分支的当下显现,而是在「某个改动想单独上线却动不了」「线上出问题不知道回滚谁」「合主干 review 卡三天」的时候才找上门。
三、为什么这种聚合几乎必然发生
只要下面三个条件同时存在,团队就会被推向聚合,几乎是必然的:
- 回归测试成本高且固定:人工 QA、慢 E2E、需要拉起完整环境------每测一轮的开销和测多少东西关系不大,所以「多攒点一起测」在直觉上就是划算的。
- 变更之间共享载体:改的是同一批页面 / 布局 / 数据模型,没法把它们摘开单独验证。
- deadline 压力:在交付节点前,「测一次」显然比「测多次」省时间。
正因为它是结构性的,整个 trunk-based development(主干开发)加 feature flag 的范式才会兴起------本质上就是为了破解「想小步合入,又不想每次全量回归」这个矛盾。
四、破局点:解耦「代码合入」与「功能启用」
长命集成分支真正的问题,在于它用一条分支同时背了两个目标:既要做「聚合测试」,又要做「延迟合入主干」。这两件事被一条分支耦死,才有了前面那一串代价。
破局的关键是把这两个目标拆开:
- 小批量合入:每个变更独立、低耦合、低周期时间。
- 大批量测试:在某个聚合点一次性回归,测试成本依然低。
一旦解耦,你就能同时拿到「测一遍」和「独立交付」,不必再靠一条越滚越大的分支硬扛。feature flag 是实现解耦最典型的武器:代码先合,功能后开。
五、四类替代实践
按落地成本从低到高,分三档。
立即可用:环境分支聚合
如果你已经有一条共享的测试环境分支(很多团队叫 test / staging),它天生就是聚合点:
- 每个变更各自独立分支、独立 MR,但都合进同一条测试环境分支,在那里聚合回归一次。
- 测通过后,各变更各自独立推进到下一环境(预发 / 主干)。
这样聚合测试达成了(一轮回归覆盖所有变更的叠加),同时保留了独立 MR、独立交付、失败可隔离。如果这条环境分支还有定期重置机制(比如每周重置回主干),它就天然短命、不会沉淀成技术债。
相比「把多个改动塞进一条 feature 分支」,差别在于:聚合发生在环境分支 (一次性、可重置),而不是特性分支(长命、会越滚越大)。
立即可用:stacked branches(栈式分支)
当变更之间有线性依赖(改动 B 必须建立在改动 A 之上)时,栈式分支是更优雅的选择。
它的核心是:把一个大改动拆成一串小而彼此依赖 的分支,每个分支以前一个分支为基底,而不是都基于主干。每个分支对应一个独立 MR,各自只承载一个清晰概念。
它和普通「切成多个独立分支」的区别就在依赖关系:栈式里分支 B 的 MR 目标是分支 A(diff 只显示 B 自己的增量),分支 C 的目标是分支 B,依此类推。
好处:
- 每个 MR 的 diff 小、聚焦,reviewer 只看单一概念的增量,而不是一个巨型 PR。
- 可以并行推进:不必等分支 A 合并,就能在它上面继续开发分支 B。
- 底层可先合:依赖链下游若安全,可以先于上游 land。
代价(也是它的使用边界):
上游一旦变化(分支 A 改了,或主干前进),整条栈需要递归 rebase 才能保持同步。这是栈式的固有成本,也意味着它和「共享分支禁止 rebase」的团队约束天然冲突 ------所以它最适合个人独占的依赖栈,不适合多人同时在一条栈上协作。
实现方式:
- 轻量:Git 2.38+ 内置
git config rebase.updateRefs true,一条git rebase就能级联更新整条栈的中间分支,无需任何第三方工具。 - 工具化:Graphite(
gtCLI 加 Web UI,自动 restack)、ghstack 等。
这套工作流源自 Meta 内部 Phabricator 的 stacked diffs,Google 也有类似实践,近年通过 Graphite 等工具进入了更广的开源生态。
中期治本:feature flag
feature flag(功能开关)是真正治本的解法:把未完成或待验证的功能用开关包起来,代码可以先合入主干、运行时默认关闭。
它怎么解掉「测两遍」的痛:
- 待验证的功能用 flag 包住,可以和别的改动一起进测试环境甚至主干,一轮回归就覆盖了它们的叠加。
- 线上通过灰度逐步打开开关。
- 出问题用紧急关闭开关(kill-switch)一键关掉,不需要回滚整批代码。
「代码合入」和「功能可见」被彻底解耦,长命分支就没有存在的必要了------这正是 trunk-based development 的核心。Martin Fowler 的 Feature Toggles 一文对各类开关(release / ops / experiment / permission)的分类和落地讲得很系统,值得一读。
中期治本:branch by abstraction
针对结构性大改(比如布局重构、底层架构替换),直接上 feature flag 可能不够,因为新旧实现在代码结构上就难以共存。这时用 branch by abstraction(抽象分支):先建一层抽象,让新旧实现并存在这层抽象之下,在主干上小步把流量从旧实现切到新实现,再配合 flag 控制启用,最后删掉旧实现。
整个过程都在主干上小步进行,不需要一条长命分支憋大招。这也是 Martin Fowler 总结的经典模式。
工程化护栏:release train 与 merge train
当批量发布是业务上的硬需求(比如必须按版本节奏走),给批处理加护栏:
- release train(发布火车) :给批次固定的节奏和时间盒。到点的改动上车,没准备好的等下一班。关键是有 deadline,避免一条分支无限累积成巨无霸。错过这班车不影响车上的人。
- merge train / merge queue(合并队列) :多个 MR 排队,在「假设前面的都合了」的基础上依次跑 CI,实现批量集成验证但保留单 MR 粒度。GitLab 原生支持 Merge Trains(注意是 Premium / Ultimate 付费层功能),GitHub 也有 Merge Queue。
六、怎么选:场景到实践的对照
| 你的场景 | 推荐实践 |
|---|---|
| 变更彼此独立,且有共享测试环境 | 环境分支聚合 |
| 变更有线性依赖,且是个人独占 | stacked branches |
| 变更是「可开关的功能」,想先合后启用 | feature flag |
| 结构性重构(布局 / 架构) | branch by abstraction 加 feature flag |
| 团队 MR 频繁,想批量验证又不丢粒度 | merge train(需付费层) |
| 业务上必须批量发布 | release train(带 deadline) |
最后点一个最常见的反模式:长命的多人共享 feature 分支无限收编新需求。它把「聚合测试」和「延迟合入」彻底耦死,越收越大,最终只剩两条难受的出路------要么是没人愿意 review 的巨型 MR,要么 squash 成一个 commit(丢掉粒度、也击穿了基于 patch-id 的合并漂移诊断)。再叠加它和主干持续分叉、靠反复 merge 主干续命,越往后越难合。
回到开头那个决策:「为了不测两遍,把多个改动合一条分支」------诉求(省回归成本)是完全正当的,错的只是承载它的工具。换成「环境分支聚合」或「feature flag」,你能在测一遍的同时,保住独立交付、风险隔离和可审查性。这才是这个老问题真正的解。
七、参考与数据源
- Donald G. Reinertsen,《The Principles of Product Development Flow: Second Generation Lean Product Development》------批量大小(batch size)理论的系统论述。豆瓣:book.douban.com/subject/384...
- Trunk Based Development:trunkbaseddevelopment.com
- Martin Fowler, Feature Toggles:martinfowler.com/articles/fe...
- Martin Fowler, Branch By Abstraction:martinfowler.com/bliki/Branc...
- GitLab Docs, Merge trains:docs.gitlab.com/ci/pipeline...
- The stacking workflow(stacked diffs 概念):stackeddiffs.com
- Graphite, Stacked diffs 指南:graphite.com/guides/stac...