通往智能体规模的 Git 基础设施之桥
过去一年,Git 流量呈指数级增长,与此同时,GitHub 上 99.99999% 的 Git 仓库开始同时存在于两个地方:我们自己的数据中心和 Azure。剩下的那些仓库是平台上最繁忙的一批,而它们只被一件事卡住了:25 毫秒的往返延迟。
在之前的一篇博客文章中,我们阐述了下一代存储架构背后的两条原则:
- 尽量减少协调
- 将存储与计算解耦
本文将介绍我们从旧设计到新设计之间的过渡桥梁。我们会聊到,改变 quorum(法定写入集合)序列化写入的方式,如何让最繁忙的仓库进入 Azure,以及这同一步骤如何为不保留任何持久状态的副本铺平道路。
我们是如何走到这一步的
迁移的时机和形态由 AI 智能体带来的变化所驱动:流量快速增长、数据中心及周边地理区域的电力容量存在硬性上限,以及物理供应链的限制。在迁移开始时,即使算上所有可能的扩展,剩余容量也只够我们安全迁移,而不够我们慢慢来。
虽然我们已经为在云基础设施上扩展 Git 原型化了新的架构方案,但在那种增长速度下,同时更换存储架构和托管平台风险太大。考虑到我们的规模以及这项服务对社区的重要性,我们需要谨慎安排工作顺序:先迁移数据,等数据到位后再演进架构。
Git 操作是 Tier 0 服务(最高优先级服务):它们是 GitHub 的核心功能,GitHub 中几乎所有东西都依赖仓库数据。这意味着数据、服务以及每一个依赖项都必须先迁移。
仓库的第一个副本于 2025 年 8 月落地 Azure 的美国中部区域。如今,大多数仓库的五个副本中有两个位于 Azure,Azure 承载了大约一半的 Git 流量。在大部分时间里,Azure 吸收了我们的增长,而我们自己的数据中心保持稳定。
迁移期间,流量跟着数据走。大多数仓库有五个副本,用来兼顾持久性、可用性和规模。为了压低读取延迟,Spokes 采用优先路由,把请求导向与调用方同区域、同可用区的副本。随着副本陆续迁往 Azure,能够由 Azure 处理的流量占比也逐步上升,直到最后一个副本迁完,旧区域便彻底停止接收请求。每个仓库都要走一遍这个流程:
不落下任何一个仓库
那么,剩下那 0.00001% 的仓库怎么办?
在现有架构里,一个仓库只要分布在多个区域,写入吞吐量的上限就会降低。Spokes 的法定人数机制(quorum)只需要多数副本确认就能提交写入,但它会等所有健康副本都响应(直到超时)才继续下一步,所以慢副本会拖累每一次写入,而不是自己落后。实际效果就是:最慢的健康副本决定了每次写入的延迟;而每次写入在提交前都会持有仓库级锁,因此它也决定了吞吐量上限。
一旦某个副本落在目标 Azure 区域,它离我们的数据中心大约 1000 英里,一次网络往返约 25 毫秒,而一次写入需要多次往返。一个已经接近吞吐量上限的仓库,无法在每次串行写入时都承受这个开销;每推送一次,它就会落得更远。
这就轮到我们的第一条原则——尽量减少协调——发挥作用了。具体到这个场景:不需要串行化的东西,就别串行化。
Git 的一致性要求是按每个引用(reference)分别生效的。服务器一旦接受了对某个引用的更新,之后任何客户端都不应再看到更旧的值,只能看到该值或更新的值。无论是快进、强制推送还是删除,都遵循这一点。但在大多数情况下,不同引用的更新彼此独立,可以分别应用。因此,我们正在把法定人数的范围从整个仓库改为单个引用。
在更新引用时全程持有仓库级锁(包括网络往返时间),会让法定人数机制变成一条单一的、无序的请求队列,同一时刻只能有一个写入在进行。队列里的每个写入都要等最慢的健康副本响应完才能结束。写入频率越高,队列积压越严重,每个写入等待的时间都比前一个更长。
改成按引用单独串行化后,只有争抢同一个引用的写入才需要互相等待。实际场景中,大多数写入针对的是不同引用,比如代理和开发者各自往自己的分支推送,因此它们不再彼此阻塞。每次写入仍然要承受跨区域延迟,但不同引用的写入可以同时进行,仓库层面那个单一瓶颈就消除了。
我们可以用同等负载下两种方案的对比,直观看到排队和超时是怎么累积起来的:

这些改动把仓库的写入吞吐上限提高了大约一个数量级,提升幅度与同时被写入的引用数量大致成正比,也让最后几批仓库顺利迁入 Azure。在模拟真实流量的内部测试中,写入不同引用的吞吐量稳定提升 3–4 倍,延迟同时更低。
让读取独立扩展
大多数仓库的读请求远多于写请求,读写比通常在 100:1 到 1000:1 之间。目前读取和写入打到同一批副本上,争夺同样的资源,无法各自独立扩展。把存储与计算解耦就能改变这一点。
一旦仓库数据被持久化到 Azure Blob Storage,并在每次写入时同步更新,我们就可以把读流量从写入的法定人数流程中拆出来,分别独立扩展各自所需的算力。举个例子,读取其实很需要更多内存缓存,而现在分配给它的内存远不够用,但这些内存同时也是数据合并(compaction)所需要的。两者分开之后,各自可以单独配置。
第一步,把对象存储纳入仲裁组,但要特殊处理,我们称它为虚拟副本。代表它的计算节点是我们的第一个完全临时的节点。它充当通往对象存储的桥梁,负责把收到的写入流式转发出去,不处理任何读取。由于它是仲裁组成员,一次写入只有在对象存储中持久化之后才会被确认。这会给每次写入增加一点延迟,但正如跨区域往返一样,按引用串行化可以避免它成为吞吐瓶颈。虚拟写入副本还会把针对不同引用的多个事务合并成一组发往对象存储的网络 I/O,因此一个繁忙的仓库面对大量并发写入时,只需付一次往返的代价,而不是每次写入都付一次。目前,虚拟写入副本还负责压缩(compaction),读副本则继承压缩后的状态。
有了虚拟写入副本,读副本就可以独立于写入路径进行扩展。
读副本不再需要是持有仓库持久副本的仲裁组成员,而是变成临时的计算资源:一个节点启动后,从对象存储 hydrate 自己的缓存,对外提供读取,之后可以被替换掉,不需要任何数据迁移。由于节点上不存在权威数据,丢掉一个节点的代价只是一次缓存预热,而不是一次修复。由于节点上没有任何数据需要持久化,扩容就是多启动几个节点,放在流量所在的地方。
读取端的变化
把读取与写入仲裁分开之后,今天存在的竞争就消除了——现在读和写共享同一批副本、同一份内存。分开之后,两边各自拥有独立的配置:
- 读副本可以按缓存大小来配置,因为读延迟取决于缓存。
- 写副本可以按压缩和网络 I/O 来配置,因为写吞吐取决于这两者。
- 容量可以在任意一侧单独增加,而不需要重新平衡另一侧。
读流量仍然按照 Spokes 的优先路由策略跟随数据走,因此请求继续由最近的副本提供服务。区别在于,这个副本不再必须是固定拓扑中五个持久副本之一。它可以在读请求所在的位置创建,并在读请求迁移后丢弃。
为什么这是桥梁,而不是终点
按引用序列化和虚拟写副本都是过渡性的步骤。它们保留了现有的 quorum 协议、现有的路由层和现有的客户端行为,同时消除了两个制约最繁忙仓库的约束:一个是将每次写入串行化的仓库级锁,另一个是只能通过增加持久副本来扩展的读容量。
这是有意为之。我们之前描述的最终状态——协调最小化、存储与计算解耦——会替换掉更多的技术栈。安全地到达那里意味着分阶段推进,而每个阶段都必须能独立支撑那些已经达到极限的仓库。
这个阶段带来的成果是具体的:
- 每个仓库都可以运行在 Azure 上,包括那 0.00001% 无法承受每次串行写入 25 毫秒往返延迟的仓库。内部测试显示,对不同引用进行写入时,吞吐量提高了 3–4 倍,延迟更低。
- 写入在确认之前已在对象存储中持久化,因此对象存储是持久性保障的一部分,而不是下游的副本。
- 读容量已解耦,因此下一数量级的读流量不需要再增加一个数量级的持久副本。
代理化转变决定了时间表,但工作的形态一如既往:找到不必要的协调,移除它,让存储和计算各自独立扩展。这座桥梁让我们在不中断流量的情况下完成跨越。
……按需为仓库提供任意数量的虚拟读副本。每个读副本从 Azure Blob Storage 预热缓存,然后开始接收读流量,而无需加入 quorum,它使用对象存储作为事实来源,并在后台根据对象存储来协调本地状态。
这一步大幅提升了读取能力。内部测试中,我们的分布式压力测试框架经常撞到现有架构的天花板;而在对虚拟只读副本(virtual read replica)施压时,测试框架反倒先撑不住了。
当然,它也带来了一些挑战,需要靠后续几步解决:
- 虚拟只读副本启动时会先在后台与对象存储(object storage)做状态对齐,因此它适合一部分读负载,但不能通吃。要承接所有类型的读请求,就得把对齐过程挪到请求路径里同步完成,并且只拉取自上次同步以来发生变化的引用状态和数据。
- 我们通过转向「按引用(per-reference)」的模型,一步步提高了写入吞吐,但仲裁(quorum)仍是继续提升的硬约束。那怎么再把写入吞吐往上推?
接下来做什么
今天的 GitHub 建立在 Spokes 仲裁之上,而多年来 Spokes 早已不只是个仲裁协调器。它还是智能路由和负载均衡层,是内部客户端访问和操作仓库数据的统一 API——每天处理超过 1000 亿次操作;它也是一组后台系统(我们叫它们“sweepers”),负责仓库放置、修复,以及存储节点之间的再平衡。
Spokes 服务旗下的大部分能力,对新旧两套架构同样适用。但我们也清楚,这套把 GitHub 带到十亿仓库的提交协议,撑不到下一个十亿乃至更远。
我们还没做完。这些步骤落地后,下一个瓶颈就是写入吞吐。每次写入仍然要等那些各自保存仓库完整副本的副本节点,可一次写入里真正需要定序的部分其实很少。Git 对象是不可变的,且以内容命名,因此存储它们不需要任何一方达成一致。剩下待定序的部分很小,难点在于尽可能快地给它排序,同时别把瓶颈转移到别处。
在画板上设计是一回事,真把它放到十亿个仓库底下运行—开发者与智能体全天候往这些仓库里写入—又是另一回事。走到这一步,我们不得不重新审视那些二阶效应,比如持久化的引用状态该存在哪里,以及压缩(compaction)和垃圾回收要怎么跟着扩展。
智能体的流量没有放缓的迹象。上面每一步落地,都会立刻为仓库腾出更多余量,不必等到终态。
下周预告:在 GitHub Universe 上,以及本系列的最后一篇文章里,我们会带你走一遍那个终态,看看它对写入吞吐意味着什么、为了抵达那里我们解决了哪些二阶问题,以及为什么技术栈的某个角落仍然保留着一个(小型的)共识组。
感谢 Brian Celenza 对本文的贡献。