Konrad Kowalski (rootsher)Principal Platform & Reliability Architect011001111111100011010101010000111001111001001001

Deferred generation: nie generuj tego, czego nikt nie potrzebuje

data
kategoria
Frontend
także w
Caching
czytanie
3 min / 543 słów

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:

text
build
  |
  v
render every known page
  |
  v
store HTML
  |
  v
deploy

Deferred generation:

text
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:

text
don't compute until needed

To nie jest po prostu SSR

Na pierwszy rzut oka pierwszy request wygląda dokładnie jak SSR:

text
request
  |
  v
server render
  |
  v
HTML

Różnica pojawia się przy drugim requestcie.

SSR:

text
request A -> render
request B -> render
request C -> render

Deferred generation:

text
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:

text
few popular products
████████████████████████

long tail
██████
████
██
█
█
█

Wygenerowanie wszystkich 5 milionów stron podczas deploymentu ma kilka problemów:

text
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.

text
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:

text
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ę:

text
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:

text
request
  |
  v
wait for generated HTML?

albo:

text
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:

text
render("/products/42") -> HTML

Klasyczny SSR wykonuje funkcję za każdym razem.

Deferred generation robi logicznie coś podobnego do:

js
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ę:

text
/products/new-iphone

W sekundę po publikacji przychodzi 1000 użytkowników.

Cache jeszcze nie istnieje.

Naiwna implementacja może zrobić:

text
request 1 -> render
request 2 -> render
request 3 -> render
...
request 1000 -> render

Właśnie straciliśmy większość korzyści.

Lepszy model:

text
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:

text
/products/42

Czy to jedna strona?

Być może użytkownik w Polsce widzi:

text
129 PLN

a użytkownik w Niemczech:

text
29 EUR

Jeżeli output zależy od:

text
path
country
language
currency

to logiczny cache key może wyglądać bardziej jak:

text
/products/42:pl:PLN

niż samo:

text
/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:

html
<p>129 zł</p>

O 14:00 cena zmienia się na:

text
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:

text
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ć.