Git 优化引用删除机制,应对海量引用场景
来源标题:Optimizing Reference Deletions in Git
Git 社区正在优化文件后端下的引用删除流程,以解决海量引用带来的性能问题。引用是 Git 存储机制的核心,文件后端为每个引用单独创建文件,在数百万引用时会导致目录膨胀。Git 2.45 引入的 reftable 后端将成为 Git 3.0 的默认格式。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
引用和对象一起构成了 Git 存储机制的骨干。简单来说,引用是仓库中指向特定对象的命名指针。
那么 Git 是如何存储引用的呢?在 Git 2.45 之前,files 后端是存储引用的唯一后端。该版本引入了更新、更好的 reftable 后端。截至 Git 2.56,files 后端仍是默认的引用后端。可以在这里了解更新的 reftable 后端,它将成为 Git 3.0 中的默认后端。
files 后端会为每个新引用创建一个文件。因此,当你创建 refs/heads/shiny-new-branch 时,会得到:
❯ git init --quiet --ref-format=files repo; cd repo ❯ git commit --allow-empty -m "new commit" [main (root-commit) 66b401c] new commit ❯ git branch shiny-new-branch ❯ eza --tree .git/refs/ .git/refs ├── heads │ ├── main │ └── shiny-new-branch └── tags ❯ cat .git/refs/heads/shiny-new-branch 66b401c8c91c975f9f126bc85d9b2eba54828d86
我们称这些为松散引用(loose refs),因为每个引用都是一个单独的文件。虽然足够简单,但它们的扩展性不佳。
当你拥有数百万个引用时,最终会把目录弄得杂乱不堪。你可能会问,谁会有数百万个引用?嗯,拥有大型 monorepo 的大公司。例如,GitLab 的 gitlab 仓库包含超过 1500 万个引用。
回到松散引用。遍历数百万个引用很慢,因为我们必须打开并解析每个单独的文件。还有其他问题,例如:
为了缓解松散引用数据结构的部分问题,Git 在 2006 年引入了 packed-refs 的概念。参见 Linus 引入 packed-refs 格式的提交。
packed-refs 格式是一个单一的 'packed-refs' 文件,其中包含引用到对象 ID 的有序映射。我们可以在自己的仓库中看到这一点:
❯ for i in $(seq 1 5) git update-ref refs/heads/branch-$i HEAD end ❯ eza --tree .git/refs/ .git/refs ├── heads │ ├── branch-1 │ ├── branch-2 │ ├── branch-3 │ ├── branch-4 │ ├── branch-5 │ ├── main │ └── shiny-new-branch └── tags ❯ git refs optimize --all ❯ eza --tree .git/refs/ .git/refs ├── heads └── tags ❯ cat .git/packed-refs
pack-refs with: peeled fully-peeled sorted
ec4aecaa91db6da8a280368110866935080f231c refs/heads/branch-1 ec4aecaa91db6da8a280368110866935080f231c refs/heads/branch-2 ec4aecaa91db6da8a280368110866935080f231c refs/heads/branch-3 ec4aecaa91db6da8a280368110866935080f231c refs/heads/branch-4 ec4aecaa91db6da8a280368110866935080f231c refs/heads/branch-5 ec4aecaa91db6da8a280368110866935080f231c refs/heads/main 66b401c8c91c975f9f126bc85d9b2eba54828d86 refs/heads/shiny-new-branch
不错。这解决了我们列出的松散引用格式的部分问题。因此 Git 的引用数据存储最终变成了两者的结合。当松散引用积累时,内务整理(housekeeping)会将它们重新打包成一个单一的 'packed-refs' 文件。
新引用总是以松散引用的形式创建,因为读取时松散引用会遮蔽打包引用。更新时,如果引用作为松散引用存在,则直接更新该松散引用。如果不存在,则用更新后的值创建一个新的松散引用。问题出在删除上。如果引用以 packed refs 格式存在,我们必须从 'packed-refs' 文件中删除它。对于大型仓库,'packed-refs' 文件很容易达到数 GB 的大小。每次删除一个 packed ref 都会重写 'packed-refs' 文件。这很慢。
❯ set -l oid (git rev-parse HEAD) ❯ begin printf '# pack-refs with: peeled fully-peeled sorted \n' awk -v o=$oid 'BEGIN { for (i = 1; i <= 10000000; i++) printf "%s refs/heads/branch-%010d\n", o, i }' end > .git/packed-refs ❯ eza -l .git/packed-refs .rw-r--r-- 700M karthik 8 Nov 15:16 .git/packed-refs ❯ time git update-ref -d refs/heads/branch-0000499999 Executed in 2.14 secs fish external usr time 1.28 secs 0.00 millis 1.28 secs sys time 0.85 secs 2.06 millis 0.85 secs ❯ eza -l .git/packed-refs .rw-r--r-- 700M karthik 8 Nov 15:28 .git/packed-refs
问题出在 files 后端的设计上。显而易见的解决方案是逐步将仓库迁移到使用更新的 reftable 后端。reftable 后端使用二进制表来存储引用,删除操作只是墓碑标记(tombstone marker)。
files 后端已经经历了大约 20 年的优化,这意味着更新的优化通常更难找到。由于我们在 GitLab 的重点是迁移到更新的 reftable 后端,我从未考虑过优化 files 后端中的删除操作。
在 Git 3.0 中,Rust 将成为强制性要求。这意味着我们将慢慢看到 Git 项目中更多地采用 Rust。为了提升我的 Rust 技能,并了解 Rust 如何逐步取代 Git 项目中的 C,我完全通过向 LLM 提问,创建了一个 files 后端的 Rust 版本。为了衡量 C <> Rust ABI 边界的开销,我决定对两者进行基准测试:
❯ ./delete-ref/run --revisions ~/code/git v2.56.0,rust-packed-refs --build-system meson --parameter-list refformat files --parameter-list refcount 100000 Benchmark 1: update-ref: delete ref (refformat = files, refcount = 100000, revision = v2.56.0) (32 runs) mean ± σ min … max Wall Time 37.1 ms ± 1.6 ms 35.2 ms … 42.4 ms Memory 11.8 MiB ± 0.1 MiB 11.5 MiB … 12.0 MiB Benchmark 2: update-ref: delete ref (refformat = files, refcount = 100000, revision = rust-packed-refs) (39 runs) mean ± σ min … max Wall Time 30.6 ms ± 1.7 ms 28.4 ms … 35.4 ms -17.7% Memory 11.8 MiB ± 0.1 MiB 11.4 MiB … 12.0 MiB +0.1%
等等。所以 Rust 版本更快?怎么会?这是用于遍历并应用引用更新的 Rust 代码:
let (rec_name, rec_start, rec_end) = match existing_rec { Some(r) => r, None => break, }; // Check for an update that exactly matches this existing record. if ui < updates.len() { let u_name = unsafe { CStr::from_ptr(updates[ui].refname).to_bytes() }; if u_name == rec_name { if !updates[ui].oid_hex.is_null() { // Replace. unsafe { write_update_line(&mut out, &updates[ui])? }; // else: delete – skip the existing record. ui += 1; continue;
(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。