abstrakcyjna grafika prezentująca architekture cachowania w Medusa.js

W programowaniu od lat krąży żart: „There are only two hard things in Computer Science: cache invalidation and naming things", czyli że w informatyce są podobno tylko dwa trudne problemy: unieważnianie cache'u i nazywanie rzeczy. Przez dłuższy czas uważałem, że ta pierwsza część jest trochę przesadzona. Ale teraz bym to powiedzenie jeszcze rozszerzył na całe zarządzanie cache'em, nie tylko jego unieważnianie.

Hipotetyczny scenariusz: użytkownik otwiera stronę produktu na Artovnii (zbudowany na bazie Medusa.js v2 marktetplace dla sztuki i rękodzieła) i widzi cenę 199 zł. Klika „dodaj do koszyka", a w koszyku produkt kosztuje już 149 zł. Nikt niczego nie zmienił, po prostu dwie różne warstwy cache nie zdążyły się ze sobą zgodzić, zanim zdążył kliknąć.

Kiedy budujesz zwykłą stronę internetową, cache rzeczywiście może wydawać się prosty. Masz stronę, która zmienia się raz na kilka godzin albo dni, wrzucasz ją na CDN, ustawiasz sensowny TTL i w większości przypadków działa.

W klasycznym sklepie robi się trudniej, ale środowisko nadal jest dość kontrolowane. Produkt ma cenę, stan magazynowy, warianty, ewentualnie promocję. Sam zarządzasz katalogiem produktów i zwykle dobrze wiesz, które dane mogą się zmienić oraz kiedy.

W marketplace liczba zależności szybko rośnie, a cache zaczyna wtedy dotyczyć nie tylko szybkości odpowiedzi, ale również spójności danych między różnymi częściami aplikacji.

Jedna karta produktu, kilka warstw cache

Wracajac do scenariusza z początku tego tekstu. Jeżeli listing korzysta z innej warstwy cache niż PDP i obie warstwy mają różne zasady świeżości, łatwo o sytuację, w której użytkownik widzi inną cenę albo inną promocję zaraz po przejściu na produkt. Podobny problem może wystąpić między PDP a koszykiem, dlatego cena, promocja czy dostępność wymagają dużo ostrożniejszego podejścia niż opis produktu albo nazwa kategorii.

W Artovnii dochodzi do tego kilka warstw:

WarstwaGdzie działaZakres
Cache przeglądarkiUrządzenie klientaPojedyncza sesja, pliki statyczne i payload
Router Cache Next.jsPamięć klienta (SPA)Odwiedzone trasy, nawigacja back/forward
CDNEdge (Vercel / Cloudflare)HTML i zasoby, globalnie
Next.js Data CacheServer (Vercel origin)Wyniki fetch() po stronie serwera
React cache() (deduplikacja)Server, pojedynczy requestMemoizacja w obrębie jednego renderu
Application cacheServer (Next.js)Wyniki unstable_cache, snapshoty
Redis (Medusa)BackendCache endpointów, kontekst promocji
Cache endpointów backenduMedusa (w procesie)Odpowiedzi Store API per endpoint

Samo pytanie czy ten endpoint jest cache'owany niewiele więc mówi. Trzeba wiedzieć również, która warstwa może zwrócić wynik, jak długo może go przechowywać, jakie dane zawiera i co dokładnie powoduje jego invalidację.

Marketplace komplikuje nawet zwykły PDP

W prostym sklepie strona produktu może korzystać z jednego dużego payloadu i kilku dodatkowych requestów. W marketplace produkt jest powiązany z dużo większą liczbą danych.

Na typowej stronie produktu Artovnii potrzebuję między innymi:

DaneWymaganie świeżości
Produkt (nazwa, opis)Bardzo długie TTL — zmienia się rzadko
WariantyDługie TTL — zmiana wymaga invalidacji
Aktualne cen wariantówKrótkie TTL + invalidacja eventowa
PromocjeKrótkie TTL (60–120s) + invalidacja eventowa
Dostępność towaruKrótkie TTL + invalidacja eventowa
Czas realizacjiŚrednie TTL
Kategorie i breadcrumbsBardzo długie TTL — snapshot
AtrybutyDługie TTL
Dane GPSRDługie TTL — wymóg prawny, zmiana rzadka
Dane sprzedawcyŚrednie TTL
Status sprzedawcy (ban / wakacje)Krótkie TTL — musi reagować szybko
RecenzjeŚrednie TTL — nowe recenzje nie są krytyczne
Inne produkty sprzedawcyDługie TTL
Proponowane produktyDługie TTL — rekomendacje nie muszą być na żywo
Opcje wysyłkiŚrednie TTL
Region klientaPer-request — nie cache'ować
Wishlisty (produkt / sprzedawca)Per-user — nie cache'ować globalnie

Jest tego sporo, a co ważniejsze, wszystkie te dane mają zupełnie różne wymagania dotyczące świeżości. Nazwa kategorii może być cache'owana bardzo długo, podobnie opis produktu, który zwykle nie wymaga odświeżania co minutę - ale status sprzedawcy powinien już reagować szybciej, a stan magazynowy i cena wymagają jeszcze większej ostrożności, bo promocja może rozpocząć się albo zakończyć o konkretnej godzinie. Nie ma więc jednego dobrego TTL dla czegoś, co na froncie nazywamy po prostu „produktem" i to jest jeden z głównych problemów do rozwiązania w scenariuszu marketplace. Nie ma jednej drogi, są przemyślane kompromisy.

Każdy marketplace radzi sobie z tym nieco inaczej, co widać już na pierwszy rzut oka przeglądajac Etsy, Amazon, Ebay czy Allegro. Każde ma inne zachowania, inne priorytety renderowania, fetchowania, UX. Etsy wypada najgorzej, stosując tanie triki szczególnie w wersji desktop.

Promocje działały poprawnie, ale można było zrobić to znacznie lepiej

Artovnia już wcześniej miała invalidację na żądanie. Zmiana promocji w backendzie powodowała wywołanie endpointu revalidacyjnego storefrontu, a odpowiednie cache tags były invalidowane. Użytkownicy nie chodzili więc po marketplace i nie oglądali przez sześć godzin starych cen po ręcznej zmianie promocji.

Problem dotyczył głównie architektury i wydajności całego mechanizmu.

promotions/batch miał cache zależny od dokładnego zestawu ID produktów. Jedna siatka miała 8 produktów, inna 15, kolejna 20, homepage potrafił wysłać 77 albo 78 ID, a każdy taki zestaw tworzył inny klucz. Cache formalnie działał, ale przy dynamicznych listingach marketplace miał stosunkowo mało okazji, żeby trafić dokładnie w ten sam zestaw.

Telemetria pokazała też, że około 75 do 80 procent czasu MISS było zużywane na przeliczanie kontekstu, który wcale nie zależał od zestawu produktów. Lista aktywnych promocji, mapowanie promocji do sprzedawców i część danych shippingowych były wspólne dla wielu requestów.

ProblemObjawPrzyczyna
Klucz cache zależny od zestawu IDMało HIT-ów przy dynamicznych listingachKażda siatka (8, 15, 20, 77 ID) tworzy osobny klucz
75–80% czasu MISS na kontekst wspólnyDrogi MISS nawet gdy tylko ID się różniąKontekst promocji liczony od zera per request
TTL dziedziczony z danych produktuPromocja żyła 6 godzin w cacheBrak osobnego TTL dla promocji

Rozwiązaniem było rozdzielenie tego na dwie warstwy. L1 nadal cache'uje gotową odpowiedź dla konkretnego zestawu ID, więc jeżeli identyczny zestaw pojawi się ponownie, dostajemy bardzo tani HIT. L2 przechowuje z kolei wspólny kontekst promocji pod stabilnym kluczem, dzięki czemu przy L1 MISS nie trzeba za każdym razem ponownie wykonywać najdroższej części pracy.

Do tego doszedł TTL 60 do 120 sekund, distributed lock chroniący przed stampede, oczekiwanie na zakończenie zapisu oraz rewizja kontekstu zabezpieczająca przed późnym zapisem starego wyniku po invalidacji. Osobno promocje dostały własny TTL 120 sekund zamiast dziedziczenia sześciogodzinnego TTL danych produktowych.

WarstwaKluczTTLRola
L1Zestaw ID produktów60–120sTani HIT dla identycznego zestawu
L2Stabilny klucz kontekstu120sWspólny kontekst promocji — unika przeliczania przy L1 MISS
Invalidacja eventowaCache tagsNa żądaniecreate / update / delete promocji, zmiana opcji dostawy
Distributed lockPer-kluczCzas zapisuOchrona przed cache stampede

Invalidacja eventowa nadal jest jednym z ważniejszych elementów: create, update i delete promocji lub kampanii, a także odpowiednie zmiany opcji dostawy, invalidują cache i tagi storefrontu, natomiast krótki TTL zabezpiecza dodatkowo zmiany wynikające wyłącznie z czasu, na przykład automatyczny start kampanii.

Event zapewnia szybką reakcję na znaną zmianę, a krótki TTL ogranicza maksymalny czas nieświeżości dla zmian wynikających z czasu oraz przypadków, których invalidacja eventowa nie obejmuje.

Jeden snapshot kategorii i kilka sposobów, żeby go przypadkiem ominąć

Kategorie przeszły kilka etapów. Sam pomysł był prosty i sensowny - zamiast wielokrotnie pobierać drzewo kategorii, storefront korzystał ze wspólnego snapshotu.

Później pojawiły się dodatkowe warstwy potrzebne przez PDP i strony kategorii, a pochodne operacje - pobranie hierarchii, wyszukanie kategorii po handle czy przygotowanie danych strony kategorii - zostały dla bezpieczeństwa również opakowane w unstable_cache.

I właśnie tutaj powstał problem. W uproszczeniu wyglądało to tak:

typescript
const getCategoryHierarchyCached = unstable_cache(

async (...) => {

const snapshot = await getCategorySnapshotCached()

// ...

}

)

getCategorySnapshotCached() również korzystało z unstable_cache, więc na poziomie kodu można było łatwo uznać, że mamy po prostu dwie warstwy cache. W używanej przez mnie wersji Next.js 15 ta kompozycja nie zachowywała się jednak tak, jak zakładałem.

W ścieżkach z zagnieżdżonym unstable_cache obserwowałęm omijanie oczekiwanego wpisu Data Cache, a wewnętrzne fetche wykonywały się ponownie, tak jakby miały force-no-store. W praktyce dodatkowa warstwa, która miała ograniczyć pracę, powodowała omijanie istniejącego Data Cache w tych ścieżkach.

Telemetria pokazała 10 pobrań snapshotu w 30 minut przy dwóch ciepłych instancjach, mimo TTL wynoszącego 24 godziny. Co ciekawe, sam layout korzystał ze snapshotu prawidłowo, problem pojawiał się dopiero w określonych ścieżkach pochodnych, używanych między innymi przez PDP i logikę kategorii.

Naprawa polegała na przywróceniu jednego autorytatywnego snapshotu w Data Cache i używaniu React cache() do request-level memoization operacji wykonywanych na tym snapshotcie. Przeszukanie drzewa kilkuset kategorii w pamięci jest bardzo tanie, w przeciwieństwie do kolejnego requestu HTTP do backendu.

Im więcej niezależnych kopii i pochodnych snapshotów zaczyna istnieć w różnych cache, tym więcej rzeczy trzeba później spójnie unieważniać. Prostszy model to jeden cache danych źródłowych i tanie operacje w pamięci, okazał się łatwiejszy do kontrolowania.

Cache może też skutecznie przechować zły HTML

Przy eksperymencie z ISR dla PDP dynamiczna strona produktu została przełączona na on-demand ISR za pomocą:

typescript
generateStaticParams() {

return []

}

W połączeniu ze szkieletem loading.tsx pojawiło się zachowanie, którego build nie wykrywał.

Pierwszy request wykonywał prerender, loading.tsx dostarczał fallback, a gotowe dane produktu znajdowały się dopiero w RSC payload. Problem polegał na tym, że prerenderowany HTML zawierał wyłącznie skeleton, który CDN później bardzo sprawnie cache'ował.

Request miał 200 OK, X-Nextjs-Prerender: 1 i X-Vercel-Cache: HIT, ale w <main> brakowało nazwy produktu, ceny, opisu, galerii, breadcrumbs i danych sprzedawcy. Dane były dostępne w RSC payload, ale nie zostały wyrenderowane do HTML. Dla użytkownika korzystającego z JavaScriptu sytuacja mogła wyglądać inaczej niż dla crawlera pobierającego dokument.

PDP wrócił do dynamicznego renderowania i streamingu. loading.tsx oraz Suspense mogą dawać natychmiastowy feedback podczas nawigacji, a crawler odczytujący pełną odpowiedź HTTP po zakończeniu streamu nadal otrzymuje wyrenderowaną treść produktu w HTML, natomiast dane mogą być niezależnie obsługiwane przez Data Cache, request-level cache() oraz CDN.

Ten sam błąd, dwa inne objawy

Skeleton zamrożony w cache'u nie był jedynym skutkiem generateStaticParams() { return [] }. To samo połączenie, ISR bez sparametryzowanych ścieżek, wywołało jeszcze dwa osobne incydenty produkcyjne, każdy z zupełnie innym objawem.

Pierwszy dotyczył useSearchParams() bez granicy Suspense. Na dynamicznie renderowanej trasie problem kończył się przejściem części drzewa na rendering kliencki, coś co dzieje się po cichu i nie psuje strony. Po przełączeniu tej samej trasy na prerendering ten sam brak granicy Suspense stawał się błędem renderowania i w produkcji kończył się odpowiedzią 500. Skrypt dopisał generateStaticParams naraz do 23 tras kategorii, a łańcuch, który wybuchł, prowadził przez page.tsx, renderCategoryPage, SmartProductsListing, ProductListing, aż do samego useSearchParams(). Inna trasa z identycznym wzorcem zawiodłaby tak samo, tylko dostawała mniej ruchu, więc rzadziej się to ujawniało.

Drugi incydent dotyczył metadanych. Title, description, canonical, tagi og i twitter zamiast trafić do <head>, lądowały w <body>. Przyczyna leży w tym samym loading.tsx, tylko w kombinacji z asynchronicznym generateMetadata. Streamowanie metadanych to świadomy mechanizm Next.js, nie uszkodzony HTML, ale w tej konkretnej kompozycji zaczął działać przeciwko nam: loading.tsx tworzy granicę Suspense na poziomie całej trasy, Next streamuje jego fallback jako początkowy HTML, a generateMetadata, czekające na region i dane produktu, nie zdążało rozwiązać się przed wysłaniem </head>, więc mechanizm odpowiedzialny za streamowanie metadanych wypychał je do <body>.

Test na czterech produktach przez curl z UA Googlebota pokazał to bardzo konkretnie. Bez cache-bustingu wszystkie cztery miały metadane w body. Z cache-bustingiem, czyli przy świeżym renderze, trzy z czterech miały metadane poprawnie w head, ale jeden, najnowszy z całej czwórki, miał je w body nawet przy świeżym renderze. Ten produkt akurat nie trafił jeszcze do Data Cache, więc pobranie jego danych trwało dłużej, a generateMetadata miało jeszcze mniej czasu, zanim head został wysłany.

Trzy różne objawy, jedna przyczyna: loading.tsx połączone z ISR bez sparametryzowanych ścieżek. To, który fragment kodu akurat czekał na coś asynchronicznego w chwili wysyłania odpowiedzi, decydowało o tym, czy produkcja dostanie pusty skeleton, błąd 500, czy metadane nieobecne w początkowym <head> odpowiedzi.

Pełny HTML i szybki PDP da się pogodzić

To jest szczególnie ważne w marketplace. Użytkownik nie musi otrzymać każdej sekcji strony w tej samej chwili. Tytuł, zdjęcia, cena, warianty, dostępność i możliwość zakupu są ważne od razu, natomiast karuzela podobnych produktów, inne produkty sprzedawcy czy część recenzji mogą pojawić się później.

Można więc zbudować stronę z wyraźnym podziałem na primary render path i dane odroczone. Streaming pozwala szybko dostarczyć istotną część interfejsu, podczas gdy pozostałe sekcje są dalej renderowane.

Trzeba przy tym sprawdzać rzeczywisty HTML odpowiedzi. Sam fakt, że dane istnieją w RSC payload albo że użytkownik po hydracji widzi poprawną stronę, nie oznacza jeszcze, że crawler dostał taki sam dokument. Test next build nie wystarczył do wykrycia problemu z ISR - potrzebny był produkcyjny next start i sprawdzenie prawdziwego HTML przez curl.

Rekomendacje i problem fan-outu

Proponowane produkty korzystały z podobnie kosztownej strategii, szukały rekomendacji na każdym poziomie hierarchii kategorii. Dla produktu znajdującego się w:

Bash
Dom

└── Lampy

└── Lampy stołowe

wykonywany był request dla każdego poziomu.

W jednej sesji telemetria pokazała 48 wywołań backendu, mimo że wiele requestów miało identyczne argumenty - dwa produkty należące do tej samej kategorii mogły wysłać dokładnie takie samo zapytanie.

Pierwszym pomysłem mogłoby być dodanie kolejnego cache, ale tutaj lepszym rozwiązaniem okazała się zmiana algorytmu. Strategia leaf-first najpierw pobiera produkty wyłącznie z najbardziej konkretnej kategorii, a jeżeli wynik wystarcza do wypełnienia karuzeli, praca się kończy - dopiero gdy produktów jest za mało, pobierani są rodzice.

Po zmianie test behawioralny potwierdził jeden fetch dla kategorii, która sama potrafiła wypełnić karuzelę, oraz trzy dla rzadkiego liścia przy głębokości drzewa wynoszącej trzy poziomy.

Cache nadal ma tutaj znaczenie, ale wcześniej warto ograniczyć liczbę operacji, które w ogóle wymagają cache'owania.

Router Cache jest częścią UX

Cache nie kończy się na API. Typowa sesja zakupowa wygląda tak:

listing → produkt → listing → kolejny produkt

Użytkownik oczekuje, że powrót do listingu będzie praktycznie natychmiastowy.

W Next.js 15 Router Cache dla dynamicznych tras miał domyślnie staleTime wynoszący 0 sekund. W produkcji nie było to zawsze oczywiste, bo kolejny request często trafiał w CDN, natomiast lokalnie brak cache był dużo bardziej widoczny.

Pierwszą poprawką było ustawienie:

typescript
experimental: {

staleTimes: {

dynamic: 30,

static: 180,

},

}

Trzydzieści sekund miało pokrywać typową pętlę przeglądania: kategoria, produkt, powrót. W praktyce nie pokrywało, bo oglądanie produktu dłużej niż pół minuty jest normą, a nie wyjątkiem, więc powrót regularnie trafiał na już wygasły wpis. Ta wartość nigdy zresztą nie została zmierzona, tylko zaokrąglona w dół do starego domyślnego progu z Next 14.

Prawdziwy problem wyszedł na jaw dopiero wtedy, gdy spojrzałem na tę liczbę razem z jej sąsiadami w łańcuchu. Router Cache nie jest jedynym mechanizmem decydującym, czy powrót na produkt jest szybki. Prefetch odpalany dotknięciem karty ma własne okno, w którym odmawia ponownego pobrania tej samej trasy, a CDN ma własny czas życia odpowiedzi.

LiczbaPrzedPoRola
Okno dedupu prefetchu5 min5 minOdmawia ponowne pobranie trasy dotkniętej wcześniej
Router Cache (staleTime)30 s300 sCzas życia wpisu trasy w pamięci klienta
CDN TTL6 godz6 godzCzas życia odpowiedzi HTML na edge
Próg pokazania nakładki (full)50 ms400 msKiedy pokazać pełnoekranowy skeleton
Minimalny czas widoczności—400 msZapobiega mignięciu nakładki
Próg paska postępu (listing)50 ms50 msCienki pasek nad kategoriami — bez zasłaniania

Wymagana kolejność: okno dedupu prefetchu ≥ Router Cache ≤ CDN TTL. Przed naprawą było odwrotnie, prefetch trzymał 5 minut, Router Cache tylko 30 sekund, więc powrót na produkt między 30. sekundą a 5. minutą trafiał na wygasły wpis, a prefetch odmawiał go odbudować.
Przez pewien czas było odwrotnie. Dedup prefetchu trzymał pięć minut, Router Cache tylko trzydzieści sekund. Każdy powrót na produkt oglądany między trzydziestą sekundą a piątą minutą trafiał na wpis, który już wygasł, podczas gdy prefetch nadal odmawiał go odbudować. Taki powrót szedł więc pełną ścieżką sieciową i odpalał pełnoekranową nakładkę ładowania, mimo że użytkownik oglądał ten sam produkt chwilę wcześniej.

Naprawą było wydłużenie Router Cache do trzystu sekund, nie skrócenie okna prefetchu. Prefetch odpala się przy każdym dotknięciu karty produktu, także podczas zwykłego scrollowania listingu na telefonie, więc krótsze okno dedupu zamieniłoby przewijanie w serię pobrań tej samej trasy. W naszym przypadku wydłużenie Router Cache z 30 do 300 sekund nie pogarszało przy tym efektywnej świeżości danych: ten sam payload przychodzi z CDN z sześciogodzinnym czasem życia, więc w chwili trafienia do Router Cache bywa już wielogodzinny. Zaciskanie ostatniego ogniwa łańcucha nic tu nie dawało, skoro wcześniejsze ogniwo było kilkadziesiąt razy luźniejsze, kupowało tylko dodatkowy round trip po tę samą, równie nieświeżą odpowiedź.

Koszyk i konto są w tym wszystkim bezpieczne niezależnie od tych liczb: ich mutacje idą przez Server Actions z revalidateTag, co w używanej przez nas wersji Next.js czyści cały kliencki Router Cache za jednym razem.

Sama nakładka ładowania miała jeszcze osobny problem, niezależny od cache'u. Pojawiała się po progu pięćdziesięciu milisekund, dobranym pod budżet stu milisekund na feedback interakcji, przy założeniu że nawigacja jest albo natychmiastowa, albo wolna. Pomiar produkcyjny pokazał, że jest ani jedno, ani drugie: round trip dla PDP trwał od dwustu czterdziestu do dwustu siedemdziesięciu pięciu milisekund. Nakładka pojawiała się więc na pięćdziesiąt milisekund i znikała jakieś dwieście milisekund później, pełnoekranowy skeleton migający przez ułamek sekundy, co czyta się jako usterka interfejsu, a nie postęp.

Rozwiązaniem były dwie osobne liczby zamiast jednej. Próg pokazania nakładki podniesiono do czterystu milisekund, wyraźnie powyżej typowej nawigacji, a nie tuż nad progiem natychmiastowości. Obok niego dodano minimalny czas widoczności, również czterysta milisekund: bez niego każda nawigacja lądująca tuż za progiem produkowałaby dokładnie to samo mignięcie, któremu próg miał zapobiegać. Cienki pasek postępu nad kategoriami został przy pięćdziesięciu milisekundach, bo nie wymienia całego layoutu i nic nie zasłania, więc nie ma tam nic do mignięcia. Ta sama liczba pasowałaby więc do jednego mechanizmu i szkodziła drugiemu: kiedy pokazać feedback i jak nie dopuścić do jego mignięcia to dwa różne pytania, i jedna stała nie odpowiada na oba naraz.

Router Cache, okno prefetchu, CDN i progi samej nakładki to pięć różnych liczb w pięciu różnych miejscach kodu, i żadna z nich nie jest osobnym detalem konfiguracji. Są ze sobą powiązane, więc zmiana jednej bez sprawdzenia pozostałych umie odtworzyć dokładnie ten sam błąd w zupełnie innym miejscu.

Ostatecznie trzeba mierzyć prawdziwe requesty

Jednym z ważniejszych elementów całego śledztwa, oprócz osobistych testów manualnych, była telemetria. Samo policzenie wywołań funkcji nie wystarczało - Data Cache może odtworzyć odpowiedź wraz z nagłówkami, więc nawet patrzenie na X-Cache potrafi prowadzić do błędnych wniosków.

Do pomiarów dodłem caller, fetchInvocations oraz generowany przez backend X-Origin-Response-Id. Dzięki temu można odróżnić:

  • liczbę wywołań wrappera,
  • liczbę odczytów z cache,
  • liczbę rzeczywistych requestów do Medusy.

Po ostatnim przebiegu rekomendacji PDP były 32 wywołania fetch, ale tylko 20 unikalnych odpowiedzi backendu. 12 wywołań zostało więc obsłużonych bez nowego origin requestu, czyli 37,5 procent. Dla ogólnego listProductsLean cztery wywołania wygenerowały tylko jeden unikalny request origin.

Ostatni pomiar promocji

Po zmianach promotions/batch wyglądał już znacznie lepiej. W testowej sesji:

  • OPTIONS /store/products/promotions/batch: 0,
  • bezpośrednie GET-y przeglądarka → Medusa: 0,
  • serwerowe GET-y Next → Medusa: 39,
  • wszystkie requesty korzystały z nowej wersji cache.

Mediana odpowiedzi wyniosła około 40,6 ms. 26 z 39 requestów zakończyło się poniżej 50 ms, 34 z 39 poniżej 100 ms, a 37 z 39 poniżej 300 ms.

Dwa requesty były wolniejsze. Jeden dotyczył homepage i batcha 77 produktów, więc nie był typowym przypadkiem PDP, a drugi był pojedynczym outlierem związanym przede wszystkim z wolniejszym odczytem produktów i zapisem do Redis. Próg 300 ms został więc dotrzymany w 94,9 procent próbek.

W dynamicznym marketplace część kluczy będzie naturalnie unikalna, więc samo patrzenie na cache HIT ratio nie wystarcza. Czasami ważniejsze od zwiększania hit rate jest sprawienie, żeby MISS był tani.

Jak dziś patrzę na caching

Kiedyś cache widziałem mniej więcej tak:

Bash
request

↓

cache

↙ ↘

HIT MISS

↓

backend

W dużej aplikacji webowej bardziej przypomina to graf zależności. Request może przejść przez Router Cache, CDN i Data Cache, a później poszczególne fragmenty strony pobierają kolejne dane niezależnie od siebie - backend ma przecież własny cache. Część odpowiedzi zależy od regionu, część od sprzedawcy, część od produktu, a część od promocji, i każda z nich może wymagać innego czasu życia: od wielu godzin po zaledwie kilkadziesiąt sekund.

Do tego dochodzą invalidacje eventowe, TTL, streaming, SSR, RSC, boty i nawigacja po stronie klienta.

Samo zapisanie czegoś do Redis jest przy tym stosunkowo prostą częścią. Najwięcej pracy wymaga określenie:

  • co jest źródłem prawdy,
  • gdzie istnieją kopie danych,
  • jak długo każda kopia może żyć,
  • jakie zdarzenia ją unieważniają,
  • czy kilka ekranów korzysta z tych samych zasad świeżości,
  • czy cache jednej warstwy nie omija albo nie wyłącza innej,
  • czy crawler otrzymuje pełny HTML,
  • czy użytkownik może szybko przechodzić między stronami,
  • ile origin requestów naprawdę wykonuje aplikacja.

Wnioski

Nie wszystko wcześniej było źle skonfigurowane. Cache proponowanych produktów powstał dopiero po dodaniu tego komponentu, a część późniejszych zmian w nim była wynikiem eksperymentów, które nie zawsze okazywały się trafione ale dały cenną wiedzę. Wraz z rozwojem PDP i rozbudową kolejnych warstw cache rosła też złożoność całego łańcucha, aż wymagała dokładnej analizy i decyzji architektonicznych, a czasem i biznesowych.

Zmiany zostały sprawdzone testami obciążeniowymi k6 w dwóch wariantach: testem HTTP po pełnej sitemapie oraz osobnym testem przeglądarkowym mierzonym przez Grafana Cloud. Ten drugi ma na obecnym koncie limit 10 Browser VU, więc pełny profil 100 VU odpalał się wyłącznie w wariancie HTTP, gdzie obciążenie backendu podczas testu utrzymywało się na poziomie do 1% maksymalnego planu.

Warto rozdzielić dwa poziomy pomiaru.

Metrykak6 E2E (HTML, n≈4220)OTel span (Medusa handler, n=1085)OTel DB time (Postgres)
avg158 ms94 ms75 ms
p50 (med)84 ms93 ms74 ms
p95410 ms124 ms97 ms
p99608 ms141 ms110 ms
max1980 ms185 ms125 ms
DB queries / request—2222
Cache ratio (CDN/edge)~74% — 3135 z 4220 requestów PDP nie dotarło do backendu

Z tego samego spana czas samej bazy danych to p99=110 ms (78% spana, 18% pełnego E2E), przy stałej liczbie 22 zapytań SQL per request.

Różnica między p99 E2E (608 ms) a p99 backendu (141 ms) to 467 ms, 77% wartości k6. To czas poza handlerem Medusy: CDN, edge, Next.js SSR, network RTT, transfer HTML.

Warto zauważyć, że p50 jest wyższe w OTel (93 ms) niż w k6 (84 ms), to dwie różne populacje, nie ten sam pomiar z innego miejsca. k6 p50 to cache hit na CDN (backend nie dotknięty), OTel p50 to cold miss w backendzie. Cache ratio dla PDP wynosi blisko 74%, k6 wykonał około 4220 iteracji PDP, ale OTel zarejestrował tylko 1085 spanów pdp.primary, co oznacza że około 3135 requestów zostało obsłużonych z cache CDN/edge i nigdy nie dotarło do backendu.

Dla strony głównej cache ratio jest jeszcze wyższe, OTel zarejestrował count=1 przy około 1580 iteracjach home w k6, co daje blisko 99,9% cache hit. A co najważniejsze, ustabilizowało się zachowanie skeletonów, w tym to, co i kiedy widzą boty, oraz liczba requestów uderzających do backendu. Data Cache HIT pod obciążeniem to 91% (Vercel dashboard). Backend pod tym samym obciążeniem odpowiadał ze średnim czasem 45 ms (OTel global avg, wszystkie 2992 trace'y) i zero błędów.

FAQ

Najczęściej zadawane pytania

Artovnia korzysta z ośmiu warstw: cache przeglądarki, Router Cache Next.js, CDN, Data Cache Next.js, React cache() do deduplikacji na poziomie requesta, application cache (unstable_cache), Redis po stronie backendu Medusa oraz cache poszczególnych endpointów. Każda warstwa ma inny zakres, czas życia i mechanizm invalidacji — samo pytanie 'czy ten endpoint jest cache'owany?' mówi bardzo mało bez znajomości wszystkich tych warstw.

Strona produktu Artovnii łączy dane o zupełnie różnych wymaganiach świeżości. Nazwa kategorii czy opis produktu mogą być cache'owane wiele godzin, ale status sprzedawcy musi reagować szybciej, a cena i dostępność wymagają jeszcze większej ostrożności, bo promocja może się zacząć lub skończyć o konkretnej godzinie. Nie ma jednego dobrego TTL dla czegoś, co na froncie nazywamy po prostu 'produktem'.

Logika kategorii Artovnii używała unstable_cache zarówno dla głównego snapshotu, jak i dla operacji pochodnych, takich jak pobieranie hierarchii. W Next.js 15 zagnieżdżone unstable_cache omija normalny odczyt Data Cache, a fetche wewnątrz zewnętrznego zakresu działają jak force-no-store. Dodatkowa warstwa, która miała ograniczyć pracę, powodowała omijanie istniejącego Data Cache. Telemetria pokazała 10 pobrań snapshotu w 30 minut mimo TTL wynoszącego 24 godziny. Naprawą był jeden autorytatywny snapshot w Data Cache i React cache() do operacji w pamięci na tym snapshocie.

To samo połączenie — pusty generateStaticParams i loading.tsx — spowodowało trzy osobne incydenty: (1) CDN zache'ował HTML z samym skeletonem, więc crawlery dostawały pustą stronę produktu mimo odpowiedzi 200 OK; (2) useSearchParams bez granicy Suspense powodował błędy 500 po przełączeniu trasy na prerendering; (3) title, description i tagi OG lądowały w <body> zamiast <head>, bo generateMetadata nie zdążało się rozwiązać przed wysłaniem head podczas streamowania fallbacku loading.tsx.

Proponowane produkty Artovnii pobierały rekomendacje na każdym poziomie hierarchii kategorii. Dla produktu w Dom → Lampy → Lampy stołowe wykonywany był request dla każdego poziomu. Telemetria zarejestrowała 48 wywołań backendu w jednej sesji, przy czym wiele requestów miało identyczne argumenty. Strategia leaf-first rozwiązała to, pobierając najpierw z najbardziej konkretnej kategorii i rozszerzając do rodziców dopiero gdy produktów jest za mało — potwierdzone jednym fetchem dla pełnej kategorii i trzema dla rzadkiego liścia na głębokości trzech poziomów.

Wymagana kolejność to: okno deduplikacji prefetchu ≥ staleTime Router Cache ≤ TTL CDN. Artovnia miała odwrotnie: prefetch trzymał 5 minut, Router Cache tylko 30 sekund. Powrót na produkt między 30. sekundą a 5. minutą trafiał na wygasły wpis, podczas gdy prefetch odmawiał go odbudować, wywołując pełne żądanie sieciowe i pełnoekranową nakładkę ładowania. Naprawą było wydłużenie Router Cache z 30 do 300 sekund — nie skrócenie okna prefetchu — bo zaciskanie ostatniego ogniwa łańcucha nic nie daje, gdy CDN i tak serwuje sześciogodzinną odpowiedź.

Do pomiarów dodano identyfikator wywołującego (caller), licznik fetchInvocations oraz generowany przez backend nagłówek X-Origin-Response-Id. Pozwala to odróżnić liczbę wywołań wrappera, odczytów z cache oraz rzeczywistych requestów do Medusy. Samo patrzenie na nagłówki X-Cache może prowadzić do błędnych wniosków, bo Data Cache może odtworzyć odpowiedź wraz z nagłówkami. Testy obciążeniowe k6 potwierdziły ~74% cache ratio CDN dla PDP: około 3135 z 4220 requestów nigdy nie dotarło do backendu.