Performance improvement
- पहले: ~1.8 seconds
- बाद में: <10 milliseconds
- Improvement: ~100x+
PayCal Lens से measured.
Business administrators के लिए Members page अब effectively instant लगता है, larger organizations में भी।
Executive summary
| Page affected | Business Members — हर business member को computed financial columns के साथ दिखाने वाली grid |
| पहले | ~1.8 seconds server time per page load |
| बाद में | Cache hits पर single-digit milliseconds (~100x+ improvement); cache misses भी पुराने path से तेज |
| Root causes | Redis के against N+1 query pattern और हर view पर एक साल की payroll math की full recomputation |
| Fix | Batched/pipelined Redis lookups, plus finished grid data का materialized cache with explicit invalidation |
| Data freshness | Member-related change पर cache तुरंत invalidated; 5-minute expiry safety net की तरह staleness bound करती है |
हम इसे क्यों publish कर रहे हैं
Performance transparency का हिस्सा है। जब कोई page slow होता है, users को यह जानना चाहिए कि slowness काम की प्रकृति से आती है या avoidable design flaw से। इस case में यह दूसरा था, दो बार, और details share करने योग्य हैं क्योंकि दोनों flaws web software की common performance mistakes हैं।
इस article में कोई security issue या user data exposure शामिल नहीं है। यह सिर्फ एक engineering story है कि slow page को fast कैसे बनाया गया।
Page क्या करता है
Business Members page किसी business के हर member को list करता है। Name और role के साथ grid पांच computed financial columns दिखाता है:
ये values finished numbers के रूप में store नहीं होतीं। ये हर member की raw work entries से computed होती हैं, यानी Redis database में stored individual shift records से। एक row बनाने का मतलब profile load करना, full year work entries load करना, regular/overtime split करना और gross pay sum करना है।
- Year-to-date gross — इस year की total earnings
- Total hours — इस year की सभी worked hours
- Regular hours — standard rate वाली hours
- Overtime hours — regular threshold से आगे की hours
- Trailing baseline — comparison के लिए rolling reference figure
Problem, part 1 — N+1 query pattern
Original implementation members list fetch करने के लिए एक query करती थी, और फिर लगभग 100 members में से हर member के लिए Redis तक separate, sequential round-trips करती थी: profile के लिए एक और full year work entries के लिए और calls.
कुछ members वाले small businesses ज्यादा प्रभावित नहीं थे; stacked latency तब दिखती है जब organizations dozens या hundreds of members तक बढ़ते हैं।
यह classic “N+1” pattern है: list के लिए one query, फिर items के लिए N more queries one at a time. Redis lookup fast है, पर हर round-trip fixed network latency pay करता है; sequential calls में ये costs linearly stack होते हैं।
Database bottleneck नहीं था। Database के साथ conversation bottleneck थी।
Problem, part 2 — हर view पर सब कुछ recompute करना
Second flaw ने first को compound किया। Payroll math, year of work entries को regular/overtime में split करना, year-to-date gross और trailing baseline calculate करना, हर member के लिए, हर page view पर scratch से दोहराया जा रहा था।
Work entries अक्सर नहीं बदलतीं। Members page की consecutive views के बीच underlying data लगभग हमेशा same होता है। फिर भी हर visit ने just-computed results को फिर से compute करने की full cost pay की।
दोनों flaws मिलकर लगभग 1.8 seconds server time per page load produce कर रहे थे, PayCal Lens से measured।
Fix, part 1 — Redis pipelining
Redis pipelining support करता है: commands का batch single round-trip में भेजना और answers साथ में receive करना। Server अब one question/wait pattern की जगह all questions at once पूछता है।
हमने batched lookup method Database::pipelineHgetall() add किया और members grid को इसे use करने में convert किया। Profile lookups one round-trip में और work-entry lookups दूसरे round-trip में gather होते हैं।
// 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));
इस change ने अकेले hundreds of sequential latency payments को handful में collapse किया।
Fix, part 2 — Materialized cache
Pipelining computation को feed करना cheaper बनाता है; second fix computation repeat करना avoid करता है। हमने BusinessMembersCache introduce किया, server-side cache जो finished computed grid data Redis में store करता है।
Cache freshness को दो rules govern करते हैं:
- 5-minute expiry. हर cached grid 5 minutes बाद automatically expire होती है।
- Change पर explicit invalidation. Role update, member added, या member removed जैसे member-related changes cached grid तुरंत delete करते हैं।
Member identity और permission checks intentionally cache नहीं किए जाते। हर request full access-control path चलाती है; केवल expensive financial arithmetic reuse होती है।
Impact
- Cache hits grid को single-digit milliseconds में serve करते हैं — previous ~1.8 seconds से ~100x+ improvement.
- Cache misses भी पुराने page से तेज हैं, क्योंकि recomputation अब pipelined, batched lookups पर चलती है।
- Correctness tested है। Contract और unit tests cache invalidation behavior cover करते हैं।
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.
हम ऐसे engineering write-ups आगे भी publish करेंगे पारदर्शिता हब.
Engineering facts
- 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