Business Members sayfasını nasıl ~100x hızlandırdık

Business Members sayfası her yüklemede yaklaşık 1,8 saniye server time alıyordu. Bunu birbirini büyüten iki design flaw’a kadar izledik, ikisini de düzelttik ve aynı sayfa artık cache üzerinden tek haneli milisaniyelerde render oluyor.

Performans iyileştirmesi

  • Önce: ~1,8 saniye
  • Sonra: <10 milisaniye
  • İyileştirme: ~100x+

PayCal Lens ile ölçüldü.

Business administrators için Members sayfası artık büyük organizasyonlarda bile neredeyse anında açılıyor.

Yönetici özeti

Etkilenen sayfa Business Members — her business member kaydını hesaplanmış finansal kolonlarla listeleyen grid
Önce ~1,8 saniye server time per page load
Sonra Cache hits üzerinde tek haneli milisaniyeler (~100x+); cache misses da eski yoldan hızlı
Kök nedenler Redis’e karşı N+1 query pattern ve her view için bir yıllık payroll hesaplarının baştan hesaplanması
Düzeltme Batched/pipelined Redis lookups ve explicit invalidation içeren finished grid data materialized cache
Veri güncelliği Member-related her değişiklikte cache hemen invalidated; 5-minute expiry staleness için safety net sağlar

Bunu neden yayınlıyoruz

Performans şeffaflığın parçasıdır. Bir sayfa yavaşsa kullanıcılar bunun yapılan işin doğasından mı yoksa kaçınılabilir bir tasarım hatasından mı kaynaklandığını bilmelidir. Bu durumda ikincisiydi, iki kez, ve iki hata da web yazılımında yaygın performans hatalarıdır.

Bu makalede güvenlik sorunu veya kullanıcı verisi açığa çıkması yoktur. Sadece yavaş bir sayfayı hızlı yapmaya dair bir engineering hikayesidir.

Sayfa ne yapıyor

Business Members sayfası bir business içindeki her member kaydını listeler. İsim ve rol yanında grid beş hesaplanmış finansal kolon gösterir:

Bu değerler final number olarak saklanmaz. Her member için Redis içinde tutulan raw work entries, yani shift records üzerinden hesaplanır. Bir satır üretmek profil yüklemek, tam yıl work entries yüklemek, regular/overtime ayırmak ve gross pay toplamaktır.

  • Year-to-date gross — bu yılki toplam kazanç
  • Total hours — bu yıl çalışılan tüm saatler
  • Regular hours — standard rate saatleri
  • Overtime hours — regular threshold üstündeki saatler
  • Trailing baseline — karşılaştırma için rolling referans değeri

Sorun, bölüm 1 — N+1 query pattern

Orijinal implementasyon member listesini almak için bir query yapıyor, sonra yaklaşık 100 member için Redis’e ayrı ve sıralı round-trip yapıyordu: biri profil için, diğerleri tam yıl work entries için.

Az üyeli small businesses çok etkilenmiyordu; stacked latency organizasyonlar onlarca veya yüzlerce member seviyesine büyüdüğünde görünür hale geliyordu.

Bu klasik “N+1” pattern’idir: liste için bir query, sonra item’lar için tek tek N query. Her Redis lookup hızlıdır, ama her round-trip sabit network latency öder; sıralı çalışınca bu maliyetler lineer birikir.

Darboğaz database değildi. Database ile yapılan conversation darboğazdı.

Sorun, bölüm 2 — Her view için her şeyi yeniden hesaplamak

İkinci hata birincisini büyüttü. Tüm payroll math, bir yıllık entries’i regular/overtime olarak ayırmak, year-to-date gross ve trailing baseline hesaplamak, her member için, her page view’da sıfırdan yapılıyordu.

Work entries sık değişmez. İki ardışık Members view arasında underlying data neredeyse her zaman aynıdır. Yine de her ziyaret yeni hesaplanmış ve atılmış sonuçların tam maliyetini yeniden ödüyordu.

Birlikte bu iki hata, PayCal Lens ile ölçülen yaklaşık 1,8 saniye server time per page load üretiyordu.

Düzeltme, bölüm 1 — Redis pipelining

Redis pipelining destekler: komut batch’ini tek round-trip içinde gönderip tüm cevapları birlikte almak. Server artık soruları tek tek sormak yerine hepsini birden sorar.

Database::pipelineHgetall() ekledik ve members grid’i bunu kullanacak şekilde dönüştürdük. Profile lookups bir round-trip, work-entry lookups başka bir round-trip içinde toplanır.

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

Bu değişiklik tek başına yüzlerce sıralı latency ödemesini birkaç taneye indirdi.

Düzeltme, bölüm 2 — Materialized cache

Pipelining computation beslemeyi ucuzlatır; ikinci fix computation tekrarını engeller. Redis içinde finished grid data tutan server-side cache BusinessMembersCache ekledik.

Cache freshness iki kuralla yönetilir:

  • 5-minute expiry. Her cached grid 5 dakika sonra otomatik expire olur.
  • Change üzerinde explicit invalidation. Her member-related change cached grid’i hemen siler.

Member identity ve permission checks bilerek cache edilmez. Her request hâlâ full access-control path çalıştırır.

Etki

  • Cache hits grid’i tek haneli milisaniyelerde sunar — önceki ~1,8 saniyeye göre ~100x+ iyileştirme.
  • Cache misses hâlâ eski sayfadan hızlıdır, çünkü recomputation artık pipelined batch lookups kullanır.
  • Correctness test edildi. Contract ve unit tests cache invalidation behavior kapsar.
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.

Bu tür engineering yazılarını yayınlamaya devam edeceğiz: Şeffaflık Merkezi.

Engineering gerçekleri

  • 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