Git 对象存储模型深度解析

一、一切皆对象

Git 不是在追踪"文件的变化",它在追踪"内容的快照"。这个区别听起来微妙,却决定了 Git 所有行为的底层逻辑。

每当你执行 git addgit commit,Git 做的事情非常朴素:把内容序列化成一种标准格式,计算其 SHA-1 哈希,然后以哈希值为文件名将其写入 .git/objects/。这就是 Git 的对象数据库------一个内容寻址的键值存储(content-addressable storage)。

复制代码
.git/objects/
  f4/d9e3c8a2b1... ← 前2位是目录,后38位是文件名
  9b/2e1a87c4f...
  pack/
    pack-abc123.pack
    pack-abc123.idx

这个设计有一个极其重要的推论:相同的内容永远产生相同的哈希,永远只存一份。如果你在两个分支里有完全相同内容的文件,Git 底层只存了一个 blob 对象。


二、四种对象类型

Git 的世界里只存在四种对象。

2.1 blob------内容的原子

blob 是最简单的对象,它存储的是文件的原始字节内容,没有文件名,没有权限,没有任何元数据。

序列化格式如下:

复制代码
blob <content-length>\0<content>

比如一个内容为 hello\n 的文件,其 blob 对象的原始字节是:

复制代码
blob 6\0hello\n

对这串字节计算 SHA-1,得到 ce013625030ba8dba906f756967f9e9ca394464a,这就是该 blob 的唯一标识。可以手动验证:

bash 复制代码
echo -n "blob 6\0hello\n" | sha1sum
# 或者用 git 的方式
echo "hello" | git hash-object --stdin

因为 blob 不含文件名,所以文件名只是 blob 的一个引用属性,由 tree 对象持有。同名不同内容 → 不同 blob;不同名同内容 → 同一个 blob。

2.2 tree------目录的快照

tree 对象描述一个目录在某个时刻的完整状态。它是一个列表,每条记录包含:文件模式(mode)、文件名(或子目录名)、以及对应 blob 或子 tree 的 SHA-1。

序列化格式(二进制):

复制代码
tree <total-size>\0
<mode> <name>\0<20-byte-SHA>
<mode> <name>\0<20-byte-SHA>
...

举一个实际例子,某个 tree 对象的可读形式:

复制代码
100644 README.md   → a8c1e9f... (blob)
100644 main.py     → f4d9e3c... (blob)
040000 src         → 7e3f1a2... (tree)
100755 run.sh      → b3c2d1e... (blob)

其中 100644 是普通文件权限,040000 是目录,100755 是可执行文件。Git 对权限的追踪非常克制,只区分这几种模式,不存储 owner、timestamp 等 POSIX 属性。

tree 对象中存储的是子 tree 的 SHA,而不是递归地内嵌内容------这意味着 tree 引用 tree,形成一棵 Merkle 树。

2.3 commit------历史的节点

commit 对象是你最常打交道的对象,它把"某一时刻的代码状态"和"这次变化的语义"绑定在一起。

序列化格式(纯文本):

复制代码
commit <size>\0
tree <tree-sha>
parent <parent-sha>       ← 可以有多个(merge commit)
author <name> <email> <timestamp> <timezone>
committer <name> <email> <timestamp> <timezone>

<commit message>

一个真实的 commit 对象内容大致如下:

复制代码
tree 9b2e1a87c4f3d2e1b8a9c6f5d4e3b2a1c8f7e6d5
parent a3f7c2d1e8b9a4c5f6d7e8a9b0c1d2e3f4a5b6c7
author Alice <alice@example.com> 1715123456 +0800
committer Alice <alice@example.com> 1715123456 +0800

fix: handle nil pointer in auth middleware

几个值得注意的细节:

parent 可以有零个或多个。 仓库的第一个 commit 没有 parent;普通 commit 有一个;merge commit 有两个或更多。git log --graph 的图形本质上就是在遍历这个 parent 链表。

author 与 committer 是分开的。 git cherry-pickgit rebase 后,committer 会变成执行操作的人,author 保持原始贡献者不变。

commit 的 SHA 包含 parent 的 SHA。 这意味着你无法在不改变后续所有 commit SHA 的情况下修改历史中间的任何一个 commit------这正是 git rebase 会"重写历史"的原因,也是 Git 历史防篡改性的基础。

2.4 tag------带签名的书签

annotated tag(git tag -a)会产生一个 tag 对象,区别于 lightweight tag(后者只是一个指向 commit 的引用文件,不产生对象)。

复制代码
tag <size>\0
object <target-sha>
type commit
tag v1.0.0
tagger Alice <alice@example.com> 1715123456 +0800

Release v1.0.0 - first stable release
-----BEGIN PGP SIGNATURE-----
...
-----END PGP SIGNATURE-----

tag 对象可以指向任意类型的对象,不只是 commit------虽然实践中几乎总是指向 commit。GPG 签名被完整嵌入其中,验证时通过 git tag -v 调用 GPG 验证签名覆盖的内容完整性。


三、对象的物理存储

3.1 Loose objects

每个对象被写入时,先拼接 "<type> <size>\0<content>",然后用 zlib deflate 压缩,写入路径:

复制代码
.git/objects/<sha[0:2]>/<sha[2:40]>

SHA 前两位作为目录名,主要目的是避免单一目录下文件数量过多导致文件系统性能下降(某些文件系统在一个目录下有数万文件时 readdir 会显著变慢)。

这个阶段的对象是不可变的(immutable) 。一旦写入,内容永远不会被修改,只可能被 git gc 清理掉(针对没有任何引用指向的悬空对象,且超过一定时间后)。

3.2 Packfile

当 loose objects 数量超过阈值(默认 6700 个),或执行 git gcgit push/git fetch 时,Git 会把 loose objects 打包成 packfile。这是 Git 存储效率的核心机制。

Packfile 由两个文件组成:

  • pack-<sha>.pack:实际的数据文件
  • pack-<sha>.idx:索引文件,用于快速定位 pack 内某个 SHA 的偏移量

Pack 文件格式简述:

复制代码
[4字节魔数: "PACK"]
[4字节版本号: 2]
[4字节对象数量]
[对象1: 类型+大小(变长编码)| 数据]
[对象2: 类型+大小 | 数据 或 delta引用+delta数据]
...
[20字节: 整个pack的SHA-1校验]

每个对象可以有两种存储方式:

  • OBJ_COMMIT / OBJ_TREE / OBJ_BLOB / OBJ_TAG:完整对象,zlib 压缩
  • OBJ_OFS_DELTA / OBJ_REF_DELTA:delta 对象,存储相对于某个基准对象的差量

3.3 Pack index(.idx 文件)

idx 文件的 v2 格式包含四个区:

内容
Fan-out table 256 个 4 字节整数,table[i] = SHA 首字节 ≤ i 的对象总数,用于二分查找加速
SHA-1 列表 所有对象的 SHA,已排序
CRC32 列表 每个对象压缩数据的 CRC,用于校验
Offset 列表 每个对象在 .pack 中的字节偏移

查找一个对象时,Git 先用 fan-out table 缩小搜索范围,然后在 SHA 列表上做二分查找,最终拿到偏移量直接 seek 到 .pack 文件对应位置读取------整个过程 O(log n),不需要扫描全文。


四、Delta 压缩的完整机制

4.1 基本指令集

Delta 数据本质上是一个由两种指令组成的脚本,用来描述如何从基准对象(base)重建目标对象(target):

COPY 指令(从基准复制)

复制代码
[1xxxxxxx]  ← 最高位为1标识COPY指令
  各bit分别控制 offset 和 length 的哪些字节存在
  offset: 最多4字节,表示从基准的哪个偏移开始
  length: 最多3字节,表示复制多少字节(0表示65536)

COPY 指令的编码极为紧凑------一个覆盖 64KB 的 COPY 只需要 2~5 字节。

ADD 指令(插入新内容)

复制代码
[0xxxxxxx]  ← 最高位为0标识ADD指令
  低7位 = 紧随其后的字面量字节数(1~127)
  [N字节字面量数据]

Delta 头部还包含两个变长整数:基准对象的大小(用于校验)和目标对象重建后的大小。

4.2 寻找基准的启发式策略

Git 在打包时用启发式算法决定哪些对象之间做 delta。核心逻辑在 pack-objects.c 中:

窗口扫描(window-based):对每个对象,Git 维护一个滑动窗口(默认大小 10),在窗口内尝试把当前对象作为 delta 相对于每个候选基准,选择压缩效果最好的。

相似性评估:Git 优先对以下特征相似的对象做 delta:

  • 文件名相同(最强信号)
  • 对象大小接近(大小差距太悬殊时 delta 收益低)
  • 相同的文件类型(通过文件名后缀推断)

深度限制 :delta 链不能无限深,默认最大深度(pack.depth)为 50。过深的 delta 链会导致读取时需要依次重建多层,拖慢 checkout 速度。

不做 delta 的情况

  • 对象太小(小于 50 字节,delta overhead 可能比内容本身还大)
  • 已压缩的二进制格式(PNG、MP4、ZIP 等,delta 效率接近零)
  • base 对象本身是 delta(避免链过深)

4.3 重建过程

读取一个 delta 对象时,Git 需要先找到并解压其 base,然后执行 delta 脚本:

复制代码
procedure apply_delta(base_data, delta_data):
    pos = 0
    read base_size from delta_data (varint)
    read target_size from delta_data (varint)
    
    result = []
    while pos < len(delta_data):
        cmd = delta_data[pos]; pos++
        
        if cmd & 0x80:  // COPY
            offset, length = decode_copy_args(cmd, delta_data, pos)
            result.append(base_data[offset : offset+length])
        else:           // ADD
            n = cmd & 0x7f
            result.append(delta_data[pos : pos+n]); pos += n
    
    assert len(result) == target_size
    return result

对于深度为 N 的 delta 链,这个过程需要递归执行 N 次。pack-objects 会尽量把 base 对象排在 pack 文件中 delta 对象的前面,以利用操作系统的页缓存。


五、引用系统------对象数据库的索引层

对象数据库是无序的键值存储,人类无法直接用 SHA-1 工作。引用(refs)是一层薄薄的别名系统,把人类可读的名字映射到对象 SHA。

复制代码
.git/
  HEAD             ← 指向当前分支(符号引用)
  refs/
    heads/
      main         ← 包含一个 commit SHA
      feature/auth ← 包含另一个 commit SHA
    tags/
      v1.0.0       ← 包含 tag 对象或 commit 的 SHA
    remotes/
      origin/
        main       ← 远程跟踪引用

HEAD 通常是一个符号引用(symref):

复制代码
ref: refs/heads/main

"detached HEAD"状态下,HEAD 直接包含一个 commit SHA,而不是指向一个分支。

packed-refs

当分支数量很多时,每个分支一个文件会造成大量小文件。Git 会把不活跃的引用压缩进 .git/packed-refs

复制代码
# pack-refs with: peeled fully-peeled sorted
a3f7c2d1e8b9a4c5f6d7e8a9b0c1d2e3f4a5b6c7 refs/heads/old-branch
^9b2e1a87c4f3d2e1b8a9c6f5d4e3b2a1c8f7e6d5

^ 开头的行是对 annotated tag 的"剥离"(peeled)------直接给出 tag 最终指向的 commit SHA,避免每次 git log 都要解引用 tag 对象。


六、从一次 git commit 看完整流程

把前面的内容串起来,看一次完整的 git commit 在底层发生了什么:

复制代码
工作目录修改了 src/auth.py
       ↓
git add src/auth.py
  1. 读取 src/auth.py 的当前内容
  2. 构造 "blob <size>\0<content>"
  3. zlib 压缩
  4. 写入 .git/objects/<sha[0:2]>/<sha[2:]>
  5. 更新 .git/index(暂存区)中该文件的条目
       ↓
git commit -m "fix auth"
  1. 读取 .git/index,为每个目录构建 tree 对象
     (递归:叶 tree → 父 tree → 根 tree)
  2. 根 tree 写入 .git/objects/
  3. 构造 commit 对象:
       tree <root-tree-sha>
       parent <HEAD-sha>
       author / committer / message
  4. commit 对象写入 .git/objects/
  5. 更新 .git/refs/heads/main → 新 commit SHA
  6. HEAD 通过 symref 自动指向新 commit

整个过程中,没有任何文件被修改,只有新文件被创建。Git 的对象数据库是纯追加的(append-only),这是其崩溃安全性的基础------写入中途断电,最多丢失一个新对象,旧对象永远完好。


七、Merkle 树与完整性保证

Git 的对象图本质上是一棵 Merkle 树(或更准确地说,一个有向无环图)。

复制代码
commit SHA = hash(tree SHA + parent SHA + metadata)
tree SHA   = hash(所有子 blob/tree 的 SHA + 文件名)
blob SHA   = hash(文件内容)

这种设计的推论是:

任何叶节点的变化都会传播到根节点。 修改一个文件 → blob SHA 变 → 其父 tree SHA 变 → 所有祖先 tree SHA 变 → commit SHA 变 → 所有后续 commit SHA 变。你无法在不改变 commit SHA 的前提下偷偷修改历史中的任何内容。

分布式一致性天然保证。 两个人从同一个 commit SHA 出发,一定在同一个代码状态上工作,无需中央服务器仲裁。git clone 时只要校验根 commit 的 SHA,整棵对象树的完整性就得到保证。

注意 SHA-1 碰撞问题。 Git 的 SHA-1 在理论上存在被伪造的风险(SHAttered 攻击,2017年)。GitHub 等平台已经部署了碰撞检测。Git 2.13 起引入了对 SHAttered 的防御,Git 的 SHA-256 迁移(extensions.objectFormat = sha256)也在逐步推进中,新的哈希下对象 ID 长 64 个十六进制字符。


八、对象的生命周期与 GC

悬空对象(dangling objects)

当一个对象没有任何引用指向它时,它就成了悬空对象。常见来源:

  • git commit --amend(旧 commit 对象失去引用)
  • git rebase(被 rebase 掉的所有 commit 对象)
  • git reset --hard(被跳过的 commit)
  • 删除分支(该分支独有的 commit 链)

这些对象不会立刻消失------它们先进入 reflog.git/logs/),默认保留 90 天(gc.reflogExpire)。这就是为什么 git reflog 能让你找回"已删除"的 commit。

git gc 的工作内容

复制代码
git gc
  1. git pack-refs         ← 压缩引用文件为 packed-refs
  2. git reflog expire     ← 清理过期的 reflog 条目
  3. git pack-objects      ← 把 loose objects 打包成 packfile(含 delta 压缩)
  4. git prune             ← 删除所有不可达的 loose objects(超过 --grace-period)
  5. git rerere gc         ← 清理 rerere 缓存
  6. git worktree prune    ← 清理过期的 worktree 引用

--aggressive 选项会重新打包已有的 packfile,用更大的 delta 窗口重新寻找压缩机会,耗时更长但结果更小。

git prune vs git gc

git prune 只删除 loose 悬空对象,不处理 packfile 里的悬空对象。git pack-refs --prune 才会处理打包后的悬空引用。完整的清理需要走完整的 git gc 流程,或者极端情况下使用 git filter-repo 重写历史。


九、两个常被误解的地方

误解一:"Git 存储的是差异(diff)"

不是。Git 存储的是快照(snapshot)。git showgit diff 显示的差异是实时计算出来的,不是存储的原始格式。底层的 delta 压缩是纯粹的存储优化,对语义层完全透明------你永远看不到它的存在,checkout 任何版本都会得到完整文件。

SVN 等系统才是真正存储差异(delta-based storage),代价是获取某个历史版本需要从头重放所有差异。Git 的快照模型让 checkout、branch、merge 在概念上更简单,性能也往往更好。

误解二:"删除分支就删除了提交"

不是。删除分支只是删除了一个引用文件(几十字节),对应的 commit 对象、tree 对象、blob 对象全部仍然存在于 .git/objects/ 中,直到 GC 清理掉不可达对象(且 reflog 也已过期)。这就是为什么 git branch -D 之后仍然可以用 git reflog 找到 commit SHA 并恢复。


十、实践:亲手观察对象数据库

理解这些原理最好的方式是直接动手:

bash 复制代码
# 初始化一个空仓库
git init demo && cd demo

# 手动写入一个 blob
echo "hello git" | git hash-object -w --stdin
# 输出: 8a5da52ed126497d224c673d4b48c6d7b313d893

# 查看它的类型和内容
git cat-file -t 8a5da52
# blob
git cat-file -p 8a5da52
# hello git

# 创建一次提交后查看完整对象图
echo "hello git" > hello.txt
git add . && git commit -m "init"

# 列出所有对象
git cat-file --batch-all-objects --batch-check
# 会列出 blob / tree / commit 三个对象

# 查看 commit 对象的原始内容
git cat-file -p HEAD

# 查看 tree 对象
git cat-file -p HEAD^{tree}

# 统计对象数量和磁盘占用
git count-objects -v

# 手动触发打包并观察 packfile
git gc
ls .git/objects/pack/
git verify-pack -v .git/objects/pack/*.idx | head -20

git verify-pack -v 的输出会告诉你每个对象是完整存储还是 delta,以及 delta 的深度和基准对象 SHA------把前面讲的所有原理都摊在你眼前。


Git 的对象存储模型是软件工程里少有的设计优雅、原理彻底、实现精巧三者兼得的系统。它用四种对象类型、一个内容寻址数据库、加上 Merkle 树的完整性保证,支撑起了世界上最广泛使用的版本控制系统。理解它,很多 Git 命令的行为会从"记忆操作"变成"推导结论"。

相关推荐
展翅飞翔的小王2 小时前
速查】Git 常用提交流程 + 强制用远端覆盖本地
git
C137的本贾尼3 小时前
分支管理(一):创建、切换与合并,体验“平行宇宙”
git
jiayong233 小时前
常用 Git 命令详解
大数据·git·elasticsearch
weixin_386468964 小时前
openharmony 6.0编译rk3568过程记录
c语言·c++·git·python·vim·harmonyos·openharmony
C137的本贾尼5 小时前
初识Git:告别“报告_final_v2.docx”的噩梦
git
梦梦代码精5 小时前
LikeShop开源多端商城系统:半年使用记录
git·uni-app·github
悟空瞎说19 小时前
# Git 交互式变基:优雅整理提交历史,告别杂乱 PR 记录
前端·git
身如柳絮随风扬19 小时前
Git 核心操作:rebase 与 merge 的区别,以及分支管理最佳实践
大数据·git
cccyi719 小时前
Git本地和远程邮箱一致,上传也有贡献显示,但是没有绿点或绿点延迟显示
git