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 Listing-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 OTel-Traces und DataDog APM. Sie waren
nicht nützlich. Die Traces waren von einem einzigen Span namens
graphql.execute dominiert, der 80 % 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 GraphQL-Tools-Resolver-Level-Instrumentierung existierte bereits, sie war nur hinter einem Feature-Flag wegen Overhead-Bedenken aus. Den Overhead profiled: 0,4 ms p50. Global an.
- Per-Dataloader-Batch-Size und Key-Cardinality, als Histogramme. Wir hatten vier Dataloader in dieser Resolver-Kette; null davon waren instrumentiert.
- Einen Flamegraph-Endpoint hinter einem internen Header, der
pprofauf dem laufenden Node-Prozess startete und das SVG in einen Scratch-S3-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 am fünfzehnten Tag auf.
Zwölf Prozent des Median-Requests, ein erheblicher Teil eines dreistelligen ms-Budgets, wurden in einer einzigen Funktion verbracht:
function attachVehicleAttributes(listings: Listing[], lookups: AttributeMap) {
return listings.map((listing) => ({
...listing,
attributes: enrichAttributes(listing, lookups),
}));
}
enrichAttributes war eine 200-Zeilen-Funktion, die tief drinnen
einen Helper aufrief, der einen Helper aufrief, der
JSON.parse(JSON.stringify(...)) benutzte, um die Lookup-Map für
jedes Listing tief zu kopieren. Mit ~24 Listings auf einer typischen
Seite und einer Lookup-Map mit ~3000 Einträgen waren das 72.000
Clone-Operationen pro Request, 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 drei 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."
// vorher, ein Clone für jede Listing-Anreicherung
return listings.map(listing => ({
...listing,
attributes: enrichAttributes(listing, lookups),
}));
// nachher, die Lookup-Map ist read-only, teile sie
const sharedLookups = Object.freeze(lookups);
return listings.map(listing => ({
...listing,
attributes: enrichAttributes(listing, sharedLookups),
}));
Das war die größte einzelne Änderung. Zwei weitere ergaben sich aus demselben Flamegraph:
- Dataloader-Cache-Leakage. Einer der vier 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 6-Zeilen-Änderung 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 Listings 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 Listing-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 drei Tage sein können, wenn ich ihn früher gehabt hätte. Die Zurückhaltung war wegen Sicherheit (pprof 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. Drei 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.