跳转至

GitHub Rubicon:5000 个 Agent 如何同时写入一个 Git 仓库

2026 年,GitHub 面临的 Git 存储压力发生了变化:开发者仍然会提交代码,但大量 Coding Agent 开始以机器速度创建分支、提交和推送。同一个仓库中,数千个 Agent 可以各自工作;它们每完成一轮任务就可能 Push 一次,随后又触发 CI、代码扫描和其他 Agent 的 Fetch。原本偶发的仓库写入,变成了持续的并发负载。

GitHub 在 2026 年 11 月公布的 Project Rubicon 内部测试中,让 5000 个并发 Agent 对同一仓库产生每秒 7200 次 Push,同时继续服务低延迟读流量。这个结果的核心是重新划分一致性边界:Git 对象不可变,适合并行持久化;分支引用可变,才需要协调。Rubicon 把大数据写入交给 Azure Blob Storage,把少量可变的引用和 Pack 清单交给 Ref-CAS,再让读写计算与后台维护独立扩缩容。

本文沿实际请求链路展开分析,主要依据 GitHub 的三篇工程文章。重试、过期 Worker、垃圾回收安全窗口等尚未公开的细节,以工程机制推演说明。Rubicon 是 GitHub 内部项目,相关实现以公开资料为准。截至 2026 年 11 月 5 日,它处于有限私测阶段,更广泛的推出计划在 2027 年初。

Agent 改变了仓库的负载形状

传统开发者在编辑、调试和提交之间有自然停顿。同一个仓库纵然很繁忙,每秒也未必有太多写入。Agent 的工作循环则可能是:读取状态、修改文件、运行测试、提交、推送,随即开始下一轮。大量 Agent 同时运行时,单仓库写入持续发生,读流量还会被构建和检查任务放大。

GitHub 在 2026 年 10 月披露:平台月度 Push 从约 6.9 亿增长到 33.5 亿,同比约 4.9 倍;2026 年 9 月 GitHub Actions 执行约 32.6 亿次。GitHub 还指出,仓库读取与写入的比例常在 100:1 到 1000:1 之间。 这组数字说明了两个不同方向的压力:写入必须具有更高的持续吞吐,而一次写入造成的读取扇出又必须足够便宜。

扩展问题还分为两个维度。横向扩展处理的是“很多仓库同时活跃”:租户放置、限流、公平性和路由已经是 Spokes 的长期职责。纵向扩展处理的是“一个仓库特别繁忙”:所有 Agent 的操作最终集中到同一套仓库元数据上。Rubicon 的主要架构改造瞄准后者。

一次 Push 的成本远超字节持久化。一个 Push 还会带来对象索引、对象连通性检查、引用更新和对其他读取者可见的状态切换。GitHub 特别指出,索引新 Pack 和验证对象连通性是写入中最耗 CPU 的工作之一。增加写入节点可以并行处理这些工作,但如果最终仍要排队修改整个仓库状态,吞吐就会再次碰到天花板。

Git 数据模型决定了哪些操作必须协调

Git 的持久化对象有四类:Blob 保存文件内容,Tree 描述目录,Commit 记录根 Tree、父提交和提交元数据,Tag 给对象附加名称与说明。对象使用内容寻址:内容确定后,对应对象 ID 随之确定,已创建的对象无需原地改写。不同提交可以共享未变化的 Blob 和 Tree。

flowchart LR R["refs/heads/main<br/>可变的引用"] --> C2["Commit C2"] C2 --> C1["父 Commit C1"] C2 --> T2["Tree T2"] C1 --> T1["Tree T1"] T2 --> B1["Blob: main.go"] T2 --> B2["Blob: go.mod"] T1 --> B1

真正经常变化的是 Reference,例如 refs/heads/main。它保存“当前这个分支指向哪个对象”的状态。同样的代码对象可以在多个位置并行上传,但两个客户端不能在不知道彼此更新的情况下,把同一个分支都当作自己独占的可变变量。

设 main 起初指向 A,两个 Agent 都从 A 创建了新提交:

初始:main -> A

Agent X 期望:A -> B
Agent Y 期望:A -> C

如果更新条件只有“写入新值”,后到者可能覆盖先到者。正确的更新要表达为 CAS(main, expected=A, new=B):只有当前值仍是 A 时才写入 B。X 成功后,Y 基于 A 的条件更新必须失败,客户端再根据远端新状态决定是否重新组织提交。CAS 冲突后应重新读取远端引用,再根据分支关系决定下一步操作。

反过来,X 更新 feature/x、Y 更新 feature/y 时,两者并不修改同一个引用,完全可以并行推进。一个 Push 也可能同时更新多个 Reference;此时应对事务涉及的引用进行整体检查和提交,保证客户端读到一致的更新结果。

数据 特性 更合适的处理方式
Git Objects 内容寻址、创建后不可变,体量较大 分散上传、复用和缓存
Pack / Index 对象的打包表示及访问索引 Blob 持久化与后台整理
References 小、可变,可能被并发修改 按涉及的 Ref 协调、条件更新
Pack Manifest 仓库当前可用 Pack 集合 与引用可见性保持一致的元数据事务

表里最后一行同样重要:仅仅知道 main -> B 还不够,读取者还必须知道 B 所属对象现在可以从哪些 Pack 找到。Rubicon 将 References 和 Pack Manifest 一起纳入 Ref-CAS 的权威状态。

Object、Pack 和索引怎样组织一次 Git 写入

Git 对象的逻辑身份和它在磁盘上的物理位置分属两层。逻辑层用 Object ID 标识内容,物理层则决定对象放在哪个 Pack、从哪个偏移读取。Git 可以把对象单独存储为 Loose Object,也可以把大量对象压缩到 Packfile。Rubicon 面向海量写入时,公开设计中的持久化核心是 Pack 及其索引。

以一次修改 src/server.go 的提交为例,客户端可能创建新的 Blob、从修改位置向上重建若干 Tree,并生成一个指向父提交的 Commit。未改变的其他文件继续引用原有对象。Git 发送给远端的 Pack 通常包含本次提交所需、远端尚未拥有的对象。网络传输与本地存储还可能使用增量编码(Delta),通过已有内容减少字节量。对于 Git,Object ID 标识解码后的逻辑对象;对于存储系统,Pack 是可以压缩、移动、合并的物理容器。

flowchart LR A["更新一个源文件"] --> B["新 Blob"] A --> C["更新受影响的 Tree"] B --> C C --> D["新 Commit"] D --> P["Packfile<br/>存储多个对象"] P --> I["Pack Index<br/>OID -> Pack 内位置"] I --> M["Multi-Pack Index<br/>跨 Pack 查找"] M --> R["Object Lookup"]

从读取一个 Object ID 开始,服务端需要判断它位于哪个 Pack,再在 Pack 内找到位置;如果内容经过 Delta 压缩,还可能需要读取增量基对象。Pack Index 解决单包定位问题,Multi-Pack Index 把多个 Pack 的索引组织起来。Reachability Bitmap 则面向另一类工作:快速计算两个提交之间可达对象集合的差异,减少 Fetch 协商和对象遍历的成本。这几类辅助数据的服务对象不同,因此各自有独立的维护成本。

这些物理表示还解释了 Compaction 的价值。假设一个仓库积累了一万个小 Pack,逻辑对象数量可能变化不大,但索引文件、文件元数据以及查询需要接触的 Pack 范围不断增加。后台把小 Pack 合并为大 Pack,主要是减少物理碎片与查询开销;原有 Object ID 仍然有效,分支所指向的 Commit 也保持不变。

一个容易忽略的细节是,客户端 Push 和服务端对象可见之间隔着完整性验证。服务端需要确认新 Commit 引用的对象图能够被解析:比如新的 Tree 引用一个已有 Blob,那么这个 Blob 要么在新上传的 Pack 中,要么已存在于仓库的可达存储中。仅仅收到 Pack 的字节还不足以接受分支更新。GitHub 也把新 Pack 索引和对象连通性检查列为写入路径最重的 CPU 工作。

对 Rubicon 来说,逻辑对象的不可变性使计算能够分散到多个 Virtual Replica;Pack Index、Manifest、缓存及后台合并则承担物理对象位置随时间变化的管理职责。把逻辑标识与物理位置分开,后续理解“先写新 Pack,再原子更新 Manifest”就顺畅了。

Spokes 的旧瓶颈:耐久副本同时承担读写计算

GitHub 原有的 Spokes 为仓库维护多个文件服务器副本,默认是五份。副本把完整仓库放在本地磁盘上,既负责持久化,也处理读写与维护工作。更新 Reference 时,旧架构通过三阶段提交协议和 Quorum 协调一致状态。

flowchart TB A["Git / API / Web"] --> S["Spokes 路由与协调"] S --> R1["文件服务器 1<br/>完整仓库 + 读写 + 维护"] S --> R2["文件服务器 2<br/>完整仓库 + 读写 + 维护"] S --> R3["文件服务器 3<br/>完整仓库 + 读写 + 维护"] S --> R4["文件服务器 4<br/>完整仓库 + 读写 + 维护"] S --> R5["文件服务器 5<br/>完整仓库 + 读写 + 维护"]

这个架构对常规仓库非常实用:本地磁盘读取快,副本提供冗余,多个节点可以分摊读取压力。但它将扩大读取能力和扩大写入参与者集合绑在了一起。增加副本能分摊读取,却也增加写入协调与同步成本;Compaction 等维护任务还和在线请求争用 CPU、内存与 I/O。

最忙仓库在跨区域迁移时暴露了更尖锐的问题。GitHub 的旧 Spokes 虽然依靠多数派提交,却会等待每个健康副本的响应(直到超时),而仓库级锁要贯穿整个写入过程。Azure 与原数据中心之间往返约 25ms,写入需要多次往返。只要有一个健康副本较慢,下一次仓库写入就得继续等。

如果把一次串行更新占用锁的时间记为 T,那么在其他开销忽略时,仓库级串行通道的吞吐上限近似是 1/T。即使 5000 个 Agent 的写入互不相关,只要都占用同一把仓库锁,增加 Agent 数只会拉长队列。这正是 Rubicon 要拆掉的耦合。

跨区域往返怎样放大仓库级锁的成本

旧架构迁移到 Azure 时,原数据中心与目标区域之间一次网络往返约 25ms。一次写入需要数次往返,仓库锁又要持有到整个更新完成。即使每次计算只耗费几毫秒,网络等待也会压住单仓库的连续写入能力。

为了看清数量级,做一个教学估算:假设一次串行更新占锁期间经历 3 个 25ms 往返,再加 5ms 本地处理,总服务时间约 80ms。这一条串行写通道每秒理论上只能完成约 12.5 次更新。如果每秒突然到达 100 次独立分支写入,剩余请求就会持续排队。这里的往返次数与处理时间是推演参数,GitHub 官方只给出了跨区域约 25ms RTT 和多次往返的事实。

请求排队的风险来自利用率。设写入到达率为 λ、仓库级串行通道的平均服务时间为 S,利用率可以粗略写成 ρ=λS。当 ρ 接近 1,排队时间对短暂抖动越来越敏感;当 λ 持续超过服务能力,队列会持续增长。更换更快的 CPU 能缩短 S 中的计算部分,却难以消除跨区域 RTT 乘以多阶段协议的成本。

Per-Reference Serialization 改变了排队的位置。更新 feature/a 的 Agent 可以与更新 feature/b 的 Agent 同时等待网络;只有它们更新同一 Reference 时才需要互相等待。这解释了官方过渡阶段 3~4 倍的吞吐提升,同时也说明 3~4 倍与单仓库有多少并发独立分支直接相关。若所有写入都集中在 main,这一优化的空间就会缩小。

旧系统的“慢健康副本”进一步放大尾延迟:协议即便只需 Quorum 达成提交,后续推进仍可能等待所有健康副本响应。完成一笔更新需要经历的耗时,受到最慢健康参与者影响。排队和尾延迟因此形成反馈:一次慢写延长仓库锁时间,后面的请求依次多等一轮。这就是先迁移再细化锁粒度的直接动机。

为什么先迁移,再重做架构

GitHub 采用渐进式迁移,分阶段验证存储位置与一致性协议的变化。2025 年 8 月第一份仓库副本进入 Azure;到 2026 年 10 月,99.99999% 的仓库已在原数据中心和 Azure 同时拥有副本。剩下的极少数繁忙仓库,恰恰因为跨区域 RTT 和仓库级串行写入,最难迁移。

过渡阶段先把写入的串行化范围缩小到单个 Reference,只让更新相同引用的操作排队。GitHub 在接近真实业务流量的内部实验中,报告不同引用的写入吞吐提高约 3~4 倍。接着加入一个特殊的 Virtual Write Replica:它作为 Quorum 成员把写入持久化至对象存储,专职处理写入;随后引入独立于写入 Quorum 的 Virtual Read Replica,从 Blob Storage 预热缓存。

这条演进路线依次完成迁移存储位置、缩小锁范围、拆分读写职责、替换最终数据面。过渡阶段的专用读写副本与 Rubicon 的通用 Virtual Replica,在职责和协调关系上有明显差别。

Rubicon 总体架构:两份权威状态、三类计算职责

Rubicon 保留 Spokes 作为 Git、API、Web 请求的统一入口,继续承担路由、负载均衡、租户隔离与公平性。Spokes 下方的数据面则换成三类职责清楚的组件。

flowchart TB C["Git Client / API / Web"] --> S["Spokes<br/>路由、放置、租户公平性"] S --> V1["Virtual Replica A<br/>读写计算 + 本地缓存"] S --> V2["Virtual Replica B<br/>读写计算 + 本地缓存"] S --> V3["Virtual Replica N<br/>读写计算 + 本地缓存"] V1 <--> B[("Azure Blob Storage<br/>Pack / Index / Read Accelerators")] V2 <--> B V3 <--> B V1 <--> R[("Ref-CAS<br/>References + Pack Manifest<br/>复制日志 / Quorum")] V2 <--> R V3 <--> R W["Compaction / GC Workers<br/>独立后台维护"] <--> B W <--> R

Azure Blob Storage 存放大体量、主要不可变的数据。 包括 Push 和维护产生的 Pack、Pack Index、Multi-Pack Index(MIDX)、Reachability Bitmap、自定义读取加速结构、热门 Fetch 的缓存响应、后台维护的记录和 Lease,以及非权威的引用检查点。这些数据适合通过不同对象 Key 并行写入与读取。

Ref-CAS 存放需要有一致提交顺序的小型元数据。 它维护 References 和 Pack Manifest,是一个独立的复制日志服务。日志负责条件更新与一致可见性;Git 对象字节的保存、索引和验证由其他组件承担。

Virtual Replica 专心处理在线请求。 它可以读、写,或同时处理读写。它的本地磁盘保存可重建缓存;Spokes 优先把同一仓库路由到已缓存数据的节点,权威状态始终由 Blob Storage 和 Ref-CAS 维护。节点失效后,其他节点继续工作,新节点按请求重新填充缓存。

Compaction 与 GC 交给独立 Worker 执行。需要更多读取或写入能力时增加在线节点,需要更多后台整理能力时增加维护 Worker。这里的“计算存储分离”有明确资源含义:扩读不必复制更多耐久仓库副本,后台整理也不应挤占在线请求的关键路径。

权威状态怎样分工

把所有数据放在两个持久化系统,首先要定义清楚“谁负责回答什么问题”。Blob Storage 回答“某个 Pack 和索引是否已经持久化、能否按 Key 读取”;Ref-CAS 回答“这个仓库当前有哪些 Reference、哪些 Pack 已正式纳入可见状态、该状态属于哪个版本”。Virtual Replica 只缓存两边的数据并执行 Git 操作。

可以给一次成功发布写出三个约束:

  1. 引用完整性:已提交的 Reference 必须能解析到持久化的 Git 对象图。
  2. 元数据原子性:一次需要一起发布的 Reference 更新与 Pack Manifest 变化,应在同一个权威事务里生效。
  3. 缓存可恢复性:客户端请求可以落在另一台 Replica 上;新节点同步权威状态、再拉取必要 Pack 后,能够继续处理请求。

这三个约束分别由不同组件共同保证。Blob 先持久化解决对象存在性,Ref-CAS 提交解决引用状态可见性,Replica 的版本检查解决本地缓存跟进权威状态。把这几条链路连接起来,才能理解 Rubicon 为什么既需要 Blob Storage,又需要独立的复制日志服务。

这里的“一致性”还需要准确界定。GitHub 官方明确描述 Reference 更新在 Quorum 后可见,且同一 Reference 已被接受的更新会按顺序出现。关于不同类型读取的完整快照协议、旧缓存的具体保护方式、超时后的客户端恢复实现,文章公开的是边界与请求路径,未给出完整代码。下文凡涉及这些实现级条件,均按通用分布式系统机制推演。

计算扩缩容的真正含义

当某个仓库增加 Virtual Replica,系统新增的是处理索引、连通性验证、Fetch 协商、网络传输及缓存的 CPU、内存和本地磁盘预算。其耐久对象与权威引用状态继续保存在原有两类持久化服务中。新增 Replica 需要从 Blob 和 Ref-CAS 取得当前状态,缓存预热有实际成本;高频热点仓库因此适合保留一定的路由亲和性。

当仓库增加 Compaction Worker,新增的是扫描 Manifest、读取旧 Pack、合并与验证数据的后台处理能力。这个扩容决定和增加 Virtual Replica 可以分别进行。倘若在线延迟持续恶化,而 Compaction 积压没有增长,继续增加后台 Worker 可能收效有限;如果 Pack 数随 Push 持续上涨,单纯增加在线 Replica 又会把更多读流量摊给已经膨胀的 Pack 集合。

这也是“解耦”在容量规划里的实际价值:将问题拆成可观测的独立资源池,各自通过队列长度、CPU、缓存命中率和处理吞吐判断扩容方向。

Ref-CAS:把共识缩到真正需要修改的状态上

既然 Azure Blob Storage 足够持久,为什么 References 还需要另建 Ref-CAS?因为一个仓库里不同引用的写入模式,和“把大文件按 Key 存下来”并不一样。

GitHub 先实验过两种仅依赖 Blob 条件写入的设计。

第一种是整个仓库共用一个 Blob 索引的 Reference WAL。读取很便宜:检查 Blob 是否改变即可。但所有更新都竞争同一个条件写入点,哪怕把等待中的 Push 合批,吞吐也只能达到每秒数百次的量级。批处理减少请求数,无法消除单 Key 的往返时延上限。

第二种把 References 按哈希分散到多个 Segment Blob。不同 Segment 可以并行写,吞吐上限提高;代价是读取者要确认仓库有无变化,就得检查全部 Segment,尾延迟由最慢的 Segment 决定。对于读取远多于写入的 Git 服务,这是把写瓶颈转换成了读放大。

方案 写入特征 读取特征 核心问题
单 Blob 的 Reference WAL 全仓库串行条件写 一次状态检查 单 Key RTT 限制写入
多 Segment Blob 不同 Segment 并行 检查多个 Segment 读扇出与尾延迟
Ref-CAS 按涉及的 Ref 检查并复制提交 一次仓库版本比较,过期时同步状态 需要运维独立的复制日志服务

事务在锁什么,Quorum 又在确认什么

Ref-CAS 每次更新都带预期旧值。事务只锁住它涉及的 References,检查预期值,写入复制日志;当多数日志副本记录了更新,事务才对外可见。不同引用通常无竞争,写入可以并行复制;对同一引用的竞争则通过 CAS 消除 Lost Update。

sequenceDiagram participant X as Agent X participant Y as Agent Y participant R as Ref-CAS Note over R: feature/x=A, feature/y=M par 不同 Ref 的更新 X->>R: CAS(feature/x, A, B) Y->>R: CAS(feature/y, M, N) end R-->>X: feature/x 更新成功 R-->>Y: feature/y 更新成功 Note over R: 之后两者竞争 main=P X->>R: CAS(main, P, Q) R-->>X: 成功 Y->>R: CAS(main, P, T) R-->>Y: 冲突:当前值为 Q

不同 Reference 可以并行推进,每次成功提交仍有确定的日志顺序。Ref-CAS 会为每次成功提交分配 Log Sequence Number(LSN),以最新提交的 LSN 表示仓库版本。它将不同引用可以独立推进与读取者可以拿到统一版本标识结合起来。日志本身依然是需要维持正确性的共识系统,只是操作的数据比完整 Git 仓库小得多。

三个并发实例看清 Ref-CAS 的事务语义

先看两个 Agent 更新不同分支的情形。设 feature/a=A0、feature/b=B0。Agent A 已上传含新提交 A1 的 Pack,Agent B 也准备好了 B1。A 需要的比较条件是 feature/a == A0,B 需要的是 feature/b == B0。由于修改集合没有交集,两笔事务可以并行完成校验和日志复制。最终它们分别获得成功结果,Ref-CAS 仍为成功提交分配 LSN,以便所有 Replica 比较仓库版本。

接着让两位 Agent 同时更新 main=C0。A 希望提交 C1,B 希望提交 C2,二者都以 C0 为旧值。即使两份 Pack 都已经上传,Ref-CAS 也只会接受其中一笔基于 C0 的更新。假设 A 先成功,当前引用变为 C1,B 的 CAS 检查就会发现预期旧值已经过期。B 需要获取 C1,重新判断 C2 与 C1 的关系;这一步可能要求 Rebase、Merge,或由上层业务决定如何处理 Force Push。对象上传可以并行,引用冲突必须在发布点收敛。

还有一种需要整体处理的情况:一个客户端要求同时更新 release 和 stable,两条引用都依赖相同版本切换。

事务 T1:
  expected: release = R0, stable = S0
  desired : release = R1, stable = S1

事务 T2:
  expected: stable = S0
  desired : stable = S2

T1 与 T2 的写集合在 stable 相交,需要协调。T1 只有在两个预期条件都满足时,才有资格按事务语义提交;如果 T2 先改变了 stable,T1 的状态检查应整体失败。Git 协议是否要求多个 Ref 一起提交还受客户端请求及服务端能力影响,比如 git push --atomic 的语义;Ref-CAS 支持事务涉及多个 Ref,并不自动表示所有多分支 Push 都以原子模式执行。

一个便于教学的抽象执行过程是:

收到:updates=[(refA, oldA, newA), (refB, oldB, newB)]
    ↓
识别本事务的 Ref 写集合
    ↓
只对这些 Ref 建立互斥/串行化关系
    ↓
验证每个 Ref 的当前值符合 expected
    ↓
提交复制日志,并等待 Quorum
    ↓
分配成功提交的 LSN,更新权威可见状态
    ↓
返回成功或冲突

官方披露了按涉及 Ref 加锁、检查预期旧值、复制到 Quorum、分配 LSN 的语义。内部锁如何排序、日志如何批处理、事务如何从失败节点恢复等属于实现细节。推演这些实现时,首先要验证四个条件:相同 Ref 的顺序、不同 Ref 的独立性、事务原子性,以及宕机后的结果判定。

LSN 的“全局顺序”和细粒度锁如何共存

这一点容易让人产生疑问:既然每次提交都有 LSN,是否意味着所有写入仍然在某个地方串行?确实存在一个统一的已提交顺序,但系统可以把昂贵操作和一部分等待时间放在提交顺序之外。

举例,T1 和 T2 分别更新不同 Ref。它们可以同时准备 Pack、同时索引、同时检查各自的预期状态,并发进行日志复制;成功提交会获得可比较的 LSN,例如 901 和 902。LSN 为读取提供顺序标识,细粒度冲突控制允许无关事务在提交之前并行工作。哪一步分配号码、日志是否合批以及如何避免共识服务本身成为瓶颈,需要依据具体实现判断;官方提供了每秒数万次引用状态事务的内部结果,说明其元数据通道在测试中具备相应处理能力。

把日志顺序、Ref 序列化和客户端 Git 语义分开看会更清楚:

层次 需要保持的属性 对应问题
Git Reference 同一 Ref 的更新有一致顺序 两个 Agent 是否覆盖同一分支
Ref-CAS 事务 涉及的 Ref 一起检查和提交 跨 Ref 的事务是否出现半更新
复制日志与 LSN 已提交状态可排序、可识别版本 读取 Replica 如何判断自己落后
Git 协议与分支规则 Fast-forward、保护规则及冲突反馈 一个条件更新是否允许被业务接受

CAS 成功只代表底层并发前提满足;分支保护、权限、提交验证等规则仍由上层 Git 服务保证。原有 Git 操作对用户的语义需要延续,这也是 GitHub 设计时强调兼容开发者工作流的原因。

读多写少时,版本检查比增量分片更省

假设有 64 个 Segment Blob,每次写入按 Ref 哈希分散。一次“仓库是否变化”的检查若需要查询 64 个 Segment,就会产生较多网络请求;即使并行访问,总耗时也会被尾部响应拉长。假设大多数读取只想知道“默认分支从上一次读完后有没有动”,多 Segment 设计把一次极轻量的元数据检查扩展成了多路检查。

Ref-CAS 使用一个最新 LSN 回答“是否变化”。一台 Replica 持有 812,就带着 812 查询;如果最新 LSN 仍为 812,响应可以不包含任何 Reference;若仓库状态前进了,则获取新状态和数据指针。这样对没有变化的读取形成短路径。对于仍然变化的高频仓库,LSN 也帮助 Replica 决定哪些数据需要进一步加载。

LSN 代表可见版本,不等于任意数据都已经进入本地缓存;真实 Fetch 仍要进行对象查询和协商。这个区分十分重要,因为版本检查优化的是元数据网络成本,而 Pack/MIDX/Bitmap 和缓存优化的是随后访问对象的成本。

为什么 LSN 对读取尤其重要

假设一个 Virtual Replica 缓存的仓库版本是 LSN 812:

Replica 持有:version=812
Ref-CAS 当前:version=812
响应:状态未变,无需回传 References

它只需要一次版本比较,就知道引用状态仍然有效。如果 Ref-CAS 已经前进到 LSN 819,响应会携带当前 References、版本、Pack 数据指针以及相关维护信息(例如上次 GC 时点),随后 Replica 更新本地状态,并根据本次读取需求取回缺失数据。

因此 LSN 解决的是状态是否过期这一问题。真实 Fetch 仍需进行对象查找、协商、打包或复用缓存响应;LSN 优化的是引用状态同步。LSN 快速排除了大量“仓库根本没变”的重复元数据同步。

GitHub 报告 Ref-CAS 可达到每秒数万次引用状态事务。这个指标衡量小型元数据事务。完整 Push 还包含传输、检查、对象存储和客户端交互等成本。

用 Go 风格接口表达一致性边界

下面用一段 Go 风格教学伪代码表达发布边界。接口与函数均为便于说明而设计的抽象:

type RefUpdate struct {
    Name string
    Old  ObjectID
    New  ObjectID
}

type PublishTxn struct {
    Updates  []RefUpdate
    AddPacks []PackID
}

func Push(ctx context.Context, pack Pack, txn PublishTxn) error {
    // 准备阶段:索引、连通性等检查在提交前完成。
    if err := validatePack(ctx, pack); err != nil {
        return err
    }
    if err := blobStore.PutImmutable(ctx, pack); err != nil {
        return err
    }

    // 关键发布点:Ref 与 Pack Manifest 在一个元数据事务中提交。
    // Ref-CAS 检查每个 Old,成功复制提交后才发布 New。
    return refCAS.Commit(ctx, txn)
}

这里的边界有两层。第一,准备大对象可以在不同写入节点并行,昂贵的对象工作可以在元数据提交协调之外并行推进。第二,引用更新与“新 Pack 属于仓库可见集合”通过一个元数据事务发布,避免读取者看到已经指向新 Commit、却尚找不到对应 Pack 的状态。

如果一个事务涉及多个 Ref,概念上应把这些 Ref 作为同一条件更新集合处理;实现还要解决锁顺序、故障恢复和日志可见性。GitHub 文章尚未逐项展开这些内部细节。工程上可以通过排序加锁等并发控制策略实现,具体实现应以官方后续披露为准。

Pack Manifest:跨存储系统的原子发布边界

一次 Push 同时触及两个持久化系统:Blob Storage 保存 Pack 数据,Ref-CAS 保存仓库的逻辑可见状态。它们之间的先后次序,决定了宕机时是否会出现“分支指向了缺失对象”。

可以把提交拆成三个可观察阶段:

flowchart LR A["S0:Pack 尚未持久化<br/>旧 Ref / 旧 Manifest"] --> B["S1:Pack 已上传<br/>旧 Ref / 旧 Manifest"] B --> C["S2:Ref-CAS 提交成功<br/>新 Ref / 新 Manifest"] C --> D["后续:Compaction / GC"]

在 S0,远端分支仍指向旧提交。S1 已经有了新 Pack,但由于 Manifest 仍是旧版本,普通读取者继续按旧状态工作。提交进入 S2 时,Ref-CAS 原子地更新必要 Reference 和 Pack 清单;新 Commit 及其可达对象才作为该版本的一部分正式发布。

用一组示例状态表示:

已提交状态(LSN=200)
  refs/main      = A
  pack_manifest  = {P1, P2}

上传对象 P3 之后:
  Blob Storage   = {P1, P2, P3}
  Ref-CAS        = (LSN=200, main=A, manifest={P1,P2})

提交 CAS 成功(LSN=201)之后:
  refs/main      = B
  pack_manifest  = {P1, P2, P3}

S1 阶段可能留下未发布 Pack,属于写入协议的自然中间状态。Blob 中存在某个对象,并不能自动赋予它被当前仓库引用的资格。Ref-CAS 的提交才是版本发布的关键节点。后续清理这些未发布 Pack 需要自己的安全条件,避免误删另一个仍在准备的请求所需内容。

原子性的范围应当怎样画

可以把 Manifest 看成一张权威“已发布文件集合”表;Pack 本身是不可变文件。完整的元数据事务把两种逻辑变化关联起来:

Precondition:
  refs/main == oldOID

Commit:
  refs/main := newOID
  manifest  := manifest ∪ {newPack}
  version   := nextLSN

这个抽象解释了 GitHub 为什么强调“一笔 Ref-CAS 事务同时列出 Pack 并更新引用”。对象已经持久化,引用与对象索引清单在同一个提交点切换,对外可见状态就形成完整版本。

跨系统两阶段提交通常要求多方进入协调协议并保留待决状态。这里的大对象具有不可变、可先写入、允许暂时未引用的特性,因此可以把大部分 I/O 移出一致性事务,只把小规模元数据的可见性决策交给 Ref-CAS。这是 Rubicon 设计中很有复用价值的模式。它把跨系统的重试和清理复杂度留给后台维护,换取在线写入阶段较短的协调路径。

并发 Compaction 如何与新 Push 共存

假设后台 Worker 从 Manifest 看到 {P1,P2},正准备生成合并后的 P12。与此同时,Agent 上传新 Pack P3 并提交其引用。最终状态必须同时保留新写入和整理结果。安全的抽象事务可以表现为:

M0 = {P1, P2}
Push: Add(P3)           -> M1 = {P1, P2, P3}
Compaction: Add(P12)    -> M2 = {P1, P2, P3, P12}
Compaction: Remove(P1,P2) -> M3 = {P3, P12}

如果 Manifest 更新采用整份状态替换,必须使用版本比较并在冲突后重算,才能避免 Compaction 覆盖 Push 新增的 P3。如果采用增量集合事务,也需要保证对同一 Pack 的增删满足一致性约束。GitHub 公布了 Ref-CAS 管理 Pack Manifest 及 Compaction 的两笔发布事务,具体内部数据结构和冲突协议尚未展开;上述序列是描述应有结果的教学模型。

这也揭示了 Manifest 和缓存之间的关系:旧 Replica 可能仍握有旧版本 Pack 指针,新 Replica 可能已经收到 P12。真实物理删除需要等待安全回收条件,给滞后读取者留出完成请求或重新同步的空间。Manifest 的原子发布解决新状态完整性,历史读者与清理之间的生命周期协调则是另一个需要守护的边界。

一次 Push:先持久化对象,再发布引用

Rubicon 的写入路径可以分成“数据准备”和“元数据提交”两个阶段。

sequenceDiagram participant C as Git Client participant S as Spokes participant V as Virtual Replica participant B as Azure Blob participant R as Ref-CAS participant W as Compaction Worker C->>S: Push 更新 feature/x S->>V: 选择 Replica V->>V: 索引 Pack、验证对象连通性等 V->>B: 上传 Pack / Index B-->>V: 对象持久化完成 V->>R: CAS 更新 Ref + 添加 Pack Manifest R->>R: 校验预期旧值并复制到 Quorum alt 提交成功 R-->>V: 已提交,返回新 LSN V-->>C: Push 成功 else 同一 Ref 发生冲突 R-->>V: CAS 冲突 V-->>C: Push 拒绝 / 按协议报告冲突 end W->>B: 后台读取并合并 Pack W->>R: 后台更新 Manifest

先上传 Pack、后提交引用,是写入正确性的承重墙。若先让 main -> B 生效,再去写入 B 的对象,一旦对象写入失败,其他客户端可能读到指向缺失对象的 Reference。先让对象耐久、再发布元数据,可以让可见引用只指向已经持久化的数据。

它也让跨服务写入不必变成一笔庞大的分布式事务。Blob Storage 负责不可变数据持久化;Ref-CAS 负责可见性切换。原子性被收缩到了小元数据事务上。代价是系统要能处理“数据已经准备好,但还没被引用”的中间状态。

例如,Pack 上传成功后 CAS 冲突,新 Pack 可能暂时没有被 Manifest 引用。这份 Pack 暂时处于未发布状态,后台清理可以按生命周期和引用状态回收。这一回收思路是根据发布顺序的工程推演;GitHub 尚未公开该路径具体采用的孤儿 Pack 回收协议。

还要分清两种失败:CAS 冲突意味着有人已经更新了目标 Reference,客户端需要重新检查分支关系;网络超时则可能发生在 Ref-CAS 已提交但响应尚未到达之后。客户端应先查询最新引用,再利用条件更新与 Git 协议语义处理结果不确定的请求,确保重试不会覆盖已经成功的更新。

Push 的完整请求阶段与反馈分类

从用户的 git push 命令看,服务端接收的是一次连续操作;从内部处理看,至少可分为远端状态发现、对象传输、权限/规则检查、对象连通性验证、持久化准备和引用提交几个逻辑阶段。分支删除等只更新 Ref 的操作可能没有新的对象 Pack,下面的典型流程主要针对包含新对象的 Push。

首先,客户端根据远端引用协商要发送的对象及引用更新。服务端验证更新是否符合仓库规则,并完成对象解析和连通性检查。Virtual Replica 把需要持久化的 Pack 与索引送到 Blob Storage。直到对象准备完成,才对 Ref-CAS 发起包含预期旧值的元数据事务。这一顺序在成功路径下保证其他读取者访问新提交时有对应的对象数据。

失败反馈建议按写入是否越过发布点来分类:

阶段 典型结果 客户端或服务端的下一步
协议与权限校验 拒绝更新 返回规则或权限原因,修正请求
对象接收与校验 Pack 不完整、对象图缺失 丢弃这次提交准备,按协议重新推送
对象存储准备 Blob 上传失败或超时 先确认对象持久化状态,再安全重试
Ref-CAS 条件检查 同一 Ref 已发生更新 读取最新 Ref,处理冲突后再提交
Ref-CAS 提交响应丢失 结果可能已提交 查询远端引用与版本,再确定恢复动作
发布成功 获得新的已提交状态 客户端继续后续开发或触发 CI

这里的阶段划分是按 Git 协议和 Rubicon 公开架构做出的教学分解;每类错误实际通过哪个内部接口返回,GitHub 尚未公开。关键是让故障诊断沿着状态边界走:用户看到的“Push 失败”可能发生在几种完全不同的资源上,盲目重试会把冲突当作瞬态故障,还可能继续制造未发布 Pack。

两个很容易写错的恢复场景

场景一:上传成功,引用提交失败。 Agent A 成功上传 P3;此时另一位 Agent 已经把 main 从 A 推到 C。A 发起 CAS(main, A, B) 时发现旧值变成 C,于是引用更新被拒绝。服务端需要报告 Ref 冲突。P3 的物理存在不改变 main 状态,之后可以按安全清理策略回收。A 重新同步仓库、组织新的提交,再尝试推送。

场景二:服务端提交成功,回包丢失。 Agent A 的 CAS(main,A,B) 已复制提交至多数 Ref-CAS 副本,LSN 前进;但连接在返回成功前中断。客户端看到网络错误,正确恢复动作是先读取 main。如果它已经指向 B,说明期望的更新至少已经成为过远端可见状态;若后续又推进到别的提交,还需要根据 Git 历史和业务规则判断。客户端应依据远端事实恢复,避免把“没有收到成功回包”误解为“远端一定没提交”。

这类结果不确定性在消息队列、订单创建、Artifact 发布等场景里也很常见。可靠性来自幂等标识、条件更新、状态查询和明确的发布点。单纯增加自动重试次数,会把原来的边界问题放大。

一次 Fetch:缓存亲和性只负责性能,版本检查负责正确性

读取请求也经 Spokes 路由到 Virtual Replica。Spokes 尽量选择已有该仓库缓存的节点,以减轻从 Azure Blob Storage 重新读取 Pack 和索引的成本。

Replica 先与 Ref-CAS 比较引用状态版本。版本一致时省去重复引用同步;版本落后时获得新 References、Manifest 相关指针和维护元数据。随后根据读取对象的实际需要,从本地缓存或 Blob Storage 获取 Pack 和索引,再向客户端提供 Git 读取结果。Multi-Pack Index、Reachability Bitmap 和热门 Fetch 响应缓存等数据,都用于减少额外读取与计算。

Git Fetch / API Read
    -> Spokes 选择 Virtual Replica
    -> Ref-CAS 比较仓库版本 LSN
         -> 未变化:保留本地引用状态
         -> 已变化:同步当前引用和 Pack 位置
    -> 本地缓存命中?否则从 Blob Storage 取所需数据
    -> 处理请求并返回

这个设计把正确性与缓存绑定方式拆开:同仓库的路由亲和性使常用数据留在同一节点,但节点可以随时被销毁。即便一次请求被路由到冷节点,只要它从权威状态同步并加载必要对象,仍然可以正确处理。冷启动、缓存命中率和远端对象读取延迟主要影响性能;权威数据仍由独立持久化服务维护。

LSN 标识仓库状态版本。读取方仍需确保引用版本、Manifest 与实际取用的 Pack 属于一致的视图。具体缓存失效与读事务如何组织,属于官方文章没有全面展开的实现细节。

热缓存、冷缓存和版本同步是三笔不同的成本

设同一仓库有 Replica A、B、C。A 已缓存热门分支的 Pack 和 MIDX,B 刚被加入到服务池,C 则短暂离线后恢复。三台机器对相同 Fetch 的处理成本不同:

  • A 的 Reference 版本已最新,所需 Pack 也在本地:一次轻量版本检查之后主要消费本地 CPU、磁盘与网络输出。
  • B 的 Reference 版本已最新,但所需 Pack 尚未缓存:仍需从 Blob Storage 拉取必要的索引与 Pack,延迟受远端读影响。
  • C 持有旧 LSN:先通过 Ref-CAS 同步引用状态和数据指针,再决定所需对象哪些已经本地可用。

可以把 Fetch 的服务时间按诊断视角拆成:T_fetch ≈ T_version + T_lookup + T_remote_miss + T_pack_compute + T_network_out。这些变量并非 GitHub 公布的计时指标,只是用于解释瓶颈归因的近似模型。对常被重复读取的分支,优化 T_version 和远端缺失率十分重要;对大历史仓库,Pack Index、Bitmap、对象计算和输出字节可能占主要成本。

版本检查之后还需要一致的读取视图

设 Replica 在 LSN 301 时获取 main=B 和 Manifest {P1,P2,P3}。随后后台 Compaction 将 P1、P2 合并成 P12,并把最新 Manifest 改成 {P3,P12}。Replica 可能已经计划读取 P1;若物理清理过早发生,使用旧指针的请求就会失败。安全的读取协议必须允许旧引用继续短暂使用,或者在对象消失时明确重新同步并重试。

这里可以借用 MVCC 的版本视图思路理解:元数据版本定义一次读取看到的对象集合,后台整理先发布新的集合,再等安全窗口结束后清理旧集合。GitHub 官方披露了 LSN 校验、Pack 数据指针和延迟删除的事实。它采用哪一种旧读保护或重试协议尚未完全公开,文章因此只讨论这些必须得到满足的状态关系。

读写比决定缓存投入的回报

当读写比例达到 100:1 甚至 1000:1 时,一次 Push 造成的缓存失效和后续同步可能影响大量 Fetch。此时把对象存储做得足够快仍然重要,但更值得关注的是有多少读请求可以停留在 Replica 的本地缓存中,以及“仓库没有变化”能否通过一个轻量版本比较快速确认。

这也是从单 Blob WAL 走向 Ref-CAS 的隐含目标。写入方案需要达到数千次每秒,同时要把读取中最常见的无变化检查保持得很轻。仅追求元数据写入并行度、让每次 Fetch 检查几十个状态分片,会让巨大的读取基数抵消写入收益。

Compaction:写入并行后,后台整理必须跟得上

包含新对象的 Push 通常会产生新的 Pack。Pack Index 可以在单个 Pack 中定位对象,Multi-Pack Index 可以跨多个 Pack 加速查找;但 Pack 数量无界增长,仍会带来索引维护、查找和空间整理成本。旧 Spokes 在在线副本上反复执行整理;Rubicon 则把 Compaction 全部移到独立 Worker。

GitHub 公布的整理流程很具体:

  1. Worker 在 Blob Storage 中为某个 Pack Shard 获取 Lease,取得这一片数据的整理职责;其他 Shard 可以由其他 Worker 并行处理。
  2. Worker 读取当前 Pack Manifest,把新 Pack 收集到 Open Segment。在线 Replica 同时继续接收写入。
  3. 当 Segment 的 Pack 数、体量或年龄达到阈值,Segment 被 Seal,不再加入新 Pack;下一段可以继续接收输入。
  4. Worker 把封存段的多个 Pack 合并为更少、更大的 Pack,发布前验证新 Pack 的对象集合与输入集合完全一致。
  5. 新 Pack 先写入 Blob Storage,再通过 Ref-CAS 添加进 Manifest。
  6. 第二笔事务从 Manifest 移除旧 Pack,实际旧文件在之后清理。
  7. 未达到大小上限的合并结果会按自身哈希重新分配 Shard,继续进入下一层整理。
flowchart TB A["Pack 1 / Pack 2 / Pack 3"] --> O["Shard 的 Open Segment"] O --> S["达到大小/数量/年龄阈值<br/>Seal"] S --> M["合并 + 对象集合校验"] M --> U["上传新 Pack"] U --> P["Ref-CAS 事务 1<br/>Manifest 添加新 Pack"] P --> D["Ref-CAS 事务 2<br/>Manifest 移除输入 Pack"] D --> G["延迟清理旧文件"] P --> R["在线 Replica 可看到新 Pack"]

为什么要两次 Manifest 事务?因为先发布新数据,再撤下旧数据能避免“旧位置已失效、新位置尚未生效”的空窗。新旧 Pack 在切换期间短暂并存,为故障恢复保留了余地。

这个模式和 LSM Tree / RocksDB 的不可变文件整理有相似之处:在线路径偏向追加写,后台合并文件、发布元数据、延迟删除旧文件。Git Pack 的对象引用与连通性模型又有别于 LSM 的 Key 版本和 Tombstone 语义。因此可以借鉴不可变文件生命周期的思路,具体整理规则仍需围绕 Git 对象图设计。

用 Manifest 状态推演一次 Compaction

用一个简单例子检查两次事务为什么必要。开始时,Shard 中有三个 Pack:

Manifest M0 = {P1, P2, P3}
Open Segment = [P1, P2, P3]

Worker 取得 Shard 的 Lease,封存 Segment,读取对象并生成 P123。假设 P123 精确包含输入的全部逻辑 Git 对象,合并验证通过。现在先上传 P123;读取者仍然按 M0 查找对象。下一步,Worker 提交第一笔 Manifest 事务,加入新 Pack:

Manifest M1 = {P1, P2, P3, P123}

此时旧 Pack 和新 Pack 共存。即使 Worker 立刻宕机,旧读取路径仍然可用,下一次 Worker 恢复后可以继续确认并移除输入 Pack。第二笔 Manifest 事务之后:

Manifest M2 = {P123}
Blob 中暂时仍有 {P1, P2, P3, P123}

新读取者从 M2 进入 P123,旧读取者可能仍在访问 P1。最终物理删除发生在安全清理之后。这几个阶段展示了用暂时的冗余数据换取切换过程中持续可读的思路。

合并前验证“对象集合完全一致”也有明确含义:如果对象 A、B、C 在输入 Pack 中,新 Pack 必须让它们仍然能够被解析。允许重新编码、Delta 重新选择或物理排序变化,前提是逻辑 Object ID 对应的数据得到保留。这种校验把物理整理和逻辑 Git 对象正确性解耦。

Shard、Lease 与分层合并各自解决什么

Shard 将 Pack 按哈希分散,减少后台 Worker 对同一工作集合的竞争。Lease 给一个 Worker 有期限的整理所有权,帮助多节点协调任务执行;Seal 则切断“这次要合并的数据集合”,让 Worker 处理稳定输入。三种机制分别解决了分工、所有权和输入边界。

Compaction 产生的 Pack 还可以按新哈希重新进入某个 Shard,继续参与下一轮合并。这相当于以多级整理平衡写入频率与最终 Pack 大小。小 Pack 尽快被合并,较大 Pack 的整理频率降低;当输入速度和整理能力达到平衡时,Pack 数可以趋于稳定。官方披露的目标与测试观察就是写入增多时维护读写延迟曲线保持平坦。

如果某个 Worker Lease 过期,另一个 Worker 取得同一 Shard,系统还要处理旧 Worker 在网络恢复后继续提交的风险。通用实现可以给 Lease 附带任期号,在发布 Manifest 时验证最新所有权,也可以采用等效的条件事务。GitHub 文章公布了 Blob Lease 的工作方式,但具体失效 Worker 如何被栅栏化,仍有待源码或后续工程文章说明。审查同类系统时,应把这一点列为一致性测试重点。

Compaction 的空间成本与读写放大

独立后台 Worker 把大量 CPU 与 I/O 移出了在线路径,却同时引入了网络与 Blob 写入成本。若每次合并读取 K 字节旧 Pack,再写出近似 K 字节新 Pack,后台至少需要对应数量的读取和写入流量;分多级合并还会进一步增加写放大。切换窗口中,新旧 Pack 并存也会产生暂时空间放大。

因此 Compaction 阈值必须在几项成本之间平衡:尽早合并能减少读取需要触及的 Pack 数,延后合并能减少频繁重写大 Pack 的开销。Shard 规模、Segment 年龄阈值、Pack 上限和 Worker 并发,最终要由实际热度、Pack 大小分布与对象存储成本共同决定。官方尚未给出这些阈值的具体值,本文只总结影响它们的工程因素。

Compaction 与 GC 的职责分界

Compaction 合并 Pack 但保留输入中的所有对象。GC(垃圾回收)才分析哪些对象已不再从有效引用可达,例如分支删除或 Force Push 后留下的历史对象,并移除那些可以安全清理的内容。Rubicon 的 GC 也在独立 Worker 中运行,GitHub 明确提及它有额外的安全删除行为,但尚未公布完整协议。

这个差异关系到故障恢复。假设某个读节点仍拿着旧 Manifest,而维护 Worker 已经把对应旧 Pack 从最新 Manifest 移除,后台若立即删除物理对象,滞后读就可能失败。因此工程上需要旧读者保护、版本栅栏、保留时间或等价机制来确定何时真正可删。GitHub 公开的事实是“先从 Manifest 移除,稍后删除”,具体采用何种安全机制仍属于其内部设计。

同样,Lease 降低了多个 Worker 重复整理同一 Shard 的概率,但分布式 Lease 的过期、续租和失效 Worker 继续提交是必须处理的边界。通用工程设计通常引入 Fencing Token 或在发布元数据时再次检查所有权;这些策略属于通用工程设计;GitHub 的具体实现尚未披露。

GC 需要额外保护并发中的对象

一个对象是否可回收,由它是否从仍需保留的 Reference 或其他保护根集合可达来决定。例如 main 原先从 Commit C10 连到 C9、C8;Force Push 后它转而指向 D10。如果再没有任何其他有效引用或保留策略保护旧链,C10 等对象可能成为 GC 候选。

并发 Push 让这一判断更复杂。Agent A 正在上传将来要成为 feature/x 的 Commit X,GC 在这个时刻扫描已提交 References,还看不到 X。若它只依据当前引用集合就立即删除“所有尚不可达对象”,可能误伤正在准备的 Push。另一种情况是 GC 扫描开始后,某个旧引用被新事务重新指向历史对象;如果删除依据的版本已经过期,也会产生风险。

一个健壮的 GC 设计至少需要说明三个边界:根集合在哪个版本读取、正在准备或刚发布的对象如何保护、何时允许物理删除。常见技术包括保留窗口、在途事务保护、版本栅栏、重新扫描、可达性证明,以及读者生命周期管理。它们可以组合使用;GitHub 官方只说明 Rubicon 的 GC 由专用 Worker 执行,并有额外的安全删除机制,具体组合仍由内部实现决定。

可以把 GC 的安全目标写成一句可测试的约束:任何从当前有效根集合可达,或仍处在合法发布/读取保护窗口内的对象,都必须保持可读取。然后围绕此约束测试“GC 扫描期间发生 Push、Delete Ref、Force Push、Compaction 切换、Worker 重启”等交错事件。真正困难的是这些事件的先后关系,清理对象本身只是最终动作。

故障与竞争:沿发布边界推导系统行为

比组件名更有价值的是检查每个故障究竟会破坏什么。以下表格把官方已经描述的状态边界和进一步的工程推导放在一起。

发生的位置 应有的外部表现 原因与后续处理
Pack 上传失败 Ref 尚未发布新值 对象未耐久,不能进入可见引用状态
Pack 上传成功、Ref-CAS CAS 冲突 更新被拒绝,已有 Ref 不受影响 Prepared Pack 可能留下待回收数据;清理策略属工程推导
Ref-CAS 未形成可提交的多数派 新引用更新暂停 写入必须等待一致性服务恢复多数派
Ref-CAS 已提交、客户端超时 客户端需要重新确认实际 Ref 请求结果可能成功,属于典型的不确定响应
Virtual Replica 宕机 其他节点继续服务,新节点重建缓存 持久数据不依赖单个计算节点
Compaction 已添加新 Pack,尚未移除旧 Pack 新旧 Pack 暂时并存 以额外空间换取安全的元数据切换
Compaction 已移除旧 Pack,清理未完成 最新 Manifest 已转向新 Pack 物理删除还需考虑滞后读者和 GC 安全窗口

同一热门 Reference 仍是无法凭空消除的热点。Rubicon 让不同分支并行,但如果 5000 个 Agent 都试图更新 refs/heads/main,冲突还会发生。Merge Queue、Trunk-based Development 和发布分支的写入集中,仍需要通过上层工作流处理。Per-Reference Serialization 提高了无冲突写入的并行度;同一热点引用仍需上层工作流治理。

从运维角度,排查路径也随之变化:Push 慢时先区分 Pack 索引/验证、Blob 上传、Ref-CAS 提交排队与同 Ref 冲突;Fetch 慢时区分 LSN 同步、缓存未命中、索引查找和对象下载;尾延迟升高时再观察后台 Compaction 是否落后、Pack 数是否持续增加。把这些阶段分别计量,才能判断究竟该增加 Virtual Replica、优化缓存,还是增加 Compaction Worker。

性能结果:不同数字对应不同阶段

Rubicon 公布的性能数字分别对应不同的测试阶段和工作负载:

来源与阶段 披露数据 对应含义
2026-10-06 架构方向文章 内部测试写入吞吐最高提升 35 倍 新架构与旧基线的测试对比,非通用 SLA
2026-10-21 迁移过渡阶段 不同 Ref 写入吞吐约 3~4 倍 从仓库级串行转向按 Ref 串行的改进
2026-11-05 Ref-CAS 每秒数万次引用状态事务 小型元数据状态更新能力
2026-11-05 Rubicon 5000 个并发 Agent、7200 Push/s 单仓库读写混合负载下的内部测试结果
2026-11-05 维护路径 写入增长时保持相对平坦的延迟曲线 Compaction 与在线请求分离后的观察

这些结果来自 GitHub 的三篇工程文章。由于完整压测配置尚未公开,旧架构精确 Push/s 仍需要单独的基线数据;Ref-CAS 事务吞吐与全链路 Push 吞吐也应分别阅读。

还有一个部署范围事实:2026 年 11 月的 Rubicon 处于有限私测。Partial Replicas、跨区域 Global Replicas、Scale to Zero、按对象粒度读取、Materialized Views,以及更适合 Agent 的 Atomic Commit API、事件流等,属于官方提出的下一步方向,具体上线范围以后续发布为准。

性能验证与可观测性:怎样知道瓶颈真的被移走了

GitHub 公布了 5000 个 Agent、每秒 7200 次 Push 的内部结果,也报告 Ref-CAS 能处理每秒数万次元数据事务。但把一项架构用到生产,除了最高吞吐,还要同时评估负载形状、延迟分布与后台积压。单独把一个数字拉高,可能只是把瓶颈推迟到另一个资源池。

三组负载才足以检验锁粒度改造

第一组是独立分支高并发。例如 1000 个客户端分别更新 1000 个 feature/* 引用,提交包体控制在相近范围。这一组测试 Ref-CAS 是否真正降低了无关请求互相等待的程度,也检验 Blob Key 写入分散度。

第二组是单个热点引用。让客户端串行依赖前一次远端状态,或者并发争用 main,记录 CAS 冲突、重新读取和重试的耗时。若大量失败请求反复重试,业务完成吞吐可能远小于系统收到的事务请求数。这一组反映热点 Key 的真实性能,而非无冲突路径的最好数字。

第三组是读多写少的混合场景。持续 Push 的同时,用 100:1 或 1000:1 的读写比执行 Fetch、引用检查、历史遍历等请求,并让后台 Compaction 保持运行。观察读取 p95/p99 是否稳定,冷热 Replica 切换后是否出现远端下载风暴,以及 Pack 数随时间是否稳定。

三组测试还需要固定仓库历史深度、Pack 大小分布、新对象与复用对象比例、分支数、Region 距离、客户端连接数和测试时长。这样才能把“写入吞吐提高”与“工作负载更轻”区分开来。2026 年 GitHub 公布的是内部结果,缺少完整可复现压测配置;文章里的 35 倍、7200 Push/s、Ref-CAS 数万次事务应按原文各自对应的测试条件阅读。

在线路径至少需要拆出五段延迟

对一个典型 Push,可以将总耗时近似分解为:

T_push =
  T_protocol
+ T_index_and_connectivity
+ T_blob_upload
+ T_refcas_commit
+ T_response

这里的分段用于排障,不代表 GitHub 官方监控字段。若 T_index_and_connectivity 随 Pack 大小增长,扩展在线计算节点或优化对象处理更有用;若 T_blob_upload 占主导,先查看 Blob 读写延迟、带宽和 Key 分散度;若 T_refcas_commit 的 p99 抬升,则需要区分同 Ref 热点、复制日志延迟与 Quorum 成员异常。

对读取要额外看 Ref 版本检查命中率、Pack/MIDX 缓存命中率、远端 Blob 读取次数、Fetch 输出字节与对象遍历 CPU。维护侧则看未整理 Pack 数、Segment 年龄、每个 Shard 的待处理数据量、Compaction 输入/输出字节、Lease 争用、失败重试与垃圾回收滞后时间。这些指标可以形成一张“在线读写—元数据提交—后台整理”的性能闭环。

一个很实用的报警信号是:Push/s 稳定,但 Pack Manifest 的 Pack 数持续增长。它说明写入速度暂时还能接受,后台整理已经开始落后,未来 Fetch 查询代价可能逐渐上升。另一个信号是 Ref-CAS p99 正常,但 Push p99 明显恶化,应把视线转到对象计算、Blob 和客户端网络路径。

可用性要按故障域验证

故障注入也应覆盖独立资源池:杀掉一台 Virtual Replica,验证 Spokes 能否路由到其他节点并重建缓存;暂时降低 Blob 可用性,验证对象准备失败是否阻止引用发布;让 Ref-CAS 失去多数派,验证系统是否暂停新提交;中断 Compaction Worker,验证旧 Pack 仍然可读、任务可恢复;让 GC 与分支更新交错,验证可达对象安全。

这一组测试的价值在于将吞吐正确性和故障正确性放在同一张设计图里。高峰吞吐代表能力上限,连续运行时的队列积压、尾延迟和恢复行为才能说明架构长期是否稳定。

工程启示:设计的关键是缩小协调面

Rubicon 对后端工程最有价值的启发,是按数据性质划分状态和协调成本。

先按数据可变性拆分。 如果数据创建后不可变,优先考虑内容寻址、并行准备、缓存和可重建索引;如果状态会被并发修改,再确定需要保护的是全局实体、单个 Key,还是跨多个 Key 的事务。很多系统沿用了早期实现中的全局锁;重新检查业务一致性边界后,锁粒度往往还有缩小空间。

把持久化和发布分成两步。 先写好大量不可变数据,再使用一个小型、可复制的元数据事务决定何时可见。对象存储中的新文件可以早于业务版本出现,但业务版本不能指向还没准备好的对象。这个模式适用于制品仓库、快照系统、数据集版本管理和大量任务产物的发布。

让在线工作与维护工作分离。 Compaction、GC、索引重建常常消耗大块 CPU、内存和 I/O。放在独立 Worker 后可以各自配置并发和资源上限,但要付出 Manifest 版本管理、Lease、重试、清理和延迟读者保护等复杂度。对于规模很小的系统,额外服务不一定值得;有持续热点仓库、大量不可变对象和明显读写维护竞争时,这种拆分才开始产生净收益。

把缓存定位为可重建的加速层。 Virtual Replica 可以重建,关键在于读取请求总能找到权威元数据和持久对象。这个原则也适用于 Go Agent Runtime:执行 Worker 可以重启,而 Task 状态、Artifact 清单和可恢复的 Commit 点应存放在独立权威存储中;恢复协议应以持久化状态和可重放的事件为依据。

这类架构同时带来新的工程代价。引入独立共识日志会增加运维与故障模式;细粒度锁需要更严格的跨 Key 事务语义;后台整理虽然脱离在线路径,却引入保留期与可见性问题。只有先用实际数据确认瓶颈来自不必要的协调和资源耦合,这类架构重构才有充分依据。

最终,Rubicon 把原先“全仓库副本参与写入”的巨大协调域,压缩成“不可变对象并行准备、Reference 条件更新、Pack Manifest 原子发布”的小型一致性边界。在这一边界之外,读取、写入处理与维护工作才能真正独立扩展。它既是 GitHub 面向 Agent 的基础设施设计,也是一堂很好的分布式系统状态建模课。

把发布边界迁移到 Artifact 与 Agent Runtime

可以把 Rubicon 的做法迁到一个具体的 Go Agent 后端中。假设一项 Agent Task 会产生 100 个文件、一次工具 Trace 和一个最终报告。Worker A 负责生成,Worker B 负责复核,最终需要把 task.current_artifact 指向这次执行的产物集合。

一种自然的拆分方式是:文件字节写入 Object Storage;数据库中的 Artifact Manifest 保存对象 Key、Hash 和版本;Task 表里只保存当前已发布的 Manifest ID 和状态版本。Worker 可以在准备阶段并行上传文件,最后用一笔条件更新发布最终结果。

// 教学接口,展示对象准备与版本发布的分界。
type ArtifactManifest struct {
    ID      string
    Objects []string
}

type PublishRequest struct {
    TaskID          string
    ExpectedVersion int64
    NextManifestID  string
}

func Publish(ctx context.Context, m ArtifactManifest, req PublishRequest) error {
    if err := store.PutObjects(ctx, m.Objects); err != nil {
        return err
    }
    if err := store.PutManifest(ctx, m); err != nil {
        return err
    }
    return taskState.CompareAndPublish(ctx, req)
}

在这个模型里,文件上传成功但任务版本冲突,会留下尚未发布的产物;通过生命周期规则可清理。提交成功但 Worker 断线,可以查询任务当前版本和 Manifest ID 判断是否已经发布。两个 Worker 试图完成同一 Task 时,ExpectedVersion 让只有符合预期状态的发布进入下一版本。后台压缩、索引重建与历史产物回收则可以交给另一组 Worker。

对规模较小的 Go 服务,直接用 PostgreSQL 事务与对象存储就可以承担这些职责;引入独立 Ref-CAS 式复制日志往往会增加运维负担。等到单个热点对象有大量互不冲突的更新、跨服务元数据竞争明显、读取远多于写入时,再评估更细粒度的协调服务。Rubicon 的可复用部分是按状态性质划分权威数据与发布点,而具体技术栈要服从实际负载。

一张决策表帮助选择架构复杂度

系统特征 先采用的简单方案 升级信号
少量写入,事务主要修改一张表 PostgreSQL 条件更新或事务 同一事务覆盖大量无关资源、锁等待显著
大文件写入,元数据很小 对象存储 + 数据库 Manifest 文件发布与状态恢复跨多个服务变复杂
大量不相关 Key 并发更新 Per-Key CAS / Sharding Key 间读视图与写事务协调成为瓶颈
重复读取远高于写入 版本检查 + 可重建缓存 缓存失效扇出或远端读成本主导延迟
维护任务占用在线 CPU 与 I/O 独立 Worker + 可恢复任务状态 Compaction/GC 积压与读取代价持续攀升

Rubicon 的演进展示了清晰的优先级:先知道资源和状态在哪一层互相牵制,再缩小真正需要协调的集合,最后为已经解耦的资源池分别设计扩展和维护机制。

参考资料与证据范围

本文基于截至 2026-11-08 的公开资料。前三篇为 GitHub 官方工程文章,后两篇为 Git 官方技术文档。Go 风格伪代码、故障矩阵中的推演,以及 Lease / GC 安全策略讨论属于工程分析;有关内部实现的描述以官方披露为准。

  1. Karthik Raman, Brian Celenza, Inside GitHub’s new Git storage architecture, GitHub Engineering, 2026-11-05: https://github.blog/engineering/architecture-optimization/inside-githubs-new-git-storage-architecture/
  2. Brian Celenza, Building Git infrastructure for agent-scale development, GitHub Engineering, 2026-10-06: https://github.blog/engineering/architecture-optimization/building-git-infrastructure-for-agent-scale-development/
  3. Eleanor White, Victoria Dye, The bridge to agent-scale Git infrastructure, GitHub Engineering, 2026-10-21: https://github.blog/engineering/architecture-optimization/the-bridge-to-agent-scale-git-infrastructure/
  4. Git Reference, Git Internals — Git Objects: https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
  5. Git Documentation, Multi-Pack-Index: https://git-scm.com/docs/multi-pack-index
  6. Git Book, Git Internals — Packfiles: https://git-scm.com/book/en/v2/Git-Internals-Packfiles
  7. Git Documentation, pack-format: https://git-scm.com/docs/pack-format
  8. Git Documentation, gitprotocol-pack: https://git-scm.com/docs/gitprotocol-pack