单体仓库太大怎么办:如何缩小 Monorepo 体积并提升开发者效率
当单体仓库体积真正成为问题
在某海外大型科技公司,几乎所有产品变更都会经过同一个地方:服务器端单体仓库。
所谓单体仓库,也就是 Monorepo,是指一个单一、共享的 Git 仓库,其中包含公司内部使用的众多服务和库。团队没有把代码分散到几十个较小的仓库中,而是将大部分后端基础设施集中在同一个地方。这种架构简化了跨服务开发,但也意味着这个仓库几乎处在所有构建工作的核心位置。
在构建 AI 功能时,工程师通常需要对排序系统、检索流程、评估逻辑和用户界面进行细微调整。所有这些工作都遵循同一个工程循环:拉取最新代码、构建和测试、代码审查、合并并发布。
随着时间推移,团队开始注意到,这个循环变得越来越慢。
单体代码库已经增长到 87GB。下载完整代码库,也就是“克隆”代码库,需要一个多小时。许多持续集成(CI)任务也在反复承担同样的成本。此外,仓库体积已经接近企业级代码托管平台 100GB 的限制,这带来了真实的运维风险。
在这篇文章中,我们将分享:团队如何将代码库从 87GB 缩减到 20GB,减少了 77%;如何将克隆代码库所需时间缩短到 15 分钟以内;以及代码库为什么会增长到这种程度,还有从大规模维护 Monorepo 单体代码库中学到的经验。
为什么仓库体积会影响开发者效率?
要理解代码库体积为什么如此重要,不妨先看工程师的实际工作方式。
首次搭建开发环境时,工程师需要克隆代码库,也就是将完整代码库及其历史记录下载到本地。完成初始设置后,日常工作负担会小得多。工程师只需要获取并拉取增量更新,而不必重新下载所有内容。
但首次克隆无法避免。当代码库达到 87GB 时,这一步通常需要一个多小时。
这项成本的影响并不只限于新员工入职。许多持续集成任务,也就是每次代码变更后都会运行的自动化构建和测试工作流,都会从一次全新的克隆开始。这意味着 CI 流水线会不断重复同样的开销。
用于同步代码库的内部系统,也必须处理比以往多得多的数据,这增加了超时和性能下降的风险。
与此同时,代码库还在稳定增长。通常每天增长 20MB 到 60MB,偶尔甚至超过 150MB。照这个速度,几个月内就会触及企业级代码托管平台 100GB 的仓库大小上限。
问题不仅在于代码库很大。即使以大型科技公司的规模来看,这样的增长速度也远远超出了正常开发活动的预期。这说明问题不只是“存了什么”,还在于“这些内容是如何被存储的”。
Git 压缩机制为什么会导致仓库膨胀?
起初,团队寻找的是导致代码库膨胀的常见原因:大型二进制文件、意外提交的依赖项,或者本不应该纳入版本控制的生成文件。
但这些都无法解释当时的情况。
增长模式指向了一个不那么显眼的因素:Git 的增量压缩。
Git 并不会将每个文件的每个版本都存储为完整副本。相反,它会尝试通过存储相似文件之间的差异来节省空间。当某个文件存在多个版本时,Git 会保留一个完整版本,并将其他版本表示为相对于这个完整版本的增量,也就是差异。
在大多数代码仓库中,这种方式非常有效,能够保持较高的存储效率。
问题在于,Git 如何判断哪些文件足够相似,值得拿来进行比较。
默认情况下,Git 在做增量压缩时,只使用文件路径最后 16 个字符作为启发式规则来匹配文件。在许多代码库中,这已经足够了。名称相似的文件通常也包含相关内容。
但团队的国际化文件采用如下结构:
i18n/metaserver/[语言]/LC_MESSAGES/[文件名].po
语言代码位于路径前部,而不是最后 16 个字符中。因此,Git 经常会计算不同语言文件之间的差异,而不是同一语言内部文件的差异。
例如,对某个翻译文件进行一次小幅更新,可能会被拿来和另一种语言中并不相关的文件进行比较。结果是,Git 生成的并不是较小的差异,而是更大的差异。
于是,例行翻译更新导致生成的压缩文件异常庞大。内容本身并没有异常,问题出在目录结构与 Git 压缩算法之间存在错配。
一旦理解了这种不匹配,代码库快速增长的原因也就清楚了。
如何在本地验证 Monorepo 优化方案?
一旦怀疑增量配对是根本原因,团队就开始寻找方法,影响 Git 在压缩过程中对文件的分组方式。
团队发现了一个实验性参数,它可以改变 Git 选择增量比较候选文件的方式:不再只依赖路径最后 16 个字符,而是遍历完整目录结构,从而让相关文件更容易聚集到一起。
团队使用这个参数在本地执行了一次重新打包。简单来说,就是让 Git 重新组织并重新压缩仓库中的对象。
结果立竿见影:仓库大小从约 80GB 缩小到约 20GB。
这验证了最初的假设:问题不在于数据量本身,而在于数据的打包方式。
然而,这次成功也暴露了一个新的限制。代码托管平台方表示,这个实验性参数与他们依赖的一些服务器端优化不兼容,包括用于加快克隆和获取速度的相关优化能力。
也就是说,虽然这个修复方案在本地有效,但不能直接用于生产环境。
团队需要一种既能实现相同体积缩减,又能与代码托管平台基础设施兼容的方案。这意味着,必须在平台能够安全支持的参数范围内工作,而不是依赖实验性的客户端参数。
为什么代码仓库优化无法只靠本地完成?
本地实验证明,更优化的打包方式可以显著缩小代码库体积。但这里有一个关键限制:你不能在本地重新打包代码库,然后将其推送到代码托管平台,并指望这些优化永久保留。
代码托管平台会根据每个客户端缺少的内容,在服务器端动态构建传输包。这意味着,服务器自己的打包策略决定了克隆和获取操作的大小。
即使本地镜像已经被完美优化,平台在传输过程中仍会使用自己的配置重新打包。要永久缩小仓库体积并提升性能,重新打包操作必须在代码托管平台的服务器上执行。
$ git clone --mirror git@code-hosting.example:internal/server.git server_mirror performance: 2795.152366000 s $ du -sh server_mirror 84G server_mirror $ git repack -adf --depth=250 --window=250 performance: 31205.079533000 s (~9h) $ du -sh server_mirror 20G server_mirror
团队将发现分享给代码托管平台的支持团队,并与他们合作寻找与其基础设施兼容的解决方案。
对方建议不要依赖实验性参数,而是使用调整后的 window 和 depth 参数进行更彻底的重新打包。这些设置控制 Git 搜索相似对象的范围,以及允许的增量层数。
更高的参数值会增加重新打包期间的计算时间,但也能显著提高压缩率。
团队在仓库的镜像克隆上测试了这种方法。重新打包大约耗时 9 小时,但结果非常明显:仓库大小从 84GB 缩小到 20GB。
由于这种方法符合代码托管平台的服务器端优化策略,因此可以安全地用于生产环境。
如何安全上线仓库重新打包?
重新打包代码库,会改变数十亿个对象在磁盘上的物理组织方式。它不会改变代码内容,但会改变每次克隆、获取和推送操作背后的底层结构。
鉴于单体仓库在开发工作流中的核心地位,团队把这项工作视为一次生产基础设施变更。
在正式上线前,团队创建了一个测试镜像,并让代码托管平台先在该镜像上执行重新打包。团队监控了拉取时长分布、推送成功率和 API 延迟,以确保新的打包结构不会引入任何回归问题。
镜像大小从 78GB 降至 18GB。虽然拉取延迟的尾部略有波动,但考虑到体积减少了四倍,这完全处于可接受范围内。团队也没有发现任何稳定性问题。
完成验证后,代码托管平台在一周内逐步将重新打包后的结构推向生产环境。他们每天更新一个副本,先从读写副本开始,并在周末预留缓冲时间,以防需要回滚。
这种分阶段方式确保了如果出现任何意外,都可以安全回退。
最终结果非常显著。代码库大小从 87GB 缩减至 20GB。许多情况下,克隆时间从超过一小时缩短到 15 分钟以内。
新工程师不再需要长时间等待,才能完成开发环境搭建。CI 流水线启动更快,运行也更稳定。同步代码库的内部服务也更不容易出现超时。
此外,由于代码库体积远低于代码托管平台的 100GB 限制,团队也降低了高峰时段平台性能下降的风险。
同样重要的是,系统在整个部署过程中保持稳定。数据获取时长、推送成功率和 API 延迟都保持在预期范围内。团队在没有引入新运维风险的前提下,实现了这些改进。
Monorepo 优化后,我们学到了什么?
除了仓库体积缩减本身,这个项目也强化了团队对大型基础设施维护的一些认识。以下三点最为重要。
增长并不只是提交数量的问题
当团队最初注意到代码库膨胀时,本能反应是去看新增内容:大型文件、未使用的依赖项、生成产物。
但根本原因与提交了什么无关,而是与目录结构如何和 Git 的压缩启发式规则相互作用有关。
国际化路径让 Git 倾向于计算不同语言文件之间的差异,而不是在同一语言内部进行比较。因此,例行翻译更新会生成过大的包文件。这种增长是结构性的,而不是行为性的。
工具本身就包含假设。当你的使用模式偏离这些假设时,性能可能会随着时间推移悄悄下降。
在这个案例中,Git 的 16 字符路径启发式规则本身没有问题,只是与仓库结构不兼容。正是理解了这些内部机制,才让团队正确诊断出问题所在。
有些修复需要与平台提供商合作
团队已经找到了根本原因,也在本地验证了修复方案。但由于代码托管平台决定了代码库如何打包和传输,本地重新打包并不够。
解决方案必须与平台的服务器端基础设施保持一致。
这意味着,团队需要向平台方提供清晰数据,进行协作测试,并在其支持的参数范围内工作。
当你的系统依赖托管平台时,一些问题就存在于你的代码和托管平台代码的交界处。建立稳定的合作关系,以及共享的调试流程,非常重要。
把代码库健康状况视为生产基础设施
代码库重新打包会改变数十亿个对象的物理结构。即使代码本身没有变化,每个工程师和每个自动化系统都会与这个底层结构交互。
因此,团队处理这个项目的方式,和处理任何生产基础设施变更一样:在镜像上测试,评估实际影响,逐步部署,并保留回滚路径。
代码仓库有时会给人一种“被动存储”的感觉,好像它只是随着时间推移不断增长。但当规模变大后,它们就不再是被动的了。
它们是关键基础设施,直接影响开发者速度和持续集成可靠性。
作为这项工作的一部分,团队构建了一个定期运行的统计任务,用于跟踪单体仓库的关键健康指标,并将结果反馈到内部仪表盘。
这个任务会监控仓库整体大小、增长速度、全新克隆所需时间,以及存储空间在代码库不同部分的分布情况。
如果增长速度再次加快,或克隆时间开始变长,团队就能及早发现问题,而不是等到工程师已经明显感受到阻碍时才反应过来。
对于研发团队来说,代码库健康不应只停留在基础设施层面,也需要和需求、开发、测试、发布、知识沉淀等研发管理流程连接起来。像
PingCode 这类智能化研发管理工具,可以帮助团队把研发过程中的目标、需求、缺陷、测试、发布和 Wiki 经验沉淀串联起来,让仓库治理、CI/CD 稳定性和研发效能改进更容易被追踪和复盘。
监控增长趋势,并尽早调查异常情况,是打造健康工程团队的重要组成部分。
接下来如何持续维护单体仓库健康?
将代码库从 87GB 缩减到 20GB,对构建方式产生了立竿见影的影响。
新工程师几分钟内就能开始上手,不再需要经历漫长的初始克隆过程。CI 流水线启动更快,运行也更可靠。
对于负责 AI 功能开发的团队来说,这种改进尤其明显。他们的进展往往来自跨多个服务的大量迭代式变更,因此在每个开发周期中都能直接感受到速度提升。
这次调查也带来了结构性调整,帮助团队防止类似问题再次发生。团队更新了国际化工作流,使其与 Git 打包算法对文件的分组方式更加一致,从而降低未来出现异常增量配对的可能性。
同样重要的是,团队现在能够更清晰地了解代码库增长趋势,也对什么是“正常状态”有了更明确的认识。
更广泛地说,这个项目提供了一套可复制的操作指南。
当增长速度超出预期时,团队知道如何深入压缩层进行调查,如何安全验证修复方案,以及在必要时如何跨平台协作。
随着产品持续演进,单体仓库还会继续增长。但增长并不一定意味着摩擦。借助合适的工具和规范的管理,对于每天依赖它的工程师来说,这种增长可以做到几乎无感。
