Git在任何规模下(27分钟阅读)

TLDR AI 新闻

摘要

本文解释了在大规模下托管Git仓库的困难,重点关注Git的packfile设计和分布式特性,并概述了潜在的解决方案。

Cursor解释了为什么Git的以packfile为中心的分布式架构在大规模下作为集中式服务运营变得困难,并概述了分散文件系统、packfile或Git本身的方法。
查看原文
查看缓存全文

缓存时间: 2026/08/19 15:34

# 任意规模的 Git 托管 来源: https://cursor.com/blog/git-at-any-scale 大规模托管 Git 仓库是项艰巨的任务。当 Linus Torvalds 设计第一个版本的*地狱信息管理器*(这其实是 Git 的标语,可查看(https://github.com/git/git/commit/e83c5163316))时,他心中有一个非常具体的应用场景:他自己的需求。他想取代 BitKeeper,那个当时用于开发 Linux 内核的分布式版本控制系统。当然,替代品也必须是分布式的。内核是一个特殊的软件项目;它极度去中心化,众多子系统由许多不同的维护者负责。分布式版本控制系统天然契合这种工作流程。 二十年后,Git 已成为行业标准,但事实是,其分布式特性如今更像是一种阻碍而非优势。典型的开源软件项目并不采用去中心化的工作流程。典型的公司更是如此。他们利用分布式模型的诸多优势(例如离线工作、延迟推送等),但同时极度依赖一个集中式的托管平台。而事实证明,托管一个 Git 仓库是一项极其困难的任务。 ## Git 有何难点? (https://cursor.com/blog/git-at-any-scale#whats-hard-about-git) 大规模托管 Git 仓库的挑战,源于 Git 自身的设计:一个*分布式*版本控制系统意味着仓库的所有实例都是等同的。Git 服务器上的仓库与开发者笔记本电脑上的仓库并无本质区别。虽然乍一看,这似乎使托管 Git 仓库变得简单直接(只需在磁盘上的仓库副本前放一个 HTTP 守护进程就能搭建 Git 服务器!),但实际上存在许多棘手的扩展性和可靠性挑战,使得情况截然相反。 在一个普通的 Git 仓库中,你的代码和元数据(文件、提交、树)会被压缩并存储在*打包文件*中——这是一种简单的二进制序列化格式,在本地机器上处理很方便,但在服务器上大规模管理则并不理想。打包文件是 Git 存储*和*Git 网络传输的基本构建块。当你从仓库推送或获取数据时,它就是以打包文件的形式传输的。 这是 Git 设计的固有方式,但有人可能会认为这并非必然。毕竟,你无法控制 Git 客户端(至少在不惹恼用户和增加诸多不便的前提下),但在你自己的服务器内部,你可以*为所欲为*。没有什么能迫使你使用打包文件——Linus 不会跑来检查。唯一的限制是,对于所有 Git 操作,你确实需要通过网络接收和发送*打包文件*。 多年来,尝试大规模托管 Git 仓库的公司发现,这种基于*打包文件*的设计对可用性和扩展性都是一个重大限制。打包文件是大型二进制文件,必须存在于文件系统中才能被 Git 访问。在磁盘仓库前部署一个 HTTP 服务器的简单方法,其扩展天花板非常低。理想情况下,你希望仓库存在于多个磁盘和多台机器上(这能让你并行运行多个 Git 操作,并在服务器崩溃时保持仓库可用)。但如何做到这一点呢? 大致有三种可能的方法来实现这一点,按复杂性递增排序:分布式文件系统、分布式打包文件,或分布式 Git 本身。 ## 无打包文件的 Git (https://cursor.com/blog/git-at-any-scale#git-without-packfiles) Git 是一个内容寻址的数据存储。Git 仓库中的所有对象(blob、tree、commit 等)都以其内容的 SHA-1 值作为键。这在直觉上非常适合映射到分布式键值存储(键是 SHA-1,值是实际对象),并且可以为仓库存储的水平扩展提供一种清晰的方式。但这实际上行不通。 问题在于:Git 仓库的实际布局是一个有向无环图(简称 DAG)。你可以通过其 SHA 查找任何对象,但要在仓库中执行最简单的操作,你必须一步步地遍历 DAG。 (图示:提交 DAG、树、对象、键值存储等关系说明,展示遍历需要多次网络往返) 如果你想执行诸如列出仓库最近更改之类的操作,你必须处理它的提交。处理一个提交时,你会获得指向其树根的指针。从该树根,你获得指向每个文件和每个子树的指针。从原始提交,你会获得指向其父提交的指针(历史记录中在它之前的那个)。关键在于,在这个遍历的每一步,在你获取上一个指针之前,你都不知道下一个指针的值。如果每次获取都需要与分布式存储进行一次网络往返,事情会迅速变得极其昂贵。 这种在对象层分布式分发 Git 的方法以前曾多次尝试过,但往往在规模化时失败。最有希望的尝试是我以前的导师 Shawn Pearce 在谷歌版本控制系统团队时进行的。他的方法是(将对象存储在分布式哈希表中 (https://www.eclipse.org/lists/jgit-dev/msg01189.html))。这之所以可能,要归功于 JGit,一个用 Java 编写的自定义 Git 实现。就像任何优秀的 Java 库一样,JGit 提供了足够的接口和工厂方法来抽象普通 Git 仓库的所有细节,包括用 DHT 替换其磁盘上的打包文件。虽然这个系统能够工作,并且对于普通 Git 操作来说结果还不错,但 Git 协议的局限性(再次强调,无论你在服务器上如何存储数据,都*必须*通过网络发送*打包文件*)使得 `git clone` 的性能差到足以完全放弃这个设计。 ## GitHub 与文件系统 (https://cursor.com/blog/git-at-any-scale#github-and-filesystems) Git 开始突破其 Linux 内核生态圈几年后,一家小而精的初创公司在旧金山诞生。GitHub 成立于 2008 年,作为一个社交编码平台,其标语极具先见之明:“Git 仓库托管:不再是件烦人的事。”(我可没开玩笑,查看 (https://web.archive.org/web/20080514210148/http://github.com/))。早在 2008 年,人们就普遍认为,尽管(或者说正因为)Git 的分布式设计,你实际上需要一种集中式的方式来托管 Git 仓库以使其用户友好,而这件事做起来非常痛苦。GitHub 下定决心要改变这一点。 其平台最初(现在大部分仍是)是一个 Rails 单体应用。最早的版本运行在单台——尽管是高配——机器上,运行着 Ruby 服务器,仓库副本就存储在旁边的磁盘上。扩展 Rails 应用很容易:部署更多的实例。但在这个特定案例中,由于涉及 Git,他们很快遇到了我们试图解决的那个反复出现的问题:如果 Rails 应用需要访问磁盘上的 Git 仓库,你如何部署更多的仓库副本? 早期 GitHub 的系统工程师们是一群节俭的“杂牌军”,他们尝试了最简单的方法来解决扩展问题。他们的想法是,如果专注于分发*文件系统*(而不是打包文件或 Git 本身),他们可以保持 Rails 应用不变,将时间用于为不断增长的用户群开发更多功能,而不是折腾 Git。非常务实。但它失败了。 团队尝试了许多为 Git 数据构建分布式文件系统的方法:最显而易见的一种是使用 NFS 将所有仓库存储在集中式服务器上,但很快被放弃了。Git 的默认实现对文件系统语义(锁、撕裂、读取、同步……)做了大量假设,这些假设确保了在速度较慢的开发者笔记本本地文件系统上表现尚可,但完全没考虑它们在网络文件系统上的行为。它既慢又 bug 多。 后来又尝试了其他方法,(坦白说,事后看来很可怕)包括在块级别复制文件系统的技术。一次短暂部署了 GFS (https://en.wikipedia.org/wiki/GFS2)。一次基于 DRBD (https://en.wikipedia.org/wiki/DRBD) 的更长期部署。它们都碰壁了。这些方案在日常运维中*极其*糟糕,也没有通过良好的性能来弥补不足。这一切都归结于磁盘上*打包文件*的设计。 我们已经看到 Git 类图状的数据结构使得网络往返代价高昂。不幸的是,一个非常类似的原则也适用于磁盘上的底层数据。对象在 DAG 中的布局与它们在*打包文件*中的放置方式之间没有关联。生成*打包文件*时使用的主要启发式方法是将其大小最小化;对象被随机放置在整个包中,它们被压缩,并且关键是它们很少以完整形式存储。大多数对象存储为同一打包文件中另一个对象的 delta(增量)。在遵循图数据结构中的许多逻辑跳转后,读取单个对象还涉及到遵循磁盘格式中的物理跳转。 (图示:展示提交、树、blob 对象之间的逻辑关系与物理存储的 pack 文件之间的跳跃关系,说明随机读取模式) 这种跨越 GB 级数据的随机访问,对于在仓库上执行的每一个 Git 操作都必须发生,这与网络文件系统(无论是在文件级还是块级复制)实在合不来。唯一能避免速度降至爬行的方式是可以在本地缓存整个文件。但在同一文件系统中存放数十万个仓库的情况下,缓存并非可选方案。 最终,GitHub 的系统工程师们咬紧牙关,放弃了分布式文件系统的方案。他们开始开发一个 RPC 系统,让仓库可以存在于专用的文件服务器上,并更新 Rails 应用以进行所有远程操作。这提供了一定程度的水平扩展,但没有解决它们的可用性问题,也没有改善最繁忙仓库的性能。毕竟,每个仓库仍然只存储在一台机器上。 ## Spokes 与一致性 (https://cursor.com/blog/git-at-any-scale#spokes-and-consistency) Spokes 大约于 2013 年在 GitHub 开发,此后已成为行业标准。大多数 Git 托管服务在其架构中都使用了 Spokes 方法(针对 Git 仓库的应用级复制)的变体。Spokes 多年来一直运作良好,其主要原因是它做了三个基本选择,随着时间的推移,这些选择已被证明是最优的: 1. 它不分布式 Git 本身;它在打包文件层面工作。 2. 它将所有数据作为实际的 Git 仓库存储在本地 NVMe 磁盘上。 3. 它复制 Git 数据,但保持所有副本始终一致同步。 考虑到我们刚才讨论的跨*打包文件*的随机读取模式,将纯 Git 仓库存储在 NVMe 驱动器上基本上是确保所有基本 Git 操作保持快速的必要条件。它们也使克隆保持高效,因为无需将数据转换为 Git 客户端期望的格式。它们还让你能够专注于在 Git 之上构建产品,而不是维护一个能在你奇特仓库上运行的 Git 分支。 至关重要的是,保持所有数据副本的一致同步也非常好。这是你会从艰难经历中学到的教训,但 Git 客户端*真的*无法适应最终一致性。如果你的本地 Git 客户端推送了一个提交,然后在一次获取后无法立即读取它,那就是坏消息。Git 会感到非常困惑。如果你的 CI 流水线在一百个运行器上执行,其中三个在克隆仓库后找不到它们应该测试的提交,那也是坏消息。这也是一种非常糟糕的用户体验。 无论是客户端还是后端,在 Git 仓库的最终一致性视图下工作都有很多“坑”。因此,Spokes 付出了极高的复杂性代价来确保系统始终保持完全一致。让我们具体看看这意味着什么。 Spokes 是一个*基于共识*的分布式系统。它的工作原理是将你的 Git 仓库的几个副本存储在不同的服务器上。每当你推送新数据时,一个协调器会将你的推送扇出,使你的仓库的每个实例都收到一个副本。这个“扇出”过程与一个名为 3PC(三阶段提交)(https://en.wikipedia.org/wiki/Three-phase_commit_protocol) 的经典共识算法同步,这样只有当大多数节点都确认时,推送才会被接受。 (图示:展示 3PC 的三个阶段:投票、预提交、提交,以及协调者与参与者的关系) 在进一步讨论 Spokes 如何使用 3PC 之前,我们需要了解 Git 推送的工作原理。一个 Git 推送包含两个部分:一个*打包文件*和一个*引用事务*。我们已经讨论过的打包文件包含了你要推送到仓库的对象(包含你更改的 blob、tree 和 commit)。事务则是通过更新一个或多个引用(例如你正在工作的分支)来指向你刚刚推送的提交,从而真正将你的更改发布到仓库。 这种分离在这里非常方便,因为一个推送的提交在指向它的引用被更新之前是不可见的(用 Git 术语说是“不可达”的)。这意味着我们可以通过同时将打包文件扇出到所有主机(这里不需要同步)*然后*对引用进行三阶段提交来为我们的推送实现共识。

相似文章

Git并不好

Lobsters Hottest

本文对Git进行了批评,认为它并不像人们通常认为的那样好,并链接到Lobste.rs上的讨论。

GitHub对AI Agent的计划(90分钟阅读)

TLDR AI

本文探讨了AI编码代理的爆炸式增长(2026年增长1400%)如何使GitHub的基础设施承压,导致显著的服务可用性问题,并讨论了GitHub为使其平台适应这一新时代而制定的计划。

我们应得的代码锻造平台

Hacker News Top

本文讨论了对GitHub可靠性日益增长的不满,并提出基于AT协议的去中心化Git锻造平台Tangled,作为一个结合了中心化便利性与用户数据所有权的有前途的替代方案。