一、一切皆对象Git 不是在追踪文件的变化它在追踪内容的快照。这个区别听起来微妙却决定了 Git 所有行为的底层逻辑。每当你执行git add或git commitGit 做的事情非常朴素把内容序列化成一种标准格式计算其 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\0content比如一个内容为hello\n的文件其 blob 对象的原始字节是blob 6\0hello\n对这串字节计算 SHA-1得到ce013625030ba8dba906f756967f9e9ca394464a这就是该 blob 的唯一标识。可以手动验证echo-nblob 6\0hello\n|sha1sum# 或者用 git 的方式echohello|githash-object--stdin因为 blob 不含文件名所以文件名只是 blob 的一个引用属性由 tree 对象持有。同名不同内容 → 不同 blob不同名同内容 → 同一个 blob。2.2 tree——目录的快照tree 对象描述一个目录在某个时刻的完整状态。它是一个列表每条记录包含文件模式mode、文件名或子目录名、以及对应 blob 或子 tree 的 SHA-1。序列化格式二进制tree total-size\0 mode name\020-byte-SHA mode name\020-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 aliceexample.com 1715123456 0800 committer Alice aliceexample.com 1715123456 0800 fix: handle nil pointer in auth middleware几个值得注意的细节parent 可以有零个或多个。仓库的第一个 commit 没有 parent普通 commit 有一个merge commit 有两个或更多。git log --graph的图形本质上就是在遍历这个 parent 链表。author 与 committer 是分开的。git cherry-pick或git rebase后committer 会变成执行操作的人author 保持原始贡献者不变。commit 的 SHA 包含 parent 的 SHA。这意味着你无法在不改变后续所有 commit SHA 的情况下修改历史中间的任何一个 commit——这正是git rebase会重写历史的原因也是 Git 历史防篡改性的基础。2.4 tag——带签名的书签annotated taggit tag -a会产生一个 tag 对象区别于 lightweight tag后者只是一个指向 commit 的引用文件不产生对象。tag size\0 object target-sha type commit tag v1.0.0 tagger Alice aliceexample.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\0content然后用 zlib deflate 压缩写入路径.git/objects/sha[0:2]/sha[2:40]SHA 前两位作为目录名主要目的是避免单一目录下文件数量过多导致文件系统性能下降某些文件系统在一个目录下有数万文件时readdir会显著变慢。这个阶段的对象是不可变的immutable。一旦写入内容永远不会被修改只可能被git gc清理掉针对没有任何引用指向的悬空对象且超过一定时间后。3.2 Packfile当 loose objects 数量超过阈值默认 6700 个或执行git gc、git 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_DELTAdelta 对象存储相对于某个基准对象的差量3.3 Pack index.idx 文件idx 文件的 v2 格式包含四个区区内容Fan-out table256 个 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重建目标对象targetCOPY 指令从基准复制[1xxxxxxx] ← 最高位为1标识COPY指令 各bit分别控制 offset 和 length 的哪些字节存在 offset: 最多4字节表示从基准的哪个偏移开始 length: 最多3字节表示复制多少字节0表示65536COPY 指令的编码极为紧凑——一个覆盖 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 : offsetlength]) else: // ADD n cmd 0x7f result.append(delta_data[pos : posn]); 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通常是一个符号引用symrefref: refs/heads/maindetached 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\0content 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 prunevsgit gcgit prune只删除 loose 悬空对象不处理 packfile 里的悬空对象。git pack-refs --prune才会处理打包后的悬空引用。完整的清理需要走完整的git gc流程或者极端情况下使用git filter-repo重写历史。九、两个常被误解的地方误解一“Git 存储的是差异diff”不是。Git 存储的是快照snapshot。git show和git 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 并恢复。十、实践亲手观察对象数据库理解这些原理最好的方式是直接动手# 初始化一个空仓库gitinit democddemo# 手动写入一个 blobechohello git|githash-object-w--stdin# 输出: 8a5da52ed126497d224c673d4b48c6d7b313d893# 查看它的类型和内容gitcat-file-t8a5da52# blobgitcat-file-p8a5da52# hello git# 创建一次提交后查看完整对象图echohello githello.txtgitadd.gitcommit-minit# 列出所有对象gitcat-file --batch-all-objects --batch-check# 会列出 blob / tree / commit 三个对象# 查看 commit 对象的原始内容gitcat-file-pHEAD# 查看 tree 对象gitcat-file-pHEAD^{tree}# 统计对象数量和磁盘占用gitcount-objects-v# 手动触发打包并观察 packfilegitgcls.git/objects/pack/gitverify-pack-v.git/objects/pack/*.idx|head-20git verify-pack -v的输出会告诉你每个对象是完整存储还是 delta以及 delta 的深度和基准对象 SHA——把前面讲的所有原理都摊在你眼前。Git 的对象存储模型是软件工程里少有的设计优雅、原理彻底、实现精巧三者兼得的系统。它用四种对象类型、一个内容寻址数据库、加上 Merkle 树的完整性保证支撑起了世界上最广泛使用的版本控制系统。理解它很多 Git 命令的行为会从记忆操作变成推导结论。