前言
前段时间,Node.js 社区一个引发热议的 PR 让我深有感触——Virtual File System for Node.js #61478,一个约 2 万行改动、130 个 commit 的超大 PR,从 1 月提交到 4 月仍未合入,期间经历了架构方向争论、多轮返工、被迫转回 Draft 重写 Review Guide。
这个案例让我重新思考了一个老生常谈但很多团队仍未做好的问题:MR(Merge Request,也叫 PR)到底多大算大?大了之后该怎么拆?
本文适合不同经验水平的工程师。无论你是第一次提交 MR 的新人,还是主导架构重构的资深工程师,希望都能从中有所收获。
一、为什么 MR 不能太大?
1.1 数据说话:超过 400 行,审查质量断崖式下降
SmartBear 基于 Cisco 2500 多次代码审查的数据研究(Best Practices for Code Review)得出了一个被业界广泛引用的结论:
- 审查 200-400 行 代码时,缺陷发现率最高(约 70-90%)
- 超过 400 行 后,发现率急剧下降
- 审查速度超过 500 行/小时 时,几乎等于没看
Google 的工程实践文档(Google Engineering Practices - Small CLs)更是直接将「保持变更尽可能小」列为开发者的首要职责。
这不是审查者不认真,而是人类认知的固有限制。当一个 MR 包含 3000 行改动时,审查者的真实心理状态大概是这样的:
前 200 行:仔细看,提出有价值的反馈
200-800 行:开始疲劳,只关注明显问题
800 行以后:LGTM 🚢
说白了,一个 3 万行的 MR,本质上是一个没有被真正审查的 MR。
1.2 大 MR 的六宗罪
第一宗:审查沦为走过场
审查者打开一个 47 个文件变更的 MR,内心先产生畏惧。为了不阻塞团队进度,他们倾向于快速浏览后点击 Approve。
Google 的研究数据显示(Speed of Code Reviews),小型变更的审查响应中位数在数小时内,而大型变更常被推迟数天——因为没人愿意在工作间隙打开一个需要 2 小时才能看完的 MR。
第二宗:Bug 的天然藏身之处
改动越多,Bug 越容易躲在注意力盲区里。50 行的 MR,每一行都会被仔细审视;5000 行的 MR,一个关键的边界条件错误(比如循环少了一次、数组下标差一位)可能就藏在某个不起眼的角落。
真实案例:Node.js VFS PR(#61478)约 2 万行改动,审查者 ThanhDodeurOdoo 在审查数周后才发现一个设计层面的严重问题——fs.open() 在 VFS 路径下返回的是一个对象而非数字类型的文件描述符,这会破坏所有假定 fd 是数字的下游代码。如果 fs.open() 的集成是一个独立的小 MR,这个问题大概率在第一轮就能发现。
第三宗:合并冲突雪崩
一个存活 3 周的分支,主干上可能已经有了上百次提交。分支存活时间越长,冲突处理难度不是线性增长而是指数级上升——因为你改了 A 文件,别人也改了 A 文件,而你们的改动又各自依赖了 B、C、D 文件的不同版本。
Martin Fowler 在经典文章 Continuous Integration 中强调的核心理念就是「频繁地将代码集成到主干」。大 MR 与这一理念根本对立。
第四宗:回滚代价极高
线上出了问题需要回滚。如果问题来自 50 行的 MR,回滚影响清晰可控。如果来自 3000 行的 MR,回滚意味着同时撤销了其中 2950 行正确的代码——而这些代码可能已被后续 MR 依赖,导致连锁回滚。
第五宗:阻塞团队协作
你有一个 3 万行的 MR 在等审查。与此同时,3 个同事需要用到你写的某个工具函数。他们只能:等你合入(阻塞数天)、复制你的代码(产生重复)、或基于你的分支再开分支(嵌套依赖)。每一种都是坏选择。
第六宗:上下文鸿沟
你写这 3 万行代码用了 3 周,每个决策的来龙去脉记得清清楚楚。但审查者需要在几小时内重建你 3 周的心智模型——这几乎不可能。小 MR 让审查者只需理解一个小范围的上下文,反馈也更有针对性。
1.3 反直觉:拆成 10 个小 MR 反而更快
很多人不愿意拆分的理由是「拆成 10 个 MR 审查起来不是更慢吗?」
事实恰好相反:
| 方式 | 审查者要求 | 单次审查耗时 | 等待周期 | 总合入时间 |
|---|---|---|---|---|
| 1 个大 MR(3000 行) | 需要 2-3 个资深审查者 | 2-4 小时(常被推迟) | 3-7 天 | 1-2 周 |
| 10 个小 MR(300 行) | 1 个审查者即可 | 15-30 分钟 | 当天或次日 | 3-5 天 |
小 MR 能利用审查者的碎片时间:等构建的 10 分钟、午饭前的 20 分钟、两个会议之间的 15 分钟。没有人愿意在碎片时间里打开一个 47 个文件的 MR。
以上数据趋势也与 LinearB 基于 610 万+ PR 的工程基准报告(2025 Engineering Benchmarks)一致——PR 越小,交付周期越短。
二、MR 到底多大合适?
2.1 经验法则
| 指标 | 建议范围 | 说明 |
|---|---|---|
| 改动行数(不含测试) | 200-500 行 | 超过 800 行需要充分理由 |
| 改动文件数 | 1-10 个 | 超过 15 个文件很可能职责不单一 |
| 审查所需时间 | 15-45 分钟 | 超过 1 小时审查者容易走神 |
| MR 描述长度 | 一段话能说清 | 如果描述需要写 2 页,MR 太大了 |
Google 的建议(Small CLs)是:一个变更应该是一个最小的、独立的、完整的改动。「独立」指可以单独被审查和理解;「完整」指不会让代码处于不可用的中间状态。
2.2 例外情况
以下场景允许更大的 MR,但需要在描述中说明原因:
- 自动生成的代码(API 客户端、ORM 模型、图标文件等):标注哪些是自动生成的,审查者可以跳过
- 批量重命名或移动文件:可能涉及几十个文件,但逻辑变更为零
- 新增独立模块:全新的、不与现有代码耦合的模块,审查负担较低
- 纯测试补充:为已有代码补充测试,运行时行为不变,风险较低
三、五种拆分策略(附实战案例)
策略一:按架构层次自底向上拆
适用场景:新增子系统、基础组件、底层库替换。
核心思路——按依赖方向拆分,先合入底层,再合入上层。就像建房子:先打地基,再砌墙,最后装屋顶。
PR 1: 类型定义 + 接口声明 → 零副作用,只定义"契约"
PR 2: 核心数据模型 / 工具函数 → 可独立编写单元测试
PR 3: 业务逻辑层 → 依赖 PR 2 的接口
PR 4: 集成层(与现有系统对接) → 接入已有架构
PR 5: UI / 入口层 → 用户可见的变更
实际案例:Node.js VFS 的作者在 Review Guide 中自己划分了 10 个子系统——数据模型、文件系统、注入层、文件描述符、模块加载器、流与监听器、SEA 集成、Overlay 模式、Mock API、测试。每一个完全可以是一个独立 PR。
他后来不得不把 PR 转回 Draft,花额外时间写这份 Review Guide 帮助审查者理解——如果一开始就拆成 10 个 PR,每个 PR 本身就是自解释的,根本不需要 Guide。
策略二:Feature Flag 保护下增量合入
适用场景:大功能开发,需要长期迭代但又要频繁合入主干。
Feature Flag(功能开关)是一种通过配置控制功能是否对用户可见的技术。开关关闭时,新代码已在主干中,但用户完全无感知。
// PR 1: 引入 feature flag + 类型定义
const ENABLE_NEW_EDITOR = process.env.NEXT_PUBLIC_FF_NEW_EDITOR === 'true';
// PR 2: 新编辑器基础组件(flag 保护,用户看不到)
export const NewEditor = () => {
if (!ENABLE_NEW_EDITOR) return null;
return <EditorCore />;
};
// PR 3-8: 逐步实现子功能,每个 PR 独立可审查
// PR 9: 开启 flag,新功能上线
// PR 10: 清理 flag 相关代码
典型案例:
- React Fiber 架构重写(React 16 发布博客):Facebook 用了约 2 年,在 feature flag 保护下通过数百个小 PR 完全重写了 React 的核心渲染引擎,期间 React 15 正常发布维护,用户零感知
- Next.js App Router(Next.js 13 发布博客):Vercel 通过
appDir实验性 flag,让新路由系统与旧路由系统并存超过一年
策略三:绞杀者模式(渐进式替换)
适用场景:框架迁移、大规模重构,新旧系统需要共存过渡。
这个名字来自热带雨林中的绞杀榕——它不砍倒旧树,而是缠绕在旧树上逐渐生长,最终完全替代。Martin Fowler 将这个比喻引入了软件工程。
阶段 1 [1 个 PR]: 引入中间适配层
旧代码 → 适配层 → 旧实现(行为完全不变)
阶段 2 [N 个 PR]: 新功能走新实现
旧代码 → 适配层 → 旧实现
新代码 → 适配层 → 新实现
阶段 3 [N 个 PR]: 逐个迁移旧模块
所有代码 → 适配层 → 新实现
阶段 4 [1 个 PR]: 移除适配层
所有代码 → 新实现
典型案例:
- React Class 组件 → Hooks 迁移:新组件用 Hooks,旧组件按优先级逐个迁移,两种写法长期共存
- 数据库迁移(双写模式):先同时向新旧两个数据库写入,逐步切换读流量到新库,确认无误后停写旧库
策略四:先达成共识,再开始编码
适用场景:涉及架构决策、存在多种可能方案的改动。
PR 0: RFC / 设计文档(纯文档,不含代码)
→ 收集反馈,达成技术方案共识
→ 明确拆分计划和每个 PR 的范围
PR 1-N: 按共识方案逐步实现
RFC(Request for Comments,意见征集)是工程团队中常用的设计提案流程——先写方案文档,团队书面评审达成共识,再开始编码。Rust 语言所有重大特性都通过 RFC 流程推进。
反面案例:VFS PR 中,Qard 提出了完全不同的架构方向(依赖注入 vs 全局挂载),arcanis 则用 Yarn PnP 6 年的生产经验支持全局挂载。这个根本性的设计争论发生在 2 万行代码已经写完之后——如果事先有 RFC,可以避免大量可能被推翻的工作。
策略五:提取「零行为变更」的准备性 PR
适用场景:任何大改动都适合作为第一步。
在实现功能之前,先提交不改变任何运行行为的 PR:
PR 1: 纯重构——提取函数、拆分文件、调整目录
→ 运行前后行为完全不变,容易审查,大幅降低后续 PR 的差异噪音
PR 2: 类型定义和接口声明
→ 只定义"契约",审查者只需关注 API 设计
PR 3: 测试先行——为新功能写好测试用例(暂标记跳过)
→ 审查者通过测试用例就能理解需求和预期行为
PR 4-N: 逐步实现功能,每个 PR 解锁一批测试
这招最容易被忽视但效果最好。很多大 MR 里一半的差异来自文件移动和函数提取,这些噪音淹没了真正重要的逻辑变更。提前单独提交后,后续 PR 会干净很多。
四、「这个真的拆不了」——四种常见借口和破解方法
「必须一起改才能编译通过」
破解:引入中间过渡状态。
你的最终目标是 A → C,中间可以走 A → B → C,其中 B 是「新旧并存」的过渡态:
// PR 1: 新增新接口,保留旧接口
/** @deprecated 请使用 newMethod,将在下个迭代移除 */
function oldMethod() { /* ... */ }
function newMethod() { /* ... */ }
// PR 2: 迁移所有调用方到新接口
// PR 3: 移除旧接口
每个 PR 代码都能正常编译和运行。
「必须看到全貌才能验证架构」
破解:原型分支验证,小 PR 正式合入。
先做一个完整的原型分支(不需要达到上线标准),验证架构可行后,把原型作为参考蓝图,重新按小 PR 拆分合入主干。
看起来多做了一步,但总时间通常更短——因为小 PR 审查快、冲突少、合入快。
VFS PR 的作者实际上也走了类似的路——130 个 commit 的大分支写完后,转回 Draft 做重构。如果一开始就计划好「先原型验证,再拆分合入」,过程会顺畅得多。
「拆成小 PR 后每个都不算完整功能」
破解:区分「对用户有价值」和「对工程有价值」。
一个类型定义的 PR、一个被 feature flag 隐藏的半成品功能,对终端用户确实没有直接价值。但对工程过程有巨大价值——代码尽早进入主干、尽早被审查、尽早暴露问题。
只要每个 PR 合入后代码仍能正常编译运行(即使新功能还不可用),它就是一个合格的 PR。
「数据库结构变更必须和业务代码一起提交」
破解:向前兼容的分步迁移。
PR 1: 添加新字段(允许为空或有默认值),代码暂不使用
PR 2: 代码开始写入新字段,同时继续写入旧字段(双写)
PR 3: 运行脚本,将历史数据填充到新字段
PR 4: 代码切换到读取新字段
PR 5: 移除旧字段和双写逻辑
每一步都安全、可独立回滚。这也是 GitHub 等大型团队处理数据库变更的标准做法(参见 GitHub 工程博客:gh-ost: GitHub's Online Schema Migration Tool)。
五、提交前自检清单
每次提交 MR 前花 1 分钟对照检查:
- 改动行数(不含生成代码和测试)在 500 行以内?
- 能用一句话说清这个 MR 做了什么?
- 审查者能在 30 分钟内完成审查?
- 出问题时能安全回滚这一个 MR 而不影响其他功能?
- 没有同时包含重构和新功能?(应拆为两个 PR)
- 没有包含多个不相关的改动?(各自独立提交)
- 没有可以先独立合入的类型定义/接口/测试?
- 大功能是否有 feature flag 保护?
有一项不达标,就优先考虑拆分。
六、给不同角色的建议
给 MR 提交者
- 拆分是你的责任,不是审查者的。 不要等审查者来要求你拆
- 写好描述:说清楚「为什么做」而不仅是「做了什么」
- 系列 PR 标注关系:「X 功能第 3/7 个 PR,前置依赖 #123」
- 换位思考:审查者应该先看哪个文件?改动核心在哪里?
给审查者
- 大 MR 可以礼貌要求拆分:「范围较大,能否先拆出 X 部分?我可以更快给你反馈」
- 无法拆分时,要求提供审查指引:先看哪个文件、核心决策在哪里
- 审查不过来就坦诚说出来,比假装审查了更负责
给技术负责人
- 把 MR 大小纳入团队代码审查文化,而不仅是个人习惯
- 在 CI 中设置提醒:超过一定行数自动提示「建议拆分」
- 关注 「MR 从提交到合入的平均周期」 这一指标,它与 MR 大小高度正相关(参见 DORA 研究:交付周期是衡量团队效能的四项关键指标之一)
- 为复杂功能建立设计文档 / RFC 流程,编码前达成共识
总结
小 MR 不是额外的工作量,而是降低总工作量的手段。
拆分 MR 的能力是工程成熟度的体现:
- 初级工程师——写出能运行的代码
- 中级工程师——写出可维护的代码
- 高级工程师——确保代码以最低风险、最高效率进入生产环境
MR 的组织方式,正是最后这一环中最被低估的技能。
参考资料
| 资源 | 说明 |
|---|---|
| SmartBear - Best Practices for Code Review | 基于 Cisco 2500+ 次审查的数据,200-400 行审查效率最高 |
| Google Engineering Practices - Small CLs | Google 内部代码审查指南,「变更应尽可能小」 |
| Google Engineering Practices - Review Speed | 审查响应速度与变更大小的关系 |
| Martin Fowler - Continuous Integration | 持续集成经典论述,强调频繁合入主干 |
| Martin Fowler - Strangler Fig Application | 绞杀者模式——渐进式替换旧系统 |
| Node.js VFS PR #61478 | 2 万行超大 PR 的真实案例 |
| Rust RFC 流程 | 「先共识再编码」的典范 |
| DORA Research | Google 支持的团队效能研究,交付周期是四项关键指标之一 |
| LinearB - Reduce Cycle Time | 数千个团队的数据:PR 越小,交付周期越短 |
| GitHub Blog - gh-ost | GitHub 数据库在线迁移的工程实践 |
如果这篇文章对你有帮助,欢迎点赞收藏。也欢迎在评论区分享你们团队在拆分大 MR 方面的经验和踩过的坑。