部分克隆(Partial clone)介绍

部分克隆功能简介

什么是部分克隆?

众所周知,Git是一个分布式的版本控制系统,当在默认情况(例如不带任何参数情况下使用git clone命令)下克隆仓库时,Git会自动下载仓库所有文件的所有的历史版本。

这样的设计一方面的带来了分布式的代码协同能力, 但在另一方面, 随着开发者持续的向仓库中提交代码,仓库的体积会不可避免的变得越来越大, 因为远端仓库体积的迅速膨胀, 带来的是clone后本地后磁盘空间的迅速增长以及clone耗时的不断增加。

那么,有没有一种技术,可以优化和解决这些问题呢?答案是:有的。那就是Git的部分克隆(partial-clone)特性。目前,部分克隆已经在阿里云Codeup上线, 用户可以试用该功能体验新特性带来的研发效率的提升

简而言之,部分克隆允许您在克隆您的代码仓库时,通过添加--filter选项来配置需要过滤的对象,从而达到按需下载的目的,这种按需下载大大减少了传输的数据量和过程的耗时,同时还可以降低本地磁盘空间的占用。在后续工作中需要用到这些缺失文件的时候,Git会自动的按需下载这些文件,在不必进行任何额外的配置的情况下,用户仍然可以正常的开展工作。

部分克隆的适用场景

在许多的场景下,都可以用部分克隆来提升您的效率,我们以几个典型场景举例:

大仓库

当我们的仓库体积较大时,就可以考虑使用部分克隆来提升开发过程中的效率及体验。例如,linux的内核,目前有100万以上的提交,整个仓库包含了830万+的对象,体积约在3.3GB左右。

要全量的克隆这样一个仓库,克隆速度以2MB/s 来算,需要约26分钟的时间。在网络条件不佳的情况下,克隆可能还会耗费更久的时间。

$ git clone --mirror git@codeup.aliyun.com:6125fa3a03f23adfbed12b8f/linux.git linux
克隆到纯仓库 'linux'...
remote: Enumerating objects: 8345032, done.
remote: Counting objects: 100% (8345032/8345032), done.
remote: Total 8345032 (delta 6933809), reused 8345032 (delta 6933809), pack-reused 0
接收对象中: 100% (8345032/8345032), 3.26 GiB | 2.08 MiB/s, 完成.
处理 delta 中: 100% (6933809/6933809), 完成.

下面我们让我们开启部分克隆的blob:none选项,来看看使用部分克隆后的效果。

$ git clone --filter=blob:none  --no-checkout git@codeup.aliyun.com:6125fa3a03f23adfbed12b8f/linux.git
正克隆到 'linux'...
remote: Enumerating objects: 6027574, done.
remote: Counting objects: 100% (6027574/6027574), done.
remote: Total 6027574 (delta 4841929), reused 6027574 (delta 4841929), pack-reused 0
接收对象中: 100% (6027574/6027574), 1.13 GiB | 2.71 MiB/s, 完成.
处理 delta 中: 100% (4841929/4841929), 完成.

可以看到,使用了blob:none选项后,需要下载的对象由834万左右减少至602万左右,需要下载的数据量更是由3.26GB下降到了1.13GB,还是以2MB/s的速度来计算的话,部分克隆的时间仅需9分钟左右,与原来的全量克隆相比,时间仅为原来的三分之一左右。

如果使用treeless模式的部分克隆,需要下载的对象、耗费的时间还将进一步减少。但是,treeless模式的克隆在开发的场景下会更加频繁的触发缺失对象的下载,我们不推荐使用。

使用部分克隆,我们花费了更少的时间,克隆了更少的对象,带来的提升优化是显著的。

微服务单根代码仓

近年来,越来越多的项目选择了使用微服务的架构,将大的单体服务拆分为若干个内聚化的微型服务,每一个服务由一个微型团队进行维护,团队间的开发可以并行、互不干扰,团队间的协同复杂度大幅降低。但是,这也将带来公用代码更难重用、不同仓库之间依赖混乱、团队之间流程规范难以协同等问题。

因此,微服务单根代码仓的模式被提出,在这种模式下,子服务使用git来进行管理,并且由一个根仓库来统一管理所有的服务,看起来可能是这样的结构:

1

使用单根代码仓,公用的代码更易于共享,项目文档、流程规范可以集中于一处,也更加易于实施持续集成。

但是,这种模式也有缺点,对于一名开发者来说,即使他只关注项目中的某一部分,他也不得不克隆整个仓库。

部分克隆配合稀疏检出特性,可以帮助我们解决这一问题,我们可以首先启用部分克隆,并指定--no-checkout选项来指定克隆完成后不执行自动检出,避免检出时自动下载当前分支下的所有文件。之后,再通过稀疏检出功能,只按需下载并检出指定目录下的文件。

例如,我们创建了一个项目,具有如下的结构:

monorepo
├── README
├── backend
│   └── command
│       └── command.go
├── docs
│   └── api_specification
├── frontend
│   ├── README.md
│   └── src
│       └── main.js
└── libraries
    └── common.lib

现在,作为一名后端的开发人员,我只关心backend下的代码,并且也不想花费时间下载其他目录下的代码,那么就可以执行

$ git clone --filter=blob:none  --no-checkout https://codeup.aliyun.com/61234c2d1bd96aa110f27b9c/monorepo.git
正克隆到 'monorepo'...
remote: Enumerating objects: 24, done.
remote: Counting objects: 100% (24/24), done.
remote: Total 24 (delta 0), reused 0 (delta 0), pack-reused 0
接收对象中: 100% (24/24), 2.62 KiB | 2.62 MiB/s, 完成.

然后,我们进入该项目,开启稀疏检出,并配置为只下载backend下的文件

$ cd monorepo
$ git config core.sparsecheckout true
$ echo "backend/*" > .git/info/sparse-checkout

最后我们执行git checkout,并执行tree命令观察目录结构。可以看到,只有backend目录下的文件被下载了。

$ tree .
.
└── backend
    └── command
        └── command.go

2 directories, 1 file
应用构建

在构建的场景下,构建服务器首先需要从git仓库获取代码,并执行构建,最后发布应用。在构建的过程中,我们并不需要仓库中的历史代码,而是根据代码的最新版本来构建我们的应用。此时,我们可以用部分克隆的tree:0选项,最大程度的减少需要下载的对象数量。

对于构建的场景来说,我们还可以使用git的浅克隆特性,进一步的过滤历史的commit对象。关于git的浅克隆,请参考git-clone

部分克隆的使用及原理简介

Git底层对象类型简介

在使用部分克隆前,还需要了解一些Git的底层存储原理,以便更好的理解各个选项的含义,主要涉及blob对象,tree对象,以及commit对象。

下图以git底层对象的形式,展示了一个git仓库的结构。

其中,

  • 圆形,代表了commit对象,commit对象用于存储提交信息,并指向了父commit(如果存在)以及根tree对象。通过commit对象,我们可以回溯代码的历史版本。

  • 三角形,代表一个tree对象,tree对象用于存储文件名及目录结构信息,并且指向blob对象或其他tree对象,由此组成嵌套的目录结构。

  • 方块,代表了blob对象,存储了文件的实际内容

3

部分克隆的使用限制

  • 客户端限制: 本地的git版本在2.22.0或更高。

  • 服务端filter限制: 目前, Codeup支持指定两种--filter:

    • Blobless克隆:--filter=blob:none

    • Treeless克隆:--filter=tree:<depth>

  • 服务端功能开启限制: 目前,Codeup部分克隆功能正在灰度测试中,如果您对部分克隆

    有使用的需求,请提交工单与我们联系。

在提交了工单并为您的仓库开启了部分克隆,且本地客户端也支持的情况下,就可以使用部分克隆来提升研发效率了。

如何使用部分克隆

要使用部分克隆,有如下几种方式:

1.使用 git clone 命令创建部分克隆

git clone --filter=blob:none <仓库地址>

2.使用 git fetch 命令创建部分克隆

git init .
git remote add origin <仓库地址>
git fetch --filter=blob:none origin
git switch master

3.使用 git config 设置项目成为部分克隆

git init .
git remote add origin <仓库地址>
git config remote.origin.promisor true
git config remote.origin.partialclonefilter blob:none
git fetch origin
git switch master

这三种方式达到的效果是一样的,您可以自行选择喜欢的方式。下面,我们用git clone的方式来分别介绍blobless克隆和treeless克隆的使用以及基本的原理。

Blobless的克隆

在克隆时使用--filter=blob:none选项,即可开启blobless模式的克隆。在这种情况下,仓库中的历史committree会被下载,blob则不会。让我们用一个例子来更好的说明使用此选项克隆时仓库的结构。

首先,我们创建一个测试仓库。

  • 在第一个提交中,创建文件hello.txt,内容为hello world!

  • 在第二个提交中,创建文件src/hello.go,内容为打印"hello world"

  • 在第三个提交中,修改文件src/hello.go,修改输出内容为“hello codeup"

整个仓库的结构看起来像是这样:

1

然后,我们执行以下命令,来执行一次blobless模式的部分克隆

git clone --filter=blob:none \
https://codeup.aliyun.com/61234c2d1bd96aa110f27b9c/partial-clone-tutorial.git

克隆完成后,我们进入此仓库,并执行git rev-list命令来检视仓库中的对象,得到的输出为:

$ git rev-list --missing=print --objects HEAD
18990720b6e55a70ba9f9877213dad948e0973a2
e18cc4e7890e6ec832f683c1a0f58412b4a37964
2f7478bda13e73e1e1eaab6fae3d0dfd35e50b32
e7c719df0874ebd3b2ec02666d65879e986d537d
a0423896973644771497bdc03eb99d5281615b51 hello.txt
98a390b9c8b5ba25e9444c8b5a487634795d7c72 src
02a9d16faa87c68bd6fc2af27cbe3714e53af272 src/hello.go
b7458566de2bf5e1011142ef5fe81ccaa4c9e73e
3f2157b609fb05814ba0a45cf40a452640e663c3 src
6009101760644963fee389fc730acc4c437edc8f
?f2482c1f31b320e28f0dea5c4e7c8263a0df8fec

注意最后一行的id,第一个字符是问号,这也就意味着这个对象在本地其实是不存在的。

为什么会出现这种情况呢?其实这正是部分克隆所要达到的效果。

下面我们再来看看f2482c1f31b320e28f0dea5c4e7c8263a0df8fec这个对象是什么,执行:

$ git cat-file -p f2482c1f31b320e28f0dea5c4e7c8263a0df8fec
remote: Enumerating objects: 1, done.
remote: Counting objects: 100% (1/1), done.
remote: Total 1 (delta 0), reused 0 (delta 0), pack-reused 0
接收对象中: 100% (1/1), 109 字节 | 109.00 KiB/s, 完成.
package main
import "fmt"
func main() {
    fmt.Println("hello world")
}

注意到第五行,接收对象意味着这个对象实际上是刚被下载下来的,其中的内容为fmt.Println("hello world"),也就是第二个提交的版本。

通过以上的分析,我们可以知道,使用部分克隆的blob:none选项克隆仓库后,只有第二个提交中的hello.go文件不存在。

我们将图形上色,空心代表对象不存在,实心代表对象存在,那么仓库的结构可以表示成这个样子:

1

可以看到,仓库中的历史committree对象都存在,历史的blob对象则不存在。让我们把这个观察推广到更复杂一些的仓库,我们就可以总结出以blobless模式克隆仓库的一般形式:

1

需要注意的是,在当前HEAD分支下,所有的treeblob对象都存在,这是由于在克隆之后自动执行了一次检出。在此基础之上,我们可以修改,提交代码,展开工作。对于历史提交来说,committree对象都存在,仅有blob对象未被下载。通过不下载这些历史blob对象,我们达到了节省克隆时间,节省磁盘占用空间的目的。

如果此时我们检出历史提交,那么Git客户端会自动的批量下载这些缺失的blob对象。此外,当我们需要使用到文件的内容时,就会触发blob的下载,当我们只需要文件的OID时,就不需要了。这也就意味着我们可以运行git merge-basegit log等命令,性能与完全克隆模式相同。

Treeless的克隆

在克隆时使用--filter=tree:<depth>选项,就开启了无tree的克隆,其中depth是一个数字,代表了从commit对象开始的深度。在这种模式下,只有给定深度内的tree以及blob对象会被下载。

回到我们的测试仓库,这次我们利用--filter=tree:0来启用treeless的克隆,并用rev-list来检视本地的对象

$ git clone --filter=tree:0 \
https://codeup.aliyun.com/61234c2d1bd96aa110f27b9c/partial-clone-tutorial.git
$ cd partial-clone-tutorial

$ git rev-list --missing=print --objects HEAD
18990720b6e55a70ba9f9877213dad948e0973a2
e18cc4e7890e6ec832f683c1a0f58412b4a37964
2f7478bda13e73e1e1eaab6fae3d0dfd35e50b32
e7c719df0874ebd3b2ec02666d65879e986d537d
a0423896973644771497bdc03eb99d5281615b51 hello.txt
98a390b9c8b5ba25e9444c8b5a487634795d7c72 src
02a9d16faa87c68bd6fc2af27cbe3714e53af272 src/hello.go
?b7458566de2bf5e1011142ef5fe81ccaa4c9e73e
?6009101760644963fee389fc730acc4c437edc8f

现在问号出现在两个对象的id前,我们来看看这些对象是什么

$ git cat-file -p HEAD^^
tree 6009101760644963fee389fc730acc4c437edc8f
author yunhuai.xzy <yunhuai.xzy@alibaba-inc.com> 1631697940 +0800
committer yunhuai.xzy <yunhuai.xzy@alibaba-inc.com> 1631697940 +0800

first commit
$ git cat-file -p HEAD^
tree b7458566de2bf5e1011142ef5fe81ccaa4c9e73e
parent 2f7478bda13e73e1e1eaab6fae3d0dfd35e50b32
author yunhuai.xzy <yunhuai.xzy@alibaba-inc.com> 1631698032 +0800
committer yunhuai.xzy <yunhuai.xzy@alibaba-inc.com> 1631698032 +0800

add hello.go

注意其中的第二行,可以发现b74585b74585这两个对象,正好是第一第二个提交所指向的根树。画出我们的仓库结构,也就是:

1

推广到更一般的git仓库,则如下图所示:

1

可以看到,我们拥有所有的提交信息,以及在HEAD分支下的所有对象(还是由于自动的检出),但不包含任何历史提交中的treeblob

与blobless模式相比,treeless模式需要下载的对象更少了,克隆时间会更短,磁盘占用空间也会更少。但是在后续的工作中,treeless模式的克隆会更加频繁的触发数据的下载,并且代价也更为昂贵。例如,Git客户端会向服务端请求一颗树及其所有的子树,在这个过程中,客户端不会告诉服务端本地已有一些树,服务端不得不把所有的树都发送给客户端,然后客户端可以对缺失的blob对象发起批量请求。

为了更好的理解treeless克隆,下图是一个使用了--filter=tree:1选项的例子。可以看到,深度为1的tree对象也被下载了,更深的tree或者blob对象则没有。

1

在日常的开发过程中,我们不建议使用treeless模式的克隆,treeless克隆更加适用于自动构建的场景,快速的克隆仓库,构建,然后删除。

部分克隆的其他选项

部分克隆还可以使用其他选项,完整的选项请参考https://git-scm.com/docs/git-rev-list中的--filter=<filter-spec>节,我们正在逐步支持其他的选项。如果您对其中的某些选项有需求,请与我们联系,我们将会尽快提供支持。

部分克隆的性能陷阱

部分克隆通过只下载部分数据的方式,在首次克隆时减轻了需要传输的数据量,降低了克隆需要的时间。但是,在后续的过程中如果需要使用到这些历史数据,就会触发对象的按需下载。根据执行的命令不同,性能可能好也可能坏。

性能好的命令包括:

  • git checkout

  • git clone

  • git switch

  • git archive

  • git merge

  • git reset

这些命令支持批量下载缺失的对象,因此性能很好。

性能不好的命令包括:

  • git log --stat

  • git cat-file

  • git diff

  • git blame

这些命令会逐一的下载需要的对象,性能很差。

另外,在执行git rev-parse --verify "<object-id>^{object}"命令校验某个对象是否存在的时候,如果该对象在仓库中不存在,会执行git fetch对该对象按需获取。如果这个缺失的对象是一个提交对象,则获取过程会将该提交关联的所有历史提交、树对象等都重新下载,即使很多历史提交已经在仓库中了。这是因为部分仓库按需获取过程中执行的git fetch命令使用了-c fetch.negotiationAlgorithm=noop参数,没有在客户端和服务器之间进行提交信息的协商。

如何避免性能问题

git clonegit checkoutgit switchgit archivegit mergegit reset等命令支持对缺失对象的批量下载,因此性能很好。

其他不支持批量下载的命令可以在用如下方式优化:

  • 找到需要访问且在仓库中缺失的对象。

  • 启动git fetch进程,通过标准输入传递缺失的对象列表,批量下载缺失对象。

那么如何查找缺失对象呢?可以使用git rev-list命令,通过参数--missing=print显示缺失对象,缺失对象在打印时会以问号开头。

如下命令显示v1.0.0v2.0.0之间缺失的对象。

git rev-list --objects --missing=print v1.0.0..v2.0.0 | grep "^?"

将获取到的缺失对象列表通过管道传递给下面的git fetch进程,实现缺失对象的批量获取:

 git -c fetch.negotiationAlgorithm=noop \
     fetch origin \
     --no-tags \
     --no-write-fetch-head \
     --recurse-submodules=no \
     --filter=blob:none \
     --stdin

提升大仓库体验的其他方案

Git LFS(大文件存储)

除了部分克隆,Codeup 同时提供 Git LFS 大文件存储能力。关于Git LFS,请参考https://help.aliyun.com/document_detail/203101.html

部分克隆与Git LFS的异同

部分克隆以及大文件存储,都是为了优化,解决 Git 仓库体积过大带来的克隆时间增加以及占用磁盘空间过大的问题。然而,他们在侧重点上有所不同。

大文件存储,适用于解决向 Git 仓库中提交了大量的二进制文件的问题。比较典型的有:

  • 图片

  • 视频

  • 音频

  • 美术、设计资源

  • 模型

  • 编译产物

  • ...

与文本文件相比,二进制文件通常体积较大,因此被称作大文件存储。从本质上来讲,Git并不擅长处理在仓库中添加二进制文件,因为二进制文件难以压缩,Git又会将文件的历史版本存储在仓库中,导致仓库体积的迅速膨胀。因此,大文件存储应运而生,通过在仓库中配置对应的文件类型,Git会将这些文件转化为指针存储在仓库中,而将实际的文件存放在第三方服务器上。在检出分支时,这些指针文件才会被替换为原来的文件。从而达到大文件的历史版本以指针的形态存储,降低克隆仓库时间,减少仓库占用体积的效果。其他文件的历史版本,则不受影响,被正常的下载并存储在仓库中。

部分克隆,则更加关注于由于仓库的历史较长或文件很多,导致仓库体积增大的问题。使用部分克隆,您可以通过在克隆仓库时设置过滤器选项,从而更加精准的过滤一些不想要下载的历史对象,以减少克隆时传输的流量,缩短克隆仓库的时间,减轻仓库对本地磁盘空间的占用。部分克隆功能更适用于一些具有较长历史的仓库,或者只需要代码的最新版本等场景。

部分克隆与大文件存储,也不是割裂的,Codeup对这两个特性都进行了支持。您可以根据实际情况,来选择使用某一或同时使用两个特性,以达到更好的代码协同体验。

参考资料