⏱️ 预计阅读时间: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。你用的所有高层命令(add、commit、push),底层都在操作这四种对象。
1 万物皆对象:Git 的四种数据类型
Git 本质上是一个内容寻址的文件系统。这个文件系统里只有四种对象:
| 对象类型 | 英文名 | 作用 | 类比 |
|---------|--------|------|------|
| Blob | Binary Large Object | 存储文件的内容 | 文件里的"内容"本身 |
| Tree | Tree object | 存储目录结构(文件名 → blob 的映射) | 文件夹的"目录表" |
| Commit | Commit object | 存储版本快照的元信息 | 一次提交的"记录卡片" |
| Tag | Tag object | 给一个 commit 打上不可变的标签 | 里程碑的"纪念章" |
注意:Git 中没有"文件"这个概念。文件是工作区的概念,Git 的对象系统里只有 blob(内容)和 tree(目录结构)。
每种对象都有的共同特征
-
用 SHA-1 哈希值标识:每个对象的内容决定了它的"身份证号"
-
存储在
**.git/objects/**下:以 hash 的前两位作为目录名 -
用 zlib 压缩存储:节省磁盘空间
-
不可变:一旦创建,永远不会被修改(你可以创建新的版本,但旧的永远在那里)
# 所有对象的存储格式都一样
.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 | ⚠️ 部分文件系统开始变慢(ls、open 操作) |
| 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 的职责。 ** 这样做的好处:
-
文件重命名不产生新对象——只改 tree 中的映射
-
相同内容自动去重——两个文件内容一样,共享同一个 blob
-
移动文件不改变历史——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* 底层做了什么——将本讲的对象模型和工作流程串联起来。 *