Git 中的多包存储库

什么是多包存储库?

定义各不相同,但我们对多包存储库的定义如下:

  • 存储库包含多个逻辑项目(例如 iOS 客户端和 Web 应用)

  • 这些项目的连接很可能是无关的、连接松散的,或者可以通过其他方式(例如通过依赖关系管理工具)进行连接

  • 存储库在很多方面都很大:

    • 提交次数

    • 分支和/或标记的数量

    • 跟踪的文件数

    • 跟踪的内容的大小(通过查看存储库的 .git 目录来衡量)

Facebook 有一个多包存储库的例子:

Facebook 的主源存储库每周有数千次提交,涉及数十万个文件,比 Linux 内核还要大很多倍。2013 年,Linux 内核签入了 1,700 万行代码和 4.4 万个文件。

进行性能测试时,Facebook 使用的测试存储库如下:

  • 四百万次提交

  • 线性历史记录

  • 大约 130 万个文件

  • .git 目录的大小大约为 15GB

  • 索引文件的大小为 191MB

概念性挑战

在 Git 的多包存储库中管理不相关的项目时,会遇到许多概念上的挑战。

首先,Git 会在每次提交时跟踪整棵树的状态。这对于单个项目或相关项目来说没问题,但对于包含许多不相关项目的存储库来说,这会变得很麻烦。简而言之,树中不相关部分的提交会影响与开发人员相关的子树。随着大量提交推进树的历史记录,这个问题就会变得很明显。由于分支提示一直在变化,因此需要经常在本地进行合并或变基才能推送变更。

在 Git 中,标记是特定提交命名别名,指的是整个树。但是在多包存储库的背景下,标记的用处会降低。问问自己:如果您正在开发一个持续部署在多包存储库中的 Web 应用,那么版本控制的 iOS 客户端的发布标记有什么相关性?

性能问题

除了这些概念性挑战外,还有许多性能问题,这些问题可能会影响多包存储库设置。

提交次数

在提交级别上大规模管理单个存储库中的不相关项目可能会很麻烦。随着时间的推移,这可能会导致大量的提交,且增长率很高(Facebook 引用“每周数千次提交”)。这变得特别麻烦,因为 Git 使用有向非循环图 (DAG) 来表示项目的历史记录。在大量提交的情况下,随着历史记录的加深,任何遍历图表的命令都可能变慢。

这方面的一些示例包括通过 git log 调查存储库的历史记录或使用 git blame 注释文件上的变更。使用 git blame,如果您的存储库有大量的提交,Git 将不得不遍历大量不相关的提交才能计算责任信息。其他示例则可以回答任何类型的可访问性问题(例如,提交 A 是否可以从提交 B 中获得)。将多包存储库中发现的许多不相关的模块加在一起,性能问题就会变得更加复杂。

引用数量

您的多包存储库中的大量引用(即分支或标记)会以多种方式影响性能。

引用广告包含您的单体存储库中的所有引用。由于引用广告是所有远程 git 操作的第一阶段,因此这会影响 git clonegit fetchgit push 等操作。当存在大量的引用时,执行这些操作的性能会显著下降。您可以使用带有存储库 URL 的 git ls-remote 来查看引用广告。例如,执行 git ls-remote git://git.kernel.org/ pub/scm/linux/kernel/git/torvalds/linux.git 命令时,会列出 Linux 内核存储库中的所有引用。

如果引用存储松散,则列出分支会很慢。在 git gc 引用打包到一个文件中后,即使列出 20,000 多个引用也很快(大约 0.06 秒)。

在单体存储库中,任何需要遍历存储库提交历史记录并逐一校验每个引用的操作(例如 git branch--contains SHA1 命令)都会执行缓慢。在包含 21,708 个引用的存储库中,列出包含旧提交(该提交几乎能从所有引用追溯到)的引用,耗时如下:

用户时间(秒):146.44*

*这将因页面缓存和底层存储层而异。

跟踪的文件数

索引或目录缓存 (.git/index) 跟踪存储库中的每个文件。Git 使用此索引通过对每个文件执行 stat (1),并将文件修改信息与索引中包含的信息进行比较,以确定文件是否已变更。

因此,跟踪的文件数量会影响许多操作的性能*:

  • git status 可能很慢(统计每个文件,索引文件会很大)

  • git commit 也可能很慢(还会统计每个文件)

*这将因页面缓存和底层存储层而异,并且只有在有大量文件(成千上万)时才会明显。

大文件

单个子树/项目中的大文件会影响整个存储库的性能。例如,尽管开发人员(或内部版本支持人员)正在处理不相关的项目,但在多包存储库中添加到 iOS 客户端项目的大型媒体资产仍会被克隆。

组合效果

无论是文件数量、变更频率还是文件大小,这些问题加在一起都会对性能产生更大的影响:

  • 在分支/标记之间切换,这在子树上下文(例如我正在研究的子树)中最有用,它仍然会更新整个树。由于受影响的文件数量众多,此流程可能会很慢,或者需要解决方法。使用 git checkout ref-28642-31335 -- templates 会更新./templates 目录至匹配给定分支,但不更新 HEAD,其副作用是在索引中将更新后的文件标记为已修改。

  • 克隆和提取速度很慢,并且占用服务器上的大量资源,因为所有信息在传输前都会压缩在打包文件中。

  • 垃圾回收速度很慢,默认是在推送时触发的(如果需要垃圾回收的话)。

  • 对于所有涉及打包文件创建(重建)的操作而言,其资源占用量都会居高不下,例如 git upload-pack、git gc 等命令均是如此。

缓解策略

虽然如果 Git 能够支持单体式存储库这样的特殊用例,那就太好了,但是 Git 的设计目标使它取得了巨大的成功并且广受欢迎,有时与希望以非其设计的方式使用它的愿望不一致。对于绝大多数团队来说,好消息是,实际上,真正的大型单体式存储库往往是例外而不是常规,因此,尽管这篇文章很有趣,但它很可能不适用于您所面临的情况。

也就是说,在处理大型存储库时,有一系列缓解策略可以提供帮助。对于历史记录较长或二进制资产较大的存储库,我的同事 Nicola Paolucci 介绍了一些解决方法

删除引用

如果您的存储库有很多引用,您应该考虑删除不再需要的引用。DAG 保留了变更演变的历史记录,而合并提交指向其父项,因此即使分支已不存在,也可以追踪在分支上进行的工作。

基于分支的工作流程中,要保留的长期运行分支的数量应该很少。不要害怕在合并后删除一个短期运行的功能分支。

考虑删除所有已合并到主分支(如生产)的分支。只要可以从主分支访问提交,并且您已经将分支与合并提交合并,那么追踪变更演变的历史记录仍然是可能的。默认的合并提交消息通常包含分支名称,允许您在必要时保留此信息。

处理大量文件

如果您的存储库有大量文件(数万到数十万),则使用快速本地存储和大量可用作缓冲区缓存的内存会有所帮助。这是一个需要对客户进行更重大变更的领域,例如,类似于 Facebook 为 Mercurial 实施的变更

他们的方法使用文件系统通知来记录文件变更,而不是遍历所有文件来检查其中是否有任何变更。已经为 git 讨论了类似的方法(也使用了 watchman),但尚未最终实现。

使用 Git LFS(大型文件存储)

本部分已于 2016 年 1 月 20 日更新

对于包含视频或图形等大型文件的项目,Git LFS 是限制它们对存储库大小和整体性能的影响的一种选择。Git LFS 不是将大型对象直接存储在存储库中,而是存储一个同名的小型占位符文件,其中包含对该对象的引用,该文件本身存储在专门的大型对象存储库中。Git LFS 挂接到 Git 的原生推送、拉取、签出和提取操作,以透明地处理工作树中这些对象的传输和替换。这意味着您可以像往常一样处理存储库中的大型文件,而不会受到存储库过大的影响。

Bitbucket Server 4.3(及更高版本)嵌入了完全兼容的 Git LFS v1.0+ 实现,允许在 Bitbucket 用户界面中直接预览和比对 LFS 跟踪的大型图像资产。

图片由 LFS 追踪

我的同伴 Atlassian Steve Streeting 是 LFS 项目的积极贡献者,最近写了关于该项目的文章

确定界限并拆分存储库

最激进的解决方法是将您的多包存储库拆分为更小、更有针对性的 git 存储库。尝试不再跟踪单个存储库中的每一次变更,而是通过识别具有相似发布周期的模块或组件来确定组件边界。对于透明子组件,一个很好的试金石是在存储库中使用标记,以及它们对源树的其他部分是否有意义。

虽然如果 Git 能够支持多包存储库会很棒,但是多包存储库的概念与最初让 Git 取得巨大成功和受欢迎的原因略有冲突。但是,这并不意味着您应该因为自己只有多包存储库而放弃 Git 的功能——在大多数情况下,对于出现的任何问题,都有可行的解决方案。

为您推荐

Bitbucket 博客

DevOps 学习路径

了解有关 Git 的更多信息

在此中心查找更多 Git 指南和资源。