Konrad Kowalski (rootsher)Principal Platform & Reliability Architect000011010111000110010010110001101001001111111010

Revalidation i ISR: statyczny output, który może się zestarzeć

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

Deferred generation rozwiązuje problem pierwszego renderu.

Nie generujemy milionów stron podczas builda. Generujemy je dopiero wtedy, gdy ktoś naprawdę ich potrzebuje, a potem zachowujemy wynik.

Ale statyczny artefakt ma jedną niewygodną właściwość:

nie wie, że dane się zmieniły.

Jeśli cena produktu, stan magazynowy albo treść artykułu ulegnie zmianie, wcześniej wygenerowany HTML może stać się nieaktualny.

I tutaj pojawia się revalidation.

Cache potrzebuje pojęcia świeżości

Załóżmy, że wygenerowaliśmy stronę produktu:

html
<h1>Mechanical Keyboard</h1>
<p>129 zł</p>

O 12:00 cena zmienia się na 119 zł.

Jeżeli nic nie zrobimy, cache nadal zwróci:

text
129 zł

Musimy więc określić, kiedy wynik przestaje być fresh.

Najprostszy model:

text
generated at: 12:00
TTL: 60s

12:00:30 -> fresh
12:01:10 -> stale

Od tego momentu system wie, że artefakt powinien zostać odświeżony.

Pytanie brzmi tylko: kiedy?

Regeneracja synchroniczna

Najbardziej oczywista strategia:

text
request
  |
  v
cache stale?
  | yes
  v
render new version
  |
  v
store
  |
  v
return

To daje świeży wynik, ale pierwszy request po wygaśnięciu płaci pełny koszt renderu.

Mamy więc podobny problem jak przy deferred generation:

text
most users -> fast

one unlucky user -> slow

W wielu systemach można zrobić lepiej.

Stale-while-revalidate

Zamiast blokować użytkownika na regeneracji, możemy zwrócić stary wynik i odświeżyć go w tle.

text
request
  |
  v
cache stale?
  | yes
  v
return stale HTML
  +
start regeneration

Następny request dostanie już nową wersję.

Przykład:

text
12:01:10
request A
-> gets 129 zł
-> triggers regeneration

12:01:11
new HTML generated: 119 zł

12:01:12
request B
-> gets 119 zł

To klasyczny trade-off:

freshness vs latency.

Akceptujemy krótkie okno nieaktualności w zamian za szybki response.

Gdzie w tym ISR

Incremental Static Regeneration można traktować właśnie jako konkretną implementację tego modelu.

Nie jako magiczną kategorię renderowania, tylko jako połączenie:

text
precomputed HTML
+
cache
+
expiration
+
regeneration

To istotne, bo dzięki temu ISR przestaje wyglądać jak kolejny frameworkowy skrót.

Mechanizm jest bardzo stary.

Podobne strategie istnieją od lat w CDN-ach, reverse proxy i systemach cache.

Frontend po prostu zaczął wykorzystywać je bezpośrednio do artefaktów renderowania.

Komercyjny przykład: katalog produktów

Załóżmy duży sklep.

Opis produktu zmienia się raz na kilka dni.

Cena kilka razy dziennie.

Stan magazynowy może zmieniać się co kilka sekund.

Gdyby cały dokument miał jeden TTL, musielibyśmy wybrać kompromis:

text
TTL 24h
-> świetny cache
-> potencjalnie bardzo stara cena

TTL 5s
-> świeża cena
-> ciągła regeneracja całej strony

To pokazuje ważny problem:

różne dane mają różne wymagania dotyczące freshness.

Nie zawsze sensowne jest traktowanie całego HTML-a jako jednej jednostki cache.

Ten problem później doprowadzi do bardziej granularnych modeli renderowania.

Ciekawostka

TTL nie oznacza:

po 60 sekundach cache automatycznie generuje nową stronę.

Najczęściej oznacza jedynie:

po 60 sekundach obecny wpis nie powinien być już traktowany jako fresh.

Regeneracja może zacząć się dopiero przy kolejnym requestcie.

Jeżeli dana strona nie dostanie żadnego ruchu przez godzinę, nie ma powodu jej aktualizować.

To kolejny przykład lazy computation:

text
don't refresh until somebody cares

Dzięki temu koszt systemu może skalować się bardziej z realnym ruchem niż z teoretyczną liczbą stron.

Revalidation oparta na czasie to tylko jeden model

TTL jest prosty, ale ma wadę.

Jeśli produkt zmienił się sekundę po regeneracji, możemy przez prawie cały TTL serwować stary dokument.

Alternatywą jest event-driven invalidation.

text
CMS update
  |
  v
invalidate /products/42
  |
  v
next request regenerates

Albo:

text
database update
  |
  v
webhook
  |
  v
purge cache

To pozwala utrzymywać długi TTL albo nawet brak klasycznego expiration, a jednocześnie reagować natychmiast na zmianę danych.

Koszt to większa złożoność systemu.

Musimy wiedzieć:

które artefakty zależą od zmienionych danych?

Invalidation szybko robi się problemem grafu zależności

Załóżmy, że produkt 42 występuje na:

text
/products/42
/category/keyboards
/search?q=keyboard
/promotions/summer

Zmiana ceny produktu może oznaczać invalidację wszystkich tych stron.

Czyli relacja nie jest już:

text
data -> page

ale:

text
data
├── page A
├── page B
├── page C
└── page D

W większej aplikacji powstaje dependency graph między danymi i cache'owanymi artefaktami.

Dlatego invalidation jest trudniejsze niż samo cache'owanie.

Cache wie, co przechowuje.

Nie zawsze wie, dlaczego wynik wygląda właśnie tak.

Co jeśli wiele requestów próbuje regenerować naraz

Załóżmy popularny produkt.

Cache właśnie wygasł.

W tej samej chwili przychodzi 500 requestów.

Naiwna implementacja:

text
500 requests
  |
  v
500 regenerations
  |
  v
database cries

To klasyczny cache stampede.

System powinien zwykle pozwolić jednej instancji wykonać regenerację:

text
request A -> regeneration

request B ─┐
request C ─┼─> stale response
request D ─┘

albo sprawić, żeby pozostałe requesty czekały na ten sam wynik.

To ten sam problem concurrency, który widzieliśmy przy deferred generation, tylko teraz pojawia się cyklicznie.

Revalidation zmienia sposób myślenia o renderowaniu

Na początku mieliśmy prosty wybór:

text
render now

albo:

text
render during build

Revalidation wprowadza trzeci model:

text
render
  |
  v
reuse
  |
  v
become stale
  |
  v
render again

Rendering staje się procesem okresowej materializacji wyniku.

I to jest dużo ciekawszy mental model niż:

ISR to SSG odświeżany co jakiś czas.

Tak naprawdę mamy system cache z kontrolowaną niespójnością.

Ale nadal czekamy na cały dokument

Nawet jeśli potrafimy świetnie cache'ować i regenerować HTML, pozostaje inne ograniczenie.

Załóżmy, że strona potrzebuje:

text
product          -> 20 ms
reviews          -> 80 ms
recommendations  -> 500 ms

Jeśli serwer chce wygenerować kompletny dokument przed wysłaniem odpowiedzi, cały request może czekać na najwolniejszy fragment.

text
fast data ───────────┐
medium data ─────────┼─> wait -> HTML
slow data ───────────┘

A przecież użytkownik mógłby już zobaczyć nazwę i cenę produktu.

Nie musimy czekać na wszystko.

Możemy zacząć wysyłać dokument, zanim render całego drzewa się zakończy.

Tak dochodzimy do Streaming SSR.