Performance-Arbeit hat eine spezielle Form. Man fängt nicht mit Code an. Man fängt damit an, ehrlich zu sein, was „langsam" bedeutet, was „schnell" bedeuten würde und welche dieser Dinge man tatsächlich messen kann. Der Großteil der Arbeit ist das Messen, der Streit darüber, was die Zahlen sagen, und der gelegentliche Klarheits-Moment, in dem ein Flamegraph drei Hypothesen auf die richtige Antwort kollabieren lässt.
Das ist die Geschichte eines solchen Moments, und der sechs Wochen Instrumentierung, die ihn möglich gemacht haben.
Der Ausgangszustand
Die fragliche API war der meistgenutzte öffentliche Read-Pfad im Produkt. Über fünfzig Downstream-Integrationen auf Web, Desktop, nativem iOS, nativem Android und einer Partner-Read-API hingen davon ab. Das nutzersichtbare Latenz-Budget jedes Teams hatte diesen einen Endpoint auf dem kritischen Pfad.
Die Zahlen, als ich es übernahm:
| Perzentil | Latenz | Schmerz spürbar bei |
|---|---|---|
| p50 | mittlerer dreistelliger ms-Bereich | dem Median-Nutzer, jeden Tag |
| p75 | niedriger vierstelliger ms-Bereich | dem Durchschnittsnutzer gelegentlich |
| p95 | hoher vierstelliger ms-Bereich | der Bounce-Rate auf der Seite |
| p99 | niedriger einstelliger Sekundenbereich | dem Partner-API-SLO |
Das Team hatte das schon zweimal in Angriff genommen. Beide Versuche hatten sich auf p99 fokussiert, die Worst-Case-Latenz, die Pages auslöst, und sie inkrementell bewegt, ohne die Median-Erfahrung zu verändern. Der Großteil der nutzersichtbaren Verbesserung kommt aus dem Verschieben des p50, aber p50-Arbeit ist schwerer zu motivieren, weil dafür niemand gepaged wird.
Constraints
- Brich keine Konsumenten. Fünfzig Downstream-Integrationen, die meisten mit eigenen Caches und Client-Side-Annahmen über die Response-Form. Wire-Level-Änderungen waren in Ordnung; semantische Änderungen nicht.
- Nur Produktionsdaten. Synthetische Last sagte uns nichts über die Form des echten Traffics, Request-Verteilung, Cache-Hit-Ratios, Tageszeit-Muster.
- Zwei Wochen Instrumentierungs-Budget. Ich hatte der Squad einen messbaren Win in sechs Wochen versprochen. Zwei davon gingen darauf, das Ding messbar zu machen.
Vorgehen
Sechs Wochen, drei Phasen.
Woche 1–2: Instrumentierung
Der Endpoint hatte grundlegende Distributed Traces und APM. Sie waren nicht nützlich. Die Traces waren von einem einzigen Top-Level-Resolver- Span dominiert, der den Großteil der Wallclock-Zeit umspannte und uns keinen Einblick gab, was darin passierte.
Ich ergänzte:
- Per-Resolver-Timing, an Traces als Span-Attribute angehängt. Die Resolver-Level-Instrumentierung existierte bereits, sie war nur hinter einem Feature-Flag wegen Overhead-Bedenken aus. Den Overhead profiled: vernachlässigbar, deutlich unter einer Millisekunde im Median. Global an.
- Per-Dataloader-Batch-Size und Key-Cardinality, als Histogramme. Wir hatten eine Handvoll Dataloader in dieser Resolver-Kette; keiner davon war instrumentiert.
- Einen Flamegraph-Endpoint hinter einem internen Header, der einen CPU-Profiler auf dem laufenden Node-Prozess startete und das Ergebnis in einen Scratch-Storage-Bucket schickte. Production-safe (read-only, gesampelt, gegated).
- Einen Traffic-Mirror auf eine Non-Production-Replica, sodass wir repräsentativen Produktions-Load gegen Änderungen replayen konnten, ohne echte Nutzer:innen zu gefährden.
Zwei Wochen. Langweilige Arbeit. Ohne sie wäre alles, was folgte, geraten gewesen.
Woche 3–4: Untersuchung
Der Flamegraph, der zählte, tauchte in der Mitte dieser Phase auf.
Ein erheblicher Teil der Median-Request-Zeit, ein zweistelliger Prozentsatz eines dreistelligen ms-Budgets, wurde in einer einzigen Funktion verbracht, die einen Batch von Datensätzen mit abgeleiteten Attributen anreicherte, bevor sie zurückgegeben wurden.
Tief in dieser Funktion klonte ein Helper eine geteilte Lookup-Map für jeden einzelnen Datensatz im Batch tief, statt einmal pro Request. Auf einer typischen Seite mit ein paar Dutzend Datensätzen und einer Lookup-Map mit tausenden Einträgen summierte sich das zu zehntausenden vermeidbaren Clone-Operationen auf dem Median-Pfad.
Ich würde gern sagen, dass mir das in einem Code-Review aufgefallen ist. Dem Flamegraph ist es aufgefallen. Der Engineer, der das vor Jahren geschrieben hat, ist eine:r der stärksten im Team, das war ein Praxisbeispiel für „du kannst nicht reviewen, was sich als Funktionsaufruf in der Codebasis versteckt."
Der Fix war klein: die Lookup-Map read-only machen und dieselbe Referenz über alle Datensätze im Batch teilen, statt sie pro Datensatz zu klonen.
Das war die größte einzelne Änderung. Zwei weitere ergaben sich aus demselben Flamegraph:
- Dataloader-Cache-Leakage. Einer der Dataloader wurde per-request instanziiert, wo er per-context hätte instanziiert werden sollen. Sein Cache wurde bei jedem Request weggeworfen. Die Lösung: eine Handvoll Zeilen in der Context-Factory.
- Ein redundanter Auth-Check. Die Graph-Layer rief den Auth-Service zweimal auf, einmal, um die Nutzer:in zu laden, einmal, um zu verifizieren, dass sie auf die Datensätze zugreifen darf. Der zweite Aufruf war immer ein No-Op, weil die Auth-Tokens einen Scope-Claim enthielten. Ganz entfernt.
Woche 5–6: Rollout und Validierung
Alle drei Änderungen liefen hinter einem Feature-Flag aus, gerampt 5 % → 25 % → 50 % → 100 % über fünf Tage. Der Traffic-Mirror bestätigte die Wins auf Non-Production-Last vor allem, was echte Nutzer:innen erreichte; der gestaffelte Rollout ließ mich sofort zurückrollen, wenn synthetisches Monitoring auslöste.
Es löste nicht aus.
Ergebnis
| Perzentil | Vorher | Nachher | Delta |
|---|---|---|---|
| p50 | mittlerer dreistelliger ms | niedriger dreistelliger ms | spürbare zweistellige %-Reduktion |
| p75 | niedriger vierstelliger ms | mittlerer dreistelliger ms | ähnlich |
| p95 | hoher vierstelliger ms | niedriger vierstelliger ms | kleiner, aber real |
| p99 | niedriger einstelliger Sekunden | niedriger einstelliger Sekunden | unverändert, der Long-Tail lag woanders |
Der p99 bewegte sich nicht. Das war wichtiger Kontext: Diese Arbeit ging um die Median-Erfahrung, nicht um den Worst-Case. Das Team, das die Long-Tail-Arbeit besaß, brauchte eine separate Untersuchung. Performance-Arbeit am Median und Performance-Arbeit am Tail sind verschiedene Projekte, die ständig vermischt werden.
Nutzersichtbarer Knock-on: Die Render-Zeit der Seite sank, und die Bounce-Rate auf getrackten Journeys ging mit ihr um einen niedrig-einstelligen Prozentsatz nach unten. Andere Teams, deren kritischer Pfad diesen Endpoint enthielt, sahen ihre eigenen Dashboards verbessert, ohne etwas zu tun.
Was ich anders machen würde
Ich hätte den Flamegraph-Endpoint zuerst ausgeliefert, vor allem anderen. Er war das einzelne nützlichste Artefakt des Projekts. Die ersten zwei Wochen Untersuchung hätten die ersten paar Tage sein können, wenn ich ihn früher gehabt hätte. Die Zurückhaltung war wegen Sicherheit (ein CPU-Profiler in Produktion klang nach schlechter Idee); das tatsächliche Risikoprofil war viel kleiner als das wahrgenommene.
Ich hätte Per-Konsument-Latenz vor dem Rollout gemessen. Die fünfzig Downstream-Integrationen hatten ihre eigenen Latenz-Dashboards, und die Verbesserungs-Muster waren nicht uniform, manche Teams sahen größere Wins als andere, je nachdem, welche Felder sie abfragten. Per-Konsument-Aufschlüsselungen am Rollout-Review zu teilen, hätte mehr Goodwill gebaut und zwei Konsumenten-seitige Probleme früher zutage gefördert.
Ich hätte die Flamegraph-Methodik als internes Guide aufgeschrieben. Mehrere Engineers haben mich nachträglich gefragt, wie ich es gemacht habe. Die Methodik, was zu instrumentieren, wie zu gaten, worauf zu achten, war über viele Endpoints hinweg wiederverwendbar. Sie zu dokumentieren hätte den Effekt der Arbeit multipliziert.