Git 底层原理系列 · 第3讲 — Git 对象模型

5 阅读5分钟

⏱️ 预计阅读时间:20 分钟

目录


📚 学习导航

| 项目 | 内容 |

|------|------|

| 前置知识 | 第2讲:快照模型(每次 commit 是一个快照) |

| 核心问题 | Q1: Git 的"对象"到底是什么?
Q2: blob、tree、commit、tag 之间是什么关系?
Q3: 为什么要用 SHA-1 hash 作为对象的身份? |

| 预计收获 | 彻底理解 Git 的数据模型;能用底层命令手动创建对象 |

| 阅读路径 | 顺序阅读,第7节动手实验强烈推荐在终端跟着做 |


⚡ 认知冲突

**你以为 **git add** 是把文件"加"到了 Git 里? **

实际上 git add 把文件转换成一个 blob 对象,写入 .git/objects/ 目录。git commit 则根据当前的目录结构创建 tree 对象,再创建一个 commit 对象指向这个 tree。你用的所有高层命令(addcommitpush),底层都在操作这四种对象。


1 万物皆对象:Git 的四种数据类型

Git 本质上是一个内容寻址的文件系统。这个文件系统里只有四种对象:

| 对象类型 | 英文名 | 作用 | 类比 |

|---------|--------|------|------|

| Blob | Binary Large Object | 存储文件的内容 | 文件里的"内容"本身 |

| Tree | Tree object | 存储目录结构(文件名 → blob 的映射) | 文件夹的"目录表" |

| Commit | Commit object | 存储版本快照的元信息 | 一次提交的"记录卡片" |

| Tag | Tag object | 给一个 commit 打上不可变的标签 | 里程碑的"纪念章" |

注意:Git 中没有"文件"这个概念。文件是工作区的概念,Git 的对象系统里只有 blob(内容)和 tree(目录结构)。

每种对象都有的共同特征

  1. 用 SHA-1 哈希值标识:每个对象的内容决定了它的"身份证号"

  2. 存储在 **.git/objects/** :以 hash 的前两位作为目录名

  3. 用 zlib 压缩存储:节省磁盘空间

  4. 不可变:一旦创建,永远不会被修改(你可以创建新的版本,但旧的永远在那里)


# 所有对象的存储格式都一样

.git/objects/ab/

  └── 8b1a9953c4611296a827abf8c47804d7cd6c54e4  # 一个对象

# 目录名 = hash 的前 2 位

# 文件名 = hash 的后 38 位

为什么 Git 用前两位 hash 做目录分片?

**你可能注意到 Git 的对象不是直接放在 **.git/objects/** 下的,而是放在 **ab/** **8b/** 这样的子目录里。为什么? **

答案只有一个:**避免单个目录下的文件数量过多。 **


# 如果不分片,所有对象都在同一个目录下:

.git/objects/

├── 8b1a9953c4611296a827abf8c47804d7cd6c54e4   # 对象1

├── a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0   # 对象2

├── d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3   # 对象3

# ... 10 万个对象全部平铺!

**文件系统的性能瓶颈: **

| 单个目录下文件数 | 常见的文件系统表现 |

|:---:|:---|

| < 1,000 | ✅ 正常 |

| 10,000 | ⚠️ 部分文件系统开始变慢(lsopen 操作) |

| 100,000 | ❌ EXT4、APFS、NTFS 都明显变慢 |

| 1,000,000 | ⛔ 基本不可用 |

Linux 内核仓库有超过 600 万个 Git 对象。如果不做分片,.git/objects/ 一个目录下会有 600 万个文件——任何文件操作都会卡死。

**Git 的解决方案:用 hash 的前两位做目录分片。 **


# SHA-1 hash 是 40 位的十六进制字符串

# 前 2 位 = 256 种可能(00-ff)

# 所以最多有 256 个子目录

  


# 600 万对象 ÷ 256 个目录 ≈ 每个目录 23,000 个文件

# 这虽然在"黄色区域",但比 600 万在一个目录下好得多

  


# 而且 packfile 打包后,松散对象数量大大减少

# 正常仓库只有几百到几千个松散对象

# 分片后每个目录只有几个到几十个文件

  


# 更进一步的优化:

# 第二次 gc 后,大多数对象被压缩进 .pack 文件

# .git/objects/pack/ 下只有几个大文件

# 松散对象数量保持在低水平

**本质原因:Git 的设计诞生于 2005 年,当时的文件系统和硬件远不如今天。 ** 这个分片策略是一种"预防性优化"——即使仓库增长到极限规模,也不会因为目录文件过多而崩溃。


# 查看你的仓库有多少个对象目录

ls -d .git/objects/?? | wc -l

# 实际使用了多少个分片目录

  


# 查看每个目录下的文件数

for d in .git/objects/??; do echo "$d: $(ls $d | wc -l)"; done | head -10


2 Blob:文件内容的身份证

什么是 Blob

**Blob 只存文件的内容,不存文件名、不存权限、不存任何元信息。 **


# 创建一个 blob

echo "Hello, Git" | git hash-object --stdin -w

# → 8b1a9953c4611296a827abf8c47804d7cd6c54e4

  


# 查看 blob 的内容

git cat-file -p 8b1a9953c4611296a827abf8c47804d7cd6c54e4

# → Hello, Git

  


# 查看 blob 的类型

git cat-file -t 8b1a9953c4611296a827abf8c47804d7cd6c54e4

# → blob

Blob 的存储格式

Git 在磁盘上保存 blob 时,存储的是:


blob <内容长度>\0<文件内容>

用上面的例子:


blob 10\0Hello, Git

然后把这段字符串做 SHA-1 哈希,得到 8b1a9953c4611296a827abf8c47804d7cd6c54e4,再用 zlib 压缩后写入磁盘。

为什么文件名不和 blob 放在一起?

**这是 Git 最重要的设计决定之一。 **


# 情景:文件被重命名了

# 文件 a.txt 内容 = "Hello"

# 文件 b.txt 内容 = "Hello"(同一个内容,被重命名了)

  


# SVN 的做法:a.txt@v1 和 b.txt@v2 是两个不同的实体

# 重命名 = 删除 + 新增

  


# Git 的做法:

# 内容 "Hello" → blob hash = abc123

# a.txt 指向 abc123

# b.txt 指向 abc123

# 重命名 = 修改 tree 中的映射关系

**文件名是 tree 的职责,不是 blob 的职责。 ** 这样做的好处:

  1. 文件重命名不产生新对象——只改 tree 中的映射

  2. 相同内容自动去重——两个文件内容一样,共享同一个 blob

  3. 移动文件不改变历史——blob 不变,只改 tree

核心认知

**Blob = 文件内容本身。 ** 没有文件名,没有路径,没有权限。只是"这段内容"在 Git 对象系统中的存在。


3 Tree:目录结构的快照

什么是 Tree

Tree 对象记录了一个目录下的所有条目——文件名、文件权限、以及它们指向的 blob(或子 tree)。


# 查看一个 tree 对象

git cat-file -p master^{tree}

# → 100644 blob abc123    README.md

# → 100644 blob def456    index.js

# → 040000 tree 789abc    src/

每行的格式:


<权限> <类型> <hash>    <文件名>

| 权限 | 含义 |

|------|------|

| 100644 | 普通文件 |

| 100755 | 可执行文件 |

| 040000 | 子目录(指向另一个 tree 对象) |

| 120000 | 符号链接 |

Tree 的存储格式


tree <内容长度>\0<条目1><条目2>...

每个条目:


<权限> <文件名>\0<20 字节 hash>

Tree 如何嵌套(模拟目录树)

假设项目结构:


project/

├── README.md

├── src/

│   ├── index.js

│   └── utils.js

Git 的对象图:


tree (根目录)

├── 100644 blob abc123    README.md

└── 040000 tree def456    src/

                            ├── 100644 blob efg789    index.js

                            └── 100644 blob hij012    utils.js

**注意: ** 子目录 src/ 在 Git 中也是一个 tree 对象。根 tree 指向子 tree,子 tree 指向 blob。

Tree 就是第2讲说的"快照"

回顾第2讲——每次 commit 保存的是一个"快照"。**这个快照就是 tree 对象。 **


# commit A 的 tree = 某个时刻的完整目录结构

commit A → tree T1 → blob(a v1), blob(b v1)

  


# commit B 的 tree = 另一个时刻的完整目录结构

commit B → tree T2 → blob(a v1), blob(b v2)

                     ↑            ↑

                  没变→引用   变了→新 blob

**所以当你 **git checkout** 切换分支时,Git 本质上只是把当前分支指向的 commit 的 tree 解压到工作目录。 **


4 Commit:版本历史的锚点

什么是 Commit

**Commit 对象记录了一次提交的元信息: **


# 查看一个 commit 对象

git cat-file -p HEAD

# → tree d3f1a5b4c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1

# → parent a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

# → author Yang Yangwen <yangwen@example.com> 1712345678 +0800

# → committer Yang Yangwen <yangwen@example.com> 1712345678 +0800

# →

# → 第2讲:快照 vs 差异

一个 commit 包含五个信息:

| 字段 | 含义 | 为什么重要 |

|------|------|-----------|

| tree | 本次 commit 的根 tree(完整快照的入口) | 有了这个,任何时候都能恢复整个目录结构 |

| parent | 父 commit 的 hash(多个 parent 表示合并 commit) | 构成了版本历史的"链条" |

| author | 作者(谁写的代码) | 责任归属 |

| committer | 提交者(谁提交的) | 区分作者和提交者(如 PR 合并场景) |

| message | 提交说明 | 为什么做这次修改 |

Commit 链:历史的"单向链表"


commit v1 (初始)          commit v2                commit v3

┌─────────────┐          ┌─────────────┐          ┌─────────────┐

 tree: T1     ←parent─  tree: T2     ←parent─  tree: T3    

 parent: nil            parent: v1             parent: v2  

 msg: "init"            msg: "add"             msg: "fix"  

└─────────────┘          └─────────────┘          └─────────────┘

**每个 commit 只记录它的父 commit。 ** 要追溯完整历史,只需要从当前 commit 一直往前找 parent 字段。

合并 Commit 有两个 parent


      ┌── commit A ──┐

     main            feature

      │                │

      └─── commit B ←─┘    ← 合并 commit

           parent: [A, feature-v2]

合并 commit 的 parent 字段有两个值,指向两个分支的最新 commit。


5 Tag:不可变的里程碑

轻量标签 vs 附注标签

Git 有两种 tag:


# 轻量标签(lightweight):只是一个指针

git tag v1.0

# 内部:在 .git/refs/tags/v1.0 写入当前 commit 的 hash

  


# 附注标签(annotated):是一个完整的 tag 对象

git tag -a v1.0 -m "v1.0 release"

# 内部:创建了一个 tag 对象

Tag 对象的内部结构


git cat-file -p v1.0

# → object a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

# → type commit

# → tag v1.0

# → tagger Yang Yangwen <yangwen@example.com> 1712345678 +0800

# →

# → v1.0 release

| 字段 | 含义 |

|------|------|

| object | 指向的 commit 的 hash |

| type | 被指向对象的类型(通常是 commit) |

| tag | 标签名称 |

| tagger | 打标签的人 |

| message | 标签说明 |

为什么 tag 不可变

**因为 tag 的语义是"这个版本已经到了" **——它代表一个不可移动的里程碑。

  • Branch 可以移动(不断指向最新的 commit)

  • Tag 不应该移动(一旦打了 v1.0,v1.0 就永远指向那个 commit)


# ❌ 不要这样做

git tag -f v1.0 new_commit  # 强制移动 tag —— 会让别人困惑

  


# ✅ 要这样做

git tag v1.1 new_commit  # 新的版本打新的 tag


6 四者关系全景图


                        Tag

                         │

                         ▼

                    ┌─────────┐

            ┌──────▶│  Commit  │◀────── Branch (指针)

            │       └────┬────┘

            │            │

            │            │ tree

            │            ▼

            │       ┌─────────┐

            │       │  Tree   │────────── parent commit

            │       └────┬────┘

            │            │

            │     ┌──────┼──────┐

            │     │      │      │

            ▼     ▼      ▼      ▼

         ┌────┐ ┌────┐ ┌────┐ ┌──────┐

         │blob│ │blob│ │blob│ │ Tree │ (子目录)

         └────┘ └────┘ └────┘ └──┬───┘

                                  │

                            ┌─────┼─────┐

                            ▼     ▼     ▼

                          ┌────┐ ┌────┐ ┌────┐

                          │blob│ │blob│ │blob│

                          └────┘ └────┘ └────┘

**从下往上看(数据结构方向): **

  • Blob 是数据的最底层——文件内容

  • Tree 组织 blob → 形成目录结构

  • Commit 封装 tree + 元信息 → 形成版本快照

  • Tag 给 commit 打标签 → 形成里程碑

**从上往下看(用户操作方向): **

  • 用户通过 branch/tag 定位 commit

  • 通过 commit 找到当时的完整目录快照(tree)

  • 通过 tree 找到所有文件(blob)

  • 通过 blob 获取文件内容


7 动手实验:从零构建一个 commit

这个实验完全不使用 git add/git commit,只用底层命令来创建一个 commit。

第1步:初始化仓库


mkdir ~/sandbox/git-objects-lab && cd ~/sandbox/git-objects-lab

git init

# → Initialized empty Git repository in ~/sandbox/git-objects-lab/.git/

第2步:创建 blobs(文件内容)


# 创建 README.md 的内容 blob

echo "# Git Objects Lab" | git hash-object -w --stdin

# → abc123... (记下这个 hash)

  


# 创建 index.js 的内容 blob

echo "console.log('hello')" | git hash-object -w --stdin

# → def456... (记下这个 hash)

第3步:创建 tree(目录结构)


# 用之前得到的 blob hash 创建 tree

# 格式:<权限> <类型> <hash>\t<文件名>

# 注意:文件名和 hash 之间是 tab 分隔

  


# 先用 git update-index 添加文件到暂存区

git update-index --add --cacheinfo 100644 abc123... README.md

git update-index --add --cacheinfo 100644 def456... index.js

  


# 从暂存区创建 tree

git write-tree

# → 789abc... (tree 的 hash)

第4步:创建 commit(第一次提交)


# 第一次 commit(没有 parent)

echo "initial commit" | git commit-tree 789abc...

# → 012def... (commit 的 hash)

第5步:更新引用


# 让 main 分支指向这个 commit

git update-ref refs/heads/main 012def...

第6步:验证


# 查看 log

git log

# → commit 012def... (HEAD -> main)

# →     initial commit

  


# 查看内容

git show --stat

**你刚才手动完成了 **git add** + **git commit** 在底层做的所有事情。 ** 每一步对应一个底层命令:

| 高层命令 | 底层操作 |

|---------|---------|

| git add | 创建 blob 对象 + 更新 index |

| git commit | git write-tree(创建 tree)+ git commit-tree(创建 commit)+ git update-ref(更新引用) |


总结

| 对象 | 存什么 | 谁创建 | 类比 |

|------|-------|--------|------|

| Blob | 文件内容(不含文件名) | git hash-object -w | 文件内容的身份证 |

| Tree | 目录结构(文件名→hash 映射) | git write-tree | 文件夹的目录表 |

| Commit | 版本快照元信息(tree + parent + message) | git commit-tree | 提交记录卡片 |

| Tag | 不可变的里程碑(指向某个 commit) | git tag -a | 里程碑纪念章 |

**Git 的对象模型是一个精心设计的"最小完备集": **

  • Blob 解决"内容存储"——文件内容不变,永远不需要重存

  • Tree 解决"目录结构"——文件名是元数据,和内容分离

  • Commit 解决"版本历史"——通过 parent 链形成不可变的时间线

  • Tag 解决"命名里程碑"——不可移动的指针

四种对象加起来,就是一个完整的版本控制系统。


自测卡片

Q1:为什么 Git 的 blob 不存文件名?

A: 这是有意为之的设计。文件名是"元数据",和文件内容分离。好处:① 重命名不影响 blob(只改 tree)② 相同内容自动去重 ③ 文件移动不产生新对象。这也意味着 Git 不追踪"文件重命名"——它只是比较两个 tree 中的 blob hash,自动推断出重命名。

Q2:tree 对象本质上是什么数据结构的实现?

A: Tree 是一个"目录条目表"(directory entry table)。它把文件名映射到 blob(或子 tree)的 hash。每个条目包含:权限、类型、hash、文件名。子目录用另一个 tree 对象表示,通过递归嵌套形成完整的目录树。

Q3:commit 的 parent 字段在合并时有什么特殊之处?

A: 普通 commit 有一个 parent(指向父 commit)。合并 commit 有两个 parent(指向两个分支的最新 commit)。初始 commit 没有 parent(parent = nil)。通过 parent 链,Git 能追溯完整的版本历史。合并 commit 的两个 parent 告诉 Git:"这个版本融合了来自两个分支的修改"。

Q4:为什么 tag 不应该被移动?和 branch 的区别是什么?

A: Branch 是一个"可移动的指针",始终指向某个分支的最新 commit。Tag 是一个"不可移动的里程碑",代表某个版本已经发布。移动 tag 会破坏依赖这个 tag 的人(如 CI/CD 系统、其他开发者的 checkout)。要发布新版本就创建新 tag(v1.1, v1.2...)。

Q5(动手题):如果一个文件的内容完全没变,但文件名改了,Git 会创建新的 blob 吗?

A: 不会。因为 blob 只存内容,同样的内容产生同样的 hash。只是 tree 中的条目变了(原来的文件名指向原来的 blob hash,新的文件名也指向同一个 blob hash)。所以文件重命名在 Git 中"零成本"。


🎮 上瘾学习路径

动手实验

把第7节的"从零构建一个 commit"在终端跑一遍。这是理解 Git 对象模型最有效的方法。不出 10 分钟,你就能用底层命令"手搓"一个 commit。

探索练习


# 打开任意 Git 仓库

cd some-git-repo

  


# 看看 .git/objects 里有什么

find .git/objects -type f | wc -l

find .git/objects -type f | head -10

  


# 查看 HEAD commit 的完整内容

git cat-file -p HEAD

  


# 查看 HEAD 的 tree 的内容

git cat-file -p HEAD^{tree} | head -10

  


# 查看第一个 blob 的内容

git cat-file -p $(git cat-file -p HEAD^{tree} | head -1 | awk '{print $3}')

给自己出个题

"如果用 JSON 来表示一个 Git commit 的所有底层数据,应该是什么结构?"

试着画出来,你会发现 blob/tree/commit/tag 的嵌套关系一下子就清晰了。


*第3讲完。下一讲: *git add* *git commit* 底层做了什么——将本讲的对象模型和工作流程串联起来。 *