重新设计packfiles后,可在对象存储上运行Git

Hacker News Top 工具

摘要

这篇博文介绍了如何通过发明一种原生于对象存储的新packfile格式来在对象存储上运行Git,解决了传统Git packfiles在生产规模仓库中的性能问题。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/09/19 06:23

# 你可以在对象存储上运行 Git,只要你重新制作打包文件 | Tigris 对象存储 来源:https://www.tigrisdata.com/blog/objgit-packfiles/ 最近似乎有很多公司在推出某种 Git 产品。不知道是什么原因。 不管怎样,我正在构建一个基于对象存储的 Git 服务器,作为一个开源项目 (https://github.com/tigrisdata/objgit)。开始听起来很简单:Git 看起来像一个文件系统,所以我们使用文件系统作为对象存储之上的翻译层,让 Git 说对象存储的语言。这种模型……嗯,运行得还行,我猜?(https://www.tigrisdata.com/blog/objgit/) 但它无法处理真实世界规模的仓库,所以我需要一种不同的方法。Git 将所有内容存储为对象 (https://git-scm.com/book/en/v2/Git-Internals-Git-Objects),为什么不直接将它们作为对象存储在 Tigris 中呢? 事实证明,Git 打包文件以及它们与我(坦率地说有点糟糕的)文件系统垫片交互的方式是导致速度缓慢的主要原因。我最终不得不发明自己的打包文件格式,一种列式存储、原生支持对象存储的格式。这就是我所有性能分析、指标标注以及更多“德克萨斯风格”分布式系统工作 (https://www.usenix.org/system/files/1311_05-08_mickens.pdf) 的成果,其工作量之大足以让你的 k8s 集群颤抖。 这种方法在处理生产环境规模的仓库时效果出奇地好,所以我目前坚持使用这种新的打包文件格式。它似乎是对 Git 对象进行最小侵入性的改动,使其感觉像对象存储中的对象,无需任何客户端更改。 ## 什么是 Git?一堆可悲的小对象! (https://youtu.be/5tV33Ewf_hw) (https://www.tigrisdata.com/blog/objgit-packfiles/#what-is-a-git-a-miserable-little-pile-of-objects) 当你执行 `git commit` 时,Git 将你所做的更改作为对象存储在 `.git`(我称其为“dotgit”,这样我就不用写那么多反引号)文件夹中。 将 Git 想象成两样东西:对象的海洋以及指向单个对象的命名引用。每个对象都是一个内容寻址 (https://en.wikipedia.org/wiki/Content-addressable_storage) 并压缩的文件。这是一个来自小型 git 仓库的示例: ``` $ mkdir ~/tmp/gitexample $ git init && git branch -m main $ echo "Hello, blog!" >> hello.txt $ git add . $ git commit -sm "chore: initial commit" ``` 这会在磁盘上产生几个对象,如下所示: 图 01 一个对象的海洋,以及指向其中少数对象的名称 ``` .git/objects/ refs/heads/main │ ├── 1c/7a26a901..ec7966 ─────▶ commit 1c7a26a │ │ ├── 8e/67afbb2e..857bd3 ─────▶ tree 8e67afb │ │ │ hello.txt └── 9c/c9867337..09fe26 ─────▶ blob 9cc9867 "Hello, blog!" 文件名是文件中字节的 sha1, 所以相同的内容始终、 到处都是同一个对象 ``` 如果你想读取一个对象的内容,它是压缩的,所以你必须使用看起来相当邪恶的 Python 单行命令来提取其美味的内涵: ``` $ file .git/objects/**/* | grep -v directory .git/objects/1c/7a26a901724b4ce766655ac387413fb9ec7966: zlib compressed data .git/objects/8e/67afbb2ee6bdcbb79061dfdfb93febce857bd3: zlib compressed data .git/objects/9c/c9867337c2ebae85ba2350f901e0bcc209fe26: zlib compressed data $ python3 -c "import sys, zlib; sys.stdout.buffer.write(zlib.decompress(sys.stdin.buffer.read()))" < .git/objects/9c/c9867337c2ebae85ba2350f901e0bcc209fe26 blob 13 Hello, blog! ``` 如你所见,对象只是裸文件。让我们看看 Linux 内核的 Git 仓库,并尝试提取一个任意的提交。一切应该都只是数十亿个裸对象文件,对吧?通过在磁盘上查找 ID 来找到单个提交应该很容易,对吧? 要是现实如此简单就好了: ``` $ cd ~/Code/linux.git/ $ tree objects objects ├── info └── pack ├── pack-45986f41063f286029742ec12e2c2882b88c5786.idx ├── pack-45986f41063f286029742ec12e2c2882b88c5786.pack └── pack-45986f41063f286029742ec12e2c2882b88c5786.rev 3 directories, 3 files ``` 是的,我相信你猜到了,将所有东西放入各自的文件无法扩展到像 Linux 内核这样的项目。我很确定你会像以前人们在分形 `node_modules` 文件夹时代 (https://github.com/npm/npm/issues/11747) 那样遇到 inode 限制。 附注 如果你使用 Node 的时间长到还记得那件事,请去做个肠镜检查。结肠癌是一个真正的健康威胁,太多人忽视太久,过早夺去了太多生命。 Git 通过将对象放入打包文件 (https://git-scm.com/book/en/v2/Git-Internals-Packfiles) 来解决这个问题,打包文件是将对象压缩存储在同一个文件中的捆绑包。以下是我在 objgit 工作目录中打包文件效率的一个例子: ``` $ git count-objects -v count: 756 size: 3500 in-pack: 448 packs: 1 size-pack: 321 prune-packable: 0 garbage: 0 size-garbage: 0 ``` 如果你需要“强制” git 将裸对象放入打包文件,可以运行 `git gc`: ``` $ git gc [omitted for brevity] $ git count-objects -v count: 0 size: 0 in-pack: 1203 packs: 2 size-pack: 848 prune-packable: 0 garbage: 0 size-garbage: 0 ``` 附注 像我这样在对象存储上实现 Git 的美妙之处在于,我使用的平台将对象数据视为对象的海洋,并在 FoundationDB 中存储指向该海洋中点的命名引用。这是一种神圣的递归,我不知道该如何描述其美妙。上行下效,大道至简。 ## 哟,大兄弟,听说你喜欢对象 (https://www.tigrisdata.com/blog/objgit-packfiles/#yo-dawg-herd-you-like-objects) 这是一个 Linux 内核副本的对象计数: ``` xe@zohar:~/Code/linux.git$ git count-objects -v count: 0 size: 0 in-pack: 11827138 packs: 1 size-pack: 3876775 prune-packable: 0 garbage: 0 size-garbage: 0 ``` 这是 1100 万个对象,以非常宽松的假设每个 GetObject 调用 10ms 计算,获取所有这些对象需要一个多小时。事实是,磁盘上并没有 1100 万个作为单独文件的对象,它们被打包成一个巨大而愉快的 3.4GiB 打包文件。你典型的 git 仓库最终会累积打包文件,因为拆分它们是有意义的。我本地的 Tigris 博客副本有 4 个打包文件和大约 290 个裸对象。 **所以你会想,“哦,如果 git 有打包文件,那么这篇文章的其余部分在说什么?”** 嗯,像分布式系统中的许多事情一样,情况很复杂。打包文件之所以困难,是因为它们是针对本地存储和/或 mmap 设计的。Git 不断将打包文件写入磁盘,然后重新读取它们。在这种情况下,文件系统读取最多*10 纳秒*(文件系统缓存在这里帮助很大),但进行任何网络往返至少需要 *10 毫秒*。由于现实的工作方式,它至少慢一百万倍。 注意 `/usr/bin/git` 所做的一件使将其集成到对象存储中变得困难的事情是 Unix 风格的习惯:写入文件,然后立即从该文件读回以计算哈希值。在对象存储中,你无法获取尚未完成 PutObject 调用的对象。我之前通过先写入磁盘然后进行操作来解决这个问题,但实际体验有点糟糕。 ### 摆弄打包文件 (https://www.tigrisdata.com/blog/objgit-packfiles/#messin-with-packfiles) 每个打包文件都有一个索引,描述了其中的内容。这是之前那个简单 Git 仓库的打包文件索引的视图: ``` $ git gc # 强制对象进入打包文件 $ git verify-pack -v .git/objects/pack/pack-3971f5085c23c38be00e517ed0c64ca7df19b746.idx 1c7a26a901724b4ce766655ac387413fb9ec7966 commit 526 366 129 9cc9867337c2ebae85ba2350f901e0bcc209fe26 blob 13 22 378 8e67afbb2ee6bdcbb79061dfdfb93febce857bd3 tree 37 48 400 non delta: 3 objects .git/objects/pack/pack-3971f5085c23c38be00e517ed0c64ca7df19b746.pack: ok ``` 提交指向树,树中的文件 “hello.txt” 指向 blob,瞧,你就有了一个仓库。Git 使用这些二进制索引来让其知道该去哪里看以及需要在打包文件中搜索多远才能找到所需的东西。 图 02 一千一百万个对象,一个打包文件,一个索引 ``` $ git count-objects -v .git/objects/pack/ count: 0 └── pack-45986f41..c5786.pack 3.7 GiB in-pack: 11827138 packs: 1 一千一百万个裸文件会是 size-pack: 3876775 一千一百万个 inode。所以:一个文件。 .idx .pack ┌───────────────────────┐ ┌──────────────────────────────────────┐ │ 0002ff4c.. 0x0000c │ │███ ██ ████ █ ███████ ██ █ ████ ██████│ │ 0031ab90.. 0x0a13f │ └──────────────────────────────────────┘ │ 1c7a26a9.. 0x1f3a4 │ │ ... │ └───────────────────────┘ 每一行的颜色是其偏移量指向的字节序列, 所以读取操作是在一个非常大的文件内 寻找到某个偏移量 ``` 图 03 三行,三个偏移量,三段字节序列 最妙的是,当所有内容都在文件系统中时,这种方法效果非常好。Git 会 mmap (https://en.wikipedia.org/wiki/Mmap) 打包文件,这样内核就会将磁盘内容视为内存页,这意味着尝试读取超出“内存中”内容时,内核会加载它而不是用户空间。这比直接从磁盘加载更快。遗憾的是,这种设计在对象存储中行不通。 ### 倘若你能从打包文件构造范围请求该多好 (https://www.tigrisdata.com/blog/objgit-packfiles/#if-only-you-could-construct-range-requests-from-packfiles) 在某种程度上,这对对象存储听起来非常棒,对吧?你有打包文件内的偏移量,然后你可以“只需”使用 HTTP 范围请求 (https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests) 从打包文件中抓取单个对象,对吧?对象是随机放置在打包文件内的,而其他尝试将 Git 存储在对象存储中的方法最终都在此遇到问题。Tigris 非常擅长随机访问扫描,所以大部分难点在于弄清楚如何从存储桶中抓取正确的数据。 附注 HTTP 范围请求允许客户端下载文件的*部分*。它们最初是为拨号上网时代设计的,那时你并非全天在线。你连接互联网的主要方式与你拨打电话和接听电话的方式相同。因此,如果你在上网时有人给你打电话,你所有的下载都会被中断。范围请求让 Internet Explorer 能够从断点恢复下载,而不是从头开始。 它们一直延续到现代,但除了像我正在做的这种“银河系大脑”格式滥用和在线视频流媒体之外,并没有太多其他用途。 嗯,情况很复杂。我给出的示例显示所有索引条目一个接一个,但在现实中,处理一个接一个的索引条目时,你知道打包文件中单个对象的*解压后*大小,但不知道*压缩后*大小。这意味着你没有足够的信息来构造 HTTP 范围请求。 图 04 一次范围 GET,从 128 MiB 的中间获取 366 字节 ``` packs/019a7f3c..bin ┌────────────────────────────────────────────────┐ │ ██│ └───────────────────────────────────▲────────────┘ 0 │ 128 MiB └── 366 字节,就在这里 你知道它从哪里开始以及长度是多少, 所以请求完全指定的范围,不要其他: GET /packs/019a7f3c..bin Range: bytes=100663296-100663661 这就是存储桶返回的全部内容: 206 Partial Content Content-Range: bytes 100663296-100663661/134217728 ┌────┐ │████│ 线路上 366 B,而不是 128 MiB └────┘ ``` 这个核心问题是我最终需要创建自己的对象存储原生 Git 打包文件格式的一半原因。 ## 发送提示表 (https://www.tigrisdata.com/blog/objgit-packfiles/#send-cue-sheet) 早在物理介质时代,最常见的格式之一是 CD-ROM(Compact Disc Read-Only-Memory,或 CD)。CD 是一个 700MiB 的容器,它在每个会话中存储数据,每个会话最多包含 99 个音频或数据轨道。CD 最初是为了存储歌曲音频而发明的,这样你就可以以比模拟磁带更高的质量听大约一个小时的音乐。CD 还让播放器可以从一个轨道跳到另一个轨道,这样你就可以直接跳到你喜欢的歌曲或大型作品中的某个乐章。 这也是为什么你会看到指南告诉你要将 CD 和 DVD 媒体的合法备份制作成无损 `.iso` 文件。`.iso` 文件包含一个录制会话,该会话可能包含数据或音频。 图 05 CD 提示表和 objgit 提示表解决相同的问题 ``` CD AUDIO DISC OBJGIT PACKFILE ────────────────────────────────────────── ────────────────────────────────────────── U0008_0000002_0000147.WAV 一个大文件 objects.bin 一个大文件 ┌──────────┬─────────────┬─────────────────┐ ┌──────────┬─────────────┬─────────────────┐ │ track 01 │ track 02 │ ... │ │ blob A │ tree B │ ... │ └──────────┴─────────────┴─────────────────┘ └──────────┴─────────────┴─────────────────┘ ▲ ▲ ▲ ▲ │ 00:00 │ 00:13 │ @0 │ @142 FILE.cue 纯文本,自上而下解析 objects.cue 打包记录,固定宽度 ┌──────────────────────────────────────────┐ ┌──────────────────────────────────────────┐ │ FILE "U0008_0000002_0000147.WAV" WAVE │ │ HEADER 16 B │ │ │ │ magic version rec_size record_count │ │ TRACK 01 AUDIO │ ├──────────────────────────────────────────┤ │ INDEX 01 00:00 │ │ RECORD 0 58 B │ │ TITLE "U0008_0000002_0000147_0001" │ │ hash 3a7f...c21 type blob │ │ │ │ comp zstd size 311 │ │ TRACK 02 AUDIO │ │ bin_offset 0 bin_length 142 │ │ INDEX 01 00:13 │ │ delta_base 0000...0000 │ │ TITLE "U0008_0000002_0000147_0002" │ ├──────────────────────────────────────────┤ └──────────────────────────────────────────┘ │ RECORD 1 58 B │ └──────────────────────────────────────────┘ 要到达轨道 2,播放器需要解析其上方的 每一行。它已经拥有磁盘内容。 记录 N 位于 16 + N*58。一次寻道, 然后一次范围 GET 进 objects.bin。 ``` 然而,有一个问题有点毁掉了这种备份 CD 的简单方法:它们可以在同一张光盘上存储*多个*录制会话。大多数时候,这除了让 90 年代末/00 年代初某些游戏主机的盗版变得更麻烦之外,并没有其他用途,但确实有那么一个理光 Encryease 产品 (https://youtu.be/_5ucImqdKbY) 它将用户可录制区域与工厂印刷区域结合在一起,这样你就可以在共享给别人的 CD 上加密文件,并将解密软件一同附带。这听起来有多诡异就有多诡异。 注意 使用多个会话的技巧是 Dreamcast 游戏能作为音频 CD 播放并提示你将其放入 Dreamcast,或者 Xbox 360 游戏能作为 DVD 播放并提示你将其放入 Xbox 360 的原因。作为额外的好处,这意味着当你将光盘放入电脑时,电脑会认为它是音频 CD 或 DVD,这意味着懒惰的盗版者无法轻易将所有游戏文件提取到他们的硬盘驱动器上。 因此,需要一种方法来妥善处理这一点以用于归档目的。最终的结果是创建提示表 (https://en.wikipedia.org/wiki/Cue_sheet_(computing)) 与数据二进制大对象一起存储。`.cue` 表存储的信息允许播放器在没有完整轨道索引的情况下在光盘中导航。(等等,这听起来是不是很熟悉?)

相似文章

大家把 Git 放到 S3 上的方式都搞错了

Lobsters Hottest

一篇技术博客深入分析了把 Git 仓库放到对象存储(S3)上的常见误区,指出当前 S3-backed WAL 方案在写密集场景下的不足,并重新剖析了 Git 作为内容寻址存储加图数据库的数据模型对存储层的真实需求。

OpenAI 如何扩展 Git规模

OpenAI Blog

OpenAI 为其 monorepo 构建了 Pando,一个全新的水平可扩展 Git 后端,以应对大规模的 agent 驱动开发,将中位推送延迟降低了近 5 倍,每天服务接近 1PB 的 Git 数据,同时保持与 Git 协议的兼容性。