Kamil Owczarek
Opublikowano

Inwalidacja cache'a w O(1): flagi z timestampem zamiast kasowania kluczy

Autorzy

Problem: inwalidacja cache'a, która trwa wieczność

Mieliśmy system cache'owania na dwóch Redisach: storage PRIMARY (3 dni) i FALLBACK (7 dni) pod Stale-While-Revalidate (SWR). Gdy treść zmieniała się w dashboardzie, musieliśmy unieważnić cache. Proste, prawda?

Oto co zrobiliśmy:

// ❌ WRONG: The slow way
export const cleanCachePartially = async () => {
  await Promise.all([
    useStorage('cache').clear(),           // Iterates ALL keys
    useStorage('cache-fallback').clear(),  // Iterates ALL keys again
  ]);
};

W czym problem? Przy setkach tysięcy wpisów w cache'u czyszczenie zajmowało 5–10 sekund. Użytkownicy klikali w dashboardzie „Wyczyść cache” i... nic. Klikali ponownie. I jeszcze raz. A cache w tym czasie wciąż czyścił się po pierwszym kliknięciu.

Gorzej: czyszczenie obu cache'ów przekreślało cały sens SWR. Po inwalidacji:

  • Cache PRIMARY: pusty
  • Cache FALLBACK: pusty
  • Kolejny request: blokujący fetch zamiast serwowania nieaktualnych danych

Rozwiązanie: flagi z timestampem

Zamiast kasować miliony kluczy, trzymamy dwie globalne flagi z timestampem:

  • cache:flag:full — timestamp pełnej inwalidacji
  • cache:flag:partial — timestamp częściowej inwalidacji (uruchamia SWR)

Każdy wpis w cache'u trzyma swój timestamp utworzenia. Przy odczycie porównujemy:

  • Wpis utworzony PO flagach? → cache ważny
  • Wpis utworzony PRZED flagami? → nieważny

Inwalidacja staje się pojedynczym zapisem do Redisa (poniżej 5 ms) zamiast iterowania po milionach kluczy.

Implementacja: handler cache'a

Podstawowa definicja typu

const TTL = 259200;  // 3 days
const FULL_RESET_FLAG_KEY = 'cache:flag:full';
const PARTIAL_RESET_FLAG_KEY = 'cache:flag:partial';

type CacheEntry<T> = {
    data: T;
    createdAt: number;  // Unix timestamp from getCurrentUTC().getTime()
};

Event handler

export const defineCustomCacheEventHandler = <T>(
    handler: (event: H3Event) => T | Promise<T>,
) => {
    return originalDefineEventHandler(async (event: H3Event) => {
        if (process.env.NODE_ENV === 'development') {
            return await handler(event);
        }

        const url = getRequestURL(event);
        const cacheKey = encodeURIComponent(url.hostname + url.pathname + url.search);
        const storage = useStorage('cache');

        // Parallel fetch: entry + flags
        const [entry, fullResetRaw, partialResetRaw] = await Promise.all([
            storage.getItem<CacheEntry<T>>(cacheKey),
            storage.getItem<number>(FULL_RESET_FLAG_KEY),
            storage.getItem<number>(PARTIAL_RESET_FLAG_KEY),
        ]);

        const fullReset = fullResetRaw || 0;
        const partialReset = partialResetRaw || 0;
        const entryCreatedAt = entry?.createdAt || 0;

        // Valid cache: entry created AFTER both flags
        if (entry && entryCreatedAt > fullReset && entryCreatedAt > partialReset) {
            console.log(`[Cache] HIT - ${cacheKey}`);
            return entry.data;
        }

        // Auth users always get fresh data
        const session = await getServerSession(event);
        if (session?.user) {
            console.log(`[Cache] Auth user - fetching fresh`);
            const result = await handler(event);
            await storage.setItem(cacheKey, {
                data: result,
                createdAt: getCurrentUTC().getTime()
            }, { ttl: TTL });
            return result;
        }

        // SWR mode: entry created AFTER fullReset but NOT after partialReset
        if (entry && entryCreatedAt > fullReset) {
            console.log(`[Cache] HIT (SWR) - ${cacheKey}`);

            // Background revalidation
            const waitUntil = event.context.waitUntil || event.context.cloudflare?.ctx?.waitUntil;
            const revalidate = async () => {
                try {
                    const result = await handler(event);
                    await storage.setItem(cacheKey, {
                        data: result,
                        createdAt: getCurrentUTC().getTime()
                    }, { ttl: TTL });
                } catch (error) {
                    console.error(`[Cache] Revalidation failed - ${cacheKey}:`, error);
                }
            };

            if (waitUntil) {
                waitUntil(revalidate());
            } else {
                revalidate().catch(() => {});
            }

            return entry.data;  // Serve stale immediately
        }

        // COLD miss - fetch fresh
        console.log(`[Cache] COLD miss - ${cacheKey}`);
        const result = await handler(event);
        await storage.setItem(cacheKey, {
            data: result,
            createdAt: getCurrentUTC().getTime()
        }, { ttl: TTL });
        return result;
    });
};

Funkcje inwalidacji

// Partial invalidation - preserves SWR
export const cleanCachePartially = async () => {
    const storage = useStorage('cache');
    await storage.setItem(PARTIAL_RESET_FLAG_KEY, getCurrentUTC().getTime());
    console.log(`[Cache] Partial reset flag set - stale entries serve via SWR`);
};

// Complete invalidation - for critical data issues
export const cleanCacheCompletely = async () => {
    const storage = useStorage('cache');
    await storage.setItem(FULL_RESET_FLAG_KEY, getCurrentUTC().getTime());
    console.log(`[Cache] Full reset flag set - all entries invalidated`);
};

Jak to działa: scenariusze przepływu

Scenariusz 1: świeży cache (brak inwalidacji)

  • Wpis utworzony w timestampie 1000
  • Żadna flaga nie jest ustawiona (obie 0)
  • Sprawdzenie: 1000 > 0 && 1000 > 0PRAWDA
  • Wynik: natychmiast wraca dane z cache'a

Scenariusz 2: częściowa inwalidacja (tryb SWR)

  • Wpis utworzony w 1000
  • Flaga częściowego resetu ustawiona na 2000
  • Pełny reset nieustawiony (0)
  • Sprawdzenie: 1000 > 0 && 1000 > 2000FAŁSZ
  • Wpada w sprawdzenie SWR: 1000 > 0PRAWDA
  • Wynik: serwuje nieaktualne dane + rewalidacja w tle

Scenariusz 3: pełna inwalidacja

  • Wpis utworzony w 1000
  • Flaga pełnego resetu ustawiona na 3000
  • Sprawdzenie: 1000 > 3000FAŁSZ
  • Wpada w sprawdzenie SWR: 1000 > 3000FAŁSZ
  • Wynik: zimny miss, pobranie świeżych danych

Scenariusz 4: po rewalidacji

  • Częściowy reset w 2000
  • Nowy wpis utworzony w 3000 (po rewalidacji w tle)
  • Pełny reset nieustawiony (0)
  • Sprawdzenie: 3000 > 0 && 3000 > 2000PRAWDA
  • Wynik: trafienie w świeży cache

Kluczowe detale implementacji

Dlaczego entry?.createdAt || 0 jest bezpieczne

const entryCreatedAt = entry?.createdAt || 0;

if (entry && entryCreatedAt > fullReset && entryCreatedAt > partialReset) {
    return entry.data;
}

Warunek entry && zwiera obwód przed porównaniem timestampów. Jeśli wpisu nie ma, nigdy nie wyliczamy entryCreatedAt > flag, więc domyślne 0 jest bezpieczne.

A stare wpisy bez createdAt? Domyślnie dostają 0, czyli wartość starszą niż jakikolwiek prawdziwy timestamp, więc unieważniają się przy pierwszym zapisie flagi. Idealnie.

Timestampy UTC dla spójności

ZAWSZE używaj getCurrentUTC() do timestampów:

import { getCurrentUTC } from '~/utils/timezone.utils';

// ✅ CORRECT: Server timezone independent
createdAt: getCurrentUTC().getTime()

// ❌ WRONG: Depends on server timezone
createdAt: new Date().getTime()

Dzięki temu cache zachowuje się identycznie niezależnie od lokalizacji serwera.

Omijanie cache'a dla zalogowanych

Zalogowani użytkownicy zawsze dostają świeże dane (bez cache'a):

const session = await getServerSession(event);
if (session?.user) {
    const result = await handler(event);
    await storage.setItem(cacheKey, {
        data: result,
        createdAt: getCurrentUTC().getTime()
    }, { ttl: TTL });
    return result;
}

To sprawdzenie dzieje się PO sprawdzeniu trafienia w główny cache, więc 95% requestów (te trafiające w cache) w ogóle nie woła getServerSession().

Wpływ na wydajność

MetrykaPrzed (kasowanie kluczy)Po (flagi z timestampem)
Czas inwalidacji5–10 sekundponiżej 5 ms
Responsywność dashboarduZawieszony na czas czyszczeniaNatychmiastowy feedback
Requesty po inwalidacjiBlokujący fetchSWR (serwuj nieaktualne + rewaliduj)
Operacje na RedisieIteracja po kluczach O(n)Zapis flagi O(1)
Sprzątanie storage'uRęczne kasowanieNaturalne wygasanie po TTL

Typowe pułapki, których warto unikać

Nie rób tego: porównanie przez <

// ❌ WRONG: Confusing semantics
if (entry && entry.createdAt < partialReset) {
    return entry.data;  // Returns INVALID cache!
}

Semantycznie poprawne jest porównanie przez >:

  • Wpis utworzony PO fladze inwalidacji = ważny (nowszy niż zdarzenie inwalidacji)
  • Wpis utworzony PRZED flagą inwalidacji = nieważny (starszy niż zdarzenie inwalidacji)

Nie rób tego: czyszczenie obu cache'ów przy częściowej inwalidacji

// ❌ WRONG: Defeats SWR purpose
export const cleanCachePartially = async () => {
    await Promise.all([
        useStorage('cache').clear(),
        useStorage('cache-fallback').clear(),  // Don't do this!
    ]);
};

Częściowa inwalidacja powinna zachować FALLBACK na potrzeby trybu SWR.

Nie rób tego: pomijanie obsługi błędów rewalidacji w tle

// ⚠️ CAREFUL: Silent failures
revalidate().catch(() => {});  // Swallows errors

// ✅ BETTER: Log for monitoring
revalidate().catch((error) => {
    console.error('[Cache] Background revalidation failed:', error);
});

Spójność między aplikacjami

Wdrożyliśmy ten wzorzec w dwóch aplikacjach, z drobnymi różnicami:

deante.pl (z autoryzacją):

// Has getServerSession check
if (session?.user) {
    const result = await handler(event);
    // ...
}

deantedesign.studio (serwis publiczny):

// Auth section commented out for future implementation
// TODO: Uncomment when auth is implemented
// if (session?.user) { ... }

Obie używają identycznych:

  • Nazw flag (FULL_RESET_FLAG_KEY, PARTIAL_RESET_FLAG_KEY)
  • TTL (259200 sekund / 3 dni)
  • Logiki porównania (entryCreatedAt > flag)
  • Formatu komunikatów logowanych

Dobre praktyki

  1. Zawsze używaj getCurrentUTC() do timestampów (niezależność od strefy czasowej serwera)
  2. Pobieraj flagi i wpis równolegle przez Promise.all (wydajność)
  3. Domyślnie ustawiaj brakujące flagi na 0 (wsteczna kompatybilność)
  4. Sprawdzaj trafienie w cache PRZED autoryzacją (unikaj zbędnych odczytów sesji)
  5. Używaj SWR przy częściowej inwalidacji (zachowaj wydajność podczas aktualizacji treści)
  6. Pełną inwalidację zostaw na krytyczne sytuacje (błędne dane, bugi bezpieczeństwa, zmiany schematu)

Kiedy używać którego typu inwalidacji

Reset częściowy (cleanCachePartially)

Używaj, gdy:

  • Treść została zaktualizowana w CMS-ie
  • Zmieniły się ceny lub stany magazynowe produktów
  • Potrzebne jest odświeżenie niekrytycznych danych

Zachowanie: uruchamia tryb SWR — natychmiast serwuje nieaktualne dane, rewaliduje w tle

Reset pełny (cleanCacheCompletely)

Używaj, gdy:

  • Krytycznie błędne dane nie mogą trafić do użytkowników
  • Zmienił się schemat bazy danych (stary cache jest niekompatybilny)
  • Wystąpił problem bezpieczeństwa (wyciek wrażliwych danych)

Zachowanie: wymusza zimny miss — wszystkie requesty pobierają świeże dane

Podsumowanie

Najważniejsze wnioski z naszej inwalidacji cache'a opartej na timestampach:

  1. Inwalidacja w O(1) przez pojedynczy zapis flagi zamiast iteracji po kluczach w O(n)
  2. SWR zachowane przy częściowej inwalidacji, dzięki czemu aktualizacje treści nie kosztują latencji
  3. Naturalne wygasanie po TTL eliminuje potrzebę ręcznego sprzątania
  4. Timestampy UTC gwarantują spójne zachowanie niezależnie od regionu
  5. Świadomość sesji optymalizuje cache pod ruch publiczny i zalogowany jednocześnie

Wzorzec jest przetestowany na produkcji, na platformie e-commerce obsługującej ponad 11 rynków europejskich. Inwalidacja cache'a spadła z 5–10 sekund do poniżej 5 ms, dashboard zaczął reagować natychmiast, a tryb SWR usunął skoki latencji po inwalidacji.


Realny wzorzec z monorepo e-commerce na Nuxt 3 z cache'em na dwóch Redisach. Czas inwalidacji cache'a skrócony o 100% (z 5–10 s do poniżej 5 ms).