在过去一年里,随着 Git 流量呈指数级增长,GitHub 上 99.99999% 的 Git 仓库开始同时存在于两个地方:我们自己的数据中心和 Azure。其余的是平台上最繁忙的一批,而它们只被一件事拖累:25ms 的往返延迟。
在一篇早前的博客文章中,我们阐述了下一代存储架构背后的两条原则:
- 最小化协调
- 将存储与计算解耦
在本文中,我们将探讨旧设计与新设计之间的直接过渡。我们将讨论对我们 quorum 序列化写入方式的改动如何让最繁忙的仓库进入 Azure,以及这同一步骤如何为自身不保留任何持久状态的副本打开大门。
我们是如何走到这一步的
这次迁移的时机和形态由智能体化转变所驱动:流量快速增长、我们的数据中心及周边地理区域的电力容量存在硬性上限,以及物理供应链约束。在迁移开始时,即使算上所有可能的扩展,剩余容量也只够我们安全地完成迁移,但不足以慢慢来做。
虽然我们已经在云基础设施上为扩展 Git 原型化了新的架构方案,但在那样的增长之下同时改变存储架构和托管平台,风险太大。考虑到我们的规模以及这项服务对社区的重要性,我们需要谨慎地安排工作顺序:先迁移数据,等数据到位后再演进架构。
Git 操作是一项 Tier 0 服务:它们是 GitHub 的核心功能,GitHub 中几乎所有内容都依赖仓库数据。这意味着数据、服务以及每一个依赖项都必须先行迁移。第一个仓库副本于 2025 年 8 月落地 Azure 的美国中部区域。如今,大多数仓库的五个副本中有两个位于 Azure,Azure 承载了约一半的 Git 流量。在这段时间的大部分里,Azure 吸收了我们的增长,而我们自己的数据中心保持稳定。
迁移期间,流量跟随数据走。大多数仓库有五个副本,以平衡持久性、可用性和规模。为最小化读取延迟,Spokes 使用优先路由,将流量导向与请求处于同一区域和可用区的副本。随着副本迁移到 Azure,可从 Azure 提供服务的流量份额随每个副本的增加而增长,直到最后一个副本到达,旧区域完全停止接收请求。每个仓库都经历这个过程:
不落下任何一个仓库
那么,剩下的 0.00001% 仓库怎么办?
在我们当前的架构中,托管在多个区域的仓库写入吞吐量上限较低。Spokes 的法定人数只需多数派即可提交写入,但它会等待每个健康副本响应(直到超时)后才继续,因此慢副本会延迟每次写入,而不是落后。在实践中,这意味着最慢的健康副本决定了每次写入的延迟,并且由于每次写入在提交前都持有仓库范围的锁,它也决定了吞吐量上限。一旦副本位于目标 Azure 区域,它距离我们的数据中心约 1,000 英里,使每次网络往返约 25ms,而一次写入需要多次往返。一个已经接近吞吐量上限的仓库无法在每次串行写入中承受这种开销;每次推送都会让它进一步落后。
这正是我们的第一原则——最小化协调——发挥作用的地方。具体到这个案例:不要序列化不需要序列化的东西。Git 的一致性要求分别适用于每个引用。一旦服务器接受了对某个引用的更新,客户端之后就不应看到更旧的值,只能看到该值或更新的值。无论更新是快进、强制推送还是删除,这一点都成立。但在大多数情况下,对不同引用的更新彼此不依赖,可以独立应用。
因此,我们正在将法定人数的范围从整个仓库改为单个引用。
在整个引用更新期间持有仓库级锁(包括往返过程),会把法定人数变成单一的、无序的请求队列,一次只有一个写入在进行中。该队列中的每个写入耗时都等同于最慢的健康副本。随着写入速率增长,队列也随之增长,每个写入等待的时间都比前一个更长。
采用按引用串行化后,只有争用同一共享引用的写入才需要相互等待。在实践中,对仓库的大多数写入触及的是不同的引用——例如,智能体和开发者推送到各自的分支——因此它们不再相互等待。每个写入仍然会经历跨区域延迟,但对不同引用的写入可以同时进行。这消除了仓库上的单一瓶颈。我们可以通过在相似负载下并排比较这两种方法,来直观地看到排队和超时是如何累积的:
这些改动使仓库的写入吞吐量上限大致与同时写入的引用数量成正比,并将最后剩余的仓库迁移到 Azure。在具有真实流量的内部测试中,对不同引用的写入始终以更低的延迟实现了 3–4 倍的吞吐量。
独立扩展读取
大多数仓库严重偏向读取,每次写入对应大约 100 到 1,000 次读取。目前,读取和写入命中相同的副本,因此它们争用相同的资源,无法独立扩展。
将存储与计算解耦改变了这一点。一旦仓库数据持久存储在 Azure Blob Storage 中,并随每个传入写入进行更新,我们就可以开始将读取流量与写入法定人数解耦,并独立扩展各自所需的计算资源。例如,读取将受益于比我们今天提供的多得多的内存缓存,但这些内存也是压缩所需要的。一旦它们被分离,各自就获得自己的配置。
第一步是让对象存储成为 quorum 的一员,但它是特殊的一员,我们称之为虚拟副本。代表它的计算节点是我们的第一个完全临时性节点。它充当通往对象存储的桥梁,流式接收传入的写入,但不处理任何读取。由于它是 quorum 的成员,写入只有在对象存储中持久化之后才会被确认。
这会给每次写入增加一点延迟,但正如跨区域往返一样,按引用串行化可以防止它成为吞吐量瓶颈。虚拟写入副本还会将不相交引用上的事务合并为对对象存储的单组网络 I/O,因此一个繁忙的仓库在许多并发写入下只需支付一次往返,而不是每次写入都支付一次。目前,虚拟写入副本还负责压缩,读副本则继承压缩后的状态。
有了虚拟写入副本,我们可以按需为仓库扩展任意数量的虚拟读副本。每个读副本从 Azure Blob Storage 预热缓存,并在不加入 quorum 的情况下开始接收读流量,以对象存储作为事实来源,并在后台将本地状态与之对账。
这一步释放了显著的读取扩展能力。在内部测试中,我们的分布式负载测试工具经常发现当前架构的上限。面对虚拟读副本时,该工具反而先达到了自身的极限。它也带来了一些挑战,这些挑战将在后续步骤中解决:
- 虚拟读副本一开始会在后台与对象存储进行对账,这使它们适合某些读取工作负载,但并非全部。要服务所有类型的读取,需要将这种对账移到在线路径中,只获取自副本上次追上以来发生变化的引用状态和数据。
- 虽然我们通过转向按引用模型逐步提高了写入吞吐量,但 quorum 仍然是进一步提高的制约因素。
我们如何进一步推高写入吞吐量?
接下来是什么
今天的 GitHub 建立在 Spokes 法定人数(quorum)之上,多年来 Spokes 已经远不止是一个法定人数协调器。它还是一个智能路由和负载均衡层,一个供内部客户端访问和交易仓库数据的统一 API,每天处理超过 1000 亿次操作,以及一组我们称为“sweepers”的后台系统,负责存储节点之间的仓库放置、修复和再平衡。
Spokes 服务伞下所承载的绝大部分内容,对新架构的适用程度与对现有架构一样。但我们也认识到,将 GitHub 带到 10 亿个仓库的提交协议,无法将它带到下一个 10 亿乃至更远。
我们还没有完成。随着这些步骤到位,下一个限制是写入吞吐量。每次写入仍然要等待各自持有仓库完整副本的副本节点,而实际上写入中需要排序的部分非常少。Git 对象是不可变的,并且以其内容命名,因此可以在无人对其达成一致的情况下存储。剩下的部分很小,难点在于尽可能快地对其排序,同时不把瓶颈转移到别处。在白板上为此做设计是一回事。在开发者与智能体全天候写入的 10 亿个仓库之下付诸实践则是另一回事。要达到那里,还迫使我们重新思考二阶效应,比如持久引用状态存放在哪里,以及压缩和垃圾回收如何扩展。
智能体流量并未放缓,而上述每一步在落地时都会为仓库带来更多余量,无需等待最终状态。
下周推出:在 GitHub Universe 以及本系列的最后一篇文章中,我们将带您浏览那个最终状态、它对写入吞吐量的影响、我们为达到那里而必须解决的二阶问题,以及为什么技术栈中某处仍然存在一个(小型的)共识组。
感谢 Brian Celenza 对本文的贡献。
作者
高级软件工程师
资深软件工程师