Deferred generation: nie generuj tego, czego nikt nie potrzebuje
SSG ma świetną właściwość: request użytkownika może trafić bezpośrednio do wcześniej przygotowanego HTML-a.
Problem pojawia się wtedy, gdy liczba możliwych stron rośnie szybciej niż liczba stron, które ktokolwiek realnie odwiedzi.
Marketplace z milionem produktów może mieć ogromny katalog, ale 80% ruchu trafia do niewielkiej części ofert. Generowanie całego katalogu przy każdym buildzie oznacza wykonywanie pracy dla stron, których być może nikt nigdy nie otworzy.
Naturalny pomysł brzmi:
nie generuj strony podczas builda, wygeneruj ją dopiero przy pierwszym requestcie.
SSG, ale leniwe
Klasyczne SSG:
build
|
v
render every known page
|
v
store HTML
|
v
deploy
Deferred generation:
build
|
v
deploy without every page
first request /products/928374
|
v
render page
|
v
store result
|
v
return HTML
next request
|
v
serve stored result
Pierwszy użytkownik płaci koszt renderowania.
Kolejni dostają statyczny artefakt.
To jest bardzo podobne do lazy initialization znanego z programowania:
don't compute until needed
To nie jest po prostu SSR
Na pierwszy rzut oka pierwszy request wygląda dokładnie jak SSR:
request
|
v
server render
|
v
HTML
Różnica pojawia się przy drugim requestcie.
SSR:
request A -> render
request B -> render
request C -> render
Deferred generation:
request A -> render -> cache
request B ----------> cached HTML
request C ----------> cached HTML
To ważne rozróżnienie.
SSR zakłada, że wynik jest obliczany na request path.
Deferred generation traktuje request jedynie jako trigger materializacji artefaktu.
Po wygenerowaniu strona zachowuje się bardziej jak SSG.
Komercyjny przykład: marketplace
Załóżmy platformę z 5 milionami ofert.
Rozkład ruchu zwykle nie jest równomierny:
few popular products
████████████████████████
long tail
██████
████
██
█
█
█
Wygenerowanie wszystkich 5 milionów stron podczas deploymentu ma kilka problemów:
bardzo długi build
duży storage
koszt renderowania stron bez ruchu
trudniejsze deploymenty
konieczność przebudowy ogromnej liczby artefaktów
Zamiast tego możemy podczas builda wygenerować tylko najbardziej popularne strony.
build:
/products/1
/products/2
/products/3
A produkt numer 4928371 powstanie dopiero wtedy, gdy ktoś go pierwszy raz otworzy.
To bardzo dobrze pasuje do systemów z long-tail contentem:
marketplace
profile użytkowników
katalogi firm
katalogi produktów
strony lokalizacji
duże archiwa treści
Pierwszy request staje się specjalny
Deferred generation tworzy jednak nową asymetrię:
first visitor
|
v
slow path
everyone after
|
v
fast path
Jeżeli render trwa 600 ms, pierwszy użytkownik może dostać odpowiedź znacząco później niż kolejni.
To jest podobne do cold startu.
System musi więc zdecydować, co zrobić podczas pierwszej materializacji:
request
|
v
wait for generated HTML?
albo:
request
|
v
return fallback
|
v
generate in background
Różne implementacje mogą podejmować tę decyzję inaczej.
Ciekawostka
Deferred generation można opisać jako memoizację funkcji renderującej.
Załóżmy:
render("/products/42") -> HTML
Klasyczny SSR wykonuje funkcję za każdym razem.
Deferred generation robi logicznie coś podobnego do:
if (cache.has(path)) {
return cache.get(path);
}
const html = render(path);
cache.set(path, html);
return html;
Oczywiście realny system jest trudniejszy: cache może być rozproszony, render może odbywać się w innej instancji, a wiele requestów może dotrzeć jednocześnie.
Ale mental model jest właśnie taki:
compute once, reuse many times.
A co jeśli 100 requestów przyjdzie jednocześnie?
Tu robi się ciekawie.
Wyobraźmy sobie nową stronę:
/products/new-iphone
W sekundę po publikacji przychodzi 1000 użytkowników.
Cache jeszcze nie istnieje.
Naiwna implementacja może zrobić:
request 1 -> render
request 2 -> render
request 3 -> render
...
request 1000 -> render
Właśnie straciliśmy większość korzyści.
Lepszy model:
request 1
|
v
start generation
request 2 ─┐
request 3 ─┼─> wait for same result
request 4 ─┘
generation finished
|
v
all requests receive artifact
To mechanizm znany jako request coalescing albo single-flight.
Jeden request wykonuje kosztowną pracę, a pozostałe czekają na ten sam rezultat.
To bardzo ważny detal w systemach generujących content on demand.
Cache key staje się częścią architektury
Załóżmy, że mamy:
/products/42
Czy to jedna strona?
Być może użytkownik w Polsce widzi:
129 PLN
a użytkownik w Niemczech:
29 EUR
Jeżeli output zależy od:
path
country
language
currency
to logiczny cache key może wyglądać bardziej jak:
/products/42:pl:PLN
niż samo:
/products/42
Każdy dodatkowy wymiar zwiększa liczbę możliwych wariantów.
I nagle problem, który próbowaliśmy rozwiązać, zaczyna wracać.
Deferred generation nie usuwa combinatorial explosion.
Pozwala tylko nie materializować kombinacji, dopóki nie są potrzebne.
Gdzie pojawia się kolejny problem
Załóżmy, że produkt został wygenerowany rano:
<p>129 zł</p>
O 14:00 cena zmienia się na:
119 zł
Cache nadal zawiera stary HTML.
Deferred generation odpowiedziało na pytanie:
kiedy wygenerować stronę po raz pierwszy?
Nie odpowiedziało na pytanie:
kiedy wygenerować ją ponownie?
Możemy oczywiście usunąć artefakt ręcznie i pozwolić kolejnemu requestowi stworzyć nowy.
Ale przy większym systemie potrzebujemy bardziej formalnej strategii:
jak długo wynik jest ważny
kiedy uznać go za stale
czy użytkownik może dostać stary wynik
kto wykonuje regenerację
co jeśli kilka requestów chce ją uruchomić jednocześnie
W tym momencie przestajemy mówić tylko o generowaniu stron.
Zaczynamy mówić o cache consistency i revalidation.
I właśnie stąd bierze się kolejny model: ISR i statyczny output, który potrafi wygasać.