2318 字
约 7 分钟
6
S01-J01 · Git 内部模型:快照、对象、引用与内容寻址

适用读者:用过 commit/push/branch,但说不清"Git 到底存了什么"的人;被"reset 之后还能找回"吓到过的人。
前置:S01-B10(Git 入门)。
实测环境:Ubuntu 24.04.1 / Git 2.43.0;本篇所有 hash、对象内容、reflog 输出均为 2026-11-05 实测。
覆盖旧文:A-1849(Git 教程:数据模型与 60 命令,479 点)、A-1851(Pro Git 引用,22 点)。
新增扩展:E-S01-015(partial clone / sparse checkout / worktree,本篇只讲原理对应的部分)。
上一篇:S01-X01 · 工具与库速查:JSON、日志、测试、数据库、AI 编码客户端
下一篇:S01-J02 · 分支策略与 Code Review / CI 流程。


1. 一句话结论

Git 是一个"内容寻址的键值数据库":键 = 内容的 SHA-1/SHA-256 哈希,值 = 内容本身。提交不是"差异",而是"指向一棵树的指针"。

理解这一句,三个日常困惑立刻消失:

困惑 答案来源
每次都存全量快照,为什么仓库不大? §3(相同内容只存一份 + 压缩 + 打包)
reset --hard 删掉的提交为什么能找回? §5(对象还在,只是没有引用指向它)
rebase 后为什么每次提交的 hash 都变了? §7(hash 由内容决定,父提交变了内容就变了)

2. 三种对象:blob / tree / commit

对象 存什么 类比
blob 文件内容(不含文件名!) 一个文件的内容块
tree 目录:文件名 → 对象 hash 的映射 一层目录的清单
commit 指向一个 tree + 父提交 + 作者/时间 + 说明 一次"快照凭证"

关键点:文件名不在 blob 里,在 tree 里。所以 Git 里"重命名文件"不产生新内容对象——内容没变,只是 tree 里那条记录的键改了。

2.1 实测:把对象拆开看

mkdir /tmp/gitobj && cd /tmp/gitobj && git init -q
echo "hello" > a.txt && git add a.txt && git commit -qm "first"

BLOB=$(git hash-object a.txt); echo $BLOB
# ce013625030ba8dba906f756967f9e9ca394464a

git cat-file -t $BLOB          # blob
git cat-file -p $BLOB          # hello

tree:

TREE=$(git rev-parse HEAD^{tree}); echo $TREE
# 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1

git cat-file -p $TREE
# 100644 blob ce013625030ba8dba906f756967f9e9ca394464a	a.txt

读法:100644(文件模式)+ blob(类型)+ hash(内容地址)+ a.txt(文件名)。

commit:

git cat-file -p HEAD
# tree 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
# author T <t@t> 1793895142 +0000
# committer T <t@t> 1793895142 +0000
#
# first

一个 commit 只包含:tree 的 hash、父提交(首个提交没有)、作者、提交者、时间戳、说明。没有文件内容。

2.2 内容寻址:内容决定 hash(实测)

$ echo "hello" > a.txt; git hash-object a.txt
ce013625030ba8dba906f756967f9e9ca394464a

$ echo "hellp" > a.txt; git hash-object a.txt
d7a963a648c4564f03a0952546d2800681628048      # 改一个字符 → hash 完全不同

$ echo "hello" > b.txt; git hash-object b.txt
ce013625030ba8dba906f756967f9e9ca394464a      # 内容相同 → hash 相同(哪怕文件名不同)

最后一行是理解 Git 的钥匙:两个文件内容一样,Git 只存一份。这就是"全量快照不占空间"的第一层原因。

2.3 对象存在哪

$ find .git/objects -type f
.git/objects/ad/2ee6a5b9b521c34c7e95f585db93a5b859f859
.git/objects/2e/81171448eb9f2ee3821e3d447aa6b2fe3ddba1
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a

路径规则:hash 前 2 位做目录名,后 38 位做文件名。这是文件系统的"分桶",避免单目录塞几十万文件。

内容用 zlib 压缩存放。想直接看原始字节:

git cat-file -p ce013625...      # 解压后的内容
cat .git/objects/ce/013625... | xxd | head -2   # 压缩后的二进制(zlib 头 0x78 0x01)

3. 为什么"全量快照"不占空间:三层机制

机制 作用 实测/依据
内容去重 相同内容 = 同一 hash = 只存一次 §2.2 第三行
zlib 压缩 每个对象单独压缩 .git/objects 里的文件是压缩流
packfile + delta git gc 把松散对象打包,相似对象做增量 git count-objects -vH 的 in-pack / size-pack

3.1 count-objects 读法(实测)

$ git count-objects -vH
count: 3
size: 12.00 KiB
in-pack: 0
packs: 0
size-pack: 0 bytes
字段 含义
count / size 松散对象数量与占用(未打包)
in-pack / size-pack 已打包对象数量与占用
prune-packable 既松散又已打包(可清理的冗余)

跑一次 git gc 再对比,count 会归零、in-pack 上升、总体积下降。这就是"仓库用久了变小"的原因(同时也是 packfile delta 的效果)。

旧文的问题:A-1849 讲了"快照、对象、引用"的概念,但没有给出可动手验证的路径(没有 cat-file、没有 count-objects)。本篇把这些补齐——概念不经手摸一遍,记不住。


4. 引用:分支只是"一个文件里的一行 hash"

引用 位置 含义
分支 .git/refs/heads/<name> 指向某个 commit
远程分支 .git/refs/remotes/origin/<name> 上次 fetch 时远端的位置
HEAD .git/HEAD 当前在哪个分支(或直接指向 commit,即 detached)
ORIG_HEAD .git/ORIG_HEAD 危险操作(merge/rebase/reset)前的位置
reflog .git/logs/HEAD HEAD 的移动历史(本地,不推送)
$ cat .git/HEAD            # ref: refs/heads/main
$ cat .git/refs/heads/main # 654216a42dfc00e9adce814f9ad52546b466991e

"创建分支"为什么是瞬时操作? 因为它只是新建一个 41 字节的文件。"切换分支"为什么快? 因为只需要改 HEAD + 检出 tree 对应的文件。


5. reset --hard 之后为什么还能找回(实测)

5.1 端到端复现

$ git log --oneline
2f56673 c2
a547b41 c1

$ git reset --hard HEAD~1
HEAD is now at a547b41 c1

$ git log --oneline
a547b41 c1
$ cat f.txt
v1                     # 工作区被回退了

$ git reflog --oneline
a547b41 HEAD@{0}: reset: moving to HEAD~1
2f56673 HEAD@{1}: commit: c2          ← 丢失的提交还在这里
a547b41 HEAD@{2}: commit (initial): c1

恢复:

$ git reset --hard HEAD@{1}
HEAD is now at 2f56673 c2
$ git log --oneline
2f56673 c2
a547b41 c1
$ cat f.txt
v2                     # 回来了

5.2 没有 reflog 也能找:fsck

$ git fsck --lost-found
dangling commit 2f566732d258d2cbe97353918119fbe92a9b9e63

原理:reset 只是把分支引用从 2f56673 挪到 a547b41。2f56673 这个对象没有被删除,只是"没有引用指向它"(dangling)。gc 才会真正清理。

5.3 边界:什么时候真的找不回

情况 结果
刚 reset / 刚误删分支 reflog 里有,能找回
reflog 过期(默认 90 天;gc.reflogExpire) reflog 记录没了,但 fsck 可能还能捞到悬空对象
跑过 git gc --prune=now 对象被物理删除,找不回
未 git add 的新文件被 checkout 覆盖 Git 从未存过,找不回(只能靠 IDE 的 Local History,见 S01-B09)

实践建议:误操作后先别 gc,先 git reflog + git fsck。


6. 这条原理怎么用:四个日常推论

推论 结论 应用
分支 = 指针 建/删分支几乎零成本 大胆开分支(S01-J02 的分支策略因此才可行)
commit hash = 内容 + 父 + 作者 + 时间 任何一项变,hash 就变 rebase/cherry-pick 会产生"新提交",不是移动旧提交
提交不可变 "修改历史"= 造新对象 + 挪引用 已推送的历史改写会让他人分叉(S01-J03 讲安全做法)
对象共享 相同文件内容只存一份 为什么仓库里塞大二进制文件代价高(每次改动都是新对象,且难 delta)

6.1 大文件为什么是 Git 的痛点

  • 二进制文件(图片、jar、视频)内容几乎每次都不同 → 每次都是新 blob → 仓库膨胀。
  • delta 压缩对二进制效果差(相似度低)。
  • 结论:大文件用 LFS 或制品库(S01-A02),不要直接进 Git。

7. 与 Pro Git 的对应关系(A-1851)

旧文 A-1851 只给了一个链接(Pro Git)。这里给出精确章节,按本篇顺序读最省力:

本篇 Pro Git 章节
§2 三种对象 Git 内部原理 · 底层命令与上层命令
§2.2 内容寻址 Git 对象
§3 打包 包文件
§4 引用 引用规范

8. 自测

  1. 一个 commit 对象里存了哪些字段?没有存什么?
  2. 把 a.txt 改名成 b.txt 并提交,会产生新的 blob 吗?为什么?
  3. git reset --hard 之后立刻 git gc --prune=now,还能找回吗?
  4. 为什么 rebase 之后每个提交的 hash 都变了?
  5. 为什么把 200 MB 的视频提交进 Git 是坏主意?(用 §3、§6.1 解释)

9. 下一篇

S01-J02 · 分支策略与 Code Review / CI 流程

懂了"分支只是指针",就能理解为什么分支策略可以很轻。下一篇把旧文 A-555(543 个点位的团队 Git 规范)重组成一份可执行的流程:Commit 类型怎么选、分支从哪拉、MR 写什么、CR 看什么、谁能合、合完 CI 做什么,并补上 Conventional Commits 自动化与提交签名。


本篇属于系列《S01 · 开发环境与工具链》。旧文来源:A-1849、A-1851;扩展点 E-S01-015。所有 hash 与命令输出为 2026-11-05 实测。

S01-J01 · Git 内部模型:快照、对象、引用与内容寻址
https://www.clxhxhhr.top/posts/5166/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。