Git in jeder Größenordnung
Git-Repositories in großem Maßstab zu hosten, ist ein Albtraum. Als Linus Torvalds die erste Version von dem Informationsmanager aus der Hölle entwickelte (das ist tatsächlich der Slogan von Git, überzeuge dich selbst), hatte er einen ganz konkreten Anwendungsfall vor Augen: seinen eigenen. Er wollte BitKeeper ersetzen, das verteilte Versionskontrollsystem, mit dem der Linux-Kernel entwickelt wurde. Natürlich musste auch der Ersatz verteilt sein. Der Kernel ist ein ungewöhnliches Softwareprojekt: Er ist extrem dezentral organisiert und hat für seine vielen verschiedenen Subsysteme ebenso viele Maintainer. Ein verteiltes Versionskontrollsystem passt daher ganz natürlich zu diesem Workflow.
Zwanzig Jahre später ist Git zum Industriestandard geworden, doch in Wahrheit ist seine verteilte Natur eher ein Hindernis als ein Vorteil. Das durchschnittliche Open-Source-Softwareprojekt arbeitet nicht dezentral. Ein durchschnittliches Unternehmen erst recht nicht. Sie nutzen zwar die vielen Vorteile des verteilten Modells, etwa die Möglichkeit, offline zu arbeiten oder Pushes zu verzögern, sind aber stark auf einen zentralen Host angewiesen. Und wie sich herausstellt, ist das Hosting eines Git-Repositorys unglaublich schwierig.
Was ist an Git schwierig?
Die Herausforderung beim Hosten von Git-Repositories im großen Maßstab liegt im Design von Git selbst: Ein verteiltes Versionskontrollsystem bedeutet, dass alle Instanzen eines Repositorys identisch sind. Das Repository auf einem Git-Server unterscheidet sich in keiner Weise von einem Repository auf dem Laptop eines Entwicklers. Auf den ersten Blick mag das Hosten von Git-Repositories dadurch einfach erscheinen (man stellt einfach einen HTTP-Daemon vor eine auf einem Datenträger gespeicherte Kopie eines Repositorys, und schon läuft ein Git-Server!), doch es bringt zahlreiche schwierige Herausforderungen für Skalierbarkeit und Zuverlässigkeit mit sich, die genau das Gegenteil bewirken.
In einem normalen Git-Repository werden dein Code und deine Metadaten (Dateien, Commits, Trees) komprimiert und in Packfiles gespeichert – einem einfachen binären Serialisierungsformat, das sich auf einer lokalen Maschine bequem handhaben lässt, auf einem Server jedoch nicht ideal im großen Maßstab verwalten lässt. Packfiles sind die grundlegenden Bausteine der Git-Speicherung und der Git-Netzwerkkommunikation. Wenn du Daten in ein Repository pushst oder einen Fetch daraus ausführst, werden sie als Packfile übertragen.
So funktioniert Git von Grund auf, aber man könnte durchaus meinen, dass es nicht so sein müsste. Schließlich kontrollierst du den Git-Client nicht (zumindest nicht, ohne deine Benutzer zu verärgern und viel Reibung zu erzeugen), aber auf deinem eigenen Server kannst du alles tun, was du möchtest. Nichts zwingt dich dazu, Packfiles zu nutzen – Linus wird nicht vorbeikommen und nachsehen. Die einzige Einschränkung besteht darin, dass du bei allen Git-Operationen Packfiles über das Netzwerk empfangen und senden musst.
Im Laufe der Jahre stellten Unternehmen, die Git-Repositories im großen Maßstab hosten wollten, fest, dass dieses Packfile-basierte Design sowohl die Verfügbarkeit als auch die Skalierbarkeit erheblich einschränkte. Packfiles sind große Binärdateien, die auf einem Dateisystem vorhanden sein müssen, damit Git darauf zugreifen kann. Der einfache Ansatz, einen HTTP-Server vor ein auf einem Datenträger gespeichertes Repository zu stellen, stößt schnell an seine Grenzen. Idealerweise sollte das Repository auf vielen Datenträgern und vielen Maschinen vorhanden sein (so kannst du viele Git-Operationen parallel ausführen, und dein Repository bleibt verfügbar, wenn ein Server abstürzt). Aber wie gelingt das?
Grundsätzlich gibt es drei mögliche Ansätze, dies zu erreichen, in aufsteigender Komplexität: das Dateisystem verteilen, die Packfiles verteilen oder Git selbst verteilen.
Git ohne Packfiles
Git ist ein inhaltsadressierbarer Datenspeicher. Alle Objekte in einem Git-Repository (Blobs, Trees, Commits usw.) werden anhand des SHA-1-Hashes ihres Inhalts identifiziert. Das lässt sich intuitiv sehr gut auf einen verteilten Schlüssel-Wert-Speicher abbilden (der Schlüssel ist der SHA-1; der Wert das eigentliche Objekt) und könnte eine elegante Möglichkeit bieten, den Speicher eines Repositorys horizontal zu skalieren. Doch in der Praxis funktioniert das nicht.
Das Problem: Die tatsächliche Struktur eines Git-Repositorys ist ein gerichteter azyklischer Graph (kurz DAG). Du kannst jedes Objekt über seinen SHA nachschlagen, aber selbst für die trivialste Operation im Repo musst du den DAG Schritt für Schritt durchlaufen.
Wenn du beispielsweise die neuesten Änderungen in einem Repository auflisten möchtest, musst du dessen Commits verarbeiten. Beim Verarbeiten eines Commits erhältst du einen Verweis auf die Wurzel seines Trees. Von diesem Tree aus erhältst du Verweise auf jede Datei und jeden Unter-Tree. Vom ursprünglichen Commit erhältst du einen Verweis auf dessen Parent (den vorherigen in der Historie). Entscheidend ist: Bei jedem Schritt dieses Durchlaufs kennst du den Wert des nächsten Verweises erst, nachdem du den vorherigen abgerufen hast. Wenn jeder Fetch einen Roundtrip zu einem verteilten Speicher erfordert, wird das sehr schnell sehr teuer.
Dieser Ansatz, Git auf Objektebene zu verteilen, wurde bereits oft ausprobiert und scheitert häufig bei großem Maßstab. Die vielversprechendste Implementierung wurde von meinem früheren Mentor Shawn Pearce entwickelt, als er im Team für Versionskontrollsysteme bei Google arbeitete. Sein Ansatz bestand darin, die Objekte in einer verteilten Hashtabelle zu speichern. Möglich war dies nur dank JGit, einer maßgeschneiderten Git-Implementierung in Java. Wie jede gute alte Java-Bibliothek bietet JGit genügend Interfaces, Factories und Interface-Factories, um sämtliche Details eines normalen Git-Repositorys zu abstrahieren – einschließlich des Ersetzens der auf dem Datenträger gespeicherten Packfiles durch eine DHT. Obwohl das System funktionierte und die Ergebnisse für normale Git-Operationen gut genug waren, machten die Einschränkungen des Git-Protokolls (das wiederum verlangt, dass unabhängig von der Datenspeicherung auf dem Server Packfiles über das Netzwerk gesendet werden) die Performance von git clone so schlecht, dass das Design letztlich vollständig verworfen wurde.
GitHub und Dateisysteme
Ein paar Jahre nachdem Git begann, seiner Linux-Kernel-Blase zu entkommen, entstand in San Francisco ein kleines, unkonventionelles Startup. GitHub wurde 2008 als soziale Coding-Plattform mit einem äußerst vorausschauenden Slogan gegründet: „Git-Repository-Hosting: kein Schmerz im Arsch mehr.“ Das ist wirklich kein Witz, sieh selbst nach. Schon 2008 bestand weitgehend Einigkeit darüber, dass man trotz – oder vielleicht gerade wegen – des verteilten Designs von Git eine zentrale Möglichkeit zum Hosten von Git-Repositories brauchte, um sie benutzerfreundlich zu machen, und dass dies sehr mühsam war. GitHub wollte das ändern.
Die Plattform begann als Rails-Monolith und ist das größtenteils bis heute. Die allerersten Versionen liefen auf einer einzigen, wenn auch leistungsstarken Maschine – mit einem Ruby-Server und Kopien der Repositories auf dem Datenträger direkt daneben. Eine Rails-App zu skalieren ist einfach: Man stellt mehr Instanzen davon bereit. Doch in diesem speziellen Fall, weil Git beteiligt ist, stießen sie schnell auf die wiederkehrende Frage, die wir hier zu lösen versuchen: Wenn die Rails-App auf die Git-Repositories auf dem Datenträger zugreifen muss, wie stellt man dann weitere Kopien davon bereit?
Als sparsame Truppe von Außenseitern probierten die frühen Systemingenieure bei GitHub den einfachsten Ansatz aus, der ihre Skalierungsprobleme möglicherweise lösen konnte. Die Überlegung war: Wenn sie sich auf die Verteilung des Dateisystems statt auf Packfiles oder Git selbst konzentrierten, konnten sie die Rails-App unverändert lassen und ihre Zeit darauf verwenden, mehr Features für die ständig wachsende Benutzerbasis bereitzustellen, statt seltsame Dinge mit Git anzustellen. Sehr pragmatisch. Es funktionierte nicht.
Das Team versuchte viele Ansätze für ein verteiltes Dateisystem für Git-Daten: Der offensichtlichste – NFS zu nutzen, um alle Repositories auf einem zentralen Server zu speichern – wurde schnell verworfen. Die Standardimplementierung von Git trifft viele Annahmen über die Dateisystemsemantik (Sperren, inkonsistente Teilzugriffe, Lesen, Synchronisieren ...), die auf dem lokalen Dateisystem eines langsamen Entwickler-Laptops für ordentliche Performance sorgen, aber nicht berücksichtigen, wie sie sich über ein Netzwerkdateisystem verhalten. Es war langsam und fehleranfällig.
Weitere Versuche gab es mit Technologien, die das Dateisystem auf Blockebene replizierten – offen gesagt, rückblickend schrecklichen Technologien. Eine kurzlebige Bereitstellung mit GFS. Eine länger laufende Bereitstellung auf Basis von DRBD. Sie alle stießen an ihre Grenzen. Im täglichen Betrieb waren sie furchtbar und machten das auch nicht durch gute Performance wett. Letztlich liegt es am Design der Packfiles auf dem Datenträger.
Wir haben bereits gesehen, wie die graphartigen Datenstrukturen von Git Hin- und Rückwege unerschwinglich teuer machen. Leider gilt ein sehr ähnliches Prinzip auch für die zugrunde liegenden Daten auf dem Datenträger. Es gibt keine Korrelation zwischen der Anordnung der Objekte im DAG und der Art, wie sie in einem Packfile abgelegt werden. Die wichtigste Heuristik bei der Generierung von Packfiles ist, ihre Größe zu minimieren; Objekte werden zufällig über das gesamte Pack verteilt, komprimiert und – entscheidend – nur selten vollständig gespeichert. Die meisten Objekte werden als Delta auf Basis eines anderen Objekts im selben Packfile gespeichert. Das Lesen eines einzelnen Objekts umfasst nach den vielen logischen Sprüngen in der Graphdatenstruktur auch physische Sprünge im Format auf dem Datenträger.
Diese Art zufälliger Zugriffe über Gigabytes an Daten, die bei jeder einzelnen Git-Operation in einem Repository erfolgen müssen, funktioniert einfach nicht gut mit einem Netzwerkdateisystem – unabhängig davon, ob es auf Datei- oder Blockebene repliziert. Das funktioniert nur, ohne extrem langsam zu werden, wenn die gesamte Datei lokal gecacht werden kann. Bei Hunderttausenden von Repositories im selben Dateisystem ist Caching jedoch keine Option.
Schließlich bissen die Systemingenieure bei GitHub in den sauren Apfel und gaben die Verteilung des Dateisystems auf. Sie begannen, ein RPC-System zu entwickeln, damit Repositories auf dedizierten Dateiservern liegen konnten, und passten die Rails-App an, damit alle Operationen remote ausgeführt wurden. Das sorgte für ein gutes Maß an horizontaler Skalierbarkeit, löste jedoch weder die Verfügbarkeitsprobleme noch die Performance-Probleme der am stärksten genutzten Repositories. Schließlich war jedes Repository weiterhin nur auf einer einzigen Maschine gespeichert.
Spokes und Konsistenz
Spokes wurde ursprünglich um 2013 bei GitHub entwickelt und ist seitdem zum Branchenstandard geworden. Die meisten Git-Hosting-Dienste nutzen in ihrer Architektur eine Variante des Spokes-Ansatzes (Replikation auf Anwendungsebene für Git-Repositories). Der Hauptgrund, warum Spokes über viele Jahre hinweg gut funktioniert hat, liegt darin, dass drei grundlegende Entscheidungen getroffen wurden, die sich mit der Zeit als optimal erwiesen haben:
- Es verteilt nicht Git selbst, sondern arbeitet auf Packfile-Ebene.
- Es speichert alle Daten als echte Git-Repositories auf lokalen NVMe-Laufwerken.
- Es repliziert die Git-Daten und hält alle Kopien stets konsistent synchron.
Aufgrund der zufälligen Lesezugriffsmuster in Packfiles, die wir gerade besprochen haben, ist das Speichern nativer Git-Repositories auf NVMe-Laufwerken praktisch eine Voraussetzung dafür, dass alle grundlegenden Git-Operationen schnell bleiben. Dadurch bleiben auch Klone effizient, weil du die Daten nicht erst in das vom Git-Client erwartete Format umwandeln musst. Außerdem kannst du dich darauf konzentrieren, ein Produkt auf Git aufzubauen, anstatt selbst einen Fork von Git zu pflegen, der mit deinen ungewöhnlichen Repositories umgehen kann.
Entscheidend ist außerdem, alle Datenkopien konsistent synchron zu halten. Das lernt man auf die harte Tour: Der Git-Client kommt mit eventueller Konsistenz wirklich nicht gut zurecht. Wenn dein lokaler Git-Client einen Commit pusht und ihn unmittelbar nach einem Fetch nicht lesen kann, ist das problematisch. Git findet das sehr verwirrend. Wenn du deine CI-Pipeline auf hundert Runnern ausführst und drei davon den Commit, den sie testen sollen, nach dem Klonen deines Repositorys nicht finden, ist das ebenfalls problematisch. Auch die Nutzererfahrung ist dann sehr schlecht.
Die Arbeit mit einer nur eventuell konsistenten Ansicht eines Git-Repositorys birgt viele Fallstricke – sowohl auf dem Client als auch im Backend. Deshalb nimmt Spokes eine hohe Komplexität in Kauf, um sicherzustellen, dass das System jederzeit vollständig konsistent ist. Sehen wir uns genauer an, was das bedeutet.
Spokes ist ein konsensbasiertes verteiltes System. Es speichert mehrere Kopien deines Git-Repositorys auf verschiedenen Servern. Wenn du neue Daten pushst, verteilt ein Orchestrator deinen Push an alle Instanzen deines Repositorys, sodass jede eine Kopie erhält. Dieses „Fan-out“ wird mit einem klassischen Konsensalgorithmus namens 3PC (Drei-Phasen-Commit) synchronisiert, sodass ein Push nur akzeptiert wird, wenn ihn eine Mehrheit der Knoten bestätigt.
Bevor wir genauer darauf eingehen können, wie Spokes 3PC nutzt, müssen wir verstehen, wie ein Git-Push funktioniert. Ein Git-Push besteht aus zwei Komponenten: einem Packfile und einer Referenztransaktion. Das Packfile, über das wir bereits gesprochen haben, enthält die Objekte, die du in das Repository pushst: Blobs, Trees und Commits mit deinen Änderungen. Die Transaktion veröffentlicht deine Änderungen im Repository, indem sie eine oder mehrere Referenzen aktualisiert (zum Beispiel den Branch, an dem du arbeitest), sodass diese auf die Commits zeigen, die du gerade gepusht hast.
Diese Trennung ist hier sehr praktisch, weil ein gepushter Commit erst sichtbar – in der Git-Terminologie „erreichbar“ – wird, wenn die auf ihn verweisende Referenz aktualisiert wurde. Dadurch können wir Konsens für unsere Pushes implementieren, indem wir die Packfiles gleichzeitig an alle Hosts verteilen (hier ist keine Synchronisierung nötig) und anschließend einen Drei-Phasen-Commit für die Referenztransaktion durchführen. Diese ist deutlich kleiner und schneller zu synchronisieren als das Packfile. Git selbst unterstützt die Vorbereitung von Referenztransaktionen: Es kann eine Referenz sperren, prüfen, ob der vorhandene Wert dem erwarteten entspricht, und die Sperre halten, bis es einen Commit- oder Abbruchbefehl für die Transaktion erhält.
Mit diesem Design stellen wir sicher, dass jeder Push über alle Replikate hinweg vollständig synchronisiert ist. Lesezugriffe (Fetches, Klone) können dann sicher an jedes einzelne Replikat weitergeleitet werden, weil jedes Replikat immer auf dem neuesten Stand ist.
So funktioniert Spokes im Wesentlichen, und in den vergangenen 13 Jahren hat es ziemlich gut funktioniert. Natürlich ist Spokes nicht perfekt – kein System ist es. Im Jahr 2026 hat sich die Art und Weise, wie Menschen Git-Repositories nutzen, drastisch verändert, und wir haben dabei viele wichtige Erkenntnisse über den Aufbau verteilter Systeme gewonnen. Zeit und Erfahrung haben gezeigt, welche Entscheidungen von Spokes sich als optimal erwiesen haben und welche nicht.
Ein kritischer Mangel hat sich als die eingeschränkte horizontale Skalierbarkeit von 3PC erwiesen. Als Spokes ursprünglich veröffentlicht wurde, waren drei Replikate pro Repository der ideale Kompromiss. Ein durchschnittliches Repository ließ sich mit drei Kopien und reichlich Kapazitätsreserven betreiben – bei ausreichend Redundanz, um auch dann weiterhin Pushes anzunehmen, wenn eine Maschine ausfiel.
2026 sieht die Lage ganz anders aus. Das durchschnittliche Repository eines Enterprise-Unternehmens ist heute ein riesiges Monorepo. Drei Replikate reichen nicht aus, um den Datenverkehr für solche Repos zu bewältigen, insbesondere bei CI. Natürlich hindert nichts Spokes daran, mit mehr als drei Replikaten zu laufen – außer dem gefürchteten tail at scale. Drei-Phasen-Commit lässt sich sehr elegant auf das Git-Transaktionsmodell abbilden, doch als Konsensalgorithmus hat es grundlegende Einschränkungen: Die Latenz jedes Schritts wird durch den langsamsten Server im Cluster bestimmt. Je mehr Replikate Sie einem Cluster hinzufügen, desto schlechter wird der Push-Durchsatz.
Diese Skalierungsbeschränkung gilt auch in die andere Richtung. Wenn Agenten mit Git-Repositories im großen Maßstab arbeiten, agieren sie häufig außerhalb eines Monorepos, indem sie riesige Mengen kleiner Repositories erstellen – viele davon nur vorübergehend und die meisten kaum genutzt. Spokes hat hier Schwierigkeiten, weil es weiterhin drei Replikate für jedes dieser Repositories benötigt. Drei größtenteils ungenutzte Replikate, die nicht reduziert werden können, weil das System dann nicht mehr vollständig konsistent wäre und Datenverlust möglich wäre. Bei Drei-Phasen-Commit ist die Untergrenze stets zu hoch und die Obergrenze zu niedrig.
Ein weiterer Mangel, der im Voraus nicht erkennbar ist, aber nach leidvoller Erfahrung schmerzhaft offensichtlich wird, besteht darin, dass Spokes im großen Maßstab schwierig zu betreiben sein kann. Da die Repositories auf dem Datenträger stets die maßgebliche Quelle für den Konsens sind, ist jede Kopie jedes Repositorys sehr wichtig. Sie müssen Repositories wie Haustiere statt Nutzvieh behandeln.
Das bedeutet zunächst einmal, dass Sie genau wissen müssen, wo sich jedes Repository befindet. Dadurch entsteht eine Abhängigkeit von einer externen Datenbank – und ein potenzielles Verfügbarkeitsproblem –, die eine sehr große Routing-Tabelle führen muss, welche jedes Repository jeder Maschine zuordnet, auf der es repliziert ist. Für jedes Repository müssen außerdem Prüfsummen berechnet und diese in der Tabelle fortlaufend aktualisiert werden, um sicherzustellen, dass das Repository auf dem Datenträger intakt bleibt. Sobald etwas mit dem Repository schiefläuft (und glauben Sie mir, das passiert ständig – Git kann in der Praxis sehr empfindlich sein), müssen Sie dies erkennen und einen Reparaturjob einplanen, um es wieder in einen fehlerfreien Zustand zu versetzen. Und Sie müssen dies sehr schnell tun! Denn auch hier sind die Repositories auf dem Datenträger die maßgebliche Quelle. Eine beschädigte Kopie ist genauso schlimm wie eine fehlende. Sind zwei der drei Kopien beschädigt, kann das System keine Pushes mehr annehmen: Es gibt kein Quorum.
Continuity
Continuity (Cnt kurz) ist das bei Cursor entwickelte Git-Speichersystem. Sein Ansatz ist sehr klar: aus allem zu lernen, was Spokes gut gemacht hat, und die Dinge zu beheben, von denen wir nach vielen Jahren wissen, dass sie problematisch sind.
Cnt ist ein einfaches System (ein System kann nicht leicht zu betreiben sein, wenn es nicht einfach ist). Seine zentrale Primitive ist ein Write-Ahead-Log, das wir in S3-kompatiblem Objektspeicher ablegen. In der Produktion läuft es direkt auf S3, ist aber so konzipiert, dass es in jeder Cloud bereitgestellt werden kann.
Wenn ein Repository einen Push erhält, speichern wir ihn als WAL-Eintrag in S3. Wir bestätigen einen Push erst, wenn er vollständig persistent gespeichert wurde. Jeder Push wird als separates Objekt gespeichert; wir schreiben das gepushte Packfile auf den Datenträger und laden es gleichzeitig in S3 hoch. Das Hochladen eines WAL-Eintrags veröffentlicht ihn jedoch nicht. Ein Push ist erst sichtbar, wenn wir seine Referenztransaktion erfolgreich auf einer lokalen Kopie des Repositorys vorbereiten und einen Verweis auf den WAL-Eintrag in der WAL-Indexdatei speichern, die ein eigenes Objekt im Speicher ist. Dadurch müssen alle Pushes linearisierbar sein.
Wir versuchen, nicht für jeden Push einen einzelnen S3-Schreibvorgang auszuführen, da dies in stark genutzten Repositorys den Push-Durchsatz durch die Latenz des S3-PUT-Vorgangs stark begrenzt. Mit einer sorgfältig abgestimmten Batch-Implementierung und der einzigen Anforderung, die Referenztransaktion mit einem einzelnen lokalen Repository statt mit einem Quorum von Replikaten synchronisieren zu müssen, haben wir ein System, das Pushes so schnell aufnehmen kann, wie es unser Datenträger zulässt.
Die lokale Kopie des Repositorys ist natürlich ein normales Git-Repository auf einem sehr schnellen NVMe-Laufwerk. Wir machen dasselbe wie Spokes, weil Spokes das meiner Meinung nach genau richtig gemacht hat. Dadurch können wir die gesamte hervorragende OSS-Arbeit der Git-Community wiederverwenden, einschließlich des Upstream-Git-Clients und seiner zahlreichen Leistungsoptimierungen. So können wir uns darauf konzentrieren, neue Features auszuliefern, statt merkwürdige Dinge mit Git zu machen.
Konsens
Wir haben gesehen, dass Spokes-Cluster unter anderem deshalb schwer zu betreiben sind, weil es entscheidend ist, den Speicherort jedes Repositorys auf jedem Server im Blick zu behalten. Cnt geht hier ganz anders vor. Wo befindet sich ein Repository? Die Antwort lautet: „irgendwo“. Das spielt keine Rolle! Wir behandeln Repositorys wie einen warmen Cache auf dem Datenträger, aber die maßgebliche Quelle ist immer das Write-Ahead-Log in S3. Das System ist zustandslos, und es gibt keine Routing-Tabellen (und keine relationale Datenbank, die betrieben werden muss — hashtag blessed). Wenn beim Zugriff auf einen Host ein Repository auf dem lokalen Datenträger fehlt, materialisieren wir es einfach aus dem WAL. Das können wir sehr effizient tun, aber natürlich möchten wir das nicht ständig tun, weil es verschwenderisch wäre. In der Produktion nutzen wir Rendezvous-Hashing, um eine Repository-ID einer Liste von Knoten zuzuordnen, auf denen wir das Repository erwarten. Der gesamte Zustand, den wir zum Routing von Repositorys benötigen, besteht aus der Repository-ID und der aktuellen Menge fehlerfreier Knoten in einem Cluster. Aber selbst wenn dieser Zustand nicht mehr synchron ist (z. B. wenn ein Knoten fehlerhaft wird), ist das völlig in Ordnung. Wir materialisieren das Repository einfach auf dem jeweils nächsten Knoten.
Was ist mit Konsens? Wahlen? Welcher Server ist für ein bestimmtes Repository der Primärserver? Auch das spielt keine Rolle! Hier gibt es keinen Zustand und keinen Konsens. Jeder Server kann der Primärserver sein. Alle Aktualisierungen des Write-Ahead-Logs werden mit einer atomaren Compare-and-Swap-Operation (CAS) auf S3 synchronisiert, sodass es immer sicher ist, wenn eine beliebige Instanz eines Repositorys einen Push entgegennimmt. Wie beim Routing ist es auch hier nicht besonders effizient, einen beliebigen Server als Primärserver fungieren zu lassen (das führt zu CAS-Wiederholungen, die Pushes verzögern können). Deshalb wählen wir in der Praxis immer denselben Server als Primärserver: den ersten in der durch Rendezvous-Hashing bestimmten Rangliste. Aber in Sonderfällen — bei einem Deploy, einem Failover oder einer kurzen Netzwerkstörung — ist es uns einfach egal, welcher Server genau der Primärserver ist. Das System ist so konzipiert, dass es auch im eingeschränkten Zustand stets korrekt und im fehlerfreien Zustand stets schnell arbeitet.
Replikation
Ein Write-Ahead-Log in S3 eröffnet beim Skalieren völlig neue Möglichkeiten. Wir können buchstäblich jede beliebige Anzahl von Replikaten haben, denn die Skalierbarkeit von S3 ist unübertroffen und alle Replikate holen ihre Daten direkt von dort. Wir setzen auf optimistische Replikation, indem wir Gossip-UDP-Pakete in unserem Cluster versenden. Die Pakete enthalten alle erforderlichen Metadaten, damit jedes Replikat nach jedem Push direkt von S3 aufholen kann. „Das ist wahnsinnig“, höre ich dich hinter deinem Bildschirm durch Zeit und Raum hindurch murmeln. „UDP ist kein zuverlässiges Transportprotokoll.“ Natürlich nicht. Nichts ist in einem verteilten System zuverlässig! Die Verbindung ist nicht zuverlässig, das Routing ist nicht zuverlässig und die Topologie ebenfalls nicht. Aber das ist in Ordnung: Es spielt keine Rolle. Jedes Replikat kennt das ETag der letzten Version des WAL-Index, mit der es synchronisiert ist. Wenn du einen Lesevorgang auf einem Replikat ausführst, senden wir einen bedingten GET mit dem erwarteten ETag an S3. Eine 304-Antwort ohne Body (praktischerweise ein nahezu sofortiger Vorgang — im Durchschnitt weniger als 10 ms, da es sich um einen reinen S3-Metadatenvorgang handelt) bedeutet, dass wir auf dem neuesten Stand sind und den Fetch oder Klon sofort ausliefern können. Eine 200-Antwort enthält die neueste Version des WAL-Index, mit der wir uns synchronisieren, bevor wir den Lesevorgang ausführen.
Es spielt keine Rolle, ob das UDP-Paket zur Replikation verloren geht oder beim falschen Server ankommt, weil sich die Topologie verändert hat. Alle Lesevorgänge auf allen Replikaten sind vollständig konsistent, weil sie gegen die maßgebliche Quelle — S3 — geprüft werden. Das System ist so konzipiert, dass es auch bei eingeschränktem Betrieb stets korrekt und im Normalbetrieb stets schnell ist.
Die Auswirkungen davon sind zweifach. Erstens ist es trivial, Infrastruktur darauf aufzubauen, weil das System immer konsistent ist. Wir (unsere Agenten, unsere Weboberfläche, unsere Clients) sehen stets eine global konsistente Ansicht des Repositorys. Und weil das System in beide Richtungen skaliert, erhält jedes Repository genau die richtige Anzahl von Replikaten. Ein großes Monorepo kann auf Hunderte von Replikaten verteilt werden, um die gesamte Last seiner CI-Jobs zu bewältigen. Millionen kleiner, von Agenten erstellter Repositories können jeweils mit einem Replikat bedient werden; wir benötigen nicht mehr als eines, um die Verfügbarkeit sicherzustellen, weil S3 die maßgebliche Quelle ist. Tatsächlich benötigt ein inaktives Repository nicht einmal das: Wenn ein Replikat eine Zeit lang keinen Traffic erhalten hat, entfernen wir es per Garbage Collection vom Datenträger des Knotens und materialisieren es beim nächsten eingehenden Fetch einfach wieder aus dem WAL.
Komprimierung
Write-Ahead-Logs erfordern regelmäßige Komprimierung. Das Log darf nicht unbegrenzt wachsen: Bei einer vollständigen Wiederherstellung wird jeder Eintrag erneut abgespielt. Je mehr Einträge vorhanden sind, desto kostspieliger wird sie.
Auch ein normales Git-Repository erfordert regelmäßige Komprimierung, obwohl Git nicht auf einem WAL basiert. Wir haben gesehen, dass die grundlegende Speichereinheit in einem Git-Repository das Packfile ist. Jedes Mal, wenn du in eine Remote-Kopie eines Repositorys pushst oder einen Fetch in deine lokale Kopie ausführst, erstellst du ein neues Packfile. Das skaliert nicht unbegrenzt: Jedes Packfile hat einen eigenen Index, über den Git die darin enthaltenen Objekte effizient nachschlagen kann. Diese Suche ist jedoch nur auf Ebene einzelner Packfiles effizient. Wenn du nach einem bestimmten Objekt suchst und dein Repository 100 Packfiles enthält, musst du den Index jedes einzelnen öffnen und nach dem Objekt suchen, bis du es in einem der Packfiles findest. Eine effiziente Operation ist nicht effizient, wenn sie Hunderte oder Tausende Male ausgeführt werden muss.
Modernes Git ist mittlerweile sehr gut darin, dies zu umgehen: Es unterstützt Multi-Pack-Indizes und inkrementelle geometrische Komprimierung. Doch irgendwann musst du in den sauren Apfel beißen und dein Git-Repository auf dem Datenträger repacken. In der Vergangenheit war dies für Systeme wie Spokes ein ständiges Verfügbarkeitsproblem, da das Repacken eine sehr CPU-intensive Operation ist, selbst wenn es inkrementell erfolgt, und auf allen Replikaten des Systems durchgeführt werden muss. Wenn du versehentlich eine Wartungsoperation auf zwei oder mehr Spokes-Knoten für dasselbe Repository auslöst, kann dies leicht zu einem Failover des Repositorys führen.
Hier verteilen wir die Kosten der Komprimierung. Nur der Primärserver führt Komprimierungen durch, und das Ergebnis der Komprimierung gilt sowohl für das Repository auf dem Datenträger als auch für das WAL. Da alle Replikate dem WAL folgen, folgen sie auch den Komprimierungsereignissen. Replikate repacken nicht; sie laden einfach die bereits komprimierten Packs von S3 herunter und tauschen Bandbreite gegen CPU.
Skalierung
Replikation und Komprimierung sind die beiden Schlüsselfaktoren dafür, wie gut ein Git-Speichersystem unter Last funktioniert. Wie wir gerade gesehen haben, sind sie untrennbar miteinander verbunden: Je mehr Pushes pro Sekunde ein Repository verarbeitet, desto stärker nimmt die Leseleistung ab, da die Packfiles jedes Pushes komprimiert werden müssen, damit Git-Operationen effizient bleiben. Wenn Sie diese Pushes replizieren, muss die Komprimierung entweder ebenfalls repliziert oder auf jedem Replikat unabhängig durchgeführt werden.
Das WAL-first-Design von Continuity bietet vollständig konsistente horizontale Skalierbarkeit: Sie können eine beliebige Anzahl von Replikaten bereitstellen, und der Durchsatz für schreibgeschützte Git-Operationen wächst linear mit deren Anzahl. Da alle Replikate im Cluster vollständig konsistent sind, können wir damit sowohl das Git-Protokoll (Klone, Fetches) als auch alle RPC-Operationen skalieren, die Origin auf Repositories ausführt (Interaktionen über die Web-Oberfläche, die REST-API, all unsere agentenbasierten Schnittstellen usw.).
Wir haben synthetische Stresstests mit bis zu 100 Replikaten durchgeführt und eine konsistente lineare Skalierung bei Lesevorgängen ohne Einbußen beim Push-Durchsatz beobachtet.
Der Push-Durchsatz eines Clusters hängt von der Latenz ab, mit der wir unser WAL auf S3 aktualisieren können. Mit S3 Standard können wir bis zu 120 Pushes/s verarbeiten und dabei die komprimierten Daten auf alle anderen Knoten replizieren. Wir haben auch Hochleistungscluster auf S3 Express One Zone bereitgestellt, das für PUT-Operationen eine deutlich niedrigere Latenz bietet. Dort können wir mehr als 300 Pushes/s verarbeiten und sind effektiv durch die Geschwindigkeit begrenzt, mit der Git die Daten auf dem Datenträger komprimieren kann. Wir arbeiten an innovativen Methoden, diese Daten auf dem Datenträger anzuordnen, um die Auswirkungen der Komprimierung zu verringern. Unser Ziel ist es, die Geschwindigkeit, mit der ein Git-Repository Code aufnehmen kann, weiter zu optimieren, ohne unsere strengen Garantien für Dauerhaftigkeit und Konsistenz aufzuweichen.
- S3 Standard
- S3 Express One Zone
Push-/Klon-Durchsatz für everysphere, das Monorepo von Cursor.
Alle Pushes sind linearisierbar und werden vor der Bestätigung in externem Speicher persistiert.
Alle Klone sind vollständig konsistent.
WAL als Quelle der Wahrheit
S3 ist eine großartige Technologie. Das mit der S3-API eingeführte Konzept des Blob-Speichers hat sich als äußerst leistungsfähiger Baustein für große Datenspeichersysteme erwiesen – und das gilt insbesondere auch für das Hosting von Git-Repositorys. Das hier vorgestellte Design ist in vielerlei Hinsicht neuartig, aber nicht das erste, das Packfiles als Blobs speichert. Azure DevOps (Microsofts eigener Konkurrent zu Microsofts eigenem GitHub) verfügt über ein sehr erfolgreiches Git-Speichersystem, das Packfiles im Blob-Speicher und ihre Referenzen in einer relationalen Datenbank (MS SQL Server) speichert. Ein solches System bringt viele Abwägungen mit sich. Eine relationale Datenbank skaliert gut bei großen Referenztransaktionen. Dafür muss man jedoch eine relationale Datenbank betreiben. Wir sind fest davon überzeugt, dass die Konsistenz von Git-Daten wichtiger ist als jeder andere Aspekt. Das gab für uns letztlich den Ausschlag, ein WAL-basiertes System zu entwickeln, das nicht von externen Datenbanken abhängt.
Bei einem Git-Repository in Produktion können viele Dinge schiefgehen: Datenkorruption im Ruhezustand, Bugs beim Repacken, Race Conditions bei Pushes. Es ist eine große Sammlung von Sonderfällen. Die meisten davon wurden im Git-Upstream behoben. Aber nicht alle. Kein System ist ohne Bugs, auch nicht solche, die als OSS weit verbreitet eingesetzt werden. Unser Konsistenzmodell stellt sicher, dass wir jede grundlegende Operation nachverfolgen, die an einem Repository ausgeführt wird. Wir bestätigen niemals einen Push, bevor er nicht vollständig im WAL persistiert wurde. Wir linearisieren alle Pushes. Jede Ansicht jedes Repositorys, auf das wir zugreifen, ist stets vollständig konsistent. Da jeder Push im WAL enthalten ist, können wir jeden Zustand betrachten, in dem sich ein Repository jemals befunden hat. Wir verfügen über vollständige Herkunftsdaten für alle Pushes und Repack-Vorgänge. Wir können jedes Replikat zurück- und vorspulen. Wir müssen keinen Zustand mit einer externen Datenbank synchronisieren – unabhängig davon, ob diese Datenbank nur Referenzen oder alle Objektdaten speichert. Wenn (nicht falls) wir auf einen Bug in Git stoßen, können wir genau nachvollziehen, was passiert ist, und ihn rückgängig machen. Und neben den Bugs, die bereits in Git existieren, führen wir nur sehr wenige neue ein, denn bei all dem werden sämtliche Git-Operationen mit Standardtools in einem normalen Git-Repository auf dem Datenträger ausgeführt.
Origin
Uns ist sehr bewusst, welche Verantwortung es bedeutet, den Quellcode anderer zu hosten. Ich denke, allen, die diesen Blogbeitrag lesen und verstehen, ist das ebenso bewusst. Ein Unternehmen kann zum Stillstand kommen, wenn seine Entwickler nicht auf seine Git-Repositories pushen oder von ihnen pullen können. Die Produktivitätskosten von fünf Minuten Ausfallzeit im CI-System lassen sich kaum in Dollar beziffern, aber sie sind zweifellos immens.
Agenten haben die Art und Weise, wie wir mit Software arbeiten, grundlegend verändert und die Situation in vielerlei Hinsicht verschärft. Mehr Code, mehr PRs, mehr CI-Ausführungen. Versionskontrolle steht im Zentrum all dessen und lässt sich vermutlich am schwersten von heute auf morgen ändern.
Bei Cursor sind wir intern seit vielen Monaten mit diesen Herausforderungen konfrontiert. Deshalb haben wir viel Zeit und Sorgfalt in den Aufbau einer Plattform investiert, die diese Probleme für uns löst und hoffentlich auch unseren Kunden hilft. Unser Fokus liegt derzeit darauf, den reibungslosesten Weg zu mehr Zuverlässigkeit, höherer Leistung und besserer Skalierbarkeit zu ermöglichen und die Migration so unkompliziert wie möglich zu gestalten.
Origin ist kein Experiment, sondern das Ergebnis jahrzehntelanger Erfahrung im Aufbau genau dieser Systeme – von Menschen, die das Ausmaß der damit verbundenen Herausforderungen genau verstehen. Wir verfügen über eine bewährte Engineering- und Betriebsphilosophie und sind fest entschlossen, sie kontinuierlich weiterzuentwickeln, während sich die Welt der Versionskontrolle verändert.
Wir hoffen, dass Sie uns und unserer Plattform Ihr Vertrauen schenken.