feedback
Skip to content
/Git 虚拟文件系统设计历史

Git 虚拟文件系统设计历史

By Saeed Noursalehi

Git 虚拟文件系统设计历史

作者 Saeed Noursalehi

GVFS 在 Git 存储库下实现虚拟文件系统,以解决下面两个主要问题:

  • 只下载用户需要的内容
  • 方便本地 Git 命令只考虑用户关心的文件,而非工作目录中的所有文件

我们的目标用例是 Windows 存储库,它的工作目录中有超过 300 万个文件,源文件总大小为 270GB。 工作目录中的这 270GB 文件位于主分支的顶端。 若要克隆此存储库,必须下载大小约为 100GB 的包文件,此过程需要几个小时才能完成。 克隆成功后,本地 git 操作仍需要很长时间才能运行(如 checkout、status 和 commit 分别需要 3 小时、8 分钟和 30 分钟才能完成),因为所有这些命令与文件数量之间存在线性关系。 即使面临这些挑战,我们也仍希望将 Windows 基准代码迁移到 git,为开发者提供合理的用户体验。 同时,我们还希望可以尽量少更改 git,因为迁移到 git 的主要优势之一是,git 是一款知名工具,关于如何使用它,我们共同掌握和享有许多知识。

在决定生成 GVFS 前(提示:生成文件系统并不简单),我们探索了其他许多选项。 我们将在即将发布的文章中详细介绍 GVFS 工作方式,而现在我们要介绍的是所考虑过的其他选项,以及我们为什么最终决定生成虚拟文件系统。

背景

为什么只有一个存储库?!

我们先来解决最显而易见的问题。 为什么需要这么大的存储库?! 只需缩小存储库,生活就会变得更加美好! 是吗?!

事情并不是这么简单。 其他人已探究过单存储库的价值(有关详细信息,请访问 https://www.bing.com/search?q=monorepo)。 在 Microsoft,多个大型团队已采取将代码分解到小型存储库的做法,也有许多团队认为一个大型存储库更适合自己。

分解大型基准代码不是一件容易的事情,但也不是灵丹妙药。 对于小型存储库,优点是各个存储库中的扩展挑战不会太难应对,但缺点是跨所有这些存储库对代码进行横切式更改更加困难了。 使用多个存储库还会令发布过程更加复杂。 最后要说的是,如果缩放问题可以解决,使用一个存储库通常可以大大简化整个工作流。

VSTS

VSTS 产品包含多个相互协作的不同服务。最初迁移到 Git 时,我们将这些服务分别放入它们自己的存储库中。 我们这样做的原因是,任何一个存储库中要处理的缩放问题都会变少,而且使用多个独立的存储库也有助于强制实现一些代码边界。 但实际上,这些边界带来的弊大于利。

首先,许多开发者不得不跨存储库边界进行横切式更改。 管理这些依赖项非常痛苦,并且对提交和拉取请求进行正确排序的效率也极低。 为了克服这些困难,我们不得不生成大量工具,但随后这些工具自身也出现了问题,这让开发者非常不快。

其次,发布过程变得更加复杂。 虽然 VSTS 服务每 3 周部署一次,并且每个服务都可以几乎独立的方式进行部署,但我们每个季度还要交付一次盒装产品 Team Foundation Server。 TFS 在同一基准代码的基础之上构建而成,并附加有约束,要求所有这些服务必须安装在同一台计算机上,因此全都必须相互兼容(例如, 所有这些服务必须就共享依赖项版本达成一致)。 这些基准代码可以在三个月内有所出入,之后需要尝试为每版 TFS 将它们重新组合在一起,事实证明此过程的费用非常高。

最后,我们意识到,将所有代码都放入一个存储库中,可以大大简化我们的日常开发工作流。 这样一来,我们还可以强制实施约束。比如,所有服务都必须依赖同一版的依赖项;任何人若要更新这些组件之一,还必须更新其使用者。 这样虽然需要多完成一些前期工作,但可以大大减少每季度发布的工作量。 当然,这也意味着,我们必须致力于改进生成工具,以强制实施这些约束。此外,我们还必须完成更多工作,以创建强大的代码边界,并避免所有不同的服务间出现依赖项混乱现象。

Windows

Windows 团队在计划迁移到 Git 时,也经历了类似的过程。 多个 Windows 组件都可以放入它们自己的独立存储库中,而这一度成为记录在册的计划。 Windows 基准代码可以放入大约十几个存储库中。 不过,此计划存在两个问题。 首先,虽然这些存储库大部分都很小,可便于与现有工具配合使用,但至少有一个存储库 (OneCore) 必须为 100GB 左右,因此我们仍然摆脱不掉解决大型存储库的缩放问题的命运。 其次,我们还是引发了同样的问题,即难以执行横切式更改,而将 Windows 迁移到一个存储库的目标之一是可以更轻松地进行重大重构,而不是增加复杂程度。

我们的设计理念

作为工具开发者,我们的设计理念是:工具应方便用户针对基准代码做出正确选择。 如果使用小型存储库可以提升代码运行和团队工作效率,那么工具就应该实现这一目标,并在相应环境中实现高效工作流。 如果使用大型存储库可以提升团队工作效率,那么工具不得成为必须将存储库分解为更小区块的原因。

考虑过的备用解决方案

我们一直在努力解决以下问题:如何让团队能够处理 Git 中的超大型基准代码? 近年来,我们的设计理念确实经历了一个发展过程,下面将介绍其中一些理念。

子模块

为了解决此问题,我们首先主要尝试的是,使用 git 子模块的现有概念。 在 Git 中,一个存储库可以引用另一个存储库。因此,签出父存储库中的提交时,此提交也会指定每个子存储库中要签出的提交,以及要将它放置在父存储库工作目录中的哪个位置。 乍看之下,这似乎是将大型存储库分解为许多个小型存储库的理想方法。 我们当然也这样认为,并且花了几个月的时间来生成工具,以便能够优化命令行体验,方便用户管理子模块。

不过,子模块其实只适用于一个存储库使用另一个存储库中代码的情形。 在此模型中,子模块的表现非常不错,在用户心里就相当于 NuGet 或 npm 包。 它是独立于父存储库的库或组件,父存储库大多只是一个使用者。 当父存储库需要对子模块进行一些更改时,可以在相应子存储库中进行更改,完成它的发布过程(毕竟它是一个独立库,具有自己的代码评审、测试和发布要求)。在这些更改被接受后,立即将父存储库更新为使用这一新提交。

我们当时的想法是,为什么不进一步扩展此概念。 我们认为可以分解大型基准代码,大致步骤是将各个顶层文件夹置于它自己的存储库中,再创建一个可将所有子存储库重新拼结在一起的超级存储库。 然后,我们创建了一个便于用户执行操作的工具,如跨所有存储库运行“git status”、跨多个存储库进行提交、推送更改等。

最后,我们放弃了此方法,因为它引发的问题与它解决的问题几乎一样多。 首先,我们发现这样会令工作流复杂化,因为现在每个提交实际上都需要转化为至少两个提交或更多个,因为需要将超级存储库更新为引用要修改的所有子存储库。 其次,确实无法跨多个存储库执行原子性提交或推送。若要这样做,可以强制所有推送经过服务器上的一个瓶颈,但这样又会给开发者制造许多困境。 第三,大多数开发者并没有兴趣成为版本控制专家,他们感兴趣的是自己的日常工作,而工具只需供他们使用即可。 要求每个开发者必须成为 DAG 专家才能使用 git 已是一项艰巨挑战,而在使用这个超级存储库方法时,我们竟要求每个开发者必须使用一组松散耦合的 DAG,并保持所有 DAG 整齐有序,始终必须按顺序签出/提交/推送内容。 提出的这些要求显然过多了。

将多个存储库拼结在一起

虽然子模块的可行性不太高,但我们仍认为或许可以创建多个存储库,并使用某自定义工具将它们拼结在一起。 Android 一直将此方法用于 repo.py,因此我们认为这种方法值得一试。 然而,我们再次发现这种方法的代价并不值当。 由于每个存储库都是独立的,因此在其中一个存储库中工作时,工作流非常简单;但若需要跨存储库边界工作,工作流将会变得更加复杂。 此外,发布管理现在也成为非常困难的一个环节,因为在版本控制历史记录本身中,无法根据任何内容了解到,应为指定内部版本将各个存储库中的哪些提交 ID 组合在一起。 也就是说,现在需要生成其他版本控制系统,以供发布之用。

从以上两种方法汲取的主要教训是:不能将存储库分割的比生成和交付内容还小。

Git 备用项

若要尝试重用现有功能,可以采用 git 的备用对象存储这一概念。 每当 git 需要查找提交、树或 blob 对象时,都会查找 .gitobjects 文件夹中的松散对象,以及 .gitobjectspack 文件夹中的包文件。若配置,git 还会在备用路径中查找其他松散对象和包文件。

因此,我们使用指向网络共享的备用项,以免在克隆或提取时要将服务器上的所有 blob 复制到客户端。 这种方法有一定作用,但效率不高,只解决了历史内容太多的问题,并没有解决工作目录中的文件太多和索引中的条目太多这个问题。

这又是一个我们尝试改变功能原本用途的例子,不一致性引发的负面影响让我们无法承受。 备用项只适用于已克隆过一次存储库的用例,可以将第二次克隆配置为共享第一次克隆中的对象存储,而不是再次克隆并复制所有对象。 由于也已假定这些对象和包文件位于本地,因此可以快速访问它们,无需第二个缓存层,并且 .pack 和 .idx 文件中的随机存取 IO 也不是问题。 将备用项指向网络共享时,违反了所有这些假定,因此性能也确实受到了影响。

浅表克隆

Git 现有一项功能,可以仅下载有限数量的提交,而不下载历史记录中的所有提交。 不过,此功能对 Windows 这么大的存储库真的没有帮助,因为任何一个提交及其相关的树和 blob 都超过 80GB。 由于大多数用户并不需要一个提交的全部内容才能完成日常工作,因此我们需要通过一种方法来缩减此大小。

与备用项一样,浅表克隆对解决工作目录中文件太多的问题毫无帮助。

稀疏签出

默认情况下,当用户签出提交时,git 会将此提交引用的所有文件都置于工作目录中。 不过,可以使用文件、文件夹和模式列表配置 .gitinfosparse-checkout 文件,以指示 git 仅写入一部分文件。 我们认为此方法的发展前景广阔,因为大多数开发者只需要一小部分文件,即可完成工作。 此功能的主要限制是:

  • 稀疏签出只影响工作目录,对索引没有影响。 因此,即使只需要工作目录中的 5 万个文件,索引仍包含全部 3 百万个条目
  • 稀疏签出是静态的。 因此,如果已配置为包含文件夹 A,但稍后有人添加了对未包含的文件夹 B 的依赖性,那么在发现需要更新稀疏签出路径前,生成将开始中断
  • 稀疏签出不影响克隆或提取时的下载内容,因此即便未计划签出存储库 95% 的内容,也仍需进行下载
  • 稀疏签出的 UX 有点难用

虽说如此,稀疏签出已成为我们的解决方案的关键部分,我们以后将进行说明。

LFS

将不可比较的大型文件签入 git 存储库后,每次修改都会在历史记录中创建此文件的新副本,导致内容大量膨胀。 Git-LFS 可使用内容的文本指针重写大型 blob,从而帮助解决此问题,随后实际内容会存储在单独的存储中。 克隆存储库时,只需下载非常小的指针文件,然后 LFS 就只会为实际签出的大型文件版本下载内容。

Windows 团队花了不少精力,让 LFS 为自己所用。 此方法非常适用于缩减存储库的大小,所以他们从中受益良多。但遗憾的是,此方法无法缩减文件数量或索引大小。也就是说,它对本地命令的长运行时起不到任何帮助作用。 所以,此方法生成的存储库仍不是完全可用。

虚拟文件系统

通过上述所有实验,我们明白了:

  • 我们确实需要一个大型存储库
  • 大多数开发者无需获取大部分内容,即可完成日常工作,但他们确实需要可以灵活选择处理存储库的任何部分,而不受任意边界约束
  • 我们希望尽量利用 git 的现有功能,除非绝对必要,否则不对客户端进行任何更改

我们最终想到在存储库下实现虚拟文件系统,这种想法主要有以下几个优势:

  • 只下载开发者实际需要的 blob。 对于大多数开发者,这大概是 5-10 万个文件及其相关的历史记录,永远不需要复制存储库的大部分内容。
    • 此方法还轻松地解决了不可比较的大型文件问题
  • 借助一些巧妙的技巧,我们可以让 git 只局限于开发者实际处理的文件,以便 git status 和 checkout 等操作不再与 3 百万个文件存在线性关系,而是与更少数量的文件存在线性关系
  • 我们可以获得稀疏签出功能带来的好处,即只将必要的文件写入工作目录,而不受其弊端影响。 最重要的是,现在文件集签出是动态的,并完全以开发者所需的特定文件为依据。
  • 无需更新任何生成工具、IDE 等,即可知道这一切正在发生,因为它们只打开自己需要的文件,文件系统会确保显示相应内容

当然,也会有许多挑战。

  • 委婉地说,生成文件系统较为困难。
  • 我们明白,性能至关重要。 如果第一次文件访问由于网络延迟而较慢,开发者会谅解我们,但第二次文件访问最好没有明显开销,否则开发者会对我们大加指责。
  • 我们认为,必须明智地决定预提取内容,以免发生不必要的延迟。 通常情况下,内容越小,可感受到的延迟就越多。 例如,树对象虽然很小,但数量很多。
  • 我们仍需要思考,如何让 git 在虚拟文件系统基础之上出色运行。 我们能否不让 git 浏览全部 350 万个文件,即使这些文件看起来全都位于磁盘上? git 的所有命令遵循稀疏签出配置的彻底程度如何? 是否有将浏览太多 blob 的情况? (提示:这称为“预示”)

今后发布的文章将介绍我们是如何设计 GVFS,从而解决这些问题并实现我们的性能目标的。

Saeed Noursalehi

Saeed Noursalehi

Saeed Noursalehi is a Principal Program Manager on the Visual Studio Team Services team at Microsoft, and works on making Git scale for the largest teams in Microsoft