部分克隆(Partial clone)介绍

本文主要介绍了什么是部分克隆、使用场景,以及如何使用部分克隆。进而阐述了提升大仓库体验的其他方案-Git LFS。

部分克隆功能简介

什么是部分克隆?

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

如此设计一方面带来了分布式的代码协同能力, 但在另一方面, 随着开发者持续向仓库中提交代码,仓库的体积会不可避免的变得越来越大, 因为远端仓库体积迅速膨胀, 带来clone后本地后磁盘空间的迅速增长以及clone耗时的不断增加。Git的部分克隆(partial-clone)特性可以优化和解决这些问题。目前,部分克隆已经在阿里云Codeup上线, 用户可以试用该功能体验新特性带来的研发效率的提升

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

部分克隆的适用场景

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

大仓库

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

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

$ git clone --mirror gi*@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

image

部分克隆使用及原理简介

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>

在本地客户端支持的情况下,就可以使用部分克隆来提升研发效率了。

如何使用部分克隆

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

  • 使用 git clone 命令创建部分克隆:

    git clone --filter=blob:none <仓库地址>
  • 使用 git fetch 命令创建部分克隆

    git init .
    git remote add origin <仓库地址>
    git fetch --filter=blob:none origin
    git switch master
  • 使用 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.***@alibaba-inc.com> 1631697940 +0800
committer yunhuai.xzy <yunhuai.***@alibaba-inc.com> 1631697940 +0800

first commit
$ git cat-file -p HEAD^
tree b7458566de2bf5e1011142ef5fe81ccaa4c9e73e
parent 2f7478bda13e73e1e1eaab6fae3d0dfd35e50b32
author yunhuai.xzy <yunhuai.***@alibaba-inc.com> 1631698032 +0800
committer yunhuai.xzy <yunhuai.***@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,请参考Git-LFS 大文件存储

部分克隆与Git LFS的异同

方案

目标

应用场景

实现机制

优点

缺点

部分克隆

主要解决由于仓库历史较长或文件数量庞大导致的仓库体积增大问题。

适用于仓库历史较长或文件数量较多的情况,特别是只需要代码最新版本的场景。

通过在克隆仓库时设置过滤器选项,只下载特定的历史对象或文件,减少克隆时的传输流量和本地磁盘占用。

  • 减少克隆时的传输流量。

  • 缩短克隆仓库的时间。

  • 减轻仓库对本地磁盘空间的占用。

可能会导致某些历史记录不完整,影响回溯和调试。

Git LFS

主要解决向Git仓库中提交大量二进制文件导致的仓库体积膨胀问题。

适用于需要管理大量二进制文件(如图片、视频、音频、设计资源等)的仓库。

通过将大文件替换为指针文件存储在仓库中,实际的大文件则存储在第三方服务器上。在检出分支时,指针文件会被替换为实际文件。

  • 有效管理大文件,避免仓库体积迅速膨胀。

  • 提高克隆速度和效率。

  • 保持仓库的整洁和可维护性。

  • 需要额外的配置和管理。

  • 依赖第三方服务器,可能增加成本。

部分克隆与大文件存储也不是割裂的,可以结合使用,以达到更好的代码协同体验。例如,使用部分克隆来减少历史对象的下载,同时使用Git LFS来管理大文件。Codeup对这两个特性都进行了支持,您可以根据实际情况合理选择和结合使用部分克隆和Git LFS,以有效优化大型仓库的管理和使用体验。

参考资料