公司动态
Git学习笔记:Git 底层存储设计 — blob、tree、commit、ref 如何组成一个版本控制系统
本文首发于 栏轩·阁欢迎访问阅读原文获取更好的阅读体验。核心设计思想Git 不是传统意义上的版本控制系统它的设计理念和 SVN、CVS 完全不同。传统 VCS基于 diffSVN 等系统记录的是文件的变更——第一次提交是完整文件后续只保存每次修改的差异。要还原版本需要从初始状态起逐个叠加 diff。Git基于快照Git 每次提交保存的是完整的文件快照不是差异。如果文件没变就复用一个引用。这种设计的优势特性效果完整性每个版本都是完整的不会因为 diff 链断裂而丢失数据速度切换分支/版本只需替换工作目录文件不用逐个计算 diff分布式每个克隆都是完整仓库不依赖中央服务器内容寻址Git 最核心的设计所有对象通过其内容的 SHA-1 哈希值来寻址。内容 → SHA-1 哈希 → 文件名存在 .git/objects 目录下这意味着同样的内容永远产生同样的 SHA在不同仓库、不同机器上内容变了SHA 就变 —— 所以对象一旦创建就不可修改修改 创建新对象原对象仍然存在四类底层对象Git 只有四类底层对象所有功能都建立在这四类之上Blob文件内容 → Tree目录结构 → Commit快照 → Ref指针1. Blob — 文件内容Blob 是最底层的对象只存文件内容不存文件名。┌─────────────┐ │ Blob │ │ size: 11 │ │ content: │ │ hello │ │ SHA: abc12 │ └─────────────┘内容 → SHA → 存进.git/objects/ab/c123...关键理解Blob 不关心文件名。文件名属于 tree 的职责。所以把一个文件改名后提交Git 存储的 blob 不变只是 tree 里的路径变了。2. Tree — 目录结构Tree 记录一个目录里所有文件和子目录的布局┌──────────────────────────┐ │ Tree │ │ blob abc12 README.md │ │ blob def34 main.go │ │ tree ghi56 src/ │ │ SHA: xyz78 │ └──────────────────────────┘每个条目包含mode文件模式普通文件、可执行文件、目录、符号链接typeblob文件或 tree子目录sha指向的 blob 或 tree 的哈希path文件名或目录名关键理解Tree 是整个目录的完整快照。更新一个文件 创建新 blob → 新 tree指向新 blob→ 新 commit。3. Commit — 提交快照Commit 将 tree 固定为一个历史版本┌────────────────────────┐ │ Commit │ │ tree xyz78 │ ← 当时完整的目录快照 │ parent older123 │ ← 前一个版本 │ author You │ │ msg feat: add x │ │ SHA: commit456 │ └────────────────────────┘关键理解Commit 本质上是指向 tree 的指针加上时间戳和作者信息。不存 diff不存变更内容——就是一张完整的目录照片。4. Ref — 分支 / 标签Ref 是指向 commit 的指针是 Git 里唯一可变的东西refs/heads/main → commit456 refs/heads/dev → commit789 refs/tags/v1.0 → commit123关键理解分支就是 ref。创建分支 创建指向某个 commit 的 ref。切换分支 把工作目录还原到 ref 指向的 commit 的 tree。.git 目录结构以上四类对象在磁盘上是怎么存的初始化一个仓库看看.git目录gitinit myrepo tree .git.git/ ├── HEAD # 当前分支指针内容ref: refs/heads/main ├── config # 仓库配置用户信息、远程地址等 ├── description # 仓库描述 ├── hooks/ # 钩子脚本pre-commit、post-receive 等 ├── info/ │ └── exclude # 本地排除规则类似 .gitignore不提交 ├── objects/ # ★ 对象库 —— 核心 │ ├── 22/ # SHA 前两位 目录 │ │ └── 596363b3de... # 后 38 位 文件名压缩后的对象 │ ├── ab/ │ │ └── c123def456... # 一个文件 一个对象 │ ├── info/ # 对象库的索引信息 │ └── pack/ # 压缩后的打包文件git gc 后产生 └── refs/ # ★ 指针 —— 分支和标签 ├── heads/ │ ├── main # refs/heads/main 内容是一个 SHA │ └── dev # refs/heads/dev 也是 40 位 SHA └── tags/ └── v1.0 # refs/tags/v1.0 指向某个 commit关键目录详解objects/ — 对象库所有 blob、tree、commit 都存这里。存储规则SHA 22596363b3de40b06f981fb85dac12e096651f52 ↑ ↑ 前 2 位 目录名 后 38 位 文件名路径.git/objects/22/596363b3de40b06f981fb85dac12e096651f52文件内容是经过zlib 压缩的对象数据不是明文。可以用git cat-file查看# 查看 blob 内容gitcat-file-p22596363b3de40b06f981fb85dac12e096651f52# → hello\n# 查看对象类型gitcat-file-t22596363b3de40b06f981fb85dac12e096651f52# → blobblob、tree、commit 都在一个目录对全部混在一起。没有按类型分文件夹.git/objects/22/5963... ← 可能是 blob .git/objects/ab/cdef... ← 可能是 commit .git/objects/12/3456... ← 可能是 treeGit 怎么区分靠文件内部的头部信息。每个对象存的是对象类型 内容长度\0原始数据所以磁盘上同样的22596363...文件实际内容可能是blob 6\0hello\n而不是只有hello\n。Git 读取时先解析blob 6\0这个头部就知道类型是blob内容长度 6 字节之后hello\n才是真正的内容用git cat-file -t只看类型-p解压并显示纯内容自动跳过头部。refs/ — 指针目录分支和标签在这里只是普通文本文件文件内容就是目标 commit 的 40 位 SHAcat.git/refs/heads/main# → 22596363b3de40b06f981fb85dac12e096651f52这就是为什么创建分支成本为零——只是写一个 40 字节的文件。HEAD — 当前在哪cat.git/HEAD# → ref: refs/heads/mainHEAD 指向一个 refref 指向一个 commitcommit 指向一个 treetree 指向 blob——一条链就到了文件内容。磁盘上的完整链条.git/HEAD → ref: refs/heads/main ↓ .git/refs/heads/main → commit SHA ↓ .git/objects/ab/cdef... (commit 对象) ↓ 包含 tree 的 SHA .git/objects/12/3456... (tree 对象) ↓ 包含 blob 的 SHA .git/objects/22/5963... (blob 对象 文件内容)你平时执行git log、git checkout main、git diff等命令Git 就是在遍历这条链。工作区 / 暂存区 / 仓库Git 的三棵树Three Trees概念工作区Working Directory ← 你眼睛看到的文件 │ │ git add ↓ 暂存区Staging Area / Index ← .git/index准备提交的内容 │ │ git commit ↓ 仓库Repository / .git ← 已提交的历史版本工作区就是你电脑上看到的文件目录。你在这里新建、编辑、删除文件——这些操作Git 不会主动记录。暂存区Index.git/index文件存的是下次要提交的文件清单。就是git add后的状态# 暂存区里到底存了什么gitls-files--stage# → 100644 22596363b3de40b06f981fb85dac12e096651f52 0 README.md# ↑mode ↑blob SHA ↑文件名暂存区不存文件内容只存blob SHA → 文件路径的映射。文件内容已经进了.git/objects/。仓库Repository就是.git/目录已提交的所有历史版本都在这里。三棵树的工作流程文件在手里工作区 │ git add → 内容写入 objects/路径写入 index ↓ 文件在暂存区.git/index │ git commit → 创建 tree当前 index 的快照→ 创建 commit ↓ 文件在仓库.git/objects/ refs/所以git commit的本质是把 .git/index暂存区拍一张快照 → 存成 tree → 创建 commit 指向它这就是为什么 Git 说先 add 再 commit——add 把内容存进objects/commit 把目录结构存成 tree。链条从文件到版本一次完整的提交链条文件内容 → SHA → Blob ↘ Tree目录结构 ↙ Commit快照 │ ↓ Ref指针分支/标签具体流程1. 你在本地echo hello README.md ↓ 2. Git 计算 hello\n 的 SHA → 存为 blob ↓ 3. Git 创建一个 tree记录 README.md → blob 的 SHA ↓ 4. Git 创建一个 commit指向这个 tree记录时间/作者 ↓ 5. 分支 refs/heads/main 指向这个 commit同一个内容的文件只存一份如果你在两个仓库里都创建了内容为hello\n的文件它们的 blob SHA完全一样。因为 SHA 只依赖内容# 在任何机器上运行结果都一样echohello|githash-object--stdin# → 22596363b3de40b06f981fb85dac12e096651f52这就是内容寻址的优势同样的内容全球共享一个 SHAGit 不需要重复存储。为什么这些设计重要1. 不可变性 数据完整性对象创建后不可修改。如果有人篡改了仓库的某个版本它的 SHA 会变后续所有 commit 的链条都会断裂——篡改无处遁形。2. 分支便宜得像白菜分支就是一个文件ref存的是 40 位 SHA。创建一百个分支只需要写一百个小文件和仓库大小无关。3. 分布式不需要中央服务器每个克隆的git clone复制的是完整的对象库。你本地就有全部历史离线也能提交、查日志、对比版本。4. 快照对比 diff 的优势基于 diff基于快照Git还原 v100应用 99 个 diff直接取出第 100 个 tree切换分支逐个计算差异直接替换工作目录文件仓库损坏diff 链断裂 → 全损每个快照独立用 curl 验证这些概念以下示例用 GitHub 的 Git Data API 验证上面的理论。不需要 Gocurl就够了。1. 创建一个 blob# 文件内容 hello → blobcurl-s-XPOST https://api.github.com/repos/cloud-drive-01/t/git/blobs\-HAuthorization: Bearer$token\-HContent-Type: application/json\-d{content:aGVsbG8K,encoding:base64}# 返回:{sha:22596363b3de40b06f981fb85dac12e096651f52,...}aGVsbG8K是hello\n的 base64 编码。这个 SHA 在任何 Git 仓库中只要内容是hello\n就一模一样。2. 创建一个 tree# blob → tree把文件组织到目录里curl-s-XPOST https://api.github.com/repos/cloud-drive-01/t/git/trees\-HAuthorization: Bearer$token\-HContent-Type: application/json\-d{tree:[{path:README.md,mode:100644,type:blob,sha:22596363b3de40b06f981fb85dac12e096651f52}]}3. 创建一个 commit# tree → commit拍一张快照curl-s-XPOST https://api.github.com/repos/cloud-drive-01/t/git/commits\-HAuthorization: Bearer$token\-HContent-Type: application/json\-d{message:first commit,tree:tree的SHA,parents:[]}4. 更新 ref# commit → ref让分支指向这个版本curl-s-XPATCH https://api.github.com/repos/cloud-drive-01/t/git/refs/heads/main\-HAuthorization: Bearer$token\-HContent-Type: application/json\-d{sha:commit的SHA,force:true}这四步就是 Git 提交的完整链。你平时敲git add、git commit、git push时Git 就在背后做这些事。验证一下# 查看当前仓库的根 treecurl-shttps://api.github.com/repos/cloud-drive-01/t/git/trees/tree的SHA\-HAuthorization: Bearer$token# 返回的 tree 里每个条目就是一个文件或子目录总结四类对象的职责对象职责可变存什么Blob文件内容❌ 不可变原始二进制内容Tree目录结构❌ 不可变文件名 → SHA 的映射Commit版本快照❌ 不可变指向 tree 时间 作者Ref指针✅ 可变指向 commit 的 SHA数据流文件内容 ↓ (SHA-1) Blob ↓ (加入 tree) Tree目录结构的快照 ↓ (创建提交) Commit历史版本 ↓ (指向) Ref分支/标签Git 设计哲学内容寻址一切通过内容哈希定位同样的内容只存一次不可变对象只创建不修改保证历史完整性快照代替 diff每次提交是完整目录快照切换/还原极快指针代替副本分支只是一个文件40 字节创建成本为零完整本地仓库每个 clone 包含全部对象不依赖网络理解 Git 的底层设计后再看 Git Data API 和 Contents API——它们不是附加功能而是 Git 核心设计的外露接口。Blob、tree、commit、ref 不是 GitHub 的概念是 Git 本身的概念。你日常敲的每一行git命令最终都在操作这四类对象。