你的 MR 超过 500 行了吗?——大型代码合并请求拆分实战指南

前言

前段时间,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 模型、图标文件等):标注哪些是自动生成的,审查者可以跳过
  • 批量重命名或移动文件:可能涉及几十个文件,但逻辑变更为零
  • 新增独立模块:全新的、不与现有代码耦合的模块,审查负担较低
  • 纯测试补充:为已有代码补充测试,运行时行为不变,风险较低

三、五种拆分策略(附实战案例)

策略一:按架构层次自底向上拆

适用场景:新增子系统、基础组件、底层库替换。

核心思路------按依赖方向拆分,先合入底层,再合入上层。就像建房子:先打地基,再砌墙,最后装屋顶。

yaml 复制代码
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(功能开关)是一种通过配置控制功能是否对用户可见的技术。开关关闭时,新代码已在主干中,但用户完全无感知。

typescript 复制代码
// 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 RouterNext.js 13 发布博客):Vercel 通过 appDir 实验性 flag,让新路由系统与旧路由系统并存超过一年

策略三:绞杀者模式(渐进式替换)

适用场景:框架迁移、大规模重构,新旧系统需要共存过渡。

这个名字来自热带雨林中的绞杀榕------它不砍倒旧树,而是缠绕在旧树上逐渐生长,最终完全替代。Martin Fowler 将这个比喻引入了软件工程

ini 复制代码
阶段 1 [1 个 PR]: 引入中间适配层
    旧代码 → 适配层 → 旧实现(行为完全不变)

阶段 2 [N 个 PR]: 新功能走新实现
    旧代码 → 适配层 → 旧实现
    新代码 → 适配层 → 新实现

阶段 3 [N 个 PR]: 逐个迁移旧模块
    所有代码 → 适配层 → 新实现

阶段 4 [1 个 PR]: 移除适配层
    所有代码 → 新实现

典型案例

  • React Class 组件 → Hooks 迁移:新组件用 Hooks,旧组件按优先级逐个迁移,两种写法长期共存
  • 数据库迁移(双写模式):先同时向新旧两个数据库写入,逐步切换读流量到新库,确认无误后停写旧库

策略四:先达成共识,再开始编码

适用场景:涉及架构决策、存在多种可能方案的改动。

yaml 复制代码
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:

yaml 复制代码
PR 1: 纯重构------提取函数、拆分文件、调整目录
      → 运行前后行为完全不变,容易审查,大幅降低后续 PR 的差异噪音

PR 2: 类型定义和接口声明
      → 只定义"契约",审查者只需关注 API 设计

PR 3: 测试先行------为新功能写好测试用例(暂标记跳过)
      → 审查者通过测试用例就能理解需求和预期行为

PR 4-N: 逐步实现功能,每个 PR 解锁一批测试

这招最容易被忽视但效果最好。很多大 MR 里一半的差异来自文件移动和函数提取,这些噪音淹没了真正重要的逻辑变更。提前单独提交后,后续 PR 会干净很多。


四、「这个真的拆不了」------四种常见借口和破解方法

「必须一起改才能编译通过」

破解:引入中间过渡状态。

你的最终目标是 A → C,中间可以走 A → B → C,其中 B 是「新旧并存」的过渡态:

typescript 复制代码
// 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。

「数据库结构变更必须和业务代码一起提交」

破解:向前兼容的分步迁移。

yaml 复制代码
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 方面的经验和踩过的坑。

相关推荐
神三元2 小时前
大模型工具调用输出的 JSON,凭什么能保证不出错?
前端·ai编程
得物技术2 小时前
基于 Cursor Agent 的流水线 AI CR 实践|得物技术
前端·程序员·全栈
188号安全攻城狮2 小时前
【前端安全】Trusted Types 全维度技术指南:CSP 原生 DOM XSS 防御终极方案
前端·安全·网络安全·xss
墨渊君2 小时前
从 0 到 1:用 Node 打通 OpenClaw WebSocket 通信全流程
前端·openai·agent
Novlan13 小时前
一个油猴脚本,解决掘金编辑器「转存失败」的烦恼
前端
前端老石人3 小时前
HTML 入门指南:从规范视角建立正确知识体系
开发语言·前端·html
前端付豪3 小时前
实现右侧记忆面板可编辑
前端·人工智能·后端
DanCheOo3 小时前
从"会用 AI"到"架构 AI":高级前端的认知升级
前端·agent