Come abbiamo reso la pagina Business Members ~100x più veloce

La pagina Business Members richiedeva circa 1,8 secondi di tempo server a ogni caricamento. Abbiamo individuato due difetti di design sovrapposti, li abbiamo corretti entrambi, e la stessa pagina ora renderizza dalla cache in millisecondi a una cifra.

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

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