Wie wir die Business-Members-Seite ~100x schneller gemacht haben

Die Business-Members-Seite benötigte bei jedem Laden etwa 1,8 Sekunden Serverzeit. Wir haben zwei sich verstärkende Designfehler gefunden, beide behoben, und dieselbe Seite rendert aus dem Cache nun in einstelligen Millisekunden. Dieser Artikel erklärt, was langsam war, warum es langsam war und was wir genau geändert haben.

Performance-Verbesserung

  • Vorher: ~1,8 Sekunden
  • Nachher: <10 Millisekunden
  • Verbesserung: ~100x+

Gemessen mit PayCal Lens.

Für Business-Administratoren wirkt die Members-Seite jetzt effektiv sofort, auch bei größeren Organisationen.

Kurzfassung

Betroffene Seite Business Members — das Grid mit allen Mitgliedern eines Unternehmens und berechneten Finanzspalten
Vorher ~1,8 Sekunden Serverzeit pro Seitenaufruf
Nachher Einstellige Millisekunden bei Cache-Hits (~100x+ Verbesserung); Cache-Misses sind ebenfalls schneller als der alte Pfad
Ursachen Ein N+1-Abfragemuster gegen Redis und vollständige Neuberechnung eines Jahres Payroll-Mathematik bei jedem View
Fix Gebündelte Redis-Lookups per Pipeline plus materialisierter Cache der fertigen Grid-Daten mit expliziter Invalidierung
Datenaktualität Der Cache wird bei jeder memberbezogenen Änderung sofort invalidiert; ein 5-Minuten-Ablauf begrenzt Staleness als Sicherheitsnetz

Warum wir das veröffentlichen

Performance ist Teil von Transparenz. Wenn eine Seite langsam ist, sollten Nutzer wissen können, ob die Langsamkeit in der Arbeit selbst liegt oder aus einem vermeidbaren Designfehler entsteht. In diesem Fall war es Letzteres, gleich doppelt, und wir halten die Details für teilenswert, weil beide Fehler zu den häufigsten Performance-Problemen in Websoftware gehören.

Nichts in diesem Artikel betrifft ein Sicherheitsproblem oder eine Offenlegung von Nutzerdaten. Es ist rein eine Engineering-Geschichte darüber, eine langsame Seite schnell zu machen.

Was die Seite macht

Die Business-Members-Seite listet jedes Mitglied eines Unternehmens auf. Neben Name und Rolle zeigt das Grid fünf berechnete Finanzspalten:

Keiner dieser Werte wird irgendwo als fertige Zahl gespeichert. Sie werden aus den rohen Arbeitseinträgen jedes Mitglieds berechnet, den einzelnen Schichtdatensätzen in unserer Redis-Datenbank. Eine Grid-Zeile zu erzeugen bedeutet, Profil und komplettes Arbeitsjahr eines Mitglieds zu laden, Stunden in regulär und Überstunden aufzuteilen und Bruttoentgelt zu summieren. Multipliziert mit jedem Mitglied des Unternehmens ist das die Arbeit der Seite.

  • Brutto seit Jahresbeginn — gesamte Einnahmen in diesem Jahr
  • Gesamtstunden — alle in diesem Jahr gearbeiteten Stunden
  • Reguläre Stunden — Stunden zum Standardsatz
  • Überstunden — Stunden über dem regulären Schwellenwert
  • Trailing baseline — rollierende Referenzzahl für Vergleiche

Das Problem, Teil 1 — Das N+1-Abfragemuster

Die ursprüngliche Implementierung machte eine Abfrage für die Mitgliederliste und dann, für jedes von ungefähr 100 Mitgliedern, separate, sequentielle Round-Trips zu Redis: einen für das Profil und weitere für das vollständige Jahr an Arbeitseinträgen.

Kleine Unternehmen mit wenigen Mitgliedern waren weitgehend nicht betroffen; die gestapelte Latenz wird erst sichtbar, wenn Organisationen auf dutzende oder hunderte Mitglieder wachsen und Round-Trips pro Mitglied zu Sekunden Serverzeit anwachsen.

Das ist das klassische “N+1”-Muster: eine Abfrage für die Liste, dann N weitere Abfragen nacheinander für die Elemente darin. Einzelne Redis-Lookups sind schnell, deutlich unter einer Millisekunde tatsächlicher Arbeit, aber jeder Round-Trip zahlt auch feste Netzwerklatenz. Sequentiell überlappen diese Kosten nicht; sie stapeln sich linear.

Die Datenbank war nie der Engpass. Das Gespräch mit der Datenbank war es.

Das Problem, Teil 2 — Alles bei jedem View neu berechnen

Der zweite Fehler verstärkte den ersten. Die gesamte Payroll-Mathematik, ein Jahr Arbeitseinträge in reguläre und Überstunden zu teilen, Brutto seit Jahresbeginn zu berechnen, die trailing baseline abzuleiten, für jedes Mitglied, wurde bei jedem einzelnen Seitenaufruf von Grund auf neu berechnet.

Arbeitseinträge ändern sich nicht sehr häufig. Zwischen zwei aufeinanderfolgenden Aufrufen der Members-Seite sind die zugrunde liegenden Daten fast immer identisch. Trotzdem zahlte jeder Besuch die vollen Kosten für Ergebnisse, die kurz zuvor bereits berechnet und dann verworfen wurden.

Zusammen erzeugten die beiden Fehler etwa 1,8 Sekunden Serverzeit pro Seitenaufruf, gemessen mit PayCal Lens, unserer integrierten Server-Timing-Instrumentierung.

Der Fix, Teil 1 — Redis Pipelining

Redis unterstützt Pipelining: einen Stapel Befehle in einem einzigen Round-Trip senden und alle Antworten zusammen erhalten. Statt eine Frage zu stellen, zu warten, die nächste zu stellen und wieder zu warten, stellt der Server nun alle Fragen auf einmal.

Wir haben die Batch-Lookup-Methode Database::pipelineHgetall() ergänzt und das Members-Grid darauf umgestellt. Alle Profil-Lookups werden in einem Round-Trip gesammelt und alle Work-Entry-Lookups in einem weiteren, statt ein Round-Trip pro Mitglied und Datentyp.

// Before - one round-trip per member, latency stacks linearly
foreach ($memberIds as $id) {
    $profiles[$id] = Database::hgetall($profileKey($id));
}

// After - one round-trip for the whole batch
$profiles = Database::pipelineHgetall(array_map($profileKey, $memberIds));

Diese Änderung allein reduzierte hunderte sequentielle Latenzzahlungen auf wenige.

Der Fix, Teil 2 — Ein materialisierter Cache

Pipelining macht die Berechnung günstiger zu füttern; der zweite Fix vermeidet, die Berechnung überhaupt zu wiederholen. Wir haben BusinessMembersCache eingeführt, einen serverseitigen Cache, der die fertig berechneten Grid-Daten, also die Finanzzusammenfassungen pro Mitglied, in Redis speichert.

Zwei Regeln steuern die Cache-Aktualität:

  • 5-Minuten-Ablauf. Jedes gecachte Grid läuft automatisch nach 5 Minuten ab und begrenzt damit, wie alt die Daten maximal sein können.
  • Explizite Invalidierung bei Änderungen. Jede memberbezogene Änderung, Rollenupdate, hinzugefügtes oder entferntes Mitglied, löscht das gecachte Grid sofort. Nach relevanten Edits wird der Cache sofort invalidiert, sodass der nächste Request frische Daten erzeugt.

Mitgliedsidentität und Berechtigungsprüfungen werden bewusst nicht gecacht. Jeder Request durchläuft weiterhin den vollständigen Access-Control-Pfad; nur die teure Finanzarithmetik wird wiederverwendet.

Auswirkung

  • Cache-Hits liefern das Grid in einstelligen Millisekunden — eine ~100x+ Verbesserung gegenüber den früheren ~1,8 Sekunden.
  • Cache-Misses sind immer noch schneller als die alte Seite, weil die Neuberechnung nun auf gepipelten Batch-Lookups statt auf hunderten sequentiellen Round-Trips läuft.
  • Korrektheit ist getestet. Contract- und Unit-Tests decken das Invalidierungsverhalten ab und prüfen, dass Rollenupdates und Mitgliedschaftsänderungen das gecachte Grid entfernen und abgelaufene oder nicht passende Cache-Einträge nie ausgeliefert werden.
Performance improvement summary showing ~1.8 seconds before, under 10 milliseconds after, and roughly 100x improvement measured with PayCal Lens
PayCal Lens measured server time before and after the fix.
PayCal Lens performance summary before optimization showing 1812 milliseconds total duration with financial summaries, profile hydration, and sequential work entry lookups as the slowest paths
Before: PayCal Lens ranked the per-member financial recomputation and sequential Redis round trips as the dominant costs (~1812 ms total).
PayCal Lens performance summary after optimization showing 7 milliseconds total duration with BusinessMembersCache get as the top path and cache hit status
After: the same page on a cache hit completes in single-digit milliseconds (~7 ms), with the materialized grid read as the primary work.
Business members grid table showing member names, roles, year-to-date gross, and total hours columns populated
The members grid loads immediately on repeat visits while access control still runs on every request.

Was wir daraus mitnehmen

  • Erst messen, dann optimieren. PayCal Lens zeigte genau, wohin die 1,8 Sekunden gingen. Ohne Timing pro Request wären beide Fehler Vermutungen geblieben.
  • Latenz stapelt sich; bündele sie. Viele kleine schnelle Abfragen nacheinander sind langsamer als eine große Batch-Abfrage. Round-Trips, nicht Datenvolumen, dominierten diese Seite.
  • Fertige Arbeit cachen, eifrig invalidieren. Ein Cache ist nur vertrauenswürdig, wenn jeder betroffene Schreibpfad ihn auch leert. Der Ablauf ist Sicherheitsnetz, nicht Mechanismus.

Wir werden weiterhin Engineering-Berichte wie diesen im Transparenz-Hub.

Engineering-Fakten

  • Commit(s): 3db2229b, 2b3eafb8, f63773ea
  • Files Changed: 14 (under html/)
  • Tests Added: 12 (BusinessMembersCacheTest — 9 cases; LensRenderTest — 3 cases)
  • Tests Passing: 1901 (full PHPUnit suite, June 2026)
  • Performance Impact: ~1.8 s → ~7 ms (<10 ms on cache hits)
  • Production Status: Deployed