走进 GitHub 全新的 Git 存储架构
上周在 GitHub Universe 大会上,我们公布了全新的 Git 存储架构。这套架构每秒能处理数千次写入,读取能力则按需扩展。内部测试中,我们验证了它可以支撑 5000 个并发代理同时操作同一个仓库,每秒向仓库推送 7200 次,同时读取流量始终保持低延迟。这套新架构是 GitHub 支撑数亿开发者进行智能体开发的基础,而整个建设过程是在 GitHub 持续运行的状态下于底层完成的。
过去一年,代理改变了 GitHub 上繁忙仓库的样貌。在本系列的第一篇文章中,我们阐述了下一代 Git 存储架构背后的两条原则:最小化协调,以及将存储与计算解耦。在上一篇文章里,我们介绍了过渡方案:把 99.99999% 的仓库迁移到 Azure,将写入仲裁的范围缩小到单个引用,并搭建由 Azure Blob Storage 支撑的虚拟副本。文末我们留下了一个观察:一次写入中真正需要排序的部分其实很少。Git 对象不可变,并以内容命名,这意味着两个写入者可以同时存储对象,而无需就任何事情达成一致。推送中需要达成一致的部分是引用更新,而引用更新相比之下非常小。虚拟副本是围绕这一点构建的第一步,也是我们将仓库接入新架构的手段。架构的其余部分都建立在这个观察之上。
只有引用更新需要协调。这让过去紧密耦合的 Git 基础设施各部分——存储数据、处理请求、维护仓库——可以独立扩展。对开发者而言,Git 还是那个 Git:推送、拉取、建分支的方式不变,保护机制也照旧。
下面,我们先梳理设计背后的需求,再跟随一个请求走一遍系统,最后深入剖析其中两个组件。
为代理规模而构建
我们从两个维度来思考 GitHub 平台的规模:横向规模指的是有多少开发者和代理可以在众多仓库上协同工作。
在这个维度上,GitHub 现有的很多做法都经过了多年实战检验,并延续到新架构中:比如把租户分散到不同基础设施、对租户流量做隔离和公平性保障、按负载进行路由等等。垂直扩展指的是有多少开发者和 agent 能同时在一个仓库上工作。随着 agent 编写更多代码,我们预计单个仓库会变得更大、更繁忙,因此大部分需求都来自垂直维度。
目前,GitHub.com 上最繁忙的仓库每秒最多收到几十次写入,通常表现为短时突发。随着越来越多的工作转移到 7×24 小时并行运行的 agent 上,我们预计这些突发将在明年内变成持续速率。每次 push 都会引发更多读取:CI 运行、部署系统拉取新提交,其他 agent 也来抓取以获取上下文。这种读取扇出(read fan-out)会对整体流量产生巨大的放大效应,且随写入量同步增长。我们在曲线顶端的仓库上已经看到了这一趋势。下图展示了一个 GitHub 仓库从 3 月到 5 月的每日请求量,其间流量增长了约 4 倍;此后它继续沿指数轨迹攀升。
我们必须为一个 agent 驱动开发持续加速的世界做规划。我们没有按“下一个增量”来做规划,而是把目标定在至少两个数量级的规模提升。对单个仓库而言,这意味着新架构需要做到:
- 每秒接受数千次写入。
- 支持多个写入节点。 这么做有两个原因:经验告诉我们,为收到的 pack 建索引、检查对象连通性,是写入过程中最耗 CPU 的环节,而且这个成本随写入规模增长,单节点很快就会撞上算力上限;把这项工作分散到多个节点上,还能提升容错能力,节点故障时缩短恢复时间。
- 尽量减少提交延迟。 agent 每完成一次 push 才能继续下一步。
- 按顺序记录每个引用的变更,并能快速跨引用做线性化。
虚拟副本如果落后了,只需下载处理当前请求所需的数据就能追上进度。在没有变更时让读取保持低成本——考虑到读写比例,这属于常见情况。每个组件都要能独立扩展。除此之外,系统还必须能够提升整体可靠性并实现全球扩展。
引入“Project Rubicon”
2026年5月,在完成初步 Azure 迁移之后,我们紧接着启动了一项设计工作,目标是满足上述需求。我们尝试了多种方案。所有方案中,pack 数据都存放在 Azure Blob Storage 里;其中一些方案还尝试把引用状态(reference state)也放在那里。下文会介绍我们发现的若干权衡,但最终确定的设计,我们认为能在保持 GitHub 这类平台所要求的正确性、持久性、可用性和延迟保证的同时,最大化吞吐量。我们把它命名为“Rubicon”,呼应行业跨过智能体开发这道不归点。
下面自上而下介绍 Rubicon。
许多东西保持不变:来自 Git 客户端、API 和 Web UI 的请求仍然到达 Spokes,由它负责路由和负载均衡,并提供 GitHub 其他部分用来读写仓库数据的内部 API。Spokes 仍然承担放置与均衡服务的角色,维护水平扩展所需的分区、隔离和租户公平性。
相比之下,Spokes 下面的数据平面是全新的。Spokes 将每个请求路由到分配给该仓库的一小组虚拟副本之一。虚拟副本是一组一次性计算资源,负责处理读、写或两者兼有。虚拟副本的本地磁盘是一份缓存,随时可以重建。Spokes 会尽量把某个仓库的流量路由到同一批虚拟副本,让它们的缓存保持热度、延迟更低——但正确性并不依赖请求落到某个特定的虚拟副本上。当某个虚拟副本消失时,该仓库的其他虚拟副本会继续服务,替代者随着流量到来逐步填充自己的缓存。当仓库变忙时,我们就增加更多虚拟副本。
另外,还有一组压缩节点在后台运行,直接对 Blob Storage 和 Ref-CAS 操作,合并每个仓库的 pack 并清除不可达对象。这类工作从不在虚拟副本上执行,因此虚拟副本的 CPU 和内存几乎可以全部用于处理请求。
持久化状态存放在两个地方:Git 对象(以 pack 和辅助读取加速数据的形式)存放在 Azure Blob Storage;引用和构成仓库的 pack 列表(即 pack 清单)则存放在独立的复制日志服务中,我们称之为 Ref-CAS(即“引用比较并交换”)。
我们从一次写入和一次读取的角度来梳理这套架构:
写入:虚拟副本为传入的 pack 建立索引并上传到 Blob Storage,然后向 Ref-CAS 提交一个事务,在清单中记录该 pack 并更新引用。之后,压缩节点会将该 pack 与其他 pack 合并,以保持读取速度。
读取:虚拟副本向 Ref-CAS 查询引用版本,从 Blob Storage 拉取所需的 pack 到缓存,然后从缓存中提供读取服务。
新架构中有许多不同的做法,从虚拟副本上的软件栈,到针对不同用例(例如大量小仓库 vs. 单个大仓库)调整流量路由和工作负载调度。我们深入介绍架构中的两个组件,围绕两条设计原则讲述它们的故事。
最小化协调:高吞吐量下变更引用状态
像 Azure Blob Storage 这样的对象存储服务在本职工作上表现出色,天然适合源代码存储等数据密集型应用。它们持久且可复制,容量按需扩展,每字节成本相对较低,并且只要流量分散在众多键上,就能提供极高的聚合吞吐量。因此,新 Git 存储架构中几乎所有持久化数据都存放在 Azure Blob Storage,包括:
- 由推送、压缩和垃圾回收写入的 pack 和索引。
各类读取加速结构(比如多包索引、可达性位图,以及一些自定义格式),由我们的维护流程基于这些包构建。还有一些记录,用于让压实和垃圾回收能够恢复或审计自己的工作,以及压实节点用来认领任务的租约。频繁重复拉取(fetch)的缓存响应。仓库引用状态的检查点,不过不是用来服务请求的权威状态。这些数据大部分只写入某个独立的键一次,之后就不再改变,因此非常适合放在 Blob Storage 里。
引用则恰恰相反。一个繁忙仓库的引用是一块相对较小的状态,但会有大量写入同时竞争更新。这种访问模式用 Blob Storage 比较难支撑,我们最初的几个原型就说明了原因。
对于这类工作负载,对象存储是个很自然的选择,在一定的规模范围内也确实好用:条件写入天然提供了比较并交换(compare-and-swap),数据本身也已经持久化并做了副本。但我们遇到了一组两难的选择,下面用我们尝试过的两种方案来说明。
第一种方案是为仓库的所有引用维护一份预写日志(write-ahead log),用一个 blob 做索引。每次更新都是对这个 blob 的一次条件写入。这样在正确性上没问题,读也很便宜:一个虚拟副本只需问一下这个 blob 有没有变,没变就不用再查了。问题出在写上。同一个 blob 同时只能有一个条件写入在进行,所以即便我们把一次写入期间到达的所有推送都批处理到下一次写入里,这个仓库的写入上限仍然是一次写入对应一次到单个 blob 的往返。实际跑下来,写入吞吐只有每秒几百次。
第二种方案把引用拆分到多个分段 blob 上,每个引用按哈希落到其中一个分段。不同分段里的引用写入不再互相等待,上限就提高了。但读的时候,现在必须检查每一个分段,才能知道有没有东西发生变化,而且速度取决于最慢的那个分段。我们等于拿写延迟换了读延迟,而读操作的数量比写操作高出好几个数量级。
可以通过并排展示多次写入和一次读取来直观说明这一点:
这两种方案都没能满足全部需求,于是我们另起炉灶:专门为引用构建了一个复制日志服务,也就是 Ref-CAS 服务。它就是上一篇末尾提到的、当时说「仍在堆栈某处」的那个(小型)共识组。它的工作方式如下。
Ref-CAS 中每次引用更新,都是一次由多数派提交的比较并交换(compare-and-swap)操作。一次事务只锁定它涉及的引用,检查每个引用是否仍是写入方预期的值,一旦日志的大多数副本都记录下这次操作,它便对外可见。Agent 大多推送到自己的分支,所以大部分更新不会互相争抢,可以并行复制。当两次推送确实更新同一个引用时,只有一次能写入日志。
每次提交到 Ref-CAS 都会获得一个日志序列号(LSN),仓库的版本就是其最新一次提交的 LSN。读取时会先拿这个版本做一次校验。虚拟副本会把自己当前持有的引用状态版本发过去。如果该版本仍然是最新的,回复里根本不携带任何引用——也就是说,仓库没有变化时,只需一次比较就够了。如果虚拟副本落后了,它就会收到当前的引用和版本,外加指向 pack 数据的指针,以及其他需要知道的信息,比如上一次垃圾回收发生在什么时候。
与旧架构中的共识组不同,Ref-CAS 从不持有或校验对象数据,只存储指向它们的指针。虚拟副本会先把一次推送的 pack 上传到 Blob 存储,然后再提交引用事务。这样一来,引用永远只会指向已经持久化的对象。
这些做法让我们仅凭引用状态的传输就能达到每秒数万次操作,也为在复制日志上添加额外标注提供了基础——这些标注能帮助读取端判断,针对某个请求需要下载哪些内容。
但要在单个仓库上支撑每秒数千次写入,还会引出第二层问题:如何维持读写性能。为此,我们需要一套不同的压实(compaction)方案。
存储与计算分离:每秒数千次写入下的压缩(compaction)
每次推送都会向 Blob Storage 新增一个 pack。Git 可以从多个 pack 中提供仓库服务,但每多一个 pack,查找成本都会略微上升,因此 pack 数量不能无限制增长。在旧架构里,压缩运行在同时对外提供服务的副本上,而且每个副本都得跑一遍。新架构则完全不同:压缩完全在后台运行,跑在独立节点上,直接针对持久存储操作,并借助 Ref-CAS 服务把不断变化的状态通知给虚拟副本。每个压缩节点通过租约(lease)认领仓库数据的一个子集,这样压缩就能横向扩展。下面看一个压缩节点如何处理仓库 pack 的单个分片(shard):
节点首先在 Blob Storage 中对该分片加租约,确保同一时间只有一个节点在压缩这个分片。与此同时,虚拟副本仍在不断上传 pack,并把它们列入 pack 清单(manifest)。节点读取清单,找出新的 pack,把它们收集到一个「开放段」(open segment)里——可以理解为即将被压缩的数据的暂存区。当这个段达到预设上限(按 pack 数量、大小或存在时间判断),节点就封闭该段,不再接收新 pack,然后把这些 pack 合并成数量更少、体积更大的 pack。压缩进行期间,该分片的下一个段可以开始收集自己的输入 pack。
在正式发布任何内容之前,节点会校验新 pack 包含的对象与输入完全一致。然后上传新 pack 并加入清单。第二个事务把输入从清单中移除,清理工作稍后再删除它们。
要横向扩展,这套流程会同时针对许多分片独立运行。每个 pack 的哈希映射到某个分片,所以增加分片和节点就能提升压缩吞吐。压缩后仍低于大小上限的 pack,会按自身哈希重新归入某个分片再次合并,于是 pack 逐级增大,直到触及上限。最终效果是:推送量持续攀升,pack 数量却基本保持平稳——压缩合并了 pack,但保留了每一个对象。
垃圾回收负责清理不再被任何对象引用的数据,比如已删除分支和强制推送(force-push)留下的残留对象。在新架构中,垃圾回收的工作方式与压缩(compaction)非常相似——同样用专用 worker 承担计算量大的部分。它还包含一些用于安全删除数据的特殊行为,值得单独讨论。借助这些方法,我们已经能够稳定展示出:随着写入量攀升,延迟曲线保持平稳。此外,我们还有其他优化计划,让压缩和垃圾回收过程本身更高效。
接下来
我们出发时的两条原则,让架构的每个部分都能独立扩展。读和写靠增加虚拟副本来扩展;Ref-CAS 对每个引用独立排序,而不是对整个仓库排序;维护靠增加压缩节点来扩展。我们的目标是:从某个开发者的业余项目到最繁忙的单体仓库(monorepo),所有仓库都跑在同一套架构上,繁忙的仓库也不需要特殊处理。仓库不会一直很小。一个业余项目可能一夜之间被一群智能体(agent)盯上,我们不应该为此要求它迁移或做特殊处理。统一架构还意味着,我们做的每一项改进都能同时惠及所有仓库。
这个基础让我们能做更多事情。接下来我们聚焦三个方向:把架构扩展到超出当前可预测需求的规模;让智能体在 GitHub 上做事更高效;以及为智能体式开发(agentic development)演进 Git 本身。
改进架构
我们计划进一步完善这套架构。下面每一项改进,都能让仓库承载更多活动,同时减少每个请求背后的工作量:
- 只读需要的部分。 从 Blob 存储中只取单个对象,而不是取出包含它的整个 pack。
- 按类型组织 pack。 把 commit、tree 和 blob 分到各自的 pack 里。这样许多请求触及的数据就更少。
- 部分副本。 本地磁盘上不需要存放所有 pack 也能处理请求。
- 物化视图(materialized view)。
把仓库数据保持为随时可用的形态,比如预先打包好的归档、默认分支的准实时浅副本,以及随每次提交一起存储的 agent 会话上下文。
- 全局副本:副本可以放在任何区域,靠近使用它的工作负载。读取时与最新写入之间的延迟保持有界(不会落后太多)。
- 按需伸缩:根据实际活跃的仓库增减集群规模。
在 GitHub 上高效运行 agent 操作
扩展基础设施只是支撑 agent 的一面。另一面是让每次 agent 操作都更便宜:更少的数据传输、更少的往返、更少的等待变更。下面这些快速跟进项都建立在本文介绍的存储工作之上:
- 免克隆工作。很多 agent 任务根本不需要完整的工作区,它们只需要几个文件和足够的上下文来完成修改,可以直接从服务端读取。
- 原子提交 API。一次操作提交多文件修改:要么整体提交成功,要么在冲突时干净地拒绝。
- 事件流。agent 及其周边的系统最适合用实时信息工作。通用事件流让它们可以订阅变更,并在变更发生时立即响应。
为 agent 改进 Git
Git 本身也有工作要做。GitHub 长期参与 Git 项目的贡献,现在也没有停下。以下是几个我们愿意继续推进的方向:
- 轻量克隆。部分克隆(partial clone)、浅克隆(shallow clone)和稀疏检出(sparse checkout)都能让 agent 少取数据,但各有短板,而且它们之间并不总能很好地配合。我们希望改进每一块,并让它们协同工作。
- 消除隐藏的扩展成本。Git 里仍有一些代码路径,会随着仓库增长而急剧变慢。最近的版本已经修掉几个,但我们预计还能找到更多。
- 更快的历史遍历。commit-graph 这类元数据可以在不遍历每个提交的情况下回答 merge-base 和可达性问题。当 agent 的一次 API 调用变成服务端的 Git 操作时,这一点尤其重要。
- 避开常见陷阱。agent 会犯和开发者一样的错误。Git 可以捕获更多常见错误,在造成损害之前给出警告。
Agent 已经改变了软件的构建方式,GitHub 和 Git 也在随之改变。
我们很高兴让这套新架构迈出第一步。目前它已开放小范围私有预览,明年年初将逐步扩大范围。当仓库上能同时工作的智能体数量只受限于团队的创意时,我们迫不及待想看看大家会做出什么。我们会与客户和 Git 社区一起,持续演进 Git 和 GitHub。文章《Inside GitHub's new Git storage architecture》最早发布于 The GitHub Blog。
GitHub 新的 Git 存储架构,设计目标是支持成千上万个智能体(agent)同时向同一个仓库推送代码。下面讲讲它是怎么实现的。
本文首发于 The GitHub Blog。