Startseite / Artikel

Cache-Stampe in Node.js: Warum ein Redis-Cache Ihre Datenbank überlasten kann

Erfahren Sie, warum eine naive Redis-Einrichtung mit Cache-Aside den Ladevorgang auf Ihre Datenbank auslöst, wenn ein häufig genutzter Schlüssel abläuft, und wie Sperrmechanismen sowie Hintergrundaktualisierungen dies verhindern.

Verlag: REACTAPP.TOP7 Min. Lesezeit1648 Wörter

Quellen: “I Thought Redis Would Make My API Faster.” It Didn’t.

Das Hinzufügen von Redis vor eine langsame Abfrage ist die klassische Lösung für eine Datenbank unter hohem Druck, und an ruhigen Tagen scheint sie eindeutig vorteilhaft zu sein. Unter Spitzenbelastung kann jedoch eine einfache Cache-Aside-Einrichtung die Situation dramatisch verschlechtern: Sobald ein beliebter Schlüssel abläuft, fehlen alle gleichzeitig eingehenden Anfragen die Daten und belasten die Datenbank massiv. Dieser Artikel erläutert dieses Versagen, das als Cache Stampede bekannt ist, zeigt auf, wie es sich in einer Node.js-API ausbreitet, und stellt zwei Ansätze vor, die dies verhindern: ein verteiltes Regenerierungsverfahren mit Schutzmechanismus sowie vorab durchgeführte Berechnungen im Hintergrund.

Eine Lösung, die bis zur Spitzenbelastung funktioniert

Die übliche Einrichtung ist Cache-Aside. Bei jeder Anfrage prüft die API zunächst Redis; falls der Schlüssel dort nicht vorhanden ist, wird die Datenbank abgefragt und das Ergebnis wieder in Redis gespeichert, damit es beim nächsten Mal verfügbar ist. Bei geringer Verkehrslast verringert diese Methode sofort die Latenzzeit.

Dann tritt der Andrang am Montagmorgen ein und die API reagiert nicht mehr. Der Node.js-Prozess weist einen niedrigen CPU-Auslastungswert auf, sodass der Event-Loop nicht als Ursache in Frage kommt. Die Hauptdatenbank hingegen ist zu 100 % mit der CPU ausgelastet.

Redis ist nicht die Engstelle

Redis selbst ist selten das Problem. Es kann Hunderttausende von Operationen pro Sekunde mit einer Latenzzeit von unter einem Millisekunden abwickeln. Das Problem entsteht durch die Beziehung zwischen der Anwendung und dem Cache – genauer gesagt dadurch, wie sich die Anwendung verhält, wenn Daten fehlen.

Ein gängiges mentales Modell ist eine ruhige Bibliothek: Man wendet sich an die Empfangstheke (Redis), und wenn das Buch dort nicht vorhanden ist, holt ein Bibliothekar (die API) es aus den Regalen (PostgreSQL) und legt eine Kopie auf die Theke. In größeren Skalenniveaus ähnelt die Situation eher einem Börsenparkett. Wenn Tausende von Clients zum gleichen Zeitpunkt nach demselben beliebten Produkt fragen, genau dann, wenn der Cache-Eintrag abläuft, finden sie alle gleichzeitig eine leere Theke und begeben sich alle zu den Regalen.

Anatomie einer Panikwelle

Nehmen wir einen Endpunkt wie GET /products/trending. Die Erstellung der Antwort erfordert mehrere Verknüpfungen, Unterabfragen sowie einige Aggregationen in Node.js und dauert etwa 1,2 Sekunden. Das Ergebnis wird zehn Minuten lang im Cache gespeichert.

In einem stabilen Zustand ist das sehr effizient: Von zehntausend Anfragen erreicht nur eine die Datenbank, während die restlichen in etwa 5 ms aus Redis bereitgestellt werden.

Nehmen wir nun an, die zehnminütige TTL läuft um 09:00:00 ab. Um 09:00:01 kommen fünfzig Anfragen gleichzeitig an. Jede von ihnen scheitert und startet dieselbe 1,2-sekündige Abfrage. Plötzlich führt die Datenbank fünfzig rechenintensive Abfragen parallel aus. Der Connection Pool ist fast sofort erschöpft, und jede weitere API-Anfrage, die auf die Datenbank zugreifen muss – selbst für unverwandte Daten – wartet nun hinter diesen fünfzig Anfragen.

Es ist hier wichtig, präzise zu sein: Das Warten auf eine vom Pool bereitgestellte Verbindung blockiert den Node.js Event Loop nicht, da diese Wartezeiten asynchron sind. Stattdessen häufen sich die Anfragen als ausstehende Promises und offene Sockets an, weshalb der Prozess zwar untätig erscheint, während die Benutzer Zeitüberschreitungen feststellen.

Das ist das Cache-Stampede, auch als Dogpile-Effekt bekannt. Der Cache hat die Last nicht verringert; er hat vielmehr einen plötzlichen Belastungsschub auf einen einzigen Zeitpunkt synchronisiert.

Warum es zu einer Kettenreaktion kommt statt zur Besserung

Eine Panikwelle ist nicht einfach nur eine Phase der Verlangsamung – sie nährt sich selbst.

  • Die Abfristunden explodieren. Eine 1,2 Sekunden dauernde Abfrage kann auf 25 Sekunden ansteigen, sobald fünfzig Instanzen um CPU-Ressourcen, Sperren und Eingabe/Ausgabe-Operationen konkurrieren.
  • API-Instanzen halten Tausende offener HTTP-Verbindungen bei, während sie warten, was Speicher und Dateide描述oren verbraucht.
  • Die CPU-Leistung auf der API-Ebene bleibt niedrig, sodass ein Automatischer Skalierer, der sich nach der CPU-Leistung richtet, möglicherweise niemals Kapazitäten hinzufügt – und das Hinzufügen weiterer API-Instanzen würde ohnehin nur weitere Abfragen an die Datenbank senden.
  • Neue Anfragen kommen weiterhin an, werden aber ignoriert, weil die langsamen Abfragen den Wert noch nicht zurückgeschrieben haben. Schließlich versagt die Datenbank aufgrund von Speichermangel oder Verbindungszeitüberschreitungen völlig.

Panikwellen verhindern

Das Hinzufügen eines schnelleren Speichers kann das Problem, das im Grunde ein Koordinationsproblem ist, nicht beheben. Die Lösung besteht darin, zu ändern, wer und wann teure Daten erneut generieren darf.

Muster 1: ein verteiltes Regenerierungs-Sperrenmechanismus

Wenn eine API in mehreren Instanzen läuft, muss die Regeneration über alle Instanzen hinweg koordiniert werden, was einen in Redis gespeicherten Sperrenmechanismus erfordert. Der erste Anfrage, bei der der Cache fehlt, versucht, den Sperrenmechanismus zu ergreifen, bevor er die Datenbank abruft. Jede andere Anfrage, bei der der Cache fehlt, solange der Sperrenmechanismus aktiv ist, hält sich von der Datenbank fern und tut eines von zwei Dingen:

  • Wartet und versucht es erneut: schläft kurz, beispielsweise 50 ms lang, und überprüft anschließend erneut Redis, bis der neue Wert verfügbar ist.
  • Liefert veraltete Daten: gibt den vorherigen Wert zurück, falls das Produkt leicht veraltete Ergebnisse tolerieren kann.

Das untenstehende Beispiel verwendet ioredis. Zunächst wird der Cache gelesen. Bei einem Fehlschlag wird versucht, mit SET, NX und PX 5000 eine Sperre zu erstellen – diese wird nur dann gesetzt, wenn niemand anderes sie bereits hält – und nach fünf Sekunden wieder aufgehoben. Der Gewinner führt die Abfrage aus, speichert das Ergebnis für 600 Sekunden und gibt die Sperre in einem finally-Block frei; alle anderen prüfen weiter, bis der Wert eingetragen ist.

// Example: Using Redis for a locking/syncing pattern (Conceptual)
import { Redis } from 'ioredis';
const redis = new Redis();


async function getCachedTrendingProducts() {
  const cacheKey = 'products:trending';
  const lockKey = 'lock:products:trending';
  // Attempt 1: Get data
  let data = await redis.get(cacheKey);
  if (data) return JSON.parse(data);
  // Attempt 2: Cache miss. Try to acquire the regeneration lock.
  const acquiredLock = await redis.set(lockKey, 'locked', 'NX', 'PX', 5000); // 5s lock
  if (acquiredLock) {
    // We hold the lock. We are the ONLY worker hitting the DB.
    try {
      // 1.2s Database Operation
      const dbData = await fetchTrendingProductsFromDB();
      // Store in Redis (10 min TTL)
      await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
      return dbData;
    } finally {
      // Release lock
      await redis.del(lockKey);
    }
  } else {
    // We did NOT get the lock. Someone else is already regenerating.
    // Wait and Retry (e.g., exponential backoff or simple polling)
    return pollRedisUntilPopulated(cacheKey);
  }
}

Dadurch erreicht gleichzeitig höchstens eine teure Abfrage pro Schlüssel die Datenbank, unabhängig davon, wie viele Anfragen gleichzeitig eintreffen.

In der Produktion lohnt es sich, einige Verbesserungen in Betracht zu ziehen:

  • Die Ablaufzeit der Sperre muss länger sein als die längste mögliche Abfragezeit. Wenn die Regenerierung länger als fünf Sekunden dauert, verfällt die Sperre und ein zweiter Prozess startet eine doppelte Abfrage.
  • Das bedingungslose Löschen des Schlosses kann ein Schloss entfernen, das von einem anderen Prozess nach Ablauf Ihres Schlosses erworben wurde. Das Speichern eines eindeutigen Tokens als Schlosswert und das Löschen nur, wenn dieser noch übereinstimmt – in der Regel mit einem kleinen Lua-Skript – verhindert das Problem.
  • pollRedisUntilPopulated benötigt eine Zeitüberschreitung und einen Ersatzmechanismus, da sonst Warteanfragen hängen bleiben können, wenn der Inhaber des Schlosses abstürzt.
  • Innerhalb eines einzigen Node.js-Prozesses können Sie auch gleichzeitige Fehlsucheversuche durch das Teilen einer einzigen laufenden Promise pro Schlüssel zusammenfassen, wodurch die Anzahl der Schlossversuche verringert wird, bevor überhaupt Redis involviert wird.
  • Muster 2: Vorkalkulation im Hintergrund

    Der zuverlässigste Ansatz trennt die Bereitstellung von Daten von ihrer Erzeugung. Die Anfragenverarbeiter, der synchrone Weg, lesen ausschließlich aus dem Cache und erneuern diesen niemals. Wenn ein Wert fehlt, geben sie sofort einen leeren oder „loading“-Zustand zurück – was weitaus besser ist, als eine Verbindung mehrere Sekunden lang zu blockieren.

    Die Erneuerung erfolgt in einem Hintergrundprozess, wie beispielsweise einer Cron-Aufgabe, einem BullMQ-Worker oder einem Change-Data-Capture-Konsumenten, der den Schlüssel vor Ablauf seines Zeitraums aktualisiert. Im untenstehenden Beispiel erneuert ein alle neun Minuten geplanter Worker die Liste der Trends und schreibt sie mit derselben zehnminütigen TTL zurück, sodass der Schlüssel unter normalen Betriebsbedingungen niemals ungenutzt bleibt:

    // Separate background worker file
    // For example, running every 9 minutes via Cron or BullMQ
    async function regenerateTrendingProductsCache() {
      const cacheKey = 'products:trending';
      try {
        // Proactive DB fetch (1.2s)
        const dbData = await fetchTrendingProductsFromDB();
        // Refresh the 10-minute cache
        await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
        console.log('Cache proactively refreshed.');
      } catch (err) {
        console.error('Proactive refresh failed:', err);
      }
    }
    

    Weil eingehende Anfragen stets einen bereits verfügbaren Schlüssel finden, gibt es keinen Moment, in dem sie gemeinsam auf die Datenbank zurückgreifen müssten. Zwei Aspekte sorgen dafür, dass dies nicht geschieht: Die Aktualisierung wird entweder über einen einzigen Scheduler ausgeführt oder hinter einem Schloss, damit mehrere Instanzen nicht gleichzeitig aktualisieren, und es wird ein Alarm ausgelöst, wenn die Aktualisierung fehlschlägt – schließlich protokolliert der catch-Block allein nur den Fehler, und der Schlüssel läuft eine Minute später ab. Was die Scheduling-Logik betrifft, geht der Leitfaden zu zuverlässigen Hintergrundaufgaben mit BullMQ und Redis tiefer darauf ein.

    Auswahl zwischen ihnen

    Das Verriegeln beibehält den bekannten Cache-aside-Ablauf und eignet sich für Daten, deren Anfrage unvorhersehbar ist. Hintergrundvorausrechnungen sind geeignet für eine kleine Anzahl bekannter, aufwändiger und häufig gelesener Schlüssel wie eine Trendliste oder ein Dashboard-Zusammenfassung und liefern die vorhersehbarste Latenz. Das Hinzufügen einer kleinen zufälligen Varianz zu den TTL-Werten hilft ebenfalls, wenn viele Schlüssel gleichzeitig geschrieben wurden und sonst gemeinsam ablaufen würden.

    Kernpunkte

    • Redis ist schnell; Probleme entstehen durch die Reaktion der Anwendung auf einen Cache-Miss, nicht durch den Cache selbst.
    • Ein naiver Cache-aside-Ansatz verwandelt einen einzigen Ablaufzeitpunkt in einen synchronisierten Ansturm auf die langsamste Abhängigkeit, wobei Fehler hinter niedriger API-CPU-Auslastung verborgen bleiben können.
    • Ein verteiltes Schloss stellt sicher, dass nur ein Aufrufer einen Schlüssel erneut generiert; Ablaufzeiten, Eigentumsrechte und Wartezeitlimits müssen sorgfältig gehandhabt werden.
  • Durch das Erneuern des Hintergrunds wird die Regeneration vollständig aus dem Anfragenpfad entfernt, was die beste Option für häufig genutzte, ressourcenintensive Schlüssel darstellt.
  • Betrachten Sie das Caching als architektonische Entscheidung zum Schutz der Datenbank unter Last, und nicht als schnellen Geschwindigkeitsvorteil.