Startseite / Artikel / Duplikate beseitigen bei ORM-Anfragen in einer Next.js-Render-Aufgabe mithilfe von React cache()

Duplikate beseitigen bei ORM-Anfragen in einer Next.js-Render-Aufgabe mithilfe von React cache()

Erfahren Sie, warum gemeinsam platzierte Serverkomponenten denselben Datensatz mehrmals pro Anfrage abfragen können, wie Sie dies überprüfen können und wie React cache() das Problem ohne Prop-Drilling löst.

2929 Wörter

Eine Next.js-Route kann im Browser schnell erscheinen, während die Datenbank für jeden Seitenaufruf still und heimlich dieselbe Anfrage drei oder vier Mal beantwortet. Die Ursache liegt selten in einer langsamen Abfrage, sondern in einer gewöhnlichen Suche, die über Grenzen hinweg wiederholt wird, die in Ihrem Code unabhängig erscheinen: generateMetadata(), die Seite, ein Navigationspfad oder ein verschachtelter Server Component. Dieser Artikel zeigt, wie diese Duplikation entsteht, wie man nachweisen kann, dass sie tatsächlich stattfindet, und wie man sie mithilfe von Reacts cache()-Funktion beseitigen kann, ohne den Datenzugriff von den Komponenten zu entfernen, die ihn benötigen. Zudem wird hier eine klare Trennung zwischen dieser pro-Anfrage-Memoisierung und persistenter Caching-Strategie gezogen, die eine völlig andere Frage beantwortet.

Wie sich ein Seitenaufruf in vier Abfragen verwandelt

Nehmen wir einen dynamischen Produkt-Route als Beispiel:

/products/[slug]

Mehrere Teile dieser Route benötigen dasselbe Produkt. generateMetadata() verlangt den Namen und die Beschreibung für den Dokumentenkopf. Die Seite benötigt das vollständige Datensatzprofil. Die Navigationsleiste braucht die Kategorie. Ein verschachtelter Server-Component kann den Preis oder den Lagerstatus anzeigen. Jeder dieser Verarbeiter kann sinnvollerweise selbst das laden, was er benötigt. Die Metadata-Funktion macht dies wie folgt:

export async function generateMetadata({
  params,
}: PageProps<'/products/[slug]'>) {
  const { slug } = await params
  const product = await getProduct(slug)
return {
    title: product.name,
  }
}

Der Seiten-Component tut dasselbe:

export default async function ProductPage({
  params,
}: PageProps<'/products/[slug]'>) {
  const { slug } = await params
  const product = await getProduct(slug)
return <ProductDetails product={product} />
}

Und irgendwo tiefer im Baum ruft ein weiterer Server-Component unabhängig auf:

const product = await getProduct(slug)

Vom Standpunkt des Komponentendesigns aus ist das genau richtig. Jeder UI-Teil holt seine Daten dort ab, wo er sie benötigt, und die Verantwortlichkeiten bleiben klar definiert. Das Problem tritt erst dann auf, wenn man sich die andere Seite ansieht: Datenbankabfragen oder Metriken der übergeordneten API. Eine einzige eingehende Anfrage kann mehrere identische Produktabfragen auslösen. Eine Antwort wird an den Browser gesendet, doch ihre Erstellung kann für die Datenbank vier Hin- und Rückwege erfordern.

Was Next.js bereits dedupliziert und was nicht

Es gibt einen wichtigen Unterschied, den man vor dem Ändern von etwas richtig verstehen muss. Next.js merkt automatisch identische native fetch-Anfragen, die während der Renderung des React-Component-Baums gestellt werden, ab – und in seiner Dokumentation wird darauf hingewiesen, dass dies auf generateMetadata, Layouts, Seiten sowie Server Components zutrifft. Wenn getProduct() auf fetch basiert, können diese doppelten Aufrufe bereits zu einem zusammengefasst werden.

Wenn die Daten von einer Quelle stammen, die nicht fetch ist (ein ORM, ein Datenbanktreiber, ein Drittanbieter-SDK), findet keine automatische Merkung statt. In diesem Fall empfiehlt Next.js React cache() als Methode, um wiederholte Arbeiten innerhalb einer Anfrage zu teilen.

Die zugrundeliegende Erkenntnis ist klar zu formulieren: Die Leistungsbelastung entsteht oft nicht durch eine teure Anfrage, sondern durch eine auf den ersten Blick günstige Operation, die über Grenzen hinweg wiederholt wird – Grenzen, die das Framework es ermöglichen, unabhängig voneinander zu kombinieren. Die Lösung besteht nicht darin, jede Abfrage in ein riesiges Elternkomponenten zu überführen. Vielmehr geht es darum, dem wiederholten Datenzugriff eine einzige, gemeinsame Identität zu geben.

Kolokation macht wiederholte Arbeit unsichtbar

Mit Server Components ist es die natürliche Vorgehensweise, den Datenzugriff direkt neben seinem Verbraucher zu platzieren. Eine Komponente, die Produktdetails anzeigt, kann das Produkt selbst laden – und Pfade zu den jeweiligen Seiten müssen kein großes Produktobjekt enthalten, das durch unzusammenhängende Komponenten weitergeleitet wird, nur weil ein Vorgängerkomponente dieses Objekt zuerst geladen hat.

Ohne diese Freiheit besteht der übliche Versuch, doppelte Arbeit zu vermeiden, darin, alle Ladevorgänge an die Spitze der Route zu verlagern und die Ergebnisse durch jede Zwischenschicht nach unten weiterzuleiten. Das funktioniert zwar, verknüpft aber Komponenten, die keine wirkliche Beziehung zueinander haben. Die Alternative mit gemeinsamer Platzierung besteht darin, dass jede Serverkomponente von einer wiederverwendbaren Datenfunktion abhängt:

const product = await getProduct(slug)

So bleibt jede Anforderung in der Nähe ihres Verbrauchers. Die offene Frage ist, ob diese Aufrufe tatsächlich eine einzige zugrunde liegende Operation teilen.

Falls sie letztendlich identische native fetch-Anfragen senden, merkt React sie innerhalb des Komponentenbaums. Die Next.js-Dokumentation nennt genau dies als Begründung dafür, Daten in der Komponente zu laden, die sie verwendet, anstatt sie an die Spitze der Route mit nach unten weitergeleiteten Props zu bringen. Ein direkter ORM-Aufruf wie zum Beispiel:

db.product.findUnique({
  where: { slug },
})

Es tritt kein solches Verhalten auf, nur weil zwei Komponenten denselben Slug weitergeben. Für React handelt es sich dabei einfach um eine beliebige asynchrone Funktion, die jedes Mal ausgeführt wird, solange man ihr keine gememorierte Identität gibt.

Komponentengrenzen beschreiben, wer für einen Teil der Benutzeroberfläche verantwortlich ist. Sie sagen nichts darüber aus, wer für wiederholte Datenverarbeitungen zuständig ist. Die gemeinsame Verwendung ist nicht der Fehler; anzunehmen, dass dies zu einer Duplikationsvermeidung führt, schon.

Messen, bevor man memoriert

Memorisation ist eine Reaktion auf tatsächlich festgestellte Duplikationen, nicht auf Vermutungen. Die folgende Funktion auftauchen sieht man in vier verschiedenen Dateien:

await getProduct(slug)

Dies ist kein Beweis dafür, dass die Datenbank viermal abgerufen wurde. In Next.js ist dieser Unterschied besonders wichtig, da das Framework bereits identische fetch-Aufrufe duplizieren könnte. Neuere Versionen von Next.js bieten außerdem Entwickler-Logging für serverseitige fetch-Aktionen, das dabei hilft zu erkennen, was tatsächlich angefordert wird (prüfen Sie die aktuellen Dokumente, um herauszufinden, wie Sie dies in Ihrer Version aktivieren können).

Falls getProduct() auf einem ORM, einem Datenbanktreiber oder einer SDK-Bibliothek basiert, instrumentieren Sie stattdessen diese Schicht. Für eine schnelle lokale Untersuchung reicht bereits eine grobe Zeitenangabe aus, um Muster zu erkennen:

export async function getProduct(slug: string) {
  console.time(`product:${slug}`)
const product = await db.product.findUnique({
    where: { slug },
  })
  console.timeEnd(`product:${slug}`)
  return product
}

Falls Sie sehen, dass diese Beschriftung bei jedem Seitenaufruf viermal ausgegeben wird, haben Sie einen Beweis. In der Produktion benötigen Sie stärkere Anzeichen: Protokolle von Datenbankabfragen, verteiltes Tracing, APM-Spans, Zähler für vorangegangene Anfragen sowie Anfrage-IDs, die es Ihnen ermöglichen, jede Abfrage mit der Seitenansicht in Verbindung zu bringen, die sie ausgelöst hat.

Der Schlüssel besteht darin, zwei ähnlich klingende Aussagen voneinander zu trennen:

Function called four times

im Gegensatz zu:

Underlying data source hit four times

Sie sind nicht äquivalent. Wenn eine Operation ohnehin nur einmal ausgeführt wird, macht es sie durch ein weiteres Abstraktionsniveau nicht effizienter; dadurch wird die Datenverarbeitung nur schwerer nachvollziehbar. Optimieren Sie Arbeitsschritte, die tatsächlich wiederholt werden, und nicht nur wiederholte Funktionsaufrufe, die zufällig im Code zu erkennen sind.

Warum eine von einem ORM unterstützte Funktion der Doppelung entgeht

Nehmen wir an, der Produktlader ist so einfach wie möglich:

export async function getProduct(slug: string) {
  return db.product.findUnique({
    where: { slug },
  })
}

Stellen Sie sich nun vor, dass die Funktion bei einer einzigen Anfrage von der Metadatenfunktion, der Seite, den Navigationsleisten sowie einem Preiskomponenten aufgerufen wird. Die Funktion ist dieselbe, der Parameter ist derselbe und die Abfrage ist ebenfalls dieselbe. Ohne eine Memoisierungsgrenze führt jede Aufrufung weiterhin ihre eigene Abfrage gegen die Datenbank durch.

Für ORM oder direkten Zugriff auf die Datenbank bietet Reacts cache()-Funktion die pro-Anfrage-Memoisierung, die bei nativem fetch im React-Tree kostenlos verfügbar ist. Next.js dokumentiert genau dieses Muster für direkte Datenbankabfragen – einschließlich des Falls, in dem sowohl generateMetadata als auch die Seite dasselbe Datensatz benötigen.

Diese Erkenntnis schützt außerdem vor einem weit verbreiteten Mythos. Wenn getProduct() vollständig aus identischen fetch-Aufrufen bestünde, würde es kaum etwas bringen, es in cache() zu packen, nur um diese Duplikate zu beseitigen – schließlich speichert Next.js sie bereits. Daher lautet die Richtlinie nicht, cache() um jeden serverseitigen Lader zu legen. Stattdessen sollte geprüft werden, ob die Art und Weise, wie Daten geladen werden, bereits gememorisert ist, und nur dort eine gemeinsame Identität hinzugefügt werden, wo sie fehlt. Diese Variante ist viel schwieriger, blind anzuwenden.

Die Lösung: eine gememoriserte Datenfunktion

Die eigentliche Codeänderung ist gering. Hier ist der Lader zuvor:

export async function getProduct(slug: string) {
  return db.product.findUnique({
    where: { slug },
  })
}

Und hier ist er nach dem Einpacken in cache():

import { cache } from 'react'
import 'server-only'
export const getProduct = cache(async (slug: string) => {
  return db.product.findUnique({
    where: { slug },
  })
})

Zwei Aspekte sind erwähnenswert. Die server-only-Einstellung sorgt dafür, dass der Build fehlschlägt, falls dieses Modul jemals in Client-Code aufgenommen wird – was ein sinnvoller Schutzmechanismus für alles ist, das mit der Datenbank kommuniziert. Zudem umhüllt cache() die Funktion einmal auf Modulebene, sodass jeder Importeur dieselbe gememorierte Funktion erhält. Jeder Nutzer ruft sie weiterhin genauso auf wie zuvor:

const product = await getProduct(slug)

React speichert das Ergebnis für jedes Argument in seinem Server-Cache. Jeder spätere Aufruf während derselben Anfrage, über denselben Wrapper und mit dem gleichen Argument, erhält das gespeicherte Ergebnis zurück – es handelt sich dabei tatsächlich um denselben Promise, wodurch parallele Aufrufe auf eine einzige Abfrage warten. React vernichtet diese gememorierten Ergebnisse zwischen Serveranfragen.

Was sich nicht ändern musste

Der wertvolle Teil dieser Korrektur ist alles, was unverändert geblieben ist. Der Produktheader deklariert weiterhin seine eigenen Datenerfordernisse. Die Navigationsleiste erhält keine neuen Eigenschaften. Die Erstellung von Metadaten sowie die Darstellung der Seite fordern weiterhin unabhängig vom Produkt Daten an, und kein UI-Komponent wird in eine Eigentumsbeziehung gedrängt, die sie nicht von Natur aus hat. Die Optimierung findet ausschließlich an der Datengrenze statt. Was zentralisiert wird, ist nicht der Ort, an dem die Daten verbraucht werden, sondern die Identität der Operation, die sie erzeugt.

Problemfälle mit cache()

Mehrere Implementierungsdetails bestimmen, ob die Memoisierung tatsächlich funktioniert:

  • Teilen Sie eine gememorierte Funktion. Wenn man einen Loader mit cache() an zwei verschiedenen Stellen umhüllt, entstehen zwei unabhängige gememorierte Funktionen, jede mit ihrem eigenen Speicher – ein Verhalten, das React ausdrücklich dokumentiert. Definieren Sie den Wrapper einmal in Ihrem Datenzugriffsmodul und importieren Sie ihn überall.
  • Verwenden Sie lieber primitive Argumente. React vergleicht Argumente nach ihrer Identität, sodass eine Slug-String zuverlässig im Cache landet, während ein neu erstelltes Objekt wie { slug } bei jedem Aufruf jedes Mal fehlt.
  • Achten Sie auf den Kontext. cache() ist für die Serverdarstellung gedacht; außerhalb einer Anfrage, beispielsweise in einem Client-Komponenten, bietet es diese Deduplizierung nicht.

Warum nicht einfach alles auf der Seite abrufen?

Die offensichtliche Alternative besteht darin, das Produkt einmal oben zu laden und es weiterzugeben:

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} />
    </>
  )
}

Wenn die Seite tatsächlich das gesamte Produktobjekt besitzt, handelt es sich um ein durchaus gutes Design. Probleme entstehen erst, wenn man alle Datenabhängigkeiten nur deshalb aufbaut, um doppelte Arbeit im Backend zu vermeiden. Im Laufe der Zeit vermehren sich die Props, Zwischenkomponenten beginnen Daten weiterzuleiten, die sie nie verwenden, und jedes neue Kindkomponente, das ein Feld benötigt, zwingt zu Änderungen in der gesamten Hierarchie. Die Grenzen der Komponenten spiegeln schließlich Optimierungsmechanismen statt echter Eigentumsverhältnisse wider.

Bitten Sie um Anpassungen zur Memoisierung, die diesen Kompromiss herstellen. Die aktuellen Richtlinien von Next.js besagen, dass identische fetch-Aufrufe in den Komponenten bleiben können, die sie benötigen, anstatt eine Ladung auf der obersten Ebene oder das Durchdringen von Props erforderlich zu machen. Für direkten Datenbankzugriff bietet cache() eine gemeinsame Serverfunktion mit vergleichbaren Deduplizierungsmechanismen. So erhalten Sie beide Eigenschaften auf einmal:

data close to consumer
        +
deduplicated underlying work

Das Entfernen wiederholter Arbeiten sollte nicht dazu zwingen, dass unverwandte Komponenten das Eigentum an einem Datenobjekt teilen. Das Hochheben der Logik bleibt eine gute Wahl, wenn der Elternteil von Natur aus das Datenobjekt besitzt; es sollte jedoch nicht verpflichtend sein, da die Datenschicht für wiederholte Arbeiten keine Identität besitzt.

Anfragememoisierung ist kein persistentes Caching

Durch die Begrifflichkeiten von Next.js wird dies leicht verwechselt, daher ist Genauigkeit wichtig. Reacts cache() in diesem Muster wandelt keine Produktabfrage eines Besuchers in eine gespeicherte Antwort für zukünftige Besucher um. React löscht seine gememoisierten Serverergebnisse für jede Anfrage. Innerhalb derselben Anfrage werden wiederholte Aufrufe das Ergebnis wiederverwenden:

getProduct("keyboard")
getProduct("keyboard")
getProduct("keyboard")
//reuse the memoized result

Eine nachfolgende Anfrage startet mit einem leeren Cache und führt die Abfrage erneut aus:

New request
getProduct("keyboard")
//perform the lookup again

Das ist lediglich die Memoisierung von Anfragen – nichts weiter. Die Wiederverwendung von Ergebnissen über verschiedene Anfragen ist eine eigene architektonische Entscheidung. In aktuellen Next.js-Versionen bieten Cache Components die use cache-Direktive zur Caching von Ergebnissen über eine einzelne Anfrage hinaus, wobei cacheLife() bestimmt, wie lange ein Eintrag gespeichert bleibt, und cacheTag() eine markierte Invalidation ermöglicht. Der Leitfaden zu use cache und der revalidierenden Verwendung von Tags behandelt diesen Aspekt ausführlich.

Ein nützliches Denkmodell ist, dass die beiden Mechanismen unterschiedliche Fragen beantworten:

  • Memoisierung von Anfragen: Soll eine identische Operation viermal ausgeführt werden, während eine Antwort erstellt wird?
  • Persistentes Caching: Darf eine spätere Anfrage eine zuvor berechnete Antwort wiederverwenden?

Nur der zweite Punkt führt Frische und Außer Kraft Setzen ein. Betrachten Sie sie als zwei getrennte Entscheidungen, dann wird die Caching-Logik von Next.js deutlich weniger verwirrend.

Woher die Einsparungen tatsächlich stammen

Die Produktabfrage selbst kann völlig unproblematisch sein. Angenommen, Telemetriedaten zeigen vier identische Datenbankoperationen pro Anfrage an – nach der Änderung bleiben nur noch eine. Dadurch werden pro Seiteanzeige drei Abfragen eingespart, was auf den ersten Blick unbedeutend erscheint. Multiplizieren Sie das nun mit dem Traffic: Eine Route, die Tausende von Anzeigen auslöst, vermeidet dreimal so viele Abfragen, und eine stark genutzte Route vermeidet im Laufe eines Tages noch viel mehr. Kleine Doppelungen auf einer stark frequentierten Route können zusammen Tausende unnötiger Datenbankabfragen oder Aufrufe an externe Dienste verursachen, ohne dass jede einzelne Operation besonders besorgniserregend erscheint.

Deshalb gehören konkrete Einsparungszahlen nur in eine Behauptung, wenn sie aus Ihren eigenen Messungen stammen. Wenn Ihre Produktivitätsmetriken eine bestimmte Anzahl vermiedener Vorgänge anzeigen, nennen Sie diese Zahl; ohne Telemetrie beschreibt „Kostenersparnisse in Höhe von Tausenden“ ehrlich den Multiplikatoreffekt, ohne einen Fallbeispiel zu erfinden.

Aus diesem Grund ändert sich auch die Herangehensweise bei der Leistungsoptimierung. Die übliche Frage lautet:

Which query takes 800 ms?

Oft ist die wertvollere Frage jedoch:

Why are we paying for this normal query
four times within one request?

Eine Abfrage kann einzeln günstig, insgesamt aber verschwenderisch sein. Bei der Leistungsoptimierung geht es nicht nur darum, jeden Vorgang schneller zu machen; manchmal geht es auch darum, Vorgänge zu entfernen, die nie hätten existieren müssen – und das ist besonders wichtig, wenn eine kleine Ineffizienz auf einem häufig genutzten Pfad liegt.

Auswahl der richtigen Wiederverwendungsgrenze

Die Anfrage-Memoisierung ist relativ einfach zu verstehen, denn React überträgt ein gememorisierter Ergebniswert niemals auf eine zukünftige Anfrage. Für persistente Caching-Lösungen ist hingegen eine ausführlichere Diskussion über die Korrektheit erforderlich. Mit Cache Components kann gememoriserte Arbeit explizite Lebensdauern sowie Tags zur Neuvalidierung erhalten – gerade weil die Wiederverwendung über verschiedene Anfragen Fragen aufwirft, wie lange ein Wert gültig bleibt und welche Ereignisse ihn ungültig machen sollten.

Daher ist die bessere Frage nicht:

Can we cache this?

sondern:

Across which boundary is reuse correct?

Eine grobe Art, sich die möglichen Grenzen vorzustellen:

  • Innerhalb einer Anfrage: sicher für fast alle Lesevorgänge, einschließlich benutzerbezogener Daten, da nichts länger besteht als die Antwort. Hier arbeitet cache().
  • Zwischen Anfragen, für öffentliche Daten: geeignet für Inhalte, die für jeden Besucher identisch sind, vorausgesetzt man definiert eine Lebensdauer sowie einen Weg zur Ungültigkeitsmarkierung.
  • Für benutzerbezogene Daten über verschiedene Anfragen hinweg muss der Cache-Schlüssel die Benutzeridentität enthalten, andernfalls kann einem Benutzer das Ergebnis eines anderen Benutzers bereitgestellt werden.
  • Für auf Autorisierung angewiesene Daten über verschiedene Anfragen hinweg muss das, was den Zugriff bestimmt, Teil der Wiederverwendungsidentität sein, andernfalls werden Zugriffsprüfungen stillschweigend umgangen.
  • Diese letzten beiden Punkte sind nicht spezifisch für Next.js; sie gelten für jeden Cache. Sobald die Ausgabe je nach Anfragenden oder dessen Sichtbereich variiert, muss der Schlüssel, der die Wiederverwendung bestimmt, genau diese Unterscheidung kodieren. Eine hohe Trefferquote bedeutet nichts, wenn eine Antwort an eine Anfrage weitergeleitet werden kann, die dazu nicht berechtigt ist. Der beste Cache ist jener, dessen Wiederverwendungsregeln mit den Korrektheitsregeln der Daten übereinstimmen.

    Die Anfragen stellten ein Grenzproblem dar

    Blicken wir zurück auf den Aufruf, der das Ganze auslöste:

    await getProduct(slug)
    

    In generateMetadata(), auf der Seite sowie in einem verschachtelten Server Component gab es nichts Falsches. Jeder Verbraucher benötigte das Produkt tatsächlich. Der Verschwendungseffekt trat nur auf, weil diese sinnvollen Aufrufe jeweils eigenständig die gleiche Backend-Grenze überschritten. Die Lösung erforderte es nicht, irgendwelche Abfragen schneller zu machen; vielmehr musste man erkennen, dass dieselbe Antwort bei jeder Darstellung mehrfach abgerufen wurde. Im Code kann die gesamte Lösung bereits in Form eines einzigen Wrapper bestehen, der einmal im Datenmodul definiert und von allen Verbrauchern gemeinsam genutzt wird:

    cache(async (...) => ...)
    

    Wichtige Erkenntnisse

    • Identische native fetch-Aufrufe werden bereits von Next.js im React-Tree gememorisert. ORM-, Driver- und SDK-Aufrufe hingegen nicht, weshalb cache() ihnen eine pro Anfrage gememorierte Identität verleiht.
    • Messen Sie zuerst. Eine Funktion, die viermal aufgerufen wird, ist nicht dasselbe wie ein Datenquellenzugriff, der viermal erfolgt.
  • Teilen Sie eine in Cache gespeicherte Funktion; getrennte cache()-Umhüllungen teilen die Ergebnisse nicht.
  • Gute Komponentengrenzen müssen nicht geopfert werden. Duplizierungen sollten dort beseitigt werden, wo es hilft, und eine dauerhafte Caching-Strategie sollte als eigenständige Entscheidung mit eigenen Regeln zur Aktualität und Sicherheit umgesetzt werden.
  • Die übergeordnete Lektion: Viele wertvolle Leistungsverbesserungen in Next.js ergeben sich weniger davon, wo Daten geladen werden, sondern vielmehr davon, zu entscheiden, wie lange ein Datenzugriff als einzige Operation gilt.