Strona główna / Artykuły

Utrzymanie cache’ów Redis w Node.js w czystości: unieważnianie danych bez odczytów przestarzałych

Porównaj funkcje TTL, usuwania danych przy zapisie, mechanizmu pub/sub oraz unieważniania kluczy w wersjach dla Redis w Node.js, zobacz problemy wynikające z ich współdzielenia oraz stwórz otoczkę cacheAside typu fail-open.

Wydawca: REACTAPP.TOP16 min czytania3730 słów

Źródła: Redis Caching in Node.js, Part 2: Invalidation That Doesn’t Lie

Załóżmy użytkownika, który przesyła nowe zdjęcie profilowe, otrzymuje odpowiedź potwierdzającą sukces, ładuje stronę ponownie, ale nadal widzi stare zdjęcie. Próbuje jeszcze raz z tym samym wynikiem, a po kilku minutach nowe zdjęcie pojawia się samo. Raport o błędzie, który następuje potem, jest niemal niemożliwy do odtworzenia, ponieważ Redis nadal dostarcza kopię wiersza, który został już zastąpiony w bazie danych. Powolna baza danych przynajmniej zwraca prawidłową odpowiedź; szybka pamięć cache, która zwraca błędną informację, jest gorsza niż brak pamięci cache w ogóle. Ten przewodnik koncentruje się na ścieżce zapisu w pamięci cache Redis w Node.js: czterech sposobach unieważniania wpisów, sytuacjach, w których każdy z nich uzasadnia swoje koszty, warunkach rywalizacji, które każdy z nich pozostawia otwarte, ponownie używalnym wrapperze cacheAside(), który łączy mechanizmy ochrony ścieżki odczytu z unieważnianiem wpisów, oraz błędach, które najczęściej powodują utrzymywanie przestarzałych wartości.

Część związana z odczytem danych z pamięci cache, czyli ładowanie poza pamięć cache, ochrona przed nagłymi zapytaniami oraz to, co się dzieje, gdy ważność klucza kończy się, to odrębny temat; nasz przegląd zasad i pułapek związanych z cache w Redis omawia podstawy. Żadna z tych technik nie zapobiega posiadaniu przestarzałych danych po zapisaniu, co jest tematem reszty tego artykułu.

Cztery strategie unieważniania danych

Poniższe strategie są wymienione mniej więcej w kolejności, w jakiej powinieneś je rozważyć – od najtańszych po najbardziej skomplikowane.

Strategia 1: poleganie wyłącznie na TTL

Przypisz każdej pozycji datę wygaśnięcia, zaakceptuj fakt, że wartość może być starsza o tyle sekund, i zapisz tę liczbę w miejscu widocznym dla zespołu produktowego.

To jest prawidłowa odpowiedź w przypadku większej ilości danych, niż przypuszczają większość zespołów. Treści katalogu, hierarchie kategorii, tekst profilu publicznego oraz godzinowe kursy wymiany rzadko muszą być aktualne z dokładnością do milisekundy. Gdy wartość może się opóźnić o pięć minut, wystarczający czas ważności wynoszący 300 sekund to wszystko, czego potrzebuje kod:

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

Zdjęcie profilowe to inna sprawa. Osoba, która dokonała zmiany, oczekuje, że natychmiast je zobaczy, a czas ważności nie może tego zagwarantować. W przypadku danych edytowanych przez użytkownika konieczne jest unieważnienie ich po dokonaniu zmiany.

Strategia 2: usunięcie wpisu po zapisaniu

Po zaktualizowaniu bazy danych należy usunąć odpowiadającą jej klucz cache’u. Następne odczytanie, które nie odnajdzie danych w cache’u, załaduje nowy wiersz i ponownie napełni cache:

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

  return updated;
}

Dwa szczegóły sprawiają, że to rozwiązanie działa poprawnie.

Usuń zamiast nadpisywać. Ponieważ już masz zaktualizowaną wartość, zapisanie jej do Redis za pomocą SET wydaje się prostym sposobem na optymalizację. Problem pojawia się, gdy istnieją dwa jednoczesne nadpisujące: ich polecenia SET mogą dotrzeć do Redis w odwrotnej kolejności niż komitety z bazy danych, przez co starszy zapis pozostaje w pamięci cache aż wygaśnie TTL. Operacje usuwania nie zależą od kolejności; bez względu na to, która przyjdzie pierwsza, rezultatem jest pusta klucz.

Zapisz do bazy danych przed usunięciem. Odwrócenie kolejności otwiera szersze możliwości:

// 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.

Pomiędzy usunięciem a aktualizacją każdy odczytujący, który nie ma dostępu do cache, załaduje stary wiersz i od razu go zapisze z powrotem.

Zapisanie najpierw zmniejsza te możliwości, ale nie eliminuje ich całkowicie. Poniższy schemat pokazuje, jak wolny odczytujący może nadal przywrócić przestarzałe dane:

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

Czytnik pobrał wiersz przed zatwierdzeniem zmiany przez zapisującego, a następnie zapisał go po jego usunięciu. Ta stara wartość pozostaje w pamięci cache aż wygaśnie TTL. Aby to się zdarzyło, potrzebny jest wolny czytnik oraz zapisujący, który wpadnie dokładnie w niewłaściwy moment – dlatego zdarza się to rzadko, ale przy tysiącach odczytów klucza na minutę „rzadko” może łatwo oznaczać „codziennie”.

Standardowym rozwiązaniem jest drugie usunięcie, zaplanowane na nieco dłuższy czas niż potrzebuje normalne obciążenie:

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;
}

Wywołanie unref() zapobiega temu, by czekający timer utrzymywał proces w aktywnym stanie podczas wyłączania, a pusta instrukcja catch gwarantuje, że nieudane usunięcie nigdy nie spowoduje nierozwiązanej błędnej sytuacji.

Istnieją jeszcze dwa dodatkowe zasady ostrożności:

  • Transakcje. Jeśli aktualizacja odbywa się w ramach transakcji bazy danych, należy wykonać usunięcie dopiero po pomyślnym zatwierdzeniu transakcji, poza funkcją powrotną transakcji. Usunięcie przed zatwierdzeniem powoduje ponowne utworzenie niewłaściwej kolejności, jak pokazano powyżej.
  • Awarii. Jeśli proces zostanie zakończony pomiędzy zatwierdzeniem a usunięciem, informacja o unieważnieniu zostanie po prostu utracona, a jedynie TTL w końcu to skoryguje. Gdy utrata informacji o unieważnieniu jest nie do przyjęcia, należy zamiast tego uruchomić unieważnienie ze strony źródła przechwytywania zmian, na przykład Debezium w Postgres lub MySQL, albo strumieni zmian w MongoDB. W takim przypadku unieważnienie zależy od samego zatwierdzenia, a nie od tego, czy obsługa żądań będzie działać wystarczająco długo.

Strategia 3: rozsyłanie informacji o unieważnieniu do lokalnych pamięci cache

Gdy każdy proces odczytuje dane bezpośrednio z współdzielonego Redis, wszystkie one widzą jednocześnie jeden polecenie DEL, a mechanizm pub/sub nie dodaje nic nowego.

Staje się to konieczne, gdy procesy utrzymują małą warstwę w pamięci przed Redis, taką jak Map lub lru-cache, aby uniknąć dwukrotnych transmisji przez sieć dla najczęściej używanych kluczy. Gdy instancja A usuwa klucz z Redis, lokalna kopia instancji B pozostaje nietknięta. Publikowanie nieważnego klucza pozwala każdej instancji usunąć swoją własną kopię:

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

Zwróć uwagę na wywołanie duplicate(): połączenie z Redis w trybie subskrybenta nie może wykonywać zwykłych poleceń, dlatego subskrypcja wymaga własnego połączenia.

Jeśli używasz Redis 6 lub nowszej wersji, sprawdź jego wbudowaną obsługę cacheowania po stronie klienta (CLIENT TRACKING) przed implementacją własnego rozwiązania. Serwer pamięta klucze, które zostały pobrałe przez dane połączenie, i powiadamia to połączenie, gdy którykolwiek z nich zostanie zmodyfikowany. Obsługa ta różni się w zależności od bibliotek klienta, więc upewnij się, że twoja je implementuje.

Strategia 4: klucze wersjonowane

Gdy serwowanie starej wartości stanowi problem poprawnościowy, a nie tylko estetyczny, włącz numer wersji do klucza:

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;
}

Czytniki pobierają aktualną wersję z klucza licznika i na jej podstawie tworzą klucz z danymi. Zapisy polegają jedynie na zwiększeniu wartości licznika.

Zainstaluj ponownie strategię wyścigu nr 2 według tego schematu. Powolny odczytujący pobiera przestarzały wiersz; tymczasem zapisujący fiksuje zmiany i zwiększa licznik; następnie odczytujący przechowuje to, co pobrał. Jego zapis trafia do order:42:v0, klucza, którego żaden odczytujący nie będzie już szukał, więc przestarzała kopia pozostaje przy porzuconym kluczu aż do wygaśnięcia terminu ważności. To właśnie ta odporność jest ceną za dodatkową złożoność.

Zasady zapewniające bezpieczeństwo:

  • Tylko zapisujący mogą edytować licznik, i to wyłącznie za pomocą INCR. Jeśli odczyt ustawi go na 0 za pomocą czegoś w rodzaju SET, może to zniszczyć wcześniejszy wzrost wartości licznika i przywrócić poprzednią wersję.
  • Zachowaj licznik aktywny. Nie ustawiaj terminu wygaśnięcia lub ustal taki, który jest znacznie dłuższy od okresu przechowywania danych. Licznik, który wygaśnie, zaczyna od zera, co sprawia, że pozostałe wpisy v0 lub v1 ponownie stają się widoczne.
  • Kosztem jest dwie podróże tam i z powrotem przy każdej odczytce zamiast jednej, plus pamięć zajmowana przez stare wersje aż do ich wygaśnięcia. Taka kompromis jest uzasadniony w przypadku zamówień, sald, poziomów zapasów oraz wszystkiego związanego z fakturowaniem, natomiast jest nadmierny dla większości innych danych.

    Wybór strategii

    Kompaktowe porównanie:

    • Tylko TTL: proste w implementacji; dane mogą być maksymalnie tak stare, jak określa TTL; używaj tego, gdy pewien stopień przestarzałości jest akceptowalny i możesz określić jego wartość.
    • Usuń przy zapisie: niska złożoność; dane są natychmiast aktualne, z wyjątkiem rzadkich sytuacji konfliktowych trwających do momentu wygaśnięcia TTL; standard dla wszystkiego, co edytują użytkownicy.
  • Pub/sub: średnia złożoność; utrzymuje aktualne kopie w pamięci tak długo, jak długo przychodzą wiadomości; jest potrzebne po dodaniu lokalnego cache’a przed Redisem.
  • Klucze wersjonowane: wysoka złożoność; stare przekształcenia nigdy nie są odczytywane; przeznaczone dla danych, w których stara wartość oznacza błędne kwoty lub błędny stan zapasów.
  • Dla typowego serwisu wystarczą terminy wygaśnięcia w połączeniu z usuwaniem przy zapisie, plus opóźnione dodatkowe usunięcie dla kluczy intensywnie odczytywanych, które użytkownicy mogą zmieniać. Dodaj mechanizm pub/sub w dniu wprowadzenia cache’a wewnątrz procesu, a klucze wersjonowane zachowaj dla nielicznych entytetów, w których błędna odpowiedź ma konsekwencje finansowe.

    Wrapper reference cacheAside()

    Logika cacheowania rozproszona wśród obsługujących żądania funkcji to miejsce, gdzie te zasady są zapominane, dlatego warto napisać tę logikę raz i importować ją wszędzie. Poniższy moduł wykorzystuje ioredis i łączy:

    • zwroty błędu przy wywołaniach Redis, które przechodzą do bazy danych zamiast odrzucać żądania,
    • mechanizm stale-while-revalidate, dzięki któremu podczas odświeżania serwowane są nieco przestarzałe wartości,
    • model pojedynczej ścieżki działania na poziomie procesu, przez co konkurencyjne wywołania w tym samym procesie dzielą ten sam obciążenie,
    • blokada międzyprocesowa z rozblokowaniem poprzez porównanie i usunięcie, aby instancje nie budowały ponownie tej samej klucza,
    • nieważnienie danych za pomocą pub/sub w połączeniu z opóźnionym drugim usunięciem.
    // 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 };
    

    Korzystanie z niego w kodzie aplikacji wymaga jednego wywołania na ścieżce odczytu i jednego na ścieżce zapisu:

    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;
    }
    

    Kilka decyzji projektowych w tym wrapperze nie jest oczywistych, więc warto je dokładnie przeczytać przed ich zastosowaniem.

    Odrębne interwały działania dla odświeżania i ładowania

    Odnawianie tła oraz blokowanie operacji ładowania są oznaczane różnymi prefiksami: refresh: i load:. Gdy odnawianie tła traci walkę o zamek między procesami, celowo przyjmuje wartość undefined, ponieważ inna instancja zajmuje się odbudową. Jeśli ktoś, kto faktycznie potrzebuje udostępnionej wartości z tego slotu, spróbuje ją pobrać, również nic nie otrzyma.

    Druga próba odczytu z pamięci podręcznej po uzyskaniu zamka

    Gdy rebuild() już posiada zamek, odczytuje wpis jeszcze raz. Inny proces mógł zakończyć odbudowę w tym czasie, a dwukrotne uruchomienie narzędzia ładowania byłoby marnotrawstwem zasobów.

    Ograniczone czekanie po uzyskaniu zamka

    Aplikacja, która nie może uzyskać blokady, próbuje to co 50 ms maksymalnie 20 razy, czyli przez około sekundę, a następnie uruchamia sam loader. Kilka duplikatowych zapytań jest lepsze niż kolej aplikacji, która nigdy się nie opróżni, jeśli osoba trzymająca blokadę utknie.

    Zdobywanie blokady w trybie fail-open

    Jeśli Redis jest niedostępny i polecenie SET NX zostaje odrzucone, blokada .catch przekształca to odrzucenie na wartość 'OK', dzięki czemu każda aplikacja może przejść do loadera. Dzięki temu usługa pozostaje aktywna, ale oznacza to również, że baza danych musi przyjąć całe obciążenie aż do odzyskania dostępności Redis. Należy upewnić się, że jest w stanie to zrobić.

    Dostosowywanie domyślnych ustawień

    • lockSec powinien przewyższać wartość opóźnienia p99 loadera, aby blokada nie wygasła w trakcie przetwarzania.
    • redeleteMs również powinien być większy od czasu działania naj wolniejszego loadera. 500 ms wystarcza dla pojedynczego zapytania indeksowanego; agregacja danych w panelu kontrolnym trwająca cztery sekundy wymaga większej wartości.

    Co jest celowo pomijane

    Nie uwzględniono prawdopodobności wczesnego wygaśnięcia. Mechanizm Stale-while-revalidate już usuwa problem nagłego wygaśnięcia kluczy często odczytywanych, a łączenie obu w jednym formacie wpisów utrudnia rozumienie momentu odświeżenia. W przypadku kluczy o bardzo zmiennych kosztach odbudowy, sprawdzenie prawdopodobnościowe może zastąpić porównanie freshUntil w funkcji cacheAside().

    Gdzie znajduje się każdy z tych aspektów w kodzie

    • Nieudane odczytanie, ładowanie i przechowywanie: cacheAside() i rebuild()
    • Stale-while-revalidate: pola freshUntil i staleUntil każdego wpisu
    • Jednorazowe działanie w obrębie procesu: once()
    • Zamknięcie międzyprocesowe i bezpieczne odblokowanie: rebuild() oraz skrypt Lua UNLOCK
  • Behawior fail-open: .catch() przy każdej wywołaniu Redis, plus commandTimeout
  • Usuwanie przy zapisie z opóźnionym drugim usunięciem: invalidate()
  • Nieważnienie lokalnej pamięci cache: invalidate() i onInvalidate()
  • Częste pułapki powodujące przestarzałe lub uszkodzone dane

    Są one uporządkowane mniej więcej według stopnia szkód, jakie mogą spowodować.

    Traktowanie błędów Redis jako fatalnych

    Po prostu używanie await przy wywołaniu Redis powoduje wyjątek, gdy tylko serwer jest niedostępny:

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

    Pamięć cache służy do optymalizacji. Awaria powinna skutkować opóźnieniem, a nie falą odpowiedzi typu 500. Najprostszym rozwiązaniem jest przekształcenie błędów w brak dostępu do cache:

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

    Z domyślnymi ustawieniami ioredis samego .catch nie wystarcza. Gdy połączenie zostanie przerwane, klient buforuje polecenia i nadal je próbuje wysyłać – domyślnie do 20 razy na żądanie – zamiast szybko zrezygnować, co powoduje, że żądania pozostają w oczekiwaniu na serwer, który już nie istnieje. Konfiguracja commandTimeout oraz niewielkiej wartości maxRetriesPerRequest, tak jak to robi klient w tym narzędziu, sprawia, że żądania szybciej trafiają do procesora. Mimo to każde żądanie traci kilkaset milisekund podczas awarii, co jest do przyjęcia w przypadku krótkotrwałych problemów. Podczas dłuższych przerw pomocny okazuje się „przerywacz obwodu”, który przestaje wysyłać polecenia do Redis po kilku kolejnych błędach, co pozwala odzyskać stracony czas.

    Anulowanie bez TTL

    Usunięcia giną: proces zawiesza się po zapisaniu zmian, deployment kończy przetwarzanie żądania w połowie, a transmisja przychodzi w momencie, gdy subskrybent restartuje się. Bez TTL każdy z tych przypadków powoduje, że nieaktualny klucz pozostaje w Redis na nieskończoność. Nadaj każdemu kłuczu datę wygaśnięcia, nawet długą, na przykład jeden dzień. Usunięcia w większości przypadków utrzymują dane aktualne; data wygaśnięcia jest zabezpieczeniem, które zapobiega temu, by błędne dane istniały wiecznie.

    Zachowywanie wartości bez ich serializacji

    Redis przechowuje łańcuchy, więc przekazanie zwykłego obiektu w tajemnicy uszkadza dane:

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

    Przy zapisie używaj serializacji za pomocą JSON.stringify, a przy odczytywaniu – parsowania za pomocą JSON.parse. Od samego początku otaczaj wartości taką osłonką jak { value, freshUntil, staleUntil }, nawet jeśli początkowo używana jest tylko value; dodanie później mechanizmu stale-while-revalidate nie zmusza cię do obsługi dwóch formatów jednocześnie.

    Należy również świadomie podjąć decyzję dotyczącą wyników null. Osłonka przechowuje je, co stanowi technikę zwaną negatywnym cache’owaniem; dzięki temu powtarzające się żądania dotyczące nieistniejącej wiersza już nie trafiają do bazy danych, co jest zazwyczaj pożądane. W przypadkach, gdy wiersz może pojawić się krótko po nieudanym dostępie – na przykład świeżo wstawiony rekord zapytany wobec repliki, która jest w tyle – ustaw krótszy czas ważności cache’u.

    Klucze nieograniczone utworzone na podstawie danych wejściowych użytkownika

    Klucz pochodzący bezpośrednio z ciągu zapytania wydaje się nieszkodliwy:

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

    Każda unikalna strona wyszukiwania tworzy własny klucz, a każdy klient może stworzyć miliony takich kluczy. Działanie systemu jest regulowane przez parametr maxmemory-policy. Przy użyciu wartości allkeys-lru napływ tak wielu kluczy wypiera te najczęściej używane, co powoduje gwałtowny spadek wskaźnika trafień. Przy użyciu wartości noeviction, którą stosuje samodzielnie uruchamiany Redis, chyba że zostało to inaczej skonfigurowane, pamięć wkrótce się wyczerpuje, a zapisy są odrzucane ze błędami typu OOM.

    Hasowanie zapytania nie pomaga – hash ogranicza jedynie długość każdego klucza, a nie liczbę kluczy. Zamiast tego należy kanonizować dane wejściowe (usuwać spacje, zamieniać na małe litery, uporządkowywać parametry), aby równoważne zapytania odpowiadały jednemu kluczu, utrzymywać te wpisy w krótkim czasie obecności oraz przechowywać w pamięci cache tylko te zapytania, które są częste lub wymagają dużo zasobów. Lepiej celowo skonfigurować parametry maxmemory oraz politykę usuwania danych, zamiast korzystać z domyślnych ustawień.

    Dzielenie się jedną instancją Redis pomiędzy różnymi środowiskami

    Jeśli środowisko testowe i produkcyjne korzystają z tej samej instancji, oba środowiska czytają i zapisują ten sam user:42. Dedykowane instancje to prawdziwe rozwiązanie; jako alternatywę można nazwać każdy klucz nazwą przestrzeni, co może zrobić za Ciebie ioredis:

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

    Należy opierać przedrostek na specjalnie stworzonej zmiennej, takiej jak APP_ENV, ponieważ środowisko testowe często działa również z ustawieniem NODE_ENV=production. Należy pamiętać, że keyPrefix dotyczy tylko kluczy, a nie kanałów pub/sub, więc sam musisz dodać informację o środowisku do nazwy kanału anulowania.

    Mutacja współdzielonej wartości w pamięci cache

    Z powodu zasady pojedynczego lotu, wszystkie wywołania dokonane w tym samym czasie otrzymują ten sam obiekt współdzielony, zamiast oddzielnych kopii:

    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'
    

    Wszystko, co jest pobierane z Redis, jest parsowane na nowy obiekt dla każdego użytkownika, więc problem występuje tylko w przypadku współdzielonych obietnic, dlatego pojawia się rzadko i trudno go odtworzyć. Uznaj wartości z pamięci cache za niezmienne lub użyj structuredClone(), aby je sklonować przed edycją. Zamrożenie zwracanych wartości za pomocą Object.freeze w środowisku rozwojowym sprawia, że kod w trybie strict rzuca błąd przy nieprawidłowej operacji przypisania, zamiast cicho uszkodzić dane.

    Główne wnioski

    • Ustaw termin ważności dla każdego klucza: usuwanie zapewnia aktualność danych, a termin ważności określa, jak długo może przetrwać błąd.
    • Zacznij od metody cache-aside w połączeniu z usuwaniem przy zapisie: zapisz dane do bazy danych, a następnie usuń (nigdy nie nadpisuj) klucza poza żadną transakcją. Zarezerwuj ponowne usunięcie dla kluczy często edytowanych przez użytkowników.
  • Włącz tryb awaryjnego otwierania i skonfiguruj klienta tak, aby szybko reagował na błędy zamiast kolejkować polecenia podczas rozłączenia.
  • Ochroniaj popularne klucze podczas odczytu; połączenie mechanizmu stale-while-revalidate z modelem single-flight radzi sobie z większością wzorców ruchu.
  • Używaj pub/sub wyłącznie dla pamięci cache wewnątrz procesu i ustaw krótkie lokalne TTL, ponieważ wiadomości mogą zostać pominięte.
  • Zarezerwuj klucze wersjonowane dla danych, gdzie przestarzałość stanowi błąd poprawnościowy.
  • Ogranicz liczbę istniejących kluczy; haszowanie jedynie ogranicza ich długość.
  • Zcentralizuj pamięć cache w jednym narzędziu i dostosuj jego domyślne ustawienia do pomierzonych opóźnień twoich mechanizmów ładowania.
  • Ostatnia uwaga: cała ta informacja zakłada istnienie pojedynczego, samodzielnego serwera Redis. Wersje Sentinel i Cluster replikują dane asynchronicznie, więc przeniesienie pracy na zapasowe serwery może skutkować utratą najnowszych zapisów, w tym kluczy blokujących, od których zależy ten mechanizm. Bierz to pod uwagę, zanim będziesz polegał na tych mechanizmach blokowania do czegoś więcej niż tylko prostej koordynacji.

    Literatura pokrewna

  • Zasady cachingu w Redisie: wzory, potknięcia i koncepcje z rozmów rekrutacyjnych — Dowiedz się, jak działa caching w Redisie w aplikacjach Node.js, od strategii typu cache-aside i TTL po mechanizmy ochrony przed przeciążeniem, zasady usuwania danych oraz typowe pytania podczas rozmów rekrutacyjnych.
  • Przeciążenie bazy danych przez cache w Node.js: dlaczego cache Redis może zaburzyć pracę bazy danych — Dowiedz się, dlaczego prosta konfiguracja cache-aside w Redis synchronizuje obciążenie na bazie danych po wygaśnięciu klucza o wysokiej częstotliwości użycia, oraz jak blokady i tło aktualizacji zapobiegają temu problemowi.