Comment nous avons rendu la page Business Members ~100x plus rapide

La page Business Members prenait environ 1,8 seconde de temps serveur à chaque chargement. Nous avons identifié deux défauts de conception qui se renforçaient, corrigé les deux, et la même page se rend maintenant depuis le cache en quelques millisecondes.

Amélioration de performance

  • Avant : ~1,8 seconde
  • Après : <10 millisecondes
  • Amélioration : ~100x+

Mesuré avec PayCal Lens.

Pour les administrateurs métier, la page Members paraît maintenant pratiquement instantanée, même pour les grandes organisations.

Résumé exécutif

Page concernée Business Members — la grille listant chaque membre d’une entreprise avec des colonnes financières calculées
Avant ~1,8 seconde de temps serveur par chargement
Après Quelques millisecondes sur cache hit (~100x+ d’amélioration) ; les cache misses sont aussi plus rapides que l’ancien chemin
Causes racines Un motif de requêtes N+1 contre Redis et la recomputation complète d’une année de calculs de paie à chaque vue
Correction Recherches Redis groupées en pipeline, plus cache matérialisé de la grille finale avec invalidation explicite
Fraîcheur des données Le cache est invalidé immédiatement à tout changement lié aux membres ; une expiration de 5 minutes borne la staleness comme filet de sécurité

Pourquoi nous publions cela

La performance fait partie de la transparence. Quand une page est lente, les utilisateurs méritent de savoir si cette lenteur est inhérente au travail effectué ou vient d’un défaut de conception évitable. Ici, c’était la seconde option, deux fois, et les détails valent d’être partagés car ces deux défauts sont parmi les erreurs de performance les plus courantes du web.

Rien dans cet article ne concerne une faille de sécurité ou une exposition de données utilisateur. C’est simplement une histoire d’ingénierie sur la façon de rendre rapide une page lente.

Ce que fait la page

La page Business Members liste chaque membre d’une entreprise. À côté du nom et du rôle, la grille affiche cinq colonnes financières calculées :

Aucune de ces valeurs n’est stockée comme nombre final. Elles sont calculées à partir des entrées de travail brutes de chaque membre, les enregistrements de shifts conservés dans Redis. Produire une ligne signifie charger le profil, l’année complète d’entrées, séparer les heures régulières et supplémentaires, puis sommer la paie brute.

  • Brut depuis le début de l’année — revenus totaux depuis le début de l’année
  • Heures totales — toutes les heures travaillées cette année
  • Heures régulières — heures au taux standard
  • Heures supplémentaires — heures au-delà du seuil régulier
  • Trailing baseline — référence mobile utilisée pour comparaison

Le problème, partie 1 — Le motif de requêtes N+1

L’implémentation originale faisait une requête pour récupérer la liste des membres puis, pour environ 100 membres, des allers-retours Redis séparés et séquentiels : un pour le profil et d’autres pour l’année complète d’entrées.

Les petites entreprises étaient peu touchées ; la latence empilée devient visible quand les organisations atteignent des dizaines ou centaines de membres.

C’est le motif classique “N+1” : une requête pour la liste, puis N requêtes une par une pour les éléments. Chaque lookup Redis est rapide, mais chaque aller-retour paie une latence réseau fixe. En séquence, ces coûts ne se chevauchent pas.

La base de données n’a jamais été le goulot. La conversation avec la base l’était.

Le problème, partie 2 — Tout recalculer à chaque vue

Le second défaut renforçait le premier. Toute la logique de paie, séparer une année d’entrées en heures régulières et supplémentaires, calculer le brut annuel et la baseline pour chaque membre, était refaite depuis zéro à chaque affichage.

Les entrées de travail changent rarement. Entre deux vues consécutives de Members, les données sont presque toujours identiques. Pourtant chaque visite payait le coût complet de résultats tout juste calculés puis jetés.

Ensemble, les deux défauts produisaient environ 1,8 seconde de temps serveur par chargement, mesurée avec PayCal Lens.

La correction, partie 1 — Redis pipelining

Redis prend en charge le pipelining : envoyer un lot de commandes en un seul aller-retour et recevoir toutes les réponses ensemble. Le serveur pose maintenant toutes ses questions à la fois.

Nous avons ajouté Database::pipelineHgetall() et converti la grille des membres pour l’utiliser. Les profils sont chargés en un aller-retour et les entrées de travail en un autre, plutôt qu’un aller-retour par membre et type de donnée.

// 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));

Ce changement a réduit à lui seul des centaines de paiements de latence séquentiels à quelques-uns.

La correction, partie 2 — Un cache matérialisé

Le pipelining rend le calcul moins coûteux à alimenter ; la seconde correction évite de répéter le calcul. Nous avons introduit BusinessMembersCache, un cache serveur qui stocke dans Redis les données finales calculées de la grille.

Deux règles gouvernent la fraîcheur du cache :

  • Expiration de 5 minutes. Chaque grille en cache expire automatiquement après 5 minutes.
  • Invalidation explicite sur changement. Tout changement lié aux membres, rôle modifié, membre ajouté ou retiré, supprime immédiatement la grille en cache.

L’identité des membres et les contrôles de permission ne sont volontairement pas mis en cache. Chaque requête exécute toujours tout le chemin de contrôle d’accès.

L’impact

  • Les cache hits servent la grille en quelques millisecondes — une amélioration d’environ 100x par rapport aux ~1,8 seconde précédentes.
  • Les cache misses restent plus rapides que l’ancienne page, car la recomputation utilise maintenant des lookups groupés en pipeline.
  • La correction est testée. Des tests contractuels et unitaires couvrent l’invalidation du cache pour changements de rôle, changements d’adhésion et entrées expirées ou incompatibles.
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.

What We Took Away From This

  • Measure before optimizing. PayCal Lens told us exactly where the 1.8 seconds went. Without per-request timing instrumentation, both flaws would have been guesses.
  • Latency stacks; batch it. Many small fast queries issued sequentially are slower than one large batched query. Round-trips, not data volume, dominated this page.
  • Cache finished work, invalidate eagerly. A cache is only trustworthy if every write path that affects it also clears it. The expiry is a safety net, not the mechanism.

Nous continuerons à publier des notes d’ingénierie comme celle-ci dans le Centre de transparence.

Faits d’ingénierie

  • 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