Miglioramento prestazioni
- Prima: ~1,8 secondi
- Dopo: <10 millisecondi
- Miglioramento: ~100x+
Misurato con PayCal Lens.
Per gli amministratori business, la pagina Members ora appare praticamente istantanea, anche per organizzazioni più grandi.
Riepilogo esecutivo
| Pagina interessata | Business Members — la griglia che elenca ogni membro del business con colonne finanziarie calcolate |
| Prima | ~1,8 secondi di tempo server per caricamento |
| Dopo | Millisecondi a una cifra su cache hit (~100x+); anche i cache miss sono più veloci del vecchio percorso |
| Cause radice | Pattern di query N+1 su Redis e ricomputazione completa di un anno di calcoli payroll a ogni vista |
| Fix | Lookup Redis batch/pipelined più cache materializzata dei dati finali della griglia con invalidazione esplicita |
| Freschezza dati | La cache viene invalidata subito a ogni modifica legata ai membri; una scadenza di 5 minuti limita la staleness come safety net |
Perché lo pubblichiamo
La performance fa parte della trasparenza. Quando una pagina è lenta, gli utenti meritano di sapere se la lentezza è inerente al lavoro svolto o deriva da un difetto di design evitabile. In questo caso era la seconda, due volte, e i dettagli sono utili perché entrambi i difetti sono errori prestazionali comuni nel software web.
Nulla in questo articolo riguarda un problema di sicurezza o esposizione di dati utente. È solo una storia tecnica su come rendere veloce una pagina lenta.
Cosa fa la pagina
La pagina Business Members elenca ogni membro di un business. Accanto a nome e ruolo, la griglia mostra cinque colonne finanziarie calcolate:
Nessuno di questi valori viene salvato come numero finale. Sono calcolati dalle work entries grezze di ogni membro, i record dei turni salvati in Redis. Produrre una riga significa caricare profilo, anno completo di entries, separare ore regolari e straordinari e sommare la paga lorda.
- Lordo year-to-date — guadagni totali finora nell’anno
- Ore totali — tutte le ore lavorate quest’anno
- Ore regolari — ore alla tariffa standard
- Ore straordinarie — ore oltre la soglia regolare
- Trailing baseline — riferimento rolling usato per confronto
Il problema, parte 1 — Pattern di query N+1
L’implementazione originale faceva una query per la lista membri e poi, per circa 100 membri, round-trip Redis separati e sequenziali: uno per il profilo e altri per l’anno completo di work entries.
I piccoli business erano quasi non toccati; la latenza impilata diventa visibile quando le organizzazioni crescono a decine o centinaia di membri.
È il classico pattern “N+1”: una query per la lista, poi N query una alla volta. Ogni lookup Redis è veloce, ma ogni round-trip paga latenza di rete fissa; in sequenza questi costi si sommano linearmente.
Il database non era il collo di bottiglia. Lo era la conversazione con il database.
Il problema, parte 2 — Ricomputare tutto a ogni vista
Il secondo difetto amplificava il primo. Tutta la matematica payroll, divisione tra ore regolari e straordinari, lordo year-to-date e trailing baseline, per ogni membro, veniva rifatta da zero a ogni vista.
Le work entries cambiano raramente. Tra due visite consecutive della pagina Members, i dati sottostanti sono quasi sempre identici. Eppure ogni visita pagava il costo completo di risultati appena calcolati e scartati.
Insieme, i due difetti producevano circa 1,8 secondi di tempo server per caricamento, misurati con PayCal Lens.
Il fix, parte 1 — Redis pipelining
Redis supporta il pipelining: inviare un batch di comandi in un solo round-trip e ricevere tutte le risposte insieme. Il server ora fa tutte le domande in una volta.
Abbiamo aggiunto Database::pipelineHgetall() e convertito la griglia membri per usarlo. I profili vengono caricati in un round-trip e le work entries in un altro.
// 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));
Questo cambiamento da solo ha ridotto centinaia di latenze sequenziali a poche.
Il fix, parte 2 — Cache materializzata
Il pipelining rende più economico alimentare il calcolo; il secondo fix evita di ripetere il calcolo. Abbiamo introdotto BusinessMembersCache, cache server-side che salva in Redis i dati finali calcolati della griglia.
Due regole governano la freschezza della cache:
- Scadenza di 5 minuti. Ogni griglia in cache scade automaticamente dopo 5 minuti.
- Invalidazione esplicita su modifica. Ogni modifica legata ai membri, ruolo aggiornato, membro aggiunto o rimosso, elimina subito la griglia in cache.
Identità membro e controlli permessi deliberatamente non sono in cache. Ogni request esegue ancora il percorso completo di access control.
Impatto
- I cache hit servono la griglia in millisecondi a una cifra — miglioramento ~100x+ rispetto ai ~1,8 secondi precedenti.
- I cache miss restano più veloci della vecchia pagina, perché la ricomputazione usa lookup batch in pipeline.
- La correttezza è testata. Test contract e unit coprono il comportamento di invalidazione cache.
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.
Continueremo a pubblicare write-up tecnici come questo nel Hub della trasparenza.
Fatti tecnici
- 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