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.
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