Prestatieverbetering
- Voor: ~1,8 seconden
- Na: <10 milliseconden
- Verbetering: ~100x+
Gemeten met PayCal Lens.
Voor bedrijfsbeheerders voelt de Members-pagina nu vrijwel direct, zelfs bij grotere organisaties.
Managementsamenvatting
| Betrokken pagina | Business Members — de grid met elk bedrijfslid en berekende financiële kolommen |
| Voor | ~1,8 seconden servertijd per paginaload |
| Na | Milliseconden met één cijfer bij cache hits (~100x+ verbetering); cache misses zijn ook sneller dan het oude pad |
| Oorzaken | Een N+1-querypatroon tegen Redis en volledige herberekening van een jaar payroll-rekenwerk bij elke view |
| Fix | Gebatchte/pipelined Redis-lookups plus een gematerialiseerde cache van de voltooide griddata met expliciete invalidatie |
| Datafreshness | Cache wordt direct geïnvalideerd bij elke member-gerelateerde wijziging; een expiratie van 5 minuten begrenst staleness als safety net |
Waarom we dit publiceren
Performance is onderdeel van transparantie. Als een pagina traag is, moeten gebruikers kunnen weten of dat inherent is aan het werk of komt door een vermijdbare ontwerpfout. Hier was het dat laatste, twee keer, en beide fouten zijn veelvoorkomende performanceproblemen in websoftware.
Dit artikel gaat niet over een beveiligingsprobleem of blootstelling van gebruikersdata. Het is puur een engineeringverhaal over een trage pagina snel maken.
Wat de pagina doet
De Business Members-pagina toont elk lid van een bedrijf. Naast naam en rol toont de grid vijf berekende financiële kolommen:
Deze waarden worden nergens als afgeronde cijfers opgeslagen. Ze worden berekend uit de ruwe work entries van elk lid, de shiftrecords in Redis. Eén rij maken betekent profiel laden, een volledig jaar entries laden, uren splitsen in regulier en overwerk, en brutoloon optellen.
- Year-to-date gross — totale inkomsten tot nu toe dit jaar
- Totaal uren — alle gewerkte uren dit jaar
- Reguliere uren — uren tegen standaardtarief
- Overuren — uren boven de reguliere drempel
- Trailing baseline — rollende referentiewaarde voor vergelijking
Het probleem, deel 1 — Het N+1-querypatroon
De oorspronkelijke implementatie deed één query voor de ledenlijst en daarna, voor ongeveer 100 leden, losse sequentiële round-trips naar Redis: één voor het profiel en meer voor het volledige jaar work entries.
Kleine bedrijven merkten hier weinig van; de gestapelde latency wordt zichtbaar wanneer organisaties groeien naar tientallen of honderden leden.
Dit is het klassieke “N+1”-patroon: één query voor de lijst, daarna N extra queries één voor één. Redis-lookups zijn snel, maar elke round-trip betaalt vaste netwerklatency; sequentieel stapelt dat lineair.
De database was nooit de bottleneck. Het gesprek met de database was dat wel.
Het probleem, deel 2 — Alles bij elke view herberekenen
De tweede fout versterkte de eerste. Alle payroll-berekeningen, een jaar entries splitsen in regulier en overwerk, year-to-date gross berekenen en trailing baseline afleiden, werden voor elk lid bij elke page view opnieuw gedaan.
Work entries veranderen niet vaak. Tussen twee opeenvolgende views zijn de onderliggende data bijna altijd gelijk. Toch betaalde elk bezoek de volledige kost van net berekende en weggegooide resultaten.
Samen leverden de twee fouten ongeveer 1,8 seconden servertijd per paginaload op, gemeten met PayCal Lens.
De fix, deel 1 — Redis pipelining
Redis ondersteunt pipelining: een batch commando’s in één round-trip sturen en alle antwoorden samen ontvangen. De server stelt nu alle vragen tegelijk.
We voegden Database::pipelineHgetall() toe en zetten de members-grid om. Profielen gaan in één round-trip en work-entry lookups in een andere.
// 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));
Deze wijziging bracht honderden sequentiële latencybetalingen terug tot enkele.
De fix, deel 2 — Een gematerialiseerde cache
Pipelining maakt de berekening goedkoper om te voeden; de tweede fix voorkomt herhaling. We introduceerden BusinessMembersCache, een server-side cache die de voltooide griddata in Redis bewaart.
Twee regels bepalen cachefreshness:
- Expiratie van 5 minuten. Elke gecachte grid verloopt automatisch na 5 minuten.
- Expliciete invalidatie bij wijziging. Elke member-gerelateerde wijziging verwijdert de gecachte grid direct.
Memberidentiteit en permissiechecks worden bewust niet gecachet. Elke request voert nog steeds het volledige access-controlpad uit.
Impact
- Cache hits serveren de grid in milliseconden met één cijfer — een ~100x+ verbetering tegenover de eerdere ~1,8 seconden.
- Cache misses zijn nog steeds sneller dan de oude pagina, omdat herberekening nu via gepipelinede batchlookups loopt.
- Correctheid is getest. Contract- en unittests dekken cache-invalidatiegedrag.
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.
We blijven engineeringverslagen zoals dit publiceren in de Transparantiehub.
Engineeringfeiten
- 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