Strategie cache w sklepie B2B - TTL, invalidacja, hit ratio
Cache to nie „włącz Redis i działa". Cache to seria świadomych decyzji: co cache'ować, na ile długo, kiedy invalidować, jak monitorować. Złe decyzje skutkują albo bardzo niskim hit ratio (cache miss → wolny sklep), albo nieświeżymi danymi (klient widzi wczorajszą cenę). Ten artykuł to konkretne polityki, których używam.
Spis treści (9)
W skrócie
- 1. TTL = funkcja zmienności danych. Cenniki kontraktowe 5-15 min, stany 30-60s, statyki rok.
- 2. Invalidacja eventowa (webhook → flush) > tylko TTL.
- 3. Hit ratio targety: APCu >95%, Redis >80%, Varnish >70%, CDN >85%.
- 4. Monitoring obowiązkowy. Cache bez metryk = ślepa wiara.
- 5. Stale-while-revalidate dla najlepszego UX (serwuj stary cache + odśwież w tle).
TTL per typ danych
Najważniejsza decyzja architektoniczna. Tabela TTL, której używam dla typowego sklepu B2B:
| Typ danych | Warstwa | TTL | Strategia invalidacji |
|---|---|---|---|
| Konfiguracja aplikacji | APCu | 300s | Flush przy deploy |
| Kategorie (drzewo) | Redis | 1h | Webhook z PIM |
| Atrybuty produktów | Redis | 30 min | Webhook z PIM / Magento |
| Ceny katalogowe | Redis | 30 min | Webhook z ERP |
| Ceny kontraktowe | Redis | 5-15 min | Webhook z ERP + cron |
| Stany magazynowe | Redis | 30-60s | Webhook z WMS/ERP + pull on-demand |
| Limit kredytowy klienta | Redis | 1-5 min | Walidacja synchronizna przy checkout |
| Wyniki search (top queries) | Redis | 5-30 min | TTL + reindex Elastic |
| HTML strony niezalogowani | Varnish | 1h | Tag-based + manual flush |
| HTML fragmenty (ESI) | Varnish | 5-30 min | Tag-based |
| Obrazy produktów | CDN | 30 dni | Hash w URL (immutable) |
| CSS / JS bundles | CDN | 1 rok | Hash w URL (immutable) |
| Fonty webfonts | CDN | 1 rok | Wersja w URL |
Najtrudniejsze są ceny kontraktowe i stany - te wymagają najwięcej uwagi.
Cenniki kontraktowe - case study
Sklep 30k SKU, 200 klientów B2B, każdy ma własny cennik. Bez cache: każda strona produktu wywołuje ERP, sklep umiera w peak'u.
Strategia:
1. Klucz cache: price:{customer_id}:{sku} lub price:{customer_id}:{sku}:{qty} (jeśli progi ilościowe).
2. TTL: 10 min. Kompromis między „świeże dane" a „nie zabij ERP".
3. Cache aside pattern:
GET price for customer K001, SKU ABC, qty 5
-> check Redis: "price:K001:ABC:5"
-> HIT: return cached
-> MISS: call ERP, store in Redis with TTL 600s, return
4. Invalidacja:
- TTL wygasa
- Webhook z ERP przy zmianie cennika klienta →
FLUSH price:K001:* - Manual flush w panelu (przycisk „Wyczyść cache cen dla klienta")
5. Batch fetching: PLP z 50 produktami → batch ERP call, nie 50 osobnych.
6. Cache warming:
Po deploy / po flush cache, cron-job rozgrzewa cache top 1000 kombinacji (klient, produkt) w nocy.
Wynik: hit ratio >85%, średnia latency 50ms zamiast 300ms (ERP).
Stany magazynowe - case study
Inny problem. Stany zmieniają się częściej (zamówienia, dostawy), wymagają świeżości.
Strategia hybrydowa:
1. Background sync ERP → sklep. Co 1-5 minut delta synchronizacja (zmienione od ostatniego pinga).
2. Cache w sklepie + Redis. Sklep ma własny widok stanów. Cache w Redis TTL 60s.
3. On-demand pull dla konkretnego SKU. Klient otwiera PDP → sklep robi quick check do ERP (TTL 30s w Redis).
4. Walidacja synchronizna przy checkout. Klient klika „Zamów" → sklep pyta ERP o aktualny stan, blokuje rezerwację.
5. Stan zarezerwowany ≠ stan magazynowy. Stan dostępny = stan magazynowy - rezerwacje w toku. Te dwie wartości żyją osobno.
Wynik: klient widzi stan świeży w okolicach 30-60s. Brak overselling.
HTML cache dla niezalogowanych - Varnish
Strony publiczne (homepage, blog, kategorie publiczne, PDP dla niezalogowanych) świetnie się cache'ują.
Konfiguracja Varnisha dla sklepu:
sub vcl_recv {
# Standardowo nie cachuj jeśli zalogowany
if (req.http.Cookie ~ "PHPSESSID|frontend_session") {
return (pass);
}
# Wybór: cachuj koszyk dla anonimowych
if (req.url ~ "^/checkout/cart") {
return (pass);
}
# Wszystko inne cachowalne
return (hash);
}
sub vcl_backend_response {
# Domyślny TTL 1h
set beresp.ttl = 1h;
# Statyki dłużej
if (bereq.url ~ "\.(css|js|jpg|webp|woff2)$") {
set beresp.ttl = 30d;
}
# Strony produktów krócej
if (bereq.url ~ "^/produkt/") {
set beresp.ttl = 15m;
}
}
Hit ratio target: 70%+ dla niezalogowanych. Realne wartości w polskim B2B: 60-85% w zależności od ratio anonim/zalogowani.
ESI - fragmenty dynamic w cache'owanym HTML
Klient B2B zalogowany. Strona produktu w 90% taka sama jak dla wszystkich, ale cena i stan zależne od klienta.
Edge Side Includes (ESI) pozwala mieszać:
<!-- Cachowana strona produktu (1h Varnish cache) -->
<div class="product">
<h1>Nazwa produktu</h1>
<img src="...">
<p>{opis produktu}</p>
<!-- Te fragmenty pobierane per-user, NIE cachowane razem z HTML -->
<esi:include src="/fragment/price/{customer_id}/{sku}"/>
<esi:include src="/fragment/stock/{sku}"/>
<esi:include src="/fragment/credit-info/{customer_id}"/>
</div>
Varnish cachuje główną stronę. ESI fragments cachowane osobno (Redis, 5-10 min TTL). Klient widzi mix.
Wymaga: wsparcia ESI po stronie aplikacji (Symfony ma natywne, Magento - moduły).
Stale-while-revalidate
Pattern z CDN-ów, teraz wspierany przez przeglądarki i fetch API.
Idea: klient otrzymuje stary cache (szybko), aplikacja w tle odświeża cache. Następne żądania dostają już nowy.
Cache-Control: max-age=300, stale-while-revalidate=600
Browser/CDN:
- Pierwsze 300s: serwuj świeży cache
- Między 300-900s: serwuj stary cache + odświeżaj w tle
- Po 900s: blocking fetch nowego cache
Wartość: UX idealny - klient nigdy nie czeka na regenerację. Latencja od strony klienta praktycznie zero.
Wsparcie: Cloudflare, Fastly, Varnish. W aplikacji - Symfony HttpCache, ręcznie.
Tag-based invalidation
Klasyczne TTL - wystarczające. Tag-based - eleganckie.
Idea: każdy cache item ma tagi. Flush per tag invaliduje wszystko z tym tagiem.
// Magento przykład (PSR-6 cache)
$cache->save('product_123_html', $html, ['catalog_product_123', 'catalog_category_45']);
// Później, gdy produkt 123 się zmieni:
$cache->invalidateTags(['catalog_product_123']);
// Flushuje wszystkie cache items z tym tagiem
W Symfony:
$item = $cacheItemPool->getItem('product_123');
$item->set($data)->tag(['product_123', 'category_45']);
$cacheItemPool->save($item);
// Invalidacja
$cacheItemPool->invalidateTags(['product_123']);
Wartość: zamiast martwić się „które klucze flushować", flushujesz po tag.
Wsparcie: Redis (PSR-6 z tag adapterem), Memcached (z opakowaniem), Varnish (z ban patterns).
Monitoring hit ratio - must-have
Bez monitoringu cache to bomba zegarowa.
Metryki:
1. Hit ratio per cache layer.
APCu hit ratio: 97% → ok
Redis hit ratio (prices): 87% → ok
Redis hit ratio (stocks): 62% → niski, ale akceptowalny (stany się zmieniają)
Varnish hit ratio: 71% → ok dla niezalogowanych mix
CDN hit ratio: 89% → ok
2. Cache miss latency. Jak długo trwa wygenerowanie strony przy cache miss. Jeśli >3s - naprawiamy.
3. Cache size. Czy nie evict'ujesz cache (LRU usuwa stare). Redis OOM = problem.
4. Hot keys. Najpopularniejsze klucze. Pozwala wykryć anomalie (np. jeden klient ma 1000 wpisów w cache cen - może wadliwie skonfigurowany worker generuje śmieci).
Narzędzia:
- Redis:
INFO,MONITOR(uważnie - drogi), Redis Exporter dla Prometheus - Varnish:
varnishstat,varnishlog - CDN: dashboard providera (Cloudflare Analytics, Fastly Insights)
- Custom: Prometheus + Grafana, własne metryki w aplikacji
Najczęstsze błędy
1. Brak Redis dla cache cen kontraktowych. Każda strona produktu wywołuje ERP. ERP umiera.
2. Za długie TTL na ceny. TTL 1h → klient widzi cenę z poprzedniej godziny. Po godzinie rozjazdu - reklamacja.
3. Za krótkie TTL na statyki. CSS cache 5 min → klient pobiera 12 razy / godzinę. Bandwidth.
4. Cache HTML zalogowanych jako całość. Klient A widzi cenę klienta B. Katastrofa danych.
5. Brak invalidation event-based. Tylko TTL → w peak'u stale dane.
6. Brak monitoringu hit ratio. Cache „działa", ale w peak'u nie cachuje. Nikt nie wie.
7. Wspólny Redis dla wszystkiego. Sesje + cache produktów + cache cen → OOM przy peak'u. Separate instances.
FAQ
Czy Magento ma wbudowany cache? Tak, na poziomie obiektów (collection cache, block cache), full-page (FPC), Varnish jako alternative. Konfigurowalne.
Czy mogę używać Memcached zamiast Redis? Można. Redis ma więcej funkcji (persistence, structures, pub/sub). Nowe projekty PHP/Symfony - Redis.
Co z cache PHP OPcache? OPcache cache'uje kod PHP (kompilację). Niezależny od application cache. Zawsze włączony w produkcji.
Czy Varnish to konieczność? Dla Magento - praktycznie tak (bez Varnish FPC sklep wolny). Dla Shopware - opcjonalne, ale dla skali B2B zalecane.
Jak długo cache utrzymywać po awarii? Zazwyczaj TTL ratuje (cache się odbuduje). Po dużej awarii - cache warm-up cron, rozgrzanie top kluczy.
Co dalej
- Cache warstwy (architektura): Cache warstwy
- CDN (warstwa 4): CDN dla katalogów
- Core Web Vitals: Core Web Vitals
- Pillar wydajności: Wydajność e-commerce
O autorze
Jakub Owsianka
Architekt rozwiązania w WiseB2B - silniku platform B2B. Zaczynał po stronie biznesu (własne sklepy), potem deweloper, dziś projektuje wdrożenia dla sklepów z katalogami w dziesiątkach tysięcy SKU. W ostatnich latach wdrożył AI-development w zespole i funkcjonalności oparte o AI bezpośrednio w silniku sklepu.
Masz pytanie do tego artykułu?
Dodatkowy kontekst, problem z własnym wdrożeniem, druga opinia - napisz wprost. Odpowiadam osobiście w 1-2 dni robocze.