Usuwanie duplikatów zapytań ORM podczas renderowania w Next.js za pomocą React cache()
Dowiedz się, dlaczego komponenty serwerowe umieszczone w tym samym miejscu mogą wysyłać zapytania do tego samego rekordu kilka razy na żądanie, jak to sprawdzić oraz w jaki sposób funkcja React cache() rozwiązuje ten problem bez konieczności przechodzenia przez wszystkie właściwości.
Trasa w Next.js może wydawać się szybka w przeglądarce, podczas gdy baza danych cicho odpowiada na to samo pytanie trzy lub cztery razy przy każdym przeglądaniu strony. Przyczyną rzadko jest wolna zapytanie – chodzi o zwykłe wyszukiwanie powtarzane poza granicami renderowania, które w kodzie wydają się niezależne: generateMetadata(), strona, ścieżka nawigacyjna, zagnieżdżony komponent serwerowy. Ten artykuł pokazuje, jak powstaje to duplikowanie, jak udowodnić, że rzeczywiście ma miejsce, oraz jak usunąć je za pomocą funkcji cache() w React, zachowując jednocześnie dostęp do danych blisko komponentów, które go potrzebują. Pokazuje również wyraźną różnicę pomiędzy tym memetyzowaniem na poziomie każdej prośby a trwałym cacheowaniem, które odpowiada na zupełnie inne pytanie.
Jak jeden przegląd strony zamienia się w cztery wyszukiwania
Weźmy dynamiczną trasę produktu:
/products/[slug]
Kilka części tej ścieżki potrzebuje tego samego produktu. Funkcja generateMetadata() wymaga jego nazwy i opisu do sekcji nagłówkowej dokumentu. Strona potrzebuje pełnych danych o produkcie. Łączniki nawigacyjne wymagają informacji o kategorii. Zagnieżdżony komponent serwerowy może wyświetlać cenę lub stan zapasów. Każdy z tych użytkowników może rozsądnie pobrać to, czego potrzebuje, samodzielnie. Funkcja metadanych robi to w następujący sposób:
export async function generateMetadata({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return {
title: product.name,
}
}
Komponent strony robi to samo:
export default async function ProductPage({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return <ProductDetails product={product} />
}
A gdzieś głębiej w drzewie inny komponent serwerowy niezależnie wywołuje:
const product = await getProduct(slug)
Z punktu widzenia projektu komponentów jest to dokładnie właściwe. Każda część interfejsu użytkownika pobiera swoje dane tam, gdzie je potrzebuje, dzięki czemu obowiązki są jasno rozdzielone. Problem pojawia się tylko wtedy, gdy spojrzymy z drugiej strony: na logi zapytań do bazy danych lub metryki API. Jedno przychodzące żądanie może spowodować kilka identycznych wyszukiwań produktów. Jeden odpowiedź jest wysyłany do przeglądarki, ale jego utworzenie mogło kosztować bazę danych cztery przejścia tam i z powrotem.
Czego Next.js już eliminuje, a czego nie
Należy dokładnie rozróżnić pewną kwestię przed jakimikolwiek zmianami. Next.js automatycznie zapamiętuje identyczne żądania fetch wykonywane podczas renderowania drzewa komponentów React, a w jego dokumentacji zaznaczono, że dotyczy to funkcji generateMetadata, układów, stron oraz komponentów serwerowych. Jeśli getProduct() jest zbudowane na bazie fetch, te duplikowane żądania mogą już zostać połączone w jedno.
Gdy dane pochodzą z innego źródła niż fetch (ORM, sterownik bazy danych, SDK od third party), nie ma automatycznego zapamiętywania. W takim przypadku Next.js zaleca użycie funkcji React cache() jako sposobu na wspólne wykonywanie tych samych operacji w ramach jednego żądania.
Kluczowa koncepcja wymaga jasnego sformułowania: koszt wydajności często nie wynika z jednej drogiej żądania, lecz z taniej operacji powtarzanej między różnymi elementami, których składanie niezależnie jest ułatwione przez framework. Rozwiązaniem nie jest przeniesienie wszystkich zapytań do jednego ogromnego komponentu nadrzędnego, lecz nadanie powtarzanym operacjom dostępu do danych pojedynczej, wspólnej tożsamości.
Kolokacja sprawia, że powtarzająca się praca jest niewidoczna
Dzięki komponentom serwerowym naturalnym rozwiązaniem jest umieszczanie operacji dostępu do danych obok elementu, który je wykorzystuje. Komponent renderujący szczegóły produktu może sam załadować ten produkt, a ścieżki nawigacyjne nie muszą otrzymywać dużego obiektu produktu przechowywanego w niepowiązanych komponentach tylko dlatego, że jakiś komponent nadrzędny załadował go pierwszy.
Bez tej swobody typowym sposobem na uniknięcie powtarzającej się pracy jest przeniesienie całego procesu ładowania na początek ścieżki i przekazywanie wyników w dół przez każdą pośrednią warstwę. To działa, ale łączy komponenty, które nie mają między sobą rzeczywistego związku. Alternatywą jest umieszczenie funkcji danych, której można używać wielokrotnie, bezpośrednio w każdym komponencie serwerowym:
const product = await getProduct(slug)
Dzięki temu każde wymaganie znajduje się obok tego, który je realizuje. Otwartym pytaniem pozostaje to, czy te wywołania faktycznie korzystają z tej samej podstawowej operacji.
Jeśli ostatecznie wysyłają identyczne żądania typu fetch, React zapisuje je w pamięci w ramach drzewa komponentów. Dokumentacja Next.js podaje dokładnie to jako uzasadnienie dla ładowania danych w komponencie, który je używa, zamiast na początku ścieżki poprzez przekazywanie ich jako atrybutów. Przykładem może być bezpośrednie wywołanie ORM:
db.product.findUnique({
where: { slug },
})
Nie uzyskuje się takiego zachowania tylko dlatego, że dwa komponenty przekazują mu ten sam identyfikator. Dla Reacta jest to po prostu dowolna funkcja asynchroniczna, która będzie wykonywana za każdym razem, gdy zostanie wezwana, dopóki nie zostanie jej przypisana tożsamość zapisana w pamięci.
Grenice komponentów określają, kto jest odpowiedzialny za konkretną część interfejsu użytkownika. Nie mówią nic o tym, kto jest odpowiedzialny za przetwarzanie powtarzających się danych. Sam fakt umieszczenia komponentów obok siebie nie jest błędem; błędem jest założenie, że to oznacza usunięcie duplikatów.
Mierz przed zapisywaniem w pamięci
Zapisywanie danych w pamięci jest reakcją na stwierdzoną duplikację, a nie na podejrzenie jej istnienia. Oto przykład takiego wywołania w czterech różnych plikach:
await getProduct(slug)
To nie jest dowód na to, że baza danych została zapytana cztery razy. W Next.js ta różnica jest szczególnie ważna, ponieważ framework może już usuwać duplikaty identycznych wywołań fetch. Nowsze wersje Next.js oferują również logowanie rozwojowe dotyczące działań fetch po stronie serwera, co pomaga zobaczyć, o co dokładnie jest prośba (sprawdź aktualną dokumentację, aby dowiedzieć się, jak to włączyć w swojej wersji).
Jeśli getProduct() znajduje się w ORM, sterowniku bazy danych lub SDK, należy zaimplementować instrumentację w tym warstwie. Do szybkiego badania lokalnego nawet proste informacje o czasie wykonywania wystarczą, aby zidentyfikować wzorzec:
export async function getProduct(slug: string) {
console.time(`product:${slug}`)
const product = await db.product.findUnique({
where: { slug },
})
console.timeEnd(`product:${slug}`)
return product
}
Jeśli zauważysz, że ta etykieta jest wyświetlana cztery razy przy każdym załadowaniu strony, masz dowód. W środowisku produkcyjnym potrzebujesz silniejszych sygnałów: dzienników zapytań do bazy danych, rozproszonego śledzenia, informacji o czasie wykonywania zadań APM, liczników żądań wejściowych oraz identyfikatorów żądań, które umożliwią powiązanie każdego zapytania z widokiem strony, który je spowodował.
Kluczem jest rozróżnienie dwóch stwierdzeń, które brzmią podobnie:
Function called four times
vs.
Underlying data source hit four times
Nie są one równoważne. Jeśli operacja jest wykonywana już tylko raz, otoczenie jej kolejną warstwą nie stanowi optymalizacji; jedynie utrudnia zrozumienie warstwy danych. Optymalizuj te zadania, które faktycznie się powtarzają, a nie powtórzone wywołania funkcji, które przypadkowo zauważyłeś w kodzie.
Dlaczego funkcja oparta na ORM unika usuwania duplikatów
Załóżmy, że mechanizm ładowania produktów jest tak prosty, jak to tylko możliwe:
export async function getProduct(slug: string) {
return db.product.findUnique({
where: { slug },
})
}
Załóżmy teraz, że funkcja ta jest wywoływana z funkcji metadanych, strony, ścieżki nawigacyjnej oraz komponentu cenowego w ramach jednego żądania. Funkcja jest taka sama, argumenty są takie same, a zapytanie również. Bez mechanizmu memetyzacji każde wywołanie nadal wykonuje własne zapytanie do bazy danych.
Dla ORM lub bezpośredniego dostępu do bazy danych, funkcja cache() w React zapewnia memetyzację na poziomie każdego żądania, którą natywna funkcja fetch otrzymuje bezpłatnie w strukturze React. Next.js dokładnie opisuje ten wzorzec dla bezpośrednich zapytań do bazy danych, w tym przypadek, gdy to samo rekord jest potrzebne zarówno przez funkcję generateMetadata, jak i przez stronę.
To zrozumienie chroni również przed popularnym mitem. Gdyby getProduct() było budowane wyłącznie na identycznych wywołaniach fetch, otoczenie go funkcją cache() wyłącznie po to, by usunąć te duplikaty, nie przyniosłoby większych efektów, ponieważ Next.js już je memoryzuje. Dlatego zasadą nie jest umieszczanie cache() wokół każdego ładowacza po stronie serwera. Należy sprawdzić, czy sposób ładowania danych jest już memoryzowany, i dodać wspólną tożsamość tylko wtedy, gdy jej brakuje. Tę wersję znacznie trudniej jest stosować ślepo.
Rozwiązanie: jedna memoryzowana funkcja danych
Sama zmiana w kodzie jest niewielka. Oto ładowacz przed modyfikacją:
export async function getProduct(slug: string) {
return db.product.findUnique({
where: { slug },
})
}
A oto po otoczeniu go funkcją cache():
import { cache } from 'react'
import 'server-only'
export const getProduct = cache(async (slug: string) => {
return db.product.findUnique({
where: { slug },
})
})
Warto zwrócić uwagę na dwa szczegóły. Import typu server-only powoduje niepowodzenie procesu budowania, jeśli ten moduł trafi kiedykolwiek do kodu klienta, co stanowi rozsądne zabezpieczenie dla wszystkiego, co komunikuje się z bazą danych. Funkcja cache() otacza ją raz, na poziomie modułu, dzięki czemu każdy użytkownik otrzymuje tę samą funkcję z zapamiętanymi wartościami. Każdy użytkownik nadal wywołuje ją dokładnie tak samo jak wcześniej:
const product = await getProduct(slug)
React przechowuje wynik dla każdego argumentu w swojej pamięci cache po stronie serwera. Każde późniejsze wywołanie w ramach tej samej prośby, przez ten sam wrapper i z identycznym argumentem, otrzymuje już przechowany wynik, który w rzeczywistości jest tą samą obietnicą (promise), więc równoczesne wywołania czekają na jeden zapytanie. React usuwa te zapamiętane wyniki pomiędzy prośbami do serwera.
Czego nie trzeba było zmieniać
Cenną częścią tej poprawki jest to, co pozostało bez zmian. Nagłówek produktu nadal określa swoje własne wymagania dotyczące danych. Ścieżki nawigacyjne nie otrzymują żadnych nowych atrybutów. Generowanie metadanych i renderowanie strony nadal żądają produktu niezależnie, a żaden komponent interfejsu nie jest zmuszany do pełnienia roli, której naturalnie nie posiada. Optymalizacja odbywa się wyłącznie na poziomie danych. To, co skoncentrowano w centralnym miejscu, to nie miejsce konsumpcji danych, lecz identyfikator operacji, która je wytwarza.
Potknięcia przy użyciu cache()
Kilka szczegółów implementacyjnych decyduje o tym, czy mechanizm memoizacji faktycznie działa:
- Dziel się jedną funkcją z pamięcią podręczną. Otoczenie jednego ładownika funkcją
cache()w dwóch różnych miejscach daje dwa niezależne funkcje z pamięcią podręczną, z których każda ma własne przechowywanie danych – takie zachowanie jest wyraźnie opisane w dokumentacji React. Zdefiniuj ten wrapper raz w swoim module do dostępu do danych i importuj go wszędzie. - Niech argumentami będą wartości prymitywne. React porównuje argumenty pod kątem tożsamości, więc ciąg znaków typu slug zawsze trafia do pamięci podręcznej, natomiast obiekt utworzony na nowo, np.
{ slug }, przy każdym wywołaniu nie zostanie zapisany. - Pamiętaj o zakresie dostępu. Funkcja
cache()jest przeznaczona do renderowania na serwerze; poza żądaniem, np. w komponencie klienckim, nie zapewnia takiej eliminacji duplikatów.
Dlaczego po prostu nie pobrać wszystkiego na stronie?
Oczywistą alternatywą jest załadowanie produktu raz na górze strony i przekazanie go dalej:
export default async function ProductPage({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return (
<>
<Breadcrumbs product={product} />
<ProductHeader product={product} />
<ProductDetails product={product} />
</>
)
}
Gdy strona rzeczywiście posiada cały obiekt produktu, jest to doskonałe rozwiązanie projektowe. Problemy pojawiają się wtedy, gdy przenosi się wszystkie zależności od danych wyłącznie po to, by uniknąć powtarzającej się pracy na stronie serwerowej. Z upływem czasu liczba propów rośnie, komponenty pośrednie zaczynają przekazywać dane, których nigdy nie używają, a każdy nowy komponent potomny wymagający jakiegoś pola zmusza do wprowadzania zmian w całym łańcuchu. Granice komponentów ostatecznie odzwierciedlają mechanizmy optymalizacji zamiast rzeczywistej własności danych.
Zmiany w mechanizmach memetyzacji żądań oferują taki kompromis. Obecne wytyczne Next.js mówią, że identyczne wywołania fetch mogą pozostać w komponentach, które ich potrzebują, zamiast wymagać ładowania na najwyższym poziomie czy głębokiego przeszukiwania propów, a dla bezpośredniego dostępu do bazy danych cache() zapewnia funkcję serwerową dostępną dla wszystkich, która ma podobną semantykę eliminacji duplikatów. Dzięki temu uzyskujesz obie te cechy jednocześnie:
data close to consumer
+
deduplicated underlying work
Usuwanie powtarzających się zadań nie powinno zmuszać niespowiązanych komponentów do dzielenia się własnością jednego obiektu danych. Przeniesienie logiki do rodzica pozostaje dobrą opcją, gdy ten naturalnie posiada dane; nie powinno to być jednak obowiązkowe, ponieważ warstwa danych nie ma identyfikatora dla powtarzających się zadań.
Memoizacja żądań to nie trwałe przechowywanie w pamięci
Terminologia Next.js utrudnia to rozróżnienie, więc warto być precyzyjnym. Funkcja cache() w React w tym wzorcu nie przekształca wyszukiwania produktu przez jednego odwiedzającego w zapisaną odpowiedź dla przyszłych odwiedzających. React usuwa swoje zmemorizowane wyniki serwerowe po każdym żądaniu. W ramach jednego żądania powtarzane wywołania ponownie wykorzystują ten sam wynik:
getProduct("keyboard")
getProduct("keyboard")
getProduct("keyboard")
//reuse the memoized result
Kolejne żądanie rozpoczyna się od pustej pamięci cache i ponownie wykonywa wyszukiwanie:
New request
getProduct("keyboard")
//perform the lookup again
To jest tylko memetyzacja żądań, nic więcej. Ponowne wykorzystanie wyników w różnych żądaniami to odrębna decyzja architektoniczna. W obecnym Next.js komponenty Cache Components oferują dyrektywę use cache do przechowywania wyników poza ramami pojedynczego żądania, przy czym cacheLife() kontroluje czas trwania danej informacji, a cacheTag() umożliwia jej wycofanie na podstawie tagów. przewodnik po use cache i odnowie danych na podstawie tagów szczegółowo omawia ten temat.
Korzystnym modelem myślowym jest to, że te dwa mechanizmy odpowiadają na różne pytania:
- Memetyzacja żądań: czy identyczna operacja powinna być wykonywana cztery razy podczas tworzenia jednej odpowiedzi?
- Caching trwały: czy późniejsze żądanie może wykorzystać odpowiedź obliczoną wcześniej?
Tylko drugi z nich wprowadza element świeżości i unieważniania. Traktuj je jako dwie odrębne decyzje, a kwestia cache’owania w Next.js stanie się znacznie mniej myląca.
Gdzie faktycznie pochodzą oszczędności
Sama zapytanie do produktu może być w pełni prawidłowe. Załóżmy, że dane telemetryczne pokazują cztery identyczne operacje w bazie danych na każde żądanie, a po zmianie pozostaje tylko jedna. Oszczędzasz wtedy trzy zapytania na każdą przeglądanie strony, co wydaje się błahe. Pomnóż to teraz przez ilość ruchu: trasa obsługująca tysiące przeglądania unika trzy razy tylu zapytań, a intensywnie używana trasa w ciągu dnia unika jeszcze większej liczby zapytań. Nawet niewielka redundancja na bardzo ruchliwej trasie może skutkować tysiącami niepotrzebnych zapytań do bazy danych lub połączeń z zewnętrznymi serwerami, przy czym żadna pojedyncza operacja sama w sobie nie wydaje się alarmująca.
To także powód, dla którego konkretne kwoty oszczędności mogą występować jedynie w twierdzeniach, gdy pochodzą z własnych pomiarów. Jeśli wskaźniki produkcji pokazują określoną liczbę unikniętych operacji, należy podać tę liczbę; bez telemetryki stwierdzenie „można zaoszczędzić tysiące” uczciwie opisuje efekt mnożenia, bez konieczności wymyślania przypadku badawczego.
Zmienia to również sposób podejścia do pracy nad wydajnością. Typowe pytanie brzmi:
Which query takes 800 ms?
Często bardziej wartościowe pytanie brzmi:
Why are we paying for this normal query
four times within one request?
Poszukiwanie informacji może być indywidualnie tanie, ale ogółem marnotrawne. Praca nad wydajnością nie polega tylko na przyspieszaniu poszczególnych operacji; czasami chodzi o usunięcie operacji, które w ogóle nie musiały istnieć, a jest to szczególnie ważne, gdy mała nieefektywność występuje na kluczowej ścieżce wykonywania zadań.
Wybór odpowiedniej granicy ponownego użycia
Zrozumienie zasady żądania memetyzacji jest stosunkowo proste, ponieważ React nigdy nie przenosi zapisanego wyniku do kolejnego żądania. Trwałe cacheowanie wymaga szerszej dyskusji na temat poprawności działania. Dzięki komponentom cache’owym zapisane dane mogą mieć wyraźne okresy ważności oraz tagi służące do ponownej weryfikacji, właśnie dlatego że ich ponowne użycie pomiędzy żądaniami rodzi pytania dotyczące tego, jak długo wartość pozostaje ważna i które zdarzenia powinny ją unieważnić.
Zatem lepsze pytanie brzmi nie:
Can we cache this?
lecz:
Across which boundary is reuse correct?
Ogólny sposób myślenia o możliwych granicach:
- W ramach jednego żądania: bezpieczne dla niemal każdego odczytu, włącznie z danymi specyficznymi dla użytkownika, ponieważ nic nie przetrwa odpowiedzi. To właśnie tutaj działa funkcja
cache(). - Pomiędzy żądaniami, dla danych publicznych: odpowiednie dla treści identycznej dla wszystkich odwiedzających, pod warunkiem że zdefiniujesz okres ważności oraz sposób unieważnienia danych.
Te dwa ostatnie punkty nie są specyficzne dla Next.js; dotyczą każdego cache’u. Gdy tylko wynik różni się w zależności od tego, kto go żąda lub co może zobaczyć, klucz decydujący o ponownym użyciu musi zakodować tę samą różnicę. Imponujący wskaźnik trafień nic nie znaczy, jeśli odpowiedź może zostać udostępniona żądaniu, które nie miało do niej prawa. Najlepszy cache to taki, którego zasady ponownego użycia odpowiadają zasadom poprawności danych.
Żądania stanowiły problem graniczny
Spójrzmy z powrotem na wywołanie, które to zapoczątkowało:
await getProduct(slug)
Nic nie było nie tak w funkcji generateMetadata(), ani na stronie, ani w zawartym komponencie serwerowym. Każdy użytkownik rzeczywiście potrzebował tego produktu. Marnotrawstwo pojawiło się tylko dlatego, że te sensowne wywołania osobno przekraczały tę samą granicę backendu. Naprawa nie wymagała przyspieszenia żadnego zapytania; wystarczyło zauważyć, że jedna odpowiedź była kupowana kilka razy podczas każdego renderowania. W kodzie całe rozwiązanie może składać się z pojedynczego wrappera, zdefiniowanego raz w module danych i udostępnianego wszystkim użytkownikom:
cache(async (...) => ...)
Główne wnioski
- Tożsame natywne wywołania
fetchsą już memetyzowane w drzewie React przez Next.js. Wywołania ORM, driverów i SDK nie są memetyzowane, więccache()zapewnia im tożsamość memetyzowaną dla każdego żądania. - Najpierw zmierz. Funkcja wywołana cztery razy to nie to samo, co źródło danych odwiedzone cztery razy.
cache() nie dzielą się wynikami.Szersza lekcja: wiele istotnych ulepszeń wydajności w Next.js wynika nie tyle z miejsca ładowania danych, ile z decyzji o tym, jak długo jeden dostęp do danych powinien być uważany za jedną operację.