Skalierung von LunaDb, unserem internen deklarativen Datenladesystem

Team Asana – FotoArvind Vijayakumar
5. März 2026
14 Lesezeit (Minuten)
facebookx-twitterlinkedin
Gemini said Two female professionals collaborating at a desk in a bright, modern office. One woman sits while the other leans in, smiling and using the computer mouse, with a male colleague working in the background.

Hier bei Asana haben wir ein Datenladesystem namens LunaDb entwickelt, das als Fundament unserer Web-App dient. Trotz des Namens ist es keine Datenbank. Vielmehr handelt es sich um ein GraphQL-ähnliches System zum deklarativen Abrufen von Daten – im Grunde genommen eine Möglichkeit, die neueste Version der Daten und alle zukünftigen Aktualisierungen zu laden.

Wir haben LunaDb ursprünglich im Jahr 2015 als radikale Neuschreibung unserer Backend-Infrastruktur¹ eingeführt. Die zentrale Komponente dieses neuen Systems war der Synchronisierungsserver, ein Monolith, der alles von der Client-Synchronisierung über das Laden von Daten bis hin zur Zugriffskontrolle übernimmt. Ohne wesentliche Änderungen wurde diese anfängliche Architektur weit über die ursprünglich erwarteten Werte hinaus skaliert, bis hin zu Millionen wöchentlich aktiver Nutzer und Milliarden täglicher Abfragen. 

Während die Leistung weiterhin stark blieb, wurde es mit zunehmendem Traffic und zunehmender Komplexität der Funktionen aufgrund der Einschränkungen des Synchronisierungsservers immer schwieriger, LunaDb zu betreiben und zu verbessern.

Übersicht über unsere Infrastruktur zum Laden von Daten

Überblick über unsere Infrastruktur zum Laden von Daten

Warum war der Betrieb schwierig?

Traffic-Verschiebung ist teuer

Der Synchronisierungsserver verwaltete direkt die persistenten WebSocket-Verbindungen zu den Clients. Jedes Websocket wurde durch eine zustandsbehaftete Client-Sitzung unterstützt. Wenn eine Verbindung unterbrochen wurde, ging dieser gesamte Status verloren und der Client musste alle für ihn relevanten Daten erneut abonnieren. Wenn dies bei vielen Sitzungen geschieht, wird es schnell teuer. Wir mussten also vorsichtig sein, wenn wir diese Verbindungen verlagerten, da die Wiederherstellung der Verbindungen einen großen Arbeitsaufwand bedeutete.

Weniger Traffic-Umleitung

Weniger Traffic-Umleitung

Die Bereitstellung von Synchronisierungsservern bedeutet eine Verlagerung des Datenverkehrs

Natürlich kann man die Verschiebung von Traffic nicht auf Dauer verhindern. Wann immer Sie neuen Code pushen oder nach oben/unten skalieren möchten, müssen Sie Synchronisierungsserver außer Betrieb nehmen – und das erfordert die Verlagerung des gesamten Datenverkehrs von den Terminierungsinstanzen.

Fortlaufende Updates

Fortlaufende Updates

Synchronisierungsserver sind beim Start nicht leistungsfähig

Gleichzeitig würden Synchronisierungsserver erst nach einer nicht unerheblichen Prozessvorwärmung leistungsfähig werden. Die Bewältigung dieser beiden Probleme war ein ziemlich heikles Unterfangen und erforderte in der Vergangenheit umfangreiche Entwicklungsarbeit.

Synchronisierungsserver sind komplex und schwer zu überwachen

Schließlich waren die Synchronisierungsserver, da sie viele willkürliche Teile des Produktcodes (über benutzerdefinierte serverseitige Funktionen) ausführten, sehr anfällig für Performance-Regressionen aufgrund von Noisy Neighbours, die schwer zuzuordnen waren². 

das Problem mit dem lauten Nachbarn

das Problem mit dem lauten Nachbarn


Eine vernünftige Frage lautet: „Warum sind Synchronisierungsserver beim Start komplex und leistungsschwach?“

Eine häufige Ursache für diese Probleme ist unser serverseitiger Produktcode. Die wichtigsten Teile des Codes für den Synchronisierungsserver sind in Scala geschrieben.  Trotz einiger Komplexitäten im Zusammenhang mit der Verwaltung des Sitzungsstatus und den verschiedenen Aspekten des Luna-Frameworks verhält sich dieser Framework-/Plattformcode größtenteils wie erwartet (es gibt relativ wenige Betriebs- und Leistungsprobleme). 

Andererseits sind diese vom Produktserver berechneten Werte (wir nennen sie SCVs, aber denken Sie an benutzerdefinierte Resolver) in Typescript geschrieben. Beide Codesätze laufen zusammen in GraalVM, einer Polyglot-VM, die über ihr Truffle-Framework die Verwendung mehrerer Sprachen ermöglicht. Da sie in TypeScript geschrieben sind, werden SCVs im Wesentlichen beim Start interpretiert – was vorhersehbar zu einer inakzeptablen Leistung und CPU-Auslastung führt. GraalVM wird versuchen, eine Just-in-Time-Kompilierung für aufgerufene SCVs durchzuführen. Das ist gut! GraalVM/Truffle können ihre Leistung erheblich optimieren – aber das ist nicht kostenlos. Die SCV-Kompilierung kann ziemlich teuer sein (in Bezug auf CPU, Code-Cache usw.). 

unsere polyglotte VM-Einrichtung

unsere polyglotte VM-Einrichtung

Warum die beiden Sprachen?

Unser erstes Design für SCVs war vollständig in Scala. Unsere Mutations- und Asynchron-Job-Systeme sind hingegen in JavaScript/TypeScript geschrieben. Während Scala-basierte SCVs funktionierten, wurde die Duplizierung der Business-Logik zwischen unseren Mutations- und Asynchron-Job-Systemen und LunaDb, zusammen mit der mangelnden Vertrautheit der Produktentwickler mit Scala, zu einer erheblichen Belastung für die Produktgeschwindigkeit. 

Warum GraalVM?

Wir speichern viele Daten im Prozess zwischenspeichern, um die Berechnung (und Neuberechnung) von Abonnementergebnissen zu beschleunigen. Die Verwendung von GraalVM bietet uns eine einfache Möglichkeit, diese Caches sprachübergreifend zu teilen, ohne die Korrektheits- oder Leistungsprobleme, die sich aus der Aufteilung der Scala- und TypeScript-Teile in separate Container ergeben könnten.


Warum war es schwierig, Verbesserungen vorzunehmen?

Da der Server so viele Aufgaben erfüllte und relativ anfällig im Betrieb war, haben wir größere Änderungen tendenziell vermieden. Nicht nur wegen der Komplexität des Codes, sondern auch wegen des hohen Aufwands für die sichere Einführung neuer Änderungen.

Wie lösen wir Probleme?

Ja, ich stelle gerne Fragen

Angesichts der Schwierigkeiten beim Betrieb und der Verbesserung des Synchronisierungsservers haben wir die schwierige Entscheidung getroffen, unsere Architektur zu ändern. Hauptsächlich beschlossen wir, den monolithischen Synchronisierungsserver durch zwei kleinere Arten von Komponenten zu ersetzen:

  • einem Session-Broker, der Client-Verbindungen und die Statusauflösung verwaltet

  • einem synchronisierbaren Loader, der für das Laden von Daten verantwortlich ist, d. h. für die Erfüllung von Anfragen von Session Brokern

Session-Broker und Syncable-Loader

Session-Broker und synchronisierbare Loader

Warum hilft das?

Diese neue Architektur trennt sofort die Verlagerung des Websocket-Datenverkehrs (d. h. die Bereitstellung von Session Brokern) vom Aufwärmen neuer Prozesse (d. h. die Bereitstellung von synchronisierbaren Loadern). Dadurch können wir Störungen minimieren, indem wir Syncable-Loader getrennt von Session-Brokern bereitstellen.

Jede dieser neuen Komponenten ist einfacher.

  • Session Broker sind viel leichter, erfordern kein Prozess-Warming und führen keinen Produktcode aus. Daher müssen wir sie nicht so oft bereitstellen – und wenn wir es tun, ist es ziemlich einfach.

  • Syncable-Loader haben eine einfachere Oberfläche (zustandslose Abonnementanforderungen), die leichter an die standardmäßige horizontale Pod-Autoskalierung von Kubernetes angepasst werden kann. Dadurch ist es auch schneller und einfacher, sie aufzuwärmen – wir können einfach Traffic-Mirroring für die Umgebungsanfragen verwenden, die zwischen Session Brokern und Syncable Loadern fließen

Die neue Architektur ermöglicht es uns, den Produktentwicklungsprozess erheblich zu vereinfachen. Von Anfang an waren die unabhängigen Bereitstellungspläne für serverseitigen Produktcode und das Aufrufen von clientseitigem Produktcode eine Belastung für die Geschwindigkeit und eine Quelle für operativen Aufwand (aufgrund von Versionsinkompatibilität). Da Syncable-Loader jetzt der einzige verbleibende Prozess sind, der Produktcode ausführt, und ihre Bereitstellung nicht mehr störend ist, können wir sie jedes Mal neu bereitstellen, wenn wir neuen Produktcode veröffentlichen.

Diese neue Architektur ermöglicht es uns, besser auf neue Funktionen zu skalieren, indem wir verschiedene Pools von Syncable-Loadern für verschiedene Workload-Typen bereitstellen (z. B. verschiedene unterschiedliche Funktionen wie Inbox, Aufgaben, Ziele usw.). Der Session Broker fungiert als Service-Gateway, das direkt steuern kann, wie Datenabfragen an verschiedene vorgelagerte Syncable-Loader weitergeleitet werden.

Was sind die wichtigsten Design-Herausforderungen?

Großartig! Diese neue Architektur klingt viel besser, aber wie haben wir sie in die Realität umgesetzt? Der Synchronisierungsserver ist im Grunde genommen ein Monolith, da er mehrere Funktionen umfasst – und Monolithen auseinanderzunehmen, ist fast immer schwierig. In unserem Fall mussten wir einige wichtige Design-Hindernisse überwinden. 

Aufteilung von PubSub

PubSub, unser System zur Implementierung von Reaktivität, wurde um einen einzigen Prozess (den Synchronisierungsserver) herum entwickelt, der für das Laden neuer Daten und deren Senden an den Client verantwortlich ist. Wir mussten PubSub so umgestalten, dass die Korrektheit über diese beiden jetzt unabhängigen Prozesstypen (synchronisierbare Loader und Session Broker) hinweg gewährleistet ist. 

Schauen wir uns kurz an, wie es in Synchronisierungsservern implementiert ist. Hinweis: Es kann hilfreich sein, unseren vorherigen Beitrag über die Invalidation-Pipeline zu lesen, aber wir werden eine Darstellung des Systems ohne Voraussetzungen liefern.

Auf dem Synchronisierungsserver verfolgen wir Abonnements pro Sitzung. Wir verwenden die Invalidierungspipeline, um Abonnements kontinuierlich auf Updates zu überwachen. Bei jeder neuen Ungültigkeitsnachricht lädt der Synchronisierungsserver alle betroffenen Abonnements neu. 

Synchronisierungsserver speichern DB-Objekt-/Abfrageergebnisse, benutzerdefinierte Resolver-Ergebnisse und frühere Abonnementergebnisse in großem Umfang im Cache, um das Neuladen von Abonnements zu optimieren (d. h. mithilfe eines Read-Through-Musters). Zwischengespeicherte Artefakte werden passiv von derselben Invalidierungspipeline invalidiert, die auch für Abonnements verwendet wird. Wann immer wir versuchen, zwischengespeicherte Daten zu verwenden, überprüfen wir deren Gültigkeit und greifen bei Bedarf auf eine Neuberechnung zurück.

Wir können eine klare Abhängigkeit zwischen dem Neuladen von Abonnements und der Invalidierung von zwischengespeicherten Daten beobachten. Wenn wir nach Erhalt einer Ungültigkeitserklärung ein Abonnement neu laden, bevor die zwischengespeicherten Daten für ungültig erklärt wurden, berechnen wir möglicherweise ein veraltetes Ergebnis. Wenn sowohl das Laden von Daten als auch die Verwaltung von Abonnements im selben Prozess stattfinden, ist es einfach, diese Abhängigkeit zu gewährleisten – man muss lediglich die Caches vor dem erneuten Laden ungültig machen. 

Eine Race-Bedingung kann bei der Aktualisierung zu veralteten Daten führen

Eine Race-Bedingung kann bei der Aktualisierung zu veralteten Daten führen

In unserer vorgeschlagenen neuen Architektur sind sowohl Session-Broker als auch synchronisierbare Loader unabhängige Consumer der Invalidierungspipeline. Wie können wir also erzwingen, dass Caches ungültig gemacht werden, bevor Abonnements neu geladen werden?

Versionsverwaltung für Anfragen und Antworten

Wir hätten dies lösen können, indem wir die Invalidierungspipeline dazu gebracht hätten, Nachrichten im Gleichschritt zu übermitteln. Oder wir hätten einen Mechanismus entwickeln können, um die Reihenfolgegarantie durchzusetzen, dass keine Nachrichten der Invalidierungspipeline vor dem synchronisierbaren Loader beim Session Broker ankommen. Beide Lösungen hatten jedoch nicht ideale Kompromisse – entscheidend war, dass sie die Kopplung von Session Brokern und Syncable Loadern erhöhten.

Stattdessen haben wir dieses Problem gelöst, indem wir unser Datenladeprotokoll um Anfrage- und Antwortversionen erweitert haben, die auf ihrem relativen Fortschritt in den Invalidierungs-Streams basieren. Da der Stream eine vollständige Reihenfolge der Updates an den Datenbanken darstellt, kann unser Stream-Fortschritt wie ein globaler Versionszähler verwendet werden.

Anfrage- und Antwortversionen

Anfrage- und Antwortversionen

Neuladungen bei Invalidierung

Synchronisierungsserver laden eine Unmenge an Daten – den Großteil aller Lesevorgänge für die Website. Unsere neue Architektur erfordert, dass Session-Broker und Syncable-Loader viele Daten über das Netzwerk austauschen. Für neue Abonnements ist dieser Netzwerk-Overhead relativ unbedeutend. Dies ist jedoch besonders ineffizient bei Invalidierungs-Neu-Ladungen, da wir oft nicht die vollständige Antwort zurückgeben müssen – sondern nur die aktualisierten Daten³. Bestimmte Fälle sind hier besonders problematisch. Stellen Sie sich einen Fall vor, in dem ein Nutzer 10.000 Aufgaben in einem Projekt angezeigt hat und ein anderer Nutzer ständig Aufgabenbeschreibungen in diesem Projekt ändert: Bei jeder Ungültigkeitserklärung müssten alle Aufgaben gesendet werden! Natürlich wäre es ideal, nur die aktualisierten Daten zurückzusenden, aber wie setzen wir das effizient um?

Neuladungen bei Invalidierung

Neuladungen bei Invalidierung

Fingerprinting

Damit der synchronisierbare Loader das Delta der aktualisierten Daten berechnen kann, muss er wissen, über welche Daten der Anforderer bereits verfügt. Aber die neuesten Daten mit der Anfrage zu übermitteln, wäre genauso aufwändig wie die Rückgabe des vollständigen Ergebnisses.  Wir müssen die Daten platzsparender darstellen.

Nun, Hashing ist eine großartige Möglichkeit, Platz zu sparen. Jedes Bit der granularen Daten, die uns wichtig sind, wird als Syncable bezeichnet. Wir können einen 128-Bit-Murmur-Hash jedes serialisierten Syncables berechnen, um ihn als Fingerabdruck zu verwenden ⁴. Genauer gesagt ist dieser Fingerprint eine Kennung für diese Version des Syncables.

Überall, wo wir Syncable-Daten verfolgen, können wir stattdessen deren Fingerabdrücke verwenden. Wenn wir nun eine vollständige Abonnementantwort nachverfolgen wollen, können wir einfach eine Reihe von Fingerabdrücken verwenden, ohne die vollständigen Daten weitergeben zu müssen!


Randnotiz: Was ist ein Syncable und wie hängt es mit Abonnements zusammen?

Syncables sind die Inhalte des Ergebnisses eines Abonnements. Wenn wir ein Abonnement laden, werden die Ergebnisse als eine Reihe von Syncables zurückgegeben. Genauer gesagt kann ein Syncable ein Objekt, eine Abfrage oder ein SCV-Ergebnis sein.

Synchronisierbar mit Abonnementzuordnung

Synchronisierbar mit Abonnementzuordnung

Offensichtlich entspricht jedes Abonnement mehreren Syncables. Syncables können jedoch von mehreren Abonnements gemeinsam genutzt werden (wenn sie überlappende Daten laden). Daher gibt es tatsächlich eine Viele-zu-Viele-Zuordnung zwischen Abonnements und Syncables.


Wir übergeben den Satz dieser Fingerabdrücke mit jeder Anfrage vom Session-Broker. Auf dem Syncable-Loader berechnen wir die vollständige Antwort, berechnen ihren Satz von Fingerabdrücken, schließen alle Daten aus, die sich mit der Anfrage überschneiden, und geben das Delta zurück.

Wie erreichen wir das?

Angesichts der Größe und der Kritikalität der Änderung haben wir die Einführung in etwa 4 Phasen aufgeteilt. 

Phase 1 – Refactoring des Monolithen

  • Aufteilung unseres stark gekoppelten Sitzungsverwaltungs- und Datenladecodes in unabhängige Komponenten

Phase 2 – Lokaler Syncable-Loader

  • Verwendung der neuen Datenladekomponente, um einen lokalen gRPC-Server zu erstellen und das Laden von Daten zu migrieren

Phasen 1–2

Phase 3 – Remote-Syncable-Loader

  • Erstellen einer neuen syncable-loader-Binärdatei und Bereitstellung

  • Migration des gesamten Sync-Server-Datenladens zu unserer neuen Syncable-Loader-Bereitstellung

Phase 4 – Separate Session-Broker-Binärdatei

  • Erstellen einer neuen Session-Broker-Binärdatei und Bereitstellung

  • Den gesamten Traffic von Sync-Servern zu Session-Brokern migrieren

Phasen 3–4

Auf welche Herausforderungen sind wir gestoßen?

So viele. Wo soll man anfangen?

Große Antworten

Ein Problem, auf das wir schnell gestoßen sind, waren große Antwortgrößen. Da das Laden von Daten auf Sync-Servern alles innerhalb desselben Prozesses erfolgte, hatte sich dies bis dahin nicht als großes Problem erwiesen⁵. Sobald wir jedoch damit begannen, Daten über eine lokale gRPC-Grenze hinweg zu laden, stießen wir auf viele Probleme. 

Wir hatten die ganze Zeit vermutet, dass einige Antworten groß sein könnten, aber als wir anfingen, dies zu untersuchen, fanden wir wirklich verblüffende Ergebnisse. Wir hatten Tausende von Ladevorgängen pro Tag, die regelmäßig 100 MiB überschritten! Wir konnten physisch keine so großen Antworten über eine unäre gRPC-Methode zurückgeben (man erreicht dann die maximale http2-Frame-Größe). Was tun?

Wir haben einige Möglichkeiten in Betracht gezogen, um dies systematisch zu beheben, sind aber letztendlich zu dem Schluss gekommen, dass wir die zugrundeliegenden Ursachen angehen mussten. Wir hätten serverseitiges gRPC-Streaming implementieren können – aber die dadurch entstehenden hohen Serialisierungskosten und die erhöhte Socket-Konkurrenz würden sich ziemlich negativ auf Latenz und Durchsatz auswirken. Wir hätten diese Antworten einfach ablehnen können – aber die Häufigkeit war zu hoch, als dass dies akzeptabel gewesen wäre. 

Wir haben uns für einen dreistufigen Ansatz entschieden, bei dem wir alle großen Antworten markieren, jeden problematischen Fall analysieren und beseitigen und dann eine strenge Obergrenze für die Antwortgröße festlegen.

Wir haben alle Ladevorgänge markiert, die größer als 1 MB waren, und detaillierte Ereignisse zu Quelle, Nutzung und Datenaufschlüsselung protokolliert. Es gab verschiedene kostspielige Anwendungsfälle, aber der berüchtigtste waren wahrscheinlich die Miniaturansichts-Blobs für Anhänge. Es stellte sich heraus, dass sie als base64-Zeichenfolgen codiert und in die serialisierten Antworten aufgenommen wurden. In geringer Anzahl waren sie akzeptabel, aber beim Massenladen wurden sie schnell riesig – zum Beispiel beim Laden einer rasterbasierten Ansicht, in der für jede Aufgabe Miniaturansichten von Anhängen gerendert wurden.

Wir konnten Probleme wie diese schrittweise beheben, indem wir Miniaturansichten für riesige Antworten einschränkten, kleinere Miniaturansichten verwendeten und schließlich Binärdaten aus der Antwort entfernten. Nach einfachen Abhilfemaßnahmen wie diesen verschwand die Anzahl der riesigen Antworten, und wir konnten anschließend Beschränkungen für die Antwortgröße auf Framework-Ebene durchsetzen⁶.

Themenkollisionen

Ein weiteres seltsames Problem, auf das wir gestoßen sind, waren PubSub-Themenkollisionen. Es stellte sich heraus, dass wir nicht konforme Framework-Nutzungen hatten, die unabhängig von der Domain dasselbe Subscription-Topic generierten. Wenn Pubsub nur bei einem einzigen Prozesstyp auftrat, waren die Auswirkungen relativ harmlos. In der Regel entspricht ein einzelnes Pubsub-Thema Daten aus einer einzelnen Domain. Da Pubsub jedoch jetzt auf Session-Broker und Syncable-Loader aufgeteilt ist, war es möglich, dass sich die beiden Prozesstypen über die Domain eines bestimmten Themas uneinig waren. Wenn dies geschah, sahen wir aufgrund dieser „Domain-Diskrepanz“ eine stabile erhöhte Rate an Invalidierungs-Neu-Ladungen. Zum Glück war die Behebung ziemlich einfach – aber es ist interessant, wie dieser Fehler so lange in unserem Framework überlebt hat, ohne entdeckt zu werden. 

Neuausrichtung der Workloads

Session-Broker und Syncable-Loader haben Workloads, die sich erheblich von denen der ursprünglichen Synchronisierungsserver unterscheiden. Session-Broker sind nur für das Session-Management zuständig und Syncable-Loader nur für das Laden von Daten. 

Wir waren nicht ganz sicher, wie sich dies auf ihren Ressourcenbedarf auswirken würde, also starteten wir beide mit ähnlichen Ressourcenanforderungen (CPU/Mem) und horizontalen Pod-Autoscaler-Einstellungen (HPA). 

session-brokers

Bei unseren Beobachtungen wurde klar, dass Session Broker wesentlich leichter waren. Sie arbeiteten zuverlässig mit sehr geringer CPU-Auslastung (wenn überhaupt, waren sie viel stärker speicherabhängig⁷). Ein paar Replikate schienen auszureichen, um den Datenverkehr einer gesamten Infrastrukturzelle zu bedienen. Als wir jedoch die minReplicas auf dem HPA tatsächlich reduzierten, beobachteten wir, dass die Datenneuladungen und die Latenz beim Neuladen in die Höhe schossen. Was war da los?

Kurz gesagt: Wir hatten es versäumt, alle unsere gemeinsamen Einstellungen bezüglich Cache-Größe und Throttler zu berücksichtigen. Mit nur wenigen Replikaten verarbeitete jeder Session-Broker weit mehr Sitzungen pro Pod (etwa das 3,5-fache) als ein typischer Sync-Server. Da sie jeweils viel mehr Daten verarbeiteten, füllten sie ihre Pubsub-Topic- ⇔ Subscription-Caches vollständig aus, und jede Löschung löste ein Neuladen aus (aus Sicherheitsgründen). Durch eine angemessene Erhöhung dieses Schwellenwerts um das ca. 6-fache wurden die erhöhten Cache-Bereinigungs- und Neuladeraten behoben. Ebenso haben wir festgestellt, dass unsere hierarchischen Reload-Throttler für die neuen Datenverkehrsraten, die eintrafen, völlig falsch konfiguriert waren. Die Anpassung dieser Throttler-Einstellungen führte in ähnlicher Weise zu einer drastischen Reduzierung der Lade-Latenz und der End-to-End-Reaktionsverzögerung (d. h. wie lange es dauert, bis eine Web-App ihren eigenen Schreibvorgang sieht) um das etwa 5- bis 10-fache.

syncable-loaders

Andererseits waren die Syncable-Loader deutlich schwerer als erwartet. Jeder Server würde mehr Abonnements pro Sekunde (etwa 1,5 x) laden als ein Sync-Server mit gleichwertigen Ressourcen. Im Gegensatz zu Session-Brokern waren sie viel stärker CPU-abhängig ⁸.

Interessanterweise wurde ein nicht unerheblicher Teil der CPU auf eine Zunahme der Truffle-Deoptimierungen im Zusammenhang mit unserem TS-SCV-Code zurückgeführt. Höchstwahrscheinlich wurde dies dadurch verursacht, dass jeder Syncable-Loader auf einen größeren Teil unseres SCV-Codes zugreift. Unabhängig davon erforderte dies eine bescheidene Vergrößerung unseres Code-Cache⁹. 

Prozessvorwärmung

Das Prozess-Warming für das Laden von Daten war in der Vergangenheit eine Herausforderung. Zum Glück ist es in unserer neuen Architektur etwas einfacher. Die Hauptoberfläche unserer Syncable-Loader sind zustandslose Datenabfragen – wir können sie also einfach aufwärmen, indem wir den bestehenden Datenverkehr zwischen Session-Brokern und Syncable-Loadern wiederholen oder spiegeln.

Andererseits stehen wir immer noch vor vielen der gleichen Herausforderungen wie beim Warming von Sync-Servern. Hauptsächlich kostet es viel CPU, Prozesse aufzuwärmen, und dies verursacht beim Start alle möglichen Noisy-Neighbor-Probleme (für uns ist die relevante Metrik hier ein teilweiser Stillstand, da wir die k8s-Drosselungsgrenzen nicht erreichen). Eine schöne Verbesserung, die wir hier vorgenommen haben, war die Verwendung der In-Place-Pod-Größenänderung, um die Ressourcen eines Syncable-Loaders beim Start zu begrenzen, ihm aber nach dem Start zu ermöglichen, zu explodieren. 

Trotzdem dauert das Warming jedes Pods immer noch einige Minuten. Nach der Betrachtung von JFR-Profilen glauben wir, dass der Hauptengpass die unzureichende Kompilierung von TS-SCV-Code während des Warmstarts ist, und wir glauben, dass es dort mehr Spielraum gibt. Wir untersuchen aktiv, wie wir die relevanten Pfade mit gezielteren Methoden präziser aufwärmen und unsere TS-Oberflächen¹⁰ für eine bessere Kompilierung umgestalten können.

Unsere Session-Broker sind nicht für das Laden von Daten verantwortlich und benötigten in der Praxis nie irgendeine Art von Warming.

Wie hat es geholfen?

Unsere neue Architektur hat unsere betriebliche Komplexität erheblich reduziert, die Bereitstellung und die Code-Geschwindigkeit beschleunigt und zukünftige Möglichkeiten für Geschwindigkeit und Skalierung eröffnet. 

Unsere neue Architektur passt sich einfach automatisch an Änderungen im gesamten Lese-Traffic an, ohne dass ein komplexes Traffic-Management erforderlich ist. Jede Art von Traffic-Verschiebungsvorgang wird ordentlich durch einfaches Neustarten von Pods abgewickelt. Müssen alle Sitzungen gestartet werden? Führen Sie einen Zyklus für alle Session-Broker durch. Müssen Sie unsere Caches leeren? Führen Sie einen Zyklus für alle synchronisierbaren Loader durch. Beide Vorgänge sind in Bezug auf die Verfügbarkeit sicher.

In der Vergangenheit wurde die Geschwindigkeit der Produktentwicklung hauptsächlich durch die Bereitstellung von Sync-Servern behindert. In unserer neuen Welt müssen wir nur Syncable-Loader bereitstellen, um Produktcode bereitzustellen. Dies haben wir getan, indem wir Syncable-Loader in ihre eigene bereitstellbare Zelle verschoben haben, die wir häufiger bereitstellen möchten (und letztendlich neben dem Produktcode). Die Bereitstellung von Syncable-Loadern ist bereits ca. 40 % schneller (ca. 20 Minuten schneller) als die von Sync-Servern (wir können die Geschwindigkeit sicher noch viel weiter erhöhen), und wir streben in Zukunft noch größere Steigerungen an.

Bemerkenswert ist, dass Leistungsverbesserungen nicht das Ziel dieser Arbeit waren, aber es lohnt sich, darüber zu sprechen, wenn man bedenkt, wie viel von der Planung und Umsetzung damit zusammenhing. Die Latenz bei der Berechnung von Abfrageergebnissen hat sich in unserem neuen System tatsächlich verbessert (wahrscheinlich aufgrund eines besseren Lastenausgleichs/einer besseren Abstimmung/einer besseren automatischen Skalierung). Dementsprechend ist die Latenz beim ersten Abonnement spürbar besser. Andererseits ist die End-to-End-Latenz der Mutationsreflexion (d. h. wie lange es dauert, bis eine Änderung an andere Sitzungen verteilt wird) ungefähr gleich. Traces zeigen, dass dies wahrscheinlich auf die Verzerrung beim Verbrauch des PubSub-Streams zwischen Session-Brokern und Syncable-Loadern zurückzuführen ist (Syncable-Loader können eine Anfrage erst dann bearbeiten, wenn sie mit der Anfrageversion auf dem neuesten Stand sind).

Nächste Schritte

Unser aktuelles System ist deutlich verbessert, aber es begrenzt immer noch die Produktgeschwindigkeit, da bei jeder Version Überlegungen zur Abwärts-/Aufwärtskompatibilität erforderlich sind. Mit all unseren Verbesserungen bei der Bereitstellungsgeschwindigkeit ist es jedoch jetzt möglich, alle unsere Datenmodelländerungen zusammen bereitzustellen, wodurch die Notwendigkeit, über Abwärts-/Aufwärtskompatibilität nachzudenken, entfällt. Wir arbeiten aktiv daran, dies in naher Zukunft zu realisieren.

Eine unserer Hauptmotivationen für diese Arbeit waren Performance-Regressionen, die schwer zuzuordnen sind. Dies ist insbesondere ein Bereich, in dem wir durch diese Arbeit keine wesentlichen Verbesserungen erzielt haben. Positiv zu vermerken ist, dass dies nun eines der Hauptprobleme ist, mit dem wir uns in Zukunft befassen werden. Im Gegensatz zum Großteil der hier besprochenen Arbeit handelt es sich um ein wesentlich funktionsübergreifenderes Problem, das Überlegungen zu Produktbereich, Datenmodell, Framework und Infrastruktur umfasst. 

synchronisierbare Loader-Arbeiterpools nach Funktion

Synchronisierbare Loader-Arbeiterpools nach Funktion

Wir freuen uns darauf, Lösungen für die Plattforminfrastruktur (z. B. Worker-Pools nach Funktion), Plattform-Frameworks und Plattform-Tools (z. B. Black-Box-/White-Box-Tests) zu erkunden.


Biografie des Verfassers

Arvind Vijayakumar ist Ingenieur im LunaDb-Team, wo er am Aufbau und der Skalierung der zentralen Datenladeplattform von Asana mitwirkt – der kritischen Infrastruktur, die sicherstellt, dass Nutzer immer genaue, reaktive und blitzschnelle Updates in unseren Web-Apps und APIs sehen.

Danksagungen an das Team

Diese Arbeit zur Skalierung von LunaDb war ein langjähriger Teamaufwand, der sich über die letzten Jahre erstreckte und an dem viele aktuelle und ehemalige Mitglieder von LunaDb beteiligt waren: Brandon Zhang, Alex Matevish, Sean Wentzel, Eric Walton, Spencer Yu, Sophia Yao, George Ong, Tyler Prete, Koushik Ghosh, Natan Dubitski, Vinodh Chandra Murthy und viele mehr


Fußnoten

  1. Wichtig ist, dass wir es entwickelt haben, bevor Facebook GraphQL als Open Source veröffentlicht hat

  2. Weitere Details finden Sie im vorherigen Beitrag zum Prozess-Warming, aber kurz gesagt: Wir verlassen uns darauf, dass Sync-Pods willkürlich Bursts ausführen können, um schnell auf lokale Traffic-Spitzen zu skalieren

  3. Wir führen auch Aktualisierungen auf Objekt- statt auf Feldebene durch (aus historischen Gründen). Dies erhöht die Datenmenge, die bei Invalidierungs-Neu-Ladevorgängen anfällt.

  4. Wir verwenden einen 128-Bit-Murmur-Hash, um Kollisionen zu vermeiden.

  5. Bemerkenswert ist, dass wir schon sehr lange Websocket-Komprimierung verwenden, was wahrscheinlich die vom Client wahrgenommenen Auswirkungen mildert.

  6. Bemerkenswert ist auch, dass wir ursprünglich einige Probleme mit dem Overhead des Netzwerk-Hops hatten. Nach einigen Untersuchungen stellten wir fest, dass der größte Teil des Overheads tatsächlich auf unser Service-Mesh (Istio/Envoy) zurückzuführen war, und wir nahmen einige gezielte Anpassungen vor, um die Leistung in diesem Bereich zu verbessern.

  7. Ein wichtiger Faktor für den beibehaltenen Speicher war die Neuschreibung unseres serverseitigen synchronisierbaren Speichers, um nur Hashes von Daten beizubehalten. Es war kein einfaches Problem, und wir werden möglicherweise einen Folgebeitrag dazu veröffentlichen! Ohne dies führten die gespeicherten Client-Daten dazu, dass Session-Broker erheblich mehr Speicher verbrauchten.

  8. Zuvor liefen alle Sync-Server auf speicheroptimierten Instanzen. Syncable-Loader hingegen profitieren von Knotentypen mit mehr CPU-Leistung wie gemischten Instanzen.

  9. Ein bemerkenswertes Merkmal der Ausführung von TS auf GraalVM auf diese Weise ist, dass es deutlich mehr Code-Cache verwendet, als es in einer eher standardmäßigen Scala/Java-JVM-Anwendung üblich ist.

  10. Soweit wir wissen, macht ein hoher Polymorphismus die TS-Kompilierung teurer. Wir prüfen, ob wir zu monomorphen Oberflächen wechseln und polymorphe Spezialisierungen für die Berichterstattung nutzen können