Git遇上大模型(2):Cursor如何扩展Git

原文:Git at any scale
作者:Vicent Martí(Cursor)
原文发布:2026 年 8 月 18 日
本文为中文翻译;示意动画使用原文图片,交互图表请通过对应的原文链接查看。

Cursor 原文示意动画

大规模托管 Git 仓库是一场噩梦。Linus Torvalds 最初设计这款“来自地狱的信息管理器”(这真的是 Git 的自我介绍)时,心中只有一个非常具体的使用场景:他自己的场景。他想替换用于开发 Linux 内核的分布式版本控制系统 BitKeeper。替代品自然也必须是分布式的。Linux 内核是一个不寻常的软件项目:它高度去中心化,众多子系统分别由不同维护者负责。分布式版本控制系统很适合这种工作流。

二十年后,Git 已成为行业标准。但事实上,它的分布式特性带来的阻碍,往往大于好处。普通开源项目并不采用去中心化工作流,普通公司就更不会。它们享受分布式模型的诸多优点,例如离线工作、推迟推送,却又非常依赖集中式托管。事实证明,托管 Git 仓库极其困难。

Git 难在哪里?

大规模托管 Git 仓库的难点源于 Git 自身的设计:作为分布式版本控制系统,它要求仓库的各个实例彼此等价。Git 服务器上的仓库,与开发者笔记本上的仓库,并没有本质区别。乍看之下,这似乎让托管很简单:在磁盘上的仓库前放一个 HTTP 服务,Git 服务器就有了。但扩展性和可靠性方面的许多难题,使实际情况恰好相反。

在普通 Git 仓库中,代码与元数据(文件、提交、树)被压缩并存入 packfile。这是一种简单的二进制序列化格式,本地机器处理起来很方便,但不适合在服务器上进行大规模管理。packfile 同时是 Git 存储和网络传输的基本单位。向仓库推送或从仓库拉取数据时,传输的就是 packfile。

Git 的设计如此,但服务器内部未必一定要这样做。你不能控制 Git 客户端——至少不能在不打扰用户、不增加大量使用阻力的情况下这么做;但在自己的服务器里,你可以自由决定实现方式。没有什么规定服务器必须使用 packfile,Linus 也不会来检查。唯一的限制是:所有 Git 操作在网络上仍须收发 packfile。

多年来,试图大规模托管 Git 仓库的公司逐渐发现,基于 packfile 的设计严重限制了可用性与扩展性。packfile 是大型二进制文件,Git 要访问它们,就必须让它们存在于文件系统中。把 HTTP 服务器放在磁盘仓库前面的简单方案,扩展上限很低。理想情况下,仓库应分布在多块磁盘、多台机器上,以便并行执行大量 Git 操作,并在服务器故障时保持可用。问题是,如何做到?

大体有三条路,复杂度依次增加:分布文件系统、分布 packfile,或者分布 Git 本身。

不使用 packfile 的 Git

Git 是按内容寻址的数据存储。仓库中所有对象(blob、tree、commit 等)都以内容的 SHA-1 为键。直觉上,这似乎非常适合分布式键值存储:键是 SHA-1,值是对象本身;仓库的存储也因此可以横向扩展。但实际上,这行不通。

问题在于,Git 仓库的结构是一张有向无环图(DAG)。任何对象都可以通过其 SHA 查找,但即使要完成仓库中最简单的操作,也必须沿着这张图逐步遍历。

比如列出仓库最近的改动,需要处理提交。处理一个提交时,会拿到其根树的指针;从根树继续拿到各个文件和子树的指针;从原提交还能得到父提交的指针。关键是,每一步都必须先取回上一个对象,才能知道下一个指针的值。如果每次取回都要与分布式存储往返通信,成本很快就会高得惊人。

以前曾多次尝试在对象级别分布 Git,但大规模运行时往往失败。最有希望的一次尝试来自我的前导师 Shawn Pearce,当时他在 Google 的版本控制系统团队工作。他尝试把对象存入分布式哈希表。之所以做得到,是因为 JGit 这一 Java 实现提供了足够多的接口和工厂,可以抽象普通 Git 仓库的所有细节,包括用 DHT 替换磁盘上的 packfile。系统本身运转正常,普通 Git 操作的结果也不错;但 Git 协议仍要求在网络上传输 packfile,不管服务器内部怎样存储数据。最终,git clone 性能差到不得不放弃整个设计。

GitHub 与文件系统

Git 走出 Linux 内核圈子几年后,一家初创公司在旧金山诞生。GitHub 于 2008 年成立,定位是社交化编程平台,口号颇有先见之明:“托管 Git 仓库,不再让人头疼。”这不是玩笑,早期网站的存档可以作证。早在 2008 年,人们就普遍认同:尽管 Git 是分布式设计——也许正因为如此——要让它易用,仍需要集中式托管,而这件事做起来很痛苦。GitHub 决心改变它。

GitHub 平台起初是一个 Rails 单体应用,今天在很大程度上依然如此。最早的版本运行在一台性能很强的机器上:Ruby 服务器旁边,是磁盘上的各个仓库副本。扩展 Rails 应用并不难,多部署几个实例即可。但涉及 Git 后,他们很快遇到了本文反复讨论的问题:Rails 应用需要访问磁盘上的 Git 仓库,那该如何增加仓库副本?

早期 GitHub 系统工程师很务实,先尝试了看起来最简单的办法:分布文件系统,而不是 packfile 或 Git 本身。这样 Rails 应用无需修改,团队可以把精力留给不断增长的用户群及新功能。想法合理,可惜没有成功。

团队尝试了多种分布式文件系统方案。最显而易见的是用 NFS 把所有仓库存到中央服务器上,但很快就被否决。Git 默认实现对文件系统语义有许多假设,包括锁定、部分写入、读取与同步。这些假设在缓慢的开发者笔记本本地文件系统上尚能保证不错的性能,却没有考虑网络文件系统的表现。结果既慢又容易出错。

随后,他们还试过在块设备层复制文件系统的技术,回头看甚至称得上可怕:短暂使用过 GFS,较长时间使用过 DRBD。这些方案都遇到了瓶颈,日常运维十分痛苦,性能又不足以弥补代价。根源仍在磁盘上的 packfile 设计。

前面已经看到,Git 的图结构使网络往返成本高得无法接受。底层磁盘数据也有类似问题:DAG 中对象的逻辑排列,与 packfile 中的物理位置没有对应关系。生成 packfile 的主要启发式目标是尽量缩小体积,因此对象散布在 pack 中、经过压缩,而且很少完整存放。大部分对象都以同一个 packfile 中其他对象为基础,以增量形式保存。读取单个对象时,沿图结构完成多个逻辑跳转后,还得沿磁盘格式继续进行物理跳转。

每次 Git 操作都可能需要在数 GB 数据间随机游走。这与网络文件系统格格不入,不论复制发生在文件层还是块层。若想避免速度慢到无法使用,唯一办法是把整个文件缓存在本地。但同一个文件系统里有几十万个仓库,缓存根本不可行。

最终,GitHub 的系统工程师决定放弃分布文件系统,转而开发 RPC 系统,让仓库放在专用文件服务器上,并修改 Rails 应用,通过远程调用完成所有操作。这获得了相当一部分横向扩展能力,但没有解决可用性和最繁忙仓库的性能问题。毕竟,每个仓库仍只存放在一台机器上。

Spokes 与一致性

Spokes 最初由 GitHub 在 2013 年左右开发,此后成为行业标准。如今,多数 Git 托管服务的架构都使用 Spokes 思路的某种变体,即在应用层复制 Git 仓库。Spokes 多年来表现良好,主要因为它做了三个后来被证明很合适的基本选择:

  1. 不分布 Git 本身,而是在 packfile 层工作。
  2. 把所有数据作为普通 Git 仓库,存放在本地 NVMe 磁盘上。
  3. 复制 Git 数据,同时让所有副本始终保持一致。

鉴于上文所述 packfile 内部的随机读取模式,要让基本 Git 操作保持快速,把普通 Git 仓库存于 NVMe 磁盘几乎是必要条件。这也使克隆效率更高,因为不必把数据转换成客户端所期待的格式。团队可以专注于 Git 之上的产品,而不必维护一个能操作特殊仓库的 Git 分支版本。

让所有数据副本始终同步一致,同样至关重要。这往往要吃过亏才知道:Git 客户端非常不适应最终一致性。如果本地客户端刚推送一个提交,随后拉取却无法立即读到它,Git 会不知所措;如果 CI 在一百个运行器上执行,其中三个在克隆后找不到应当测试的提交,问题也很严重。用户体验同样糟糕。

无论在客户端还是后端,以最终一致的视图操作 Git 仓库,都容易踩坑。因此,Spokes 不惜付出很高的复杂度代价,保证系统始终完全一致。下面看看这意味着什么。

Spokes 是一个基于共识的分布式系统,在不同服务器上保存仓库的多份副本。每次推送新数据,编排器都会把推送扇出到仓库的每个实例。扇出过程配合经典的 3PC(三阶段提交)共识算法;只有多数节点确认后,推送才会被接受。

原文的三阶段提交动画依次展示投票、预提交和执行提交。3PC 本身并非 Git 的一部分,而是一种让系统中所有节点同意提交或回滚事务的共识算法,需要三轮往返通信。它与两阶段提交相似,但额外加入预提交阶段,使协调者在事务中途离线时系统仍可恢复。

在进一步讨论 Spokes 如何使用 3PC 之前,先看 Git 推送的两个组成部分:packfile 和引用事务。packfile 包含推向仓库的对象,即与改动相关的 blob、tree 和 commit。引用事务则通过更新一个或多个引用——例如当前分支——使其指向刚推送的提交,从而真正发布改动。

这种分离很有用,因为推送后的提交在其引用更新之前并不可见;用 Git 的术语说,它尚不可达。于是可以先把 packfile 同时扇出到所有主机,此时无需同步;然后只对引用事务执行三阶段提交。引用事务比 packfile 小得多,也更容易快速同步。Git 本身支持准备引用事务:它可以给引用加锁,验证现有值符合预期,并保持锁定,直到收到提交或中止命令。

原文提供了一个可调整副本数量和网络延迟的模拟器,展示 Spokes 先分发 pack,再对每次推送的引用事务执行三阶段提交,以及这些参数如何影响提交吞吐量。

这一设计保证每次推送都完全同步到所有副本。读取操作(拉取、克隆)随后可以安全地路由到任意一个副本,因为每个副本始终是最新的。

Spokes 大体就是这样工作,而且在过去十三年里运作得相当好。当然,它并不完美。到了 2026 年,使用 Git 仓库的方式发生了巨大变化,我们也从构建分布式系统中获得了许多重要经验。时间证明了 Spokes 的哪些选择是对的,哪些不是。

其中一个关键缺陷是 3PC 的横向扩展能力受限。Spokes 刚推出时,每个仓库配三个副本是合适的平衡点:普通仓库由三个副本服务,容量还有余量;即使一台机器故障,也有足够冗余继续接收推送。

如今情况大不相同。企业的典型仓库已经变成巨大的单体仓库,三个副本不足以服务这些仓库的流量,尤其是 CI 流量。Spokes 当然可以运行三个以上的副本,但会遭遇令人头疼的规模化尾延迟。三阶段提交与 Git 事务模型结合得很自然,但作为共识算法,它有根本限制:每一步的延迟都受集群中最慢服务器制约。副本越多,推送吞吐量越差。

扩展限制也朝另一个方向起作用。智能体大规模使用 Git 时,常常不在单体仓库里工作,而是创建数量庞大的小仓库,其中许多用完即弃,大多数访问量很低。Spokes 仍要求每个仓库有三个副本。这三个副本基本闲置,却不能减少,否则系统不再完全一致,还可能丢失数据。使用三阶段提交,资源下限总是太高,扩展上限又太低。

Spokes 的另一个缺陷,在设计之初难以察觉,吃过苦后却再明显不过:大规模运维很艰难。磁盘上的仓库始终是共识的事实来源,因此每个仓库的每份副本都非常重要。你得把仓库当作需要精心照料的“宠物”,而不是可以随时替换的“牲畜”。

首先,你必须准确知道每个仓库在哪里。这就依赖一个外部数据库:它要维护庞大的路由表,记录每个仓库复制到了哪些机器,数据库本身也成了潜在的可用性问题。每个仓库还必须做校验和,并持续更新路由表里的校验信息,确保磁盘上的仓库有效。

只要仓库出了问题——相信我,这种事经常发生,Git 在实际使用中很挑剔——就必须迅速发现并安排修复任务,让它恢复健康。动作必须非常快,因为磁盘仓库是事实来源,损坏的副本与丢失的副本一样糟。如果三份副本中有两份损坏,系统就无法再接收推送:它已失去法定多数。

Continuity

Continuity 是我们在 Cursor 开发的 Git 存储系统。它的思路很明确:学习 Spokes 做对的事,并修复多年实践后已经确认的问题。

Continuity 是一个简单的系统;系统不简单,就不可能易于运维。它的核心原语是存储在兼容 S3 的对象存储中的预写日志(WAL)。生产环境直接使用 S3,但设计允许部署在任何云上。

仓库收到推送后,我们把推送作为 WAL 条目存入 S3。在推送完全持久化之前,我们绝不确认成功。 每次推送单独保存为一个对象;传入的 packfile 同时写入本地磁盘并上传到 S3。不过,上传 WAL 条目并不等于发布它。只有在仓库的一个本地副本上成功准备引用事务,并在 WAL 索引文件中记录指向该条目的指针后,推送才会对外可见。WAL 索引文件本身也是存储中的一个对象。这使所有推送都具备线性一致性。

我们尽量避免每次推送都执行一次独立的 S3 写入,因为在繁忙仓库中,S3 PUT 的延迟会给推送吞吐量设下硬上限。通过精心调校的批处理,以及只需让引用事务与一个本地仓库同步、无须与多数副本同步,我们得到了一个能以磁盘允许的速度接收推送的系统。

本地副本当然还是一个存储在高速 NVMe 上的普通 Git 仓库。这一点与 Spokes 相同,因为我们认为 Spokes 在这里完全做对了。它让我们能够重用 Git 社区出色的开源工作,包括上游 Git 客户端及大量性能优化,也让我们专注于交付新功能,而不是折腾 Git 的特殊实现。

共识

前面看到,Spokes 集群难以运维的一个原因是:必须随时知道每台服务器上每个仓库的位置。Continuity 的做法截然不同。每个仓库在哪里?答案是“哪里都可以”,这并不重要。磁盘仓库只是一个热缓存,事实来源始终是 S3 中的 WAL。系统本身无状态,没有路由表,也无须运维关系数据库。如果访问某台主机时,本地磁盘缺少目标仓库,就从 WAL 重建。虽然这可以高效完成,但我们当然不想每次都重建,那会浪费资源。生产环境使用最高随机权重哈希(rendezvous hashing),把仓库 ID 映射到预计承载它的一组节点。路由所需的全部状态,只有仓库 ID 和集群当前的健康节点集合。即使这些状态不同步,比如某节点变得不健康,也无妨;我们会在下一个节点上重建仓库。

那么共识呢?选举呢?某个仓库的主节点是哪台?同样不重要。这里没有共识,也没有需要持久维护的主节点状态,任意服务器都能充当主节点。WAL 的全部更新都通过 S3 上的原子比较并交换(CAS)操作同步,所以任何仓库实例接收推送都是安全的。与路由一样,任意选择主节点并非最高效,可能导致 CAS 重试并拖慢推送。因此,实践中我们总是选最高随机权重哈希排序列表中的第一台服务器作为主节点。但在部署、故障切换或短暂网络故障等边界情况下,究竟哪台是主节点无关紧要。系统的设计目标是:降级时仍然正确,健康时始终快速。

复制

有了 S3 中的预写日志,扩展能力就有了很大的想象空间。副本数量几乎可以任意增加,因为 S3 的扩展能力很强,所有副本都直接从 S3 追赶进度。我们使用乐观复制:在集群内通过 UDP 发送 gossip 数据包,其中包含每次推送后副本直接从 S3 追赶所需的元数据。

我仿佛听见屏幕前的你在说:“这太疯狂了,UDP 传输并不可靠。”当然不可靠。分布式系统里没有什么绝对可靠:网络链路、路由和拓扑都不可靠。但没关系,这不影响正确性。每个副本都知道自己已追赶到的 WAL 索引版本的 ETag。读取某副本时,我们带着预期 ETag 向 S3 发起条件 GET。返回不带正文的 304,就意味着副本已是最新,可以立即提供拉取或克隆;这种请求只涉及 S3 元数据,平均不到 10 毫秒。若返回 200,则响应包含最新版 WAL 索引,副本会先用它追赶进度,再提供读取。

即使复制用的 UDP gossip 数据包丢失,或因拓扑变化到达了错误的服务器,也无关紧要。所有副本上的读取都与事实来源 S3 核对,因此始终完全一致。系统降级时仍然正确,健康时则始终快速。原文的复制交互演示展示了丢弃 UDP 消息后的行为。

这带来两方面影响。首先,系统始终一致,因此在其上构建基础设施很简单。我们、我们的智能体、Web 界面和客户端,看到的始终是仓库的全局一致视图。其次,系统可向两个方向扩展,每个仓库都能拥有恰到好处的副本数。大型单体仓库可部署到数百个副本,以承载全部 CI 任务的负载。智能体创建的数百万个小仓库,则可以各只使用一个副本;S3 才是事实来源,因此不需要更多副本来保证可用性。事实上,闲置仓库连一个常驻副本都不需要:某副本长时间没有流量后,我们就从节点磁盘上回收它;下次拉取到来时,再从 WAL 重建。

压实

预写日志必须定期压实。日志不能无限增长:完整恢复需要重放每个条目,因此条目越多,恢复成本越高。

巧的是,普通 Git 仓库虽然不是基于 WAL,也需要定期压实。前面已经看到,Git 仓库的基本存储单位是 packfile。每次向远程仓库推送,或向本地仓库拉取,都会产生一个新的 packfile。这样不能无限扩展:每个 packfile 都有自己的索引,便于 Git 查找其中的对象,但查找效率只限于单个 packfile。若仓库有 100 个 packfile,要找某个对象,就得逐一打开索引并查询,直到找到它。再高效的操作,如果要重复数百甚至数千次,也不再高效。

现代 Git 已经很擅长缓解这一问题,支持多包索引和增量几何压实。但最终仍必须对磁盘上的 Git 仓库重新打包。过去,这一直是 Spokes 等系统的可用性难题:重新打包即使以增量方式执行,也非常耗费 CPU,而且必须在系统的所有副本上进行。如果同一仓库的两个或更多 Spokes 节点意外同时开始维护操作,就很容易触发仓库故障切换。

在 Continuity 中,我们摊销压实成本。只有主节点执行压实,结果同时作用于磁盘仓库和 WAL。所有副本都跟随 WAL,也就会跟随压实事件。副本不自己重新打包,而是直接从 S3 下载已压实的 pack,以带宽换取 CPU。

扩展能力

复制和压实,是决定 Git 存储系统在负载下表现的两个关键因素。正如刚刚看到的,两者内在相关:仓库每秒接收的推送越多,读取性能越容易下降,因为每次推送产生的 packfile 都必须压实,Git 操作才能继续保持高效。如果复制这些推送,压实也要么一起复制,要么由每个副本独立执行。

Continuity 以 WAL 为先的设计提供了完全一致的横向扩展能力:可以部署任意数量的副本,只读 Git 操作的吞吐量随副本数线性增长。因为集群中所有副本完全一致,我们不仅可以扩展 Git 协议操作(克隆、拉取),也能扩展 Origin 在仓库上执行的全部 RPC 操作,例如 Web 界面交互、REST API 和各种面向智能体的接口。

在最多 100 个副本的合成压力测试中,我们观察到读取能力稳定地线性扩展,推送吞吐量没有退化。

集群的推送吞吐量取决于更新 S3 中 WAL 的延迟。使用 S3 Standard 时,在同时压实并向其他所有节点复制压实数据的情况下,我们可持续处理最多每秒 120 次推送。我们还在 PUT 延迟低得多的 S3 Express One Zone 上部署了高性能集群,处理速度超过每秒 300 次推送;此时瓶颈基本是 Git 压实磁盘数据的速度。我们正在探索新的磁盘数据布局,以减轻压实的影响。目标是在不放松严格持久性和一致性保证的前提下,继续提高 Git 仓库接收代码的速度。原文的扩展图表可查看测试展示。

以 WAL 为事实来源

S3 是一项很出色的技术。由 S3 API 推广的 blob 存储理念,已证明是大型数据存储系统的强大基础组件,Git 仓库托管也不例外。本文介绍的设计在许多方面是新的,但把 packfile 作为 blob 存储并非首创。Azure DevOps——微软自家 GitHub 的竞争产品——有一套成功的 Git 存储系统,将 packfile 存在 blob 存储中,把引用存于关系数据库(MS SQL Server)。这种系统有不少取舍:关系数据库很适合扩展大型引用事务,但你必须运维一套关系数据库。我们坚信,Git 数据的一致性比其他任何因素都重要。这最终促使我们设计不依赖外部数据库的 WAL 系统。

生产环境中的 Git 仓库可能遇到许多问题:静态数据损坏、重新打包时的缺陷、推送过程中的竞争。它几乎是边界情况的大集合。大多数问题已在上游 Git 中得到解决,但并非全部。没有系统完全没有缺陷,即使它是被广泛部署的开源软件也一样。我们的一致性模型确保能追踪仓库发生的每一项基本操作。推送未完全持久化到 WAL 前,我们绝不确认成功;所有推送都按线性顺序生效;访问任何仓库得到的每个视图,始终完全一致。

由于每次推送都进入 WAL,我们可以查看仓库经历过的每一种状态。所有推送和重新打包操作都有完整的来源记录。我们可以让任何副本回退或快进;也无须与外部数据库同步任何状态,不论数据库只保存引用,还是保存全部对象数据。当我们迟早遇到 Git 缺陷时,可以准确定位发生了什么,并将其撤销。除此之外,我们引入的新缺陷也很少,因为整个过程中,全部 Git 操作都使用现成工具,在磁盘上的普通 Git 仓库中执行。

Origin

我们非常清楚托管他人源码的责任有多重大。我想,每个读完并理解这篇文章的人也一样清楚。如果开发者无法向 Git 仓库推送或从中拉取,一家公司都可能停摆。CI 系统中断五分钟造成的生产力损失,很难用金额准确衡量,但无论按什么标准看,都十分巨大。

智能体从根本上改变了我们开发软件的方式,在许多方面也加重了这些问题:更多代码、更多 PR、更多 CI 运行。版本控制处在这一切的核心,而且可能是最难在一夜之间更换的组件。

过去几个月,Cursor 内部一直在面对这些困难。我们投入了大量思考和心力,构建一个既能解决自身问题,也有望帮助客户的平台。当前的重点是提供一条尽可能平顺的迁移路径,让用户获得更高的可靠性、性能和扩展能力,同时尽量减轻迁移的痛苦。

Origin 不是一次实验。它凝结了深刻理解这些挑战的人多年构建同类系统的经验。我们的工程和运维理念已经过实践检验,也将坚定地随着版本控制领域的变化继续演进。

希望你愿意信任我们和我们的平台。

posted @ 2026-11-03 14:09  ffl  阅读(15)  评论(0)    收藏  举报