浅析Git存储—对象、打包文件及打包文件索引

存储是Git完成其他各种功能的基础,本质上是将系统中的文件按一定格式生成版本快照。对Git的存储机制进行一定程度的学习,可有效加深对Git的理解,帮助我们更高效的使用Git,同时也可作为进一步学习Git底层原理的基础。本文主要涉及Git的对象、打包文件及打包索引等内容。

Git的对象

在Git代码库中,commit、tag、目录(tree)、文件(blob)的内容经过压缩后,最终会被保存为Git对象(object)。

有两种形式的Git对象可用于保存这些压缩后的内容,一种是 松散对象(loose object) ,另一种是 打包对象(packed object)

松散对象 的格式比较简单,每一个对象都会写入到一个独立的文件中,并以对象的SHA1值作为文件名,存储在 GIT_DIR/objects 目录中(实际会以SHA1的后38个字符[19字节]作为文件名,保存在以SHA1前两个字符[1字节]命名的子目录中)。松散对象存储的内容为 zlib.deflate("${type} ${size}\0${data}") ,可通过 git cat-file -p ${objectId} 命令还原指定对象中存储的内容(即data)。

基于松散对象执行Git操作效率较低,因此可通过 git gc 命令将松散对象转换为 打包对象 ,并保存到 GIT_DIR/objects/pack 目录的打包文件中。一个打包文件可保存大量对象信息,相比于松散对象,使用打包对象具有以下优点。

  • 大量打包对象保存在一个或几个打包文件中,并配合对应的索引文件实现内容的快速检索,可有效减少Git操作过程中需要打开的文件句柄数。
  • 使用松散对象时,即使只对文件修改了一个字符,修改后的文件也会被保存为一个新的对象文件。使用打包对象时,Git会在内容相似的对象中选择一个作为base对象进行保存。针对其他相似文件,Git只会保存base对象的引用(对象id或相对偏移量),以及相较于base对象的变更内容(delta),从而节省存储空间。
  • 在执行push/fetch操作时,基于松散对象的网络通讯,会频繁发送大量小文件,效率低下。基于打包对象时,Git可使用智能协议一次性发送包含全部所需对象的打包文件,从而提高通讯效率。
  • Git的打包文件(packfile)

    在代码库中,Git会通过单个 打包文件 保存大量(也可能是全部)打包对象。打包文件会以形如 pack-4eb8b183eb6e64e1c9ccaabe91c96f83ad11a2c5.pack 的方式命名,并保存在 GIT_DIR/objects/pack 目录下。打包文件的内容格式如下图所示。
    (注意,上图中的红色区域仅用于描述其他区域所占用的空间大小,并不是文件中真实存在的部分)

    打包文件中的内容可分为 头部(header) 数据区 尾部(footer) 。接下来对这三部分进行描述。

  • 头部:打包文件的头部由三部分组成,每部分占用4字节,所以头部的总长度是12字节。
  • 头部签名:内容为 PACK
  • 版本号:Git可接受的版本号为整数 2 3 ,但目前的打包文件格式都是V2的,V3可能用于Git后续的演进。
  • 打包文件中包含的对象数:由于是一个4字节的整数,因此单个打包文件中最多只能包含4G个打包对象。
  • 数据区:数据区包含了头部所指定的数量的数据块,每个数据块对应一个打包对象,数据块又分为 数据头 压缩数据 两部分

    数据头:数据头可能由一个或多个字节组成。

  • 每个字节的第一位如果为1,则表示下一个字节还是数据头的部分。

  • 数据头第一个字节的2-4位,用于标识当前数据块的对象类型,类型与数值的对应关系如下(其中数值5为保留类型)

  • 数据头中,第一个字节的5-8位和后续每个字节的2-8位,均用于表示实际数据在 未压缩 状态下的长度。以图中第一个数据块为例,数据头中记录的数据长度为 C << 11 | B << 4 | A

  • 对于delta类型的对象,头部除了以上信息外,还需要声明对base对象的引用。 ref_delta 类型对象直接在数据头的末尾记录20字节的base对象id。 ofs_delta 类型对象会在数据头的末尾记录base数据块开始位置相对于当前数据块开始位置的偏移量信息。具体记录格式如下

  • byte b = read();
    long ofs = b & 0x7f;
    while ((b & 0x80) != 0) {
        ofs += 1;
        b = read();
        ofs <<= 7;
        ofs += (b & 0x7f);
    

    压缩数据:打包对象的文本内容按zlib算法压缩后的结果

  • 对于非delta类型的打包对象,压缩数据解压后即为原始内容
  • 对于delta类型的打包对象,压缩数据解压后为delta内容。基于delta内容和base内容,可还原出当前对象的原始内容。delta内容格式如下
  • 尾部:尾部仅包含一个20字节的SHA1格式的校验和。
  • 由以上对数据区的描述可知,我们无法根据数据头中记录的未压缩数据长度得知后面压缩数据的结束位置。同时,我们也无法直接通过打包文件中这种略显复杂的数据组织格式实现对打包对象的快速查找。因此需要进一步基于打包文件构造索引文件,才能实现打包对象的快速检索。

    Git的打包文件索引(packfile index)

    打包文件索引会以形如pack-4eb8b183eb6e64e1c9ccaabe91c96f83ad11a2c5.idx的方式命名,并保存在GIT_DIR/objects/pack目录下。Git可以通过git index-pack ${packfile}命令为打包文件构造索引文件,因此在任何通讯交互场景下,Git均不需要传递打包索引文件,只需传递打包文件即可。

    到目前为止,打包索引文件的内容格式有两个版本,V1格式用于Git1.6版本之前,V2格式用于Git1.6版本之后。V2格式的索引文件中,包含了每个对象的CRC校验值,因此在重新打包的过程中,压缩过的对象可以直接进行包间拷贝而不用担心数据损坏。此外V2格式的索引文件可支持大于4G的打包文件。有鉴于此,这里只对V2版本的打包文件索引做进一步描述。

  • fanout表:一共256个槽位,对应SHA1格式id的前两个字符(1字节)的有序排列(即00至ff)。每个槽位里记录了索引中SHA1值前两位小于等于当前槽位值的对象数量。在查找指定对象id时,可先通过fanout表做一次基于hash的范围检索,从而缩小后续二分查找的范围。这里每个槽位占用4字节,同样也限制了一个打包文件中最多只能包含4G个打包对象。
  • 对象id区:打包文件中的对象id有序的排列在此区域中
  • 校验和区:按对象id的顺序,排列存放对应的CRC校验值信息
  • 偏移量区:按对象id的顺序,排列存放打包对象在打包文件中的偏移量。此处的偏移量对应的是打包对象压缩后在打包文件中的偏移量。
  • 整形偏移量区:记录2G以下的偏移量。当偏移量大于2G时,将记录值的最高位标记为1(即使用负值),后31位保存指向长整型偏移量区的指针。
  • 长整型偏移量区:此区域只记录大小超过2G的偏移量。正是由于引入了长整型偏移量区,V2版本的索引才得以支持存储空间大于4G的打包文件。
  • 打包文件的SHA1校验和
  • 索引文件的SHA1校验和