Startseite / Artikel

Redis-Caches in Node.js einwandfrei halten: Invalidierung ohne veraltete Lesevorgänge

Vergleichen Sie TTL, Delete-on-Write, Pub/Sub sowie die Invalidation von versionierten Schlüsseln in Redis unter Node.js, betrachten Sie die dadurch entstehenden Konfliktpotenziale und erstellen Sie einen Fail-Open-Cache-Abschirmungs Wrapper.

Verlag: REACTAPP.TOP16 Min. Lesezeit3730 Wörter

Quellen: Redis Caching in Node.js, Part 2: Invalidation That Doesn’t Lie

Stellen Sie sich einen Benutzer vor, der ein neues Profilbild hochlädt, eine Erfolgsbestätigung erhält, die Seite neu lädt – doch weiterhin das alte Bild sieht. Er versucht es erneut mit dem gleichen Ergebnis, und einige Minuten später erscheint das neue Bild von selbst. Der daraus resultierende Fehlerbericht ist fast unmöglich nachzuvollziehen, da Redis weiterhin eine Kopie der Zeile bereitstellt, die in der Datenbank bereits ersetzt wurde. Eine langsame Datenbank liefert zumindest die richtige Antwort; ein schneller Cache, der eine falsche Antwort liefert, ist schlimmer als gar kein Cache. Diese Anleitung konzentriert sich auf den Schreibweg eines Redis-Caches in Node.js: vier Möglichkeiten, Einträge zu ungültig zu machen, wann jede Methode ihre Kosten rechtfertigt, die Rennbedingungen, die jede Methode aufweist, einen wiederverwendbaren cacheAside()-Wrapper, der Schutzmechanismen für den Leseweg mit der Ungültigmachung kombiniert, sowie die Fehler, die am häufigsten dazu führen, dass veraltete Werte weiterhin bestehen bleiben.

Die Leseaspekte des Cachens – also das Cache-aside-Laden, der Schutz vor Überlastung sowie das Verhalten bei Ablauf einer „heißen“ Schlüsselkombination – bilden ein eigenes Thema; unsere Übersicht zu Grundlagen und Fallstricke des Redis-Cachens behandelt die Grundlagen. Keine dieser Techniken verhindert veraltete Daten nach einer Schreiboperation, was Gegenstand des Rests dieses Artikels ist.

Vier Invalidierungsmethoden

Die unten aufgeführten Methoden sind in etwa in der Reihenfolge aufgelistet, in der Sie sie betrachten sollten – von der kostengünstigsten bis zur aufwendigsten.

Methode 1: Allein auf TTL verlassen

Weisen Sie jeder Eintragung einen Ablaufzeitpunkt zu, akzeptieren Sie, dass ein Wert bis zu dieser Anzahl an Sekunden alt sein kann, und geben Sie diese Zahl an einem Ort an, den das Produktteam einsehen kann.

Dies ist die richtige Antwort für mehr Daten, als die meisten Teams annehmen. Katalogtexte, Kategorienhierarchien, Texte im öffentlichen Profil sowie Stundensatzwechselkurse müssen selten bis auf die Millisekunde aktuell sein. Wenn ein Wert fünf Minuten zurückliegen kann, reicht eine Gültigkeitsdauer von 300 Sekunden aus:

await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 300);

Das Profilbild ist eine andere Geschichte. Die Person, die die Änderung vornimmt, erwartet, es sofort zu sehen, und eine Gültigkeitsdauer kann das nicht garantieren. Bei von Benutzern bearbeiteten Daten muss die Gültigkeit beim Schreiben ungültig gemacht werden.

Strategie 2: Die Eintragung beim Schreiben löschen

Nach dem Aktualisieren der Datenbank sollte die entsprechende Cache-Schlüssel entfernt werden. Der nächste Lesevorgang fehlt dann den alten Datensatz, lädt die neue Zeile und füllt den Cache erneut auf:

async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  await redis.del(`user:${id}`);

  return updated;
}

Zwei Details sorgen dafür, dass dies richtig funktioniert.

Löschen anstelle von Überschreiben. Da Sie bereits den aktualisierten Wert haben, scheint es eine einfache Optimierung zu sein, ihn mit SET in Redis zu schreiben. Das Problem tritt bei zwei gleichzeitigen Schreibvorgängen auf: Ihre SET-Befehle können in Redis in umgekehrter Reihenfolge zu den Commit-Vorgängen in der Datenbank ankommen, wodurch die ältere Schreibung bis zum Ablauf des TTL im Cache bleibt. Löschvorgänge sind reihenunabhängig; egal welcher Befehl zuletzt ankommt, das Ergebnis ist ein leerer Schlüssel.

Schreiben Sie zuerst in die Datenbank, bevor Sie löschen. Durch Umkehrung der Reihenfolge entsteht ein größeres Zeitfenster:

// Wrong: delete before write
await redis.del(`user:${id}`);                     // 1. cache is empty
const updated = await db.users.update(id, patch);  // 2. write the row

// Between 1 and 2, a reader can miss the cache, read the OLD row,
// and put it back. The cache now holds the old value until its TTL.

Zwischen dem Löschen und dem Aktualisieren lädt jeder Leser, der den Cache verpasst, die alte Zeile und speichert sie wieder ab.

Durch das Zuerst-Schreiben wird das Zeitfenster zwar verkleinert, aber nicht beseitigt. Die folgende Zeitleiste zeigt, wie ein langsamer Leser dennoch veraltete Daten wiederherstellen kann:

Reader                              Writer
------                              ------
GET user:42        -> miss
SELECT user 42     -> old row
                                    UPDATE user 42 (commit)
                                    DEL user:42
SET user:42 <- old row

Der Leser hat die Zeile vor dem Speichern durch den Schreiber abgerufen und sie anschließend nach dem Löschvorgang des Schreibers gespeichert. Der alte Wert bleibt im Cache, bis die TTL abläuft. Für dieses Phänomen ist ein langsamer Leser sowie ein Schreiber erforderlich, der genau in den falschen Zeitpunkt eingreift – daher kommt es selten vor. Bei tausenden Lesevorgängen pro Minute kann „selten“ jedoch leicht zu täglichen Vorkommnissen werden.

Die übliche Lösung ist ein zweites Löschvorgang, der etwas länger als die normale Verarbeitungszeit geplant wird:

async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  const key = `user:${id}`;
  await redis.del(key);
  // Catches a reader that loaded the old row before the commit.
  setTimeout(() => redis.del(key).catch(() => {}), 500).unref();
  return updated;
}

Der Aufruf von unref() verhindert, dass der ausstehende Timer das Verfahren beim Herunterfahren offen hält, und die leere catch-Anweisung stellt sicher, dass ein fehlgeschlagener Löschvorgang niemals zu einer unverarbeiteten Abweichung führt.

Zwei weitere Vorsichtsmaßnahmen gelten:

  • Transaktionen. Wenn die Aktualisierung innerhalb einer Datenbanktransaktion ausgeführt wird, sollte das Löschen erst nach erfolgreichem Commit außerhalb der Transaktions-Rückruffunktion erfolgen. Ein Löschen vor dem Commit erzeugt wieder die oben beschriebene falsche Reihenfolge.
  • Ausfälle. Wenn der Prozess zwischen Commit und Löschen abstirbt, geht die Invalidation verloren, und nur der TTL wird sie letztendlich korrigieren. Wenn ein Verlust der Invalidation inakzeptabel ist, sollte stattdessen eine Invalidation über einen Change-Data-Capture-Feed ausgelöst werden – beispielsweise Debezium für Postgres oder MySQL oder Change Streams für MongoDB. Dadurch hängt die Invalidation vom Commit selbst ab und nicht davon, ob ein Anfragenverarbeiter lange genug überlebt.

Strategie 3: Invalidationen an lokale Speicherkacheln ausstrahlen

Wenn jeder Prozess direkt aus dem gemeinsamen Redis liest, wird eine DEL-Anweisung von allen Prozessen gleichzeitig empfangen, und Pub/Sub fügt nichts hinzu.

Es wird notwendig, wenn Prozesse eine kleine In-Memory-Schicht vor Redis bereithalten, wie beispielsweise ein Map oder lru-cache, um für besonders häufig genutzte Schlüssel einen Netzwerk-Aus- und Einwegverkehr zu vermeiden. Wenn Instanz A einen Schlüssel aus Redis löscht, bleibt die lokale Kopie von Instanz B unberührt. Das Veröffentlichen des ungültigen Schlüssels ermöglicht es jeder Instanz, ihre eigene Kopie zu löschen:

// Every instance runs this at startup.
// A subscribed connection can't run other commands, hence duplicate().
const sub = redis.duplicate();
await sub.subscribe('cache:invalidate');
sub.on('message', (_channel, key) => localCache.delete(key));

// Whoever writes to the database:
async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  await redis.del(`user:${id}`);
  await redis.publish('cache:invalidate', `user:${id}`);

  return updated;
}

Beachten Sie den Aufruf von duplicate(): Eine Redis-Verbindung im Abonnentenmodus kann keine normalen Befehle ausführen, daher benötigt die Abonnementfunktion eine eigene Verbindung.

Falls Sie Redis 6 oder neuer nutzen, sollten Sie sich vor der Implementierung eigener Lösungen die eingebaute Client-seitige Caching-Unterstützung (CLIENT TRACKING) ansehen. Der Server merkt sich die Schlüssel, die eine Verbindung abgerufen hat, und informiert diese Verbindung, wenn einer davon geändert wird. Die Unterstützung variiert je nach Client-Bibliothek, daher prüfen Sie unbedingt, ob Ihre Bibliothek diese Funktion bereitstellt.

Strategie 4: versionierte Schlüssel

Wenn das Bereitstellen eines alten Werts ein Korrektheitsproblem darstellt und nicht nur ästhetischer Natur, fügen Sie eine Versionsnummer zum Schlüssel hinzu:

async function getOrder(id) {
  const version = (await redis.get(`order:${id}:ver`)) ?? '0';
  const key = `order:${id}:v${version}`;

const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
  const order = await db.orders.findById(id);
  if (!order) return null;

  await redis.set(key, JSON.stringify(order), 'EX', 86400);

  return order;
}

async function updateOrder(id, patch) {
  const updated = await db.orders.update(id, patch);
  await redis.incr(`order:${id}:ver`);

  return updated;
}

Lesoperationen holen die aktuelle Version aus einem Zählschlüssel ab und erstellen daraufhin den Datenschlüssel. Schreiboperationen erhöhen einfach den Zähler.

Führen Sie das Rennen mit Strategie 2 erneut unter diesem Schema durch. Ein langsamer Leser holt sich die veraltete Zeile; in der Zwischenzeit speichert der Schreiber den Inhalt ab und erhöht den Zähler; anschließend speichert der Leser, was er geholt hat. Seine Schreiboperation geht an order:42:v0, einen Schlüssel, den kein Leser erneut abruft. Somit bleibt die veraltete Kopie auf einem nicht mehr genutzten Schlüssel, bis sie abläuft. Genau diese Immunität ist der Preis für die zusätzliche Komplexität.

Die Regeln, die dies sicher machen:

  • Nur Schreiber greifen den Zähler an, und zwar ausschließlich mit INCR. Wenn ein Leser ihn beispielsweise mit SET auf 0 initialisiert, kann das einen zuvor vorgenommenen Inkrement löschen und die vorherige Version wiederherstellen.
  • Halten Sie den Zähler am Laufen. Geben Sie ihm keine Ablaufzeit oder eine weitaus längere als die der Dateneinträge. Ein Zähler, dessen Ablaufzeit erreicht ist, beginnt von Null an zu zählen, wodurch alle verbleibenden v0- oder v1-Einträge erneut sichtbar werden.
  • Die Kosten betragen zwei Hin- und Rückreisen pro Lesevorgang anstelle einer, plus Speicherplatz für alte Versionen, bis diese ablaufen. Dieser Kompromiss ist bei Bestellungen, Kontoständen, Lagerbeständen und allem, was mit Abrechnungen zusammenhängt, gerechtfertigt, bei den meisten anderen Daten hingegen übertrieben.

    Strategieauswahl

    Eine kompakte Vergleichstabelle:

    • Nur TTL: Einfach umzusetzen; die Daten können bis zu einem TTL alt sein; verwenden Sie dies, wenn ein gewisser Veraltungsgrad akzeptabel ist und Sie die genaue Zahl angeben können.
    • Löschen bei Schreiben: Geringe Komplexität; die Daten sind sofort aktuell, außer in seltenen Fällen von Konkurrenzen, die bis zum TTL andauern; Standard für alles, was von Benutzern geändert wird.
  • Pub/sub: mittlere Komplexität; hält die In-Memory-Kopien so lange aktuell, wie Nachrichten ankommen; erforderlich, wenn man einen lokalen Cache vor Redis einfügt.
  • Versionierte Schlüssel: hohe Komplexität; veraltete Neubauten werden niemals gelesen; reserviert für Daten, bei denen ein alter Wert zu falschen Finanzzahlen oder falschem Lagerbestand führt.
  • Für typische Dienste reichen Ablaufdaten in Kombination mit Löschung bei Schreiben aus, zusätzlich eine verzögerte zusätzliche Löschung für stark gelesene Schlüssel, die von Benutzern geändert werden können. Fügen Sie Pub/Sub erst dann hinzu, wenn Sie einen In-Process-Cache einführen, und verwenden Sie versionierte Schlüssel nur für die wenigen Entitäten, bei denen eine falsche Antwort finanzielle Folgen hat.

    Ein Referenz-Cache mit Aside()-Wrapper

    Die über die Anfragenhandler verstreute Caching-Logik ist der Grund, warum diese Regeln oft vergessen werden; daher lohnt es sich, die Logik einmal zu schreiben und überall einzubinden. Das untenstehende Modul verwendet ioredis und kombiniert:

    • Fail-open-Strategie bei Redis-Aufrufen, die stattdessen auf die Datenbank ausweichen, anstatt die Anfragen abzulehnen,
    • Stale-While-Revalidate-Mechanismus, wodurch leicht veraltete Werte während eines Aktualisierungsprozesses bereitgestellt werden,
    • Einzelflug-Modus pro Prozess, sodass gleichzeitige Aufrufe innerhalb eines Prozesses die gleiche Last teilen,
    • Ein cross-prozessuales Schloss mit einem vergleichen-und-löschen-Mechanismus zur Freigabe, damit nicht alle Instanzen denselben Schlüssel neu erstellen,
    • Invalidierung über Pub/Sub in Kombination mit einer verzögerten zweiten Löschung.
    // cache.js
    const Redis = require('ioredis');
    const { randomUUID } = require('node:crypto');
    const logger = require('./logger');
    
    // Fail fast, so a dead Redis makes requests slower instead of hanging them.
    const redis = new Redis(process.env.REDIS_URL, {
      commandTimeout: 250,
      maxRetriesPerRequest: 1,
    });
    
    // A subscribed connection can't run other commands, so it gets its own,
    // with default options so it keeps reconnecting.
    const sub = new Redis(process.env.REDIS_URL);
    const CHANNEL = 'cache:invalidate';
    
    // Compare-and-delete, as in Part 1: never release a lock you no longer own.
    const UNLOCK = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      end
      return 0
    `;
    
    // ---- Invalidation fan-out to in-process caches ----
    const listeners = new Set();
    sub.subscribe(CHANNEL).catch(err => logger.error({ err }, 'cache: subscribe failed'));
    sub.on('message', (_channel, key) => {
      for (const fn of listeners) {
        try { fn(key); } catch (err) { logger.warn({ err, key }, 'cache: listener failed'); }
      }
    });
    
    function onInvalidate(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    }
    
    // ---- Fail-open reads and writes ----
    async function readEntry(key) {
      const raw = await redis.get(key).catch(() => null);
      if (!raw) return null;
      try { return JSON.parse(raw); } catch { return null; }
    }
    
    async function writeEntry(key, value, { freshSec, staleSec }) {
      const now = Date.now();
      const entry = {
        value,
        freshUntil: now + freshSec * 1000,
        staleUntil: now + (freshSec + staleSec) * 1000,
      };
    
      await redis.set(key, JSON.stringify(entry), 'EX', freshSec + staleSec).catch(() => {});
      return value;
    }
    
    // ---- Single-flight: per process first, then across processes ----
    const inflight = new Map();
    
    function once(slot, fn) {
      const existing = inflight.get(slot);
      if (existing) return existing;
      const p = fn().finally(() => inflight.delete(slot));
      inflight.set(slot, p);
    
      return p;
    }
    
    async function rebuild(key, loader, opts, { wait }, attempt = 0) {
      const lockKey = `lock:${key}`;
      const token = randomUUID();
    
      // If Redis is down the SET rejects. Treat that as "got the lock" and just load.
      const got = await redis.set(lockKey, token, 'NX', 'EX', opts.lockSec).catch(() => 'OK');
      if (got === 'OK') {
        try {
          // Another instance may have finished a rebuild while we were getting the lock.
          const entry = await readEntry(key);
          if (entry && Date.now() < entry.freshUntil) return entry.value;
          return await writeEntry(key, await loader(), opts);
        } finally {
          await redis.eval(UNLOCK, 1, lockKey, token).catch(() => {});
        }
      }
    
      // Someone else holds the lock. A background refresh can leave the work to them.
      if (!wait) return undefined;
      await new Promise(r => setTimeout(r, 50));
      const entry = await readEntry(key);
      if (entry && Date.now() < entry.freshUntil) return entry.value;
      if (attempt >= 20) return loader(); // about a second of waiting: do it ourselves
    
      return rebuild(key, loader, opts, { wait }, attempt + 1);
    }
    
    // ---- Public API ----
    async function cacheAside(key, loader, { freshSec = 60, staleSec = 300, lockSec = 5 } = {}) {
      const opts = { freshSec, staleSec, lockSec };
      const entry = await readEntry(key);
      const now = Date.now();
      if (entry && now < entry.freshUntil) return entry.value;
      if (entry && now < entry.staleUntil) {
        // Stale but servable: return it now, refresh once in the background.
        once(`refresh:${key}`, () => rebuild(key, loader, opts, { wait: false }))
          .catch(err => logger.warn({ err, key }, 'cache: refresh failed'));
        return entry.value;
      }
    
      // Cold, or past staleUntil: the caller waits for a load.
      return once(`load:${key}`, () => rebuild(key, loader, opts, { wait: true }));
    }
    
    async function invalidate(key, { redeleteMs = 500 } = {}) {
      await redis.del(key).catch(() => {});
      await redis.publish(CHANNEL, key).catch(() => {});
    
      // Second delete catches a rebuild that read the old row before the write committed.
      setTimeout(() => redis.del(key).catch(() => {}), redeleteMs).unref();
    }
    module.exports = { cacheAside, invalidate, onInvalidate, redis };
    

    Die Verwendung aus Anwendungscode erfordert einen Aufruf auf der Lese- und einen auf der Schreibstrecke:

    const { cacheAside, invalidate } = require('./cache');
    const user = await cacheAside(`user:${id}`, () => db.users.findById(id), {
      freshSec: 60,
      staleSec: 300,
    });
    
    async function updateUser(id, patch) {
      const updated = await db.users.update(id, patch);
      await invalidate(`user:${id}`);
      return updated;
    }
    

    Mehrere Gestaltungsentscheidungen im Wrapper sind nicht offensichtlich, daher sollte man ihn vor der Übernahme gründlich durchlesen.

    Getrennte Einzelflug-Slots für Aktualisierung und Ladevorgänge

    Hintergrundaktualisierungen und blockierende Ladevorgänge werden mit unterschiedlichen Präfixen bezeichnet, nämlich refresh: und load:. Wenn eine Hintergrundaktualisierung den Wettbewerb um den cross-prozessuellen Lock verliert, gibt sie absichtlich undefined zurück, da eine andere Instanz gerade die Neuerrichtung durchführt. Ein Aufrufer, der tatsächlich einen Wert aus diesem Slot teilen muss, erhält ebenfalls nichts.

    Zweite Lesevorgang aus dem Cache nach Erhalt des Locks

    Sobald rebuild() den Lock hält, liest es den Eintrag noch einmal. Während des Wartens könnte ein anderer Prozess die Neuerrichtung bereits abgeschlossen haben, und ein zweiter Ladevorgang würde eine Abfrage verschwenden.

    Begrenztes Warten nach Erhalt des Locks

    Ein Anrufer, der den Schutzmechanismus nicht erhalten kann, prüft alle 50 ms bis zu 20 Mal – das dauert etwa eine Sekunde – und führt anschließend den Lader selbst aus. Einige doppelte Anfragen sind besser als eine Warteschlange von Anrufern, die sich niemals leert, falls der Inhaber des Schutzes blockiert.

    Fail-open-Schutzmechanismus

    Falls Redis nicht verfügbar ist und SET NX abgelehnt wird, wandelt der .catch-Teil die Ablehnung in 'OK' um, und jeder Anrufer geht zum Lader über. Dadurch bleibt der Service aktiv, bedeutet aber auch, dass die Datenbank die volle Last bis zur Wiederherstellung von Redis tragen muss. Überprüfen Sie, ob dies möglich ist.

    Anpassung der Standardwerte

    • lockSec sollte die p99-Latenz des Laders überschreiten, damit der Schutzmechanismus nicht mitten während des Neubaus abläuft.
    • redeleteMs sollte ebenfalls länger sein als die Zeit des langsamsten Laders. 500 ms eignen sich für eine einfache indizierte Anfrage; eine Dashboard-Aggregation, die vier Sekunden dauert, benötigt mehr Zeit.

    Was absichtlich weggelassen wird

    Die probabilistische frühzeitige Ablaufzeit ist nicht enthalten. Stale-while-revalidate beseitigt bereits den Ablaufzeitpunkt für häufig gelesene Schlüssel, und die Kombination beider Mechanismen in einem einzigen Eintragsformat erschwert die Beurteilung, wann ein Neuladen stattfindet. Bei Schlüsseln mit stark schwankenden Wiederherstellungskosten kann eine probabilistische Überprüfung den freshUntil-Vergleich in cacheAside() ersetzen.

    Wo jede Problematik im Code zu finden ist

    • Fehlerrückmeldung, Laden und Speichern: cacheAside() und rebuild()
    • Stale-while-revalidate: die Felder freshUntil und staleUntil jeder Eintragsstruktur
    • Einzelflug pro Prozess: once()
    • Sperre zwischen Prozessen und sicheres Entsperrren: rebuild() sowie das Lua-Skript UNLOCK
  • Fail-open-Verhalten: .catch() bei jedem Redis-Aufruf sowie commandTimeout
  • Löschung beim Schreiben mit verzögertem zweiten Löschvorgang: invalidate()
  • Invalidierung des lokalen Caches: invalidate() und onInvalidate()
  • Häufige Fehlerquellen, die veraltete oder fehlerhafte Daten verursachen

    Diese sind grob nach dem Ausmaß des entstehenden Schadens geordnet.

    Redis-Fehler als fatal ansehen

    Ein einfaches await bei einem Redis-Aufruf wirft bereits ab, sobald Redis nicht erreichbar ist:

    const cached = await redis.get(key); // throws when Redis is unreachable
    

    Ein Cache dient der Optimierung. Ein Ausfall sollte zu Verzögerungen führen, niemals zu einer Welle von 500-Fehlern. Die minimale Lösung besteht darin, Fehler in Cache-Misses umzuwandeln:

    const cached = await redis.get(key).catch(() => null);
    

    Mit den Standardeinstellungen von ioredis reicht allein der .catch-Block nicht aus. Wenn die Verbindung abbricht, puffert der Client Befehle und versucht sie standardmäßig bis zu 20 Mal pro Anfrage erneut, anstatt schnell aufzugeben – dadurch bleiben die Anfragen hängen und warten auf einen nicht mehr verfügbaren Server. Durch die Konfiguration von commandTimeout sowie einem niedrigen Wert für maxRetriesPerRequest, wie es der Client des Wrappers tut, erreichen die Anfragen den Loader schneller. Jede Anfrage zahlt bei Ausfällen trotzdem einige hundert Millisekunden, was bei kurzen Störungen noch akzeptabel ist. Bei anhaltenden Ausfällen gewinnt ein Circuit Breaker, der nach mehreren aufeinanderfolgenden Fehlern die Anrufe an Redis stoppt, diese Zeit zurück.

    Außer Kraft Setzen ohne TTL

    Löschvorgänge gehen verloren: Ein Prozess stürzt nach der Commit-Aktion ab, eine Bereitstellung beendet einen Anfragevorgang mitten im Lauf, oder eine Übertragung erreicht den Empfänger während dieser gerade neu gestartet wird. Ohne TTL hinterlässt jede dieser Situationen einen veralteten Schlüssel in Redis auf unbestimmte Zeit. Geben Sie jedem Schlüssel eine Ablaufzeit – auch wenn sie lang ist, beispielsweise ein Tag. Löschvorgänge halten die Einträge meist aktuell; die Ablaufzeit ist die Sicherheitsmaßnahme, die sicherstellt, dass fehlerhafte Einträge nicht ewig bestehen bleiben.

    Werte speichern, ohne sie zu serialisieren

    Redis speichert Strings, wodurch das Übergeben eines einfachen Objekts den Eintrag stillschweigend beschädigt:

    await redis.set(key, JSON.stringify(value)); // correct
    await redis.set(key, value);                 // an object is stored as "[object Object]"
    

    Serialisieren Sie mit JSON.stringify beim Schreiben und parsen Sie mit JSON.parse beim Lesen. Umhüllen Sie die Werte von Anfang an mit einem Wrapper wie { value, freshUntil, staleUntil }, selbst wenn zunächst nur der value verwendet wird; das Hinzufügen von „stale-while-revalidate“ später zwingt Sie nicht dazu, gleichzeitig mit zwei Formaten umzugehen.

    Treffen Sie auch eine bewusste Entscheidung bezüglich von null-Ergebnissen. Der Wrapper speichert diese, eine Technik, die als negatives Caching bezeichnet wird, sodass wiederholte Anfragen nach einer nicht vorhandenen Zeile nicht mehr die Datenbank erreichen, was im Allgemeinen wünschenswert ist. Falls eine Zeile kurz nach einem Cache-Miss vorhanden sein könnte – beispielsweise ein frisch eingefügter Datensatz, der gegen eine rückständige Replik abgefragt wird – sollten Cache-Misses mit einer kürzeren Gültigkeitsdauer verwendet werden.

    Beschränkungsfreie Schlüssel, die aus Benutzereingaben erstellt werden

    Ein direkt aus einer Abfragesuche abgeleiteter Schlüssel scheint unbedenklich zu sein:

    const key = `search:${req.query.q}`;
    

    Jeder einzigartige Suchstring erzeugt seine eigene Schlüssel, und jeder Client kann Millionen davon erstellen. Das weitere Verhalten wird durch die maxmemory-policy bestimmt. Bei allkeys-lru verdrängen die vielen Schlüssel die wirklich häufig genutzten Einträge, wodurch die Trefferquoten stark sinken. Bei noeviction, das von selbst gehosteten Redis-Instanzen verwendet wird, sofern nicht anders konfiguriert, geht der Speicher aus und Schreibvorgänge werden mit OOM-Fehlern abgelehnt.

    Das Hashen der Abfrage hilft nicht: Ein Hash beschränkt lediglich die Länge jedes Schlüssels, nicht jedoch die Anzahl der Schlüssel. Stattdessen sollte die Eingabe kanonisiert werden (Leerzeichen entfernt, in Kleinbuchstaben umgewandelt, Parameter geordnet), damit äquivalente Suchanfragen auf denselben Schlüssel verweisen. Solche Einträge sollten kurzlebig sein, und nur die häufigen oder aufwändigen Suchanfragen sollten im Cache gespeichert werden. Konfigurieren Sie stattdessen gezielt maxmemory sowie eine Eviction-Policy, anstatt die Standardwerte zu verwenden.

    Teilen einer Redis-Instanz über verschiedene Umgebungen hinweg

    Falls Staging- und Produktionsumgebung dieselbe Instanz teilen, lesen und schreiben beide Umgebungen denselben user:42. Dedizierte Instanzen sind die eigentliche Lösung; als Alternative können Sie jedem Schlüssel einen Namespace zuweisen, was ioredis für Sie übernehmen kann:

    const redis = new Redis(process.env.REDIS_URL, {
      keyPrefix: `${process.env.APP_ENV}:`,
    });
    

    Basiert den Präfix auf einer speziell dafür vorgesehenen Variablen wie APP_ENV, da Staging-Umgebungen oft auch mit NODE_ENV=production laufen. Beachten Sie, dass keyPrefix nur Schlüssel abdeckt und nicht Pub/Sub-Kanäle, daher müssen Sie den Umgebungsnamen selbst zum Namen des Invalidation-Kanals hinzufügen.

    Ändern eines gemeinsam genutzten Cache-Werts

    Aufgrund des Single-Flight-Prinzips erhalten Aufrufer, die zeitlich übereinander liegen, alle denselben gemeinsamen Objekt statt separater Kopien:

    const a = cacheAside('user:42', load); // both wait on one promise
    const b = cacheAside('user:42', load);
    
    (await a).name = 'changed';
    console.log((await b).name); // 'changed'
    

    Alles, was aus Redis abgerufen wird, wird für jeden Aufrufer in ein neues Objekt umgewandelt, wodurch das Problem auf den Fall mit gemeinsamen Promises beschränkt bleibt. Deshalb tritt es nur selten auf und lässt sich schwer nachstellen. Betrachten Sie gespeicherte Werte als unveränderlich oder verwenden Sie structuredClone(), bevor Sie sie bearbeiten. Das Einfrieren der zurückgegebenen Werte mit Object.freeze in der Entwicklung sorgt dafür, dass Code im strengen Modus bei der fehlerhaften Zuweisung Fehler auslöst, anstatt die Daten heimlich zu verfälschen.

    Haupterkenntnisse

    • Fügen Sie jedem Schlüssel eine Ablaufzeit hinzu: Löschungen halten die Daten aktuell, Ablaufzeiten bestimmen, wie lange ein Fehler bestehen kann.
    • Beginnen Sie mit dem Ansatz „Cache-aside“ zusammen mit Löschung bei Schreiben: Speichern Sie die Daten in der Datenbank ab und löschen (niemals überschreiben) anschließend den Schlüssel außerhalb jeder Transaktion. Planen Sie für von Benutzern geänderte, häufig genutzte Schlüssel eine erneute Löschung ein.
  • Führen Sie einen Fail-Open-Modus ein und konfigurieren Sie den Client so, dass Befehle bei Verbindungsausfall schnell abgelehnt werden anstatt in der Warteschlange zu bleiben.
  • Schützen Sie beliebte Schlüssel beim Lesen; die Kombination aus „stale-while-revalidate“ und „single-flight“ bewältigt die meisten Verkehrsmuster.
  • Verwenden Sie Pub/Sub nur für In-Process-Caches und halten Sie die lokalen TTL-Werte kurz, da Nachrichten verloren gehen können.
  • Reservieren Sie versionierte Schlüssel für Daten, bei denen Veraltetheit ein Korrekturfehler darstellt.
  • Begrenzen Sie die Anzahl der vorhandenen Schlüssel; Hashing beschränkt lediglich ihre Länge.
  • Zentralisieren Sie das Caching in einem Wrapper und passen Sie seine Standardeinstellungen an die gemessenen Latenzzeiten Ihrer Lader an.
  • Eine letzte Einschränkung: All das setzt einen eigenständigen Redis-Server voraus. Sentinel- und Cluster-Einsatzmodelle replizieren asynchron, wodurch bei einem Failover die neuesten Schreibvorgänge verloren gehen können – einschließlich der Sperrschlüssel, auf die der Wrapper angewiesen ist. Berücksichtigen Sie dies, bevor Sie diese Sperrmechanismen für etwas anderes als eine reine Koordination unter Verwendung aller verfügbaren Ressourcen nutzen.