- 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 inwalidacjicache: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 > 0→ PRAWDA - 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 > 2000→ FAŁSZ - Wpada w sprawdzenie SWR:
1000 > 0→ PRAWDA - 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 > 3000→ FAŁSZ - Wpada w sprawdzenie SWR:
1000 > 3000→ FAŁ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 > 2000→ PRAWDA - 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ść
| Metryka | Przed (kasowanie kluczy) | Po (flagi z timestampem) |
|---|---|---|
| Czas inwalidacji | 5–10 sekund | poniżej 5 ms |
| Responsywność dashboardu | Zawieszony na czas czyszczenia | Natychmiastowy feedback |
| Requesty po inwalidacji | Blokujący fetch | SWR (serwuj nieaktualne + rewaliduj) |
| Operacje na Redisie | Iteracja po kluczach O(n) | Zapis flagi O(1) |
| Sprzątanie storage'u | Ręczne kasowanie | Naturalne 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
- Zawsze używaj
getCurrentUTC()do timestampów (niezależność od strefy czasowej serwera) - Pobieraj flagi i wpis równolegle przez
Promise.all(wydajność) - Domyślnie ustawiaj brakujące flagi na
0(wsteczna kompatybilność) - Sprawdzaj trafienie w cache PRZED autoryzacją (unikaj zbędnych odczytów sesji)
- Używaj SWR przy częściowej inwalidacji (zachowaj wydajność podczas aktualizacji treści)
- 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:
- Inwalidacja w O(1) przez pojedynczy zapis flagi zamiast iteracji po kluczach w O(n)
- SWR zachowane przy częściowej inwalidacji, dzięki czemu aktualizacje treści nie kosztują latencji
- Naturalne wygasanie po TTL eliminuje potrzebę ręcznego sprzątania
- Timestampy UTC gwarantują spójne zachowanie niezależnie od regionu
- Ś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).