Strategie cachowania Redis, które naprawdę działają na produkcji
Cache-aside, write-through, zapobieganie cache stampede, strategie TTL i wzorce inwalidacji. Wzorce Redis, których używam w produkcyjnych aplikacjach Node.js z prawdziwymi przykładami kodu.
Każdy mówi ci, żebyś "po prostu dodał Redisa", gdy twoje API jest wolne. Nikt nie mówi, co się dzieje sześć miesięcy później, kiedy twój cache serwuje nieaktualne dane, logika inwalidacji jest rozsiana po 40 plikach, a deploy powoduje cache stampede, który kładzie twoją bazę danych mocniej, niż gdybyś w ogóle nie cachował.
Prowadzę Redisa na produkcji od lat. Nie jako zabawkę, nie w tutorialu — w systemach obsługujących prawdziwy ruch, gdzie źle skonfigurowane cachowanie oznacza alarmy o 3 w nocy. Poniżej znajdziesz wszystko, czego nauczyłem się o robieniu tego dobrze.
Dlaczego cachować?#
Zacznijmy od oczywistego: bazy danych są wolne w porównaniu z pamięcią. Zapytanie PostgreSQL trwające 15ms to szybko jak na standardy baz danych. Ale jeśli to zapytanie wykonuje się przy każdym żądaniu API, a obsługujesz 1000 żądań na sekundę, to 15 000ms łącznego czasu bazy danych na sekundę. Twoja pula połączeń jest wyczerpana. Latencja p99 przebija sufit. Użytkownicy gapią się na spinnery.
Redis obsługuje większość odczytów w mniej niż 1ms. Te same dane, zcachowane, zamieniają operację 15ms w operację 0,3ms. To nie jest mikro-optymalizacja. To różnica między potrzebą 4 replik bazy danych a brakiem takiej potrzeby.
Ale cachowanie nie jest darmowe. Dodaje złożoność, wprowadza problemy ze spójnością i tworzy zupełnie nową klasę trybów awarii. Zanim cokolwiek zcachujesz, zadaj sobie pytanie:
Kiedy cachowanie pomaga:
- Dane są czytane znacznie częściej niż zapisywane (stosunek 10:1 lub wyższy)
- Podstawowe zapytanie jest kosztowne (joiny, agregacje, wywołania zewnętrznych API)
- Lekka nieaktualność jest akceptowalna (katalog produktów, profile użytkowników, konfiguracja)
- Masz przewidywalne wzorce dostępu (te same klucze trafiane wielokrotnie)
Kiedy cachowanie szkodzi:
- Dane zmieniają się ciągle i muszą być aktualne (ceny akcji w czasie rzeczywistym, wyniki na żywo)
- Każde żądanie jest unikalne (zapytania wyszukiwania z wieloma parametrami)
- Twój zbiór danych jest malutki (jeśli całość mieści się w pamięci twojej aplikacji, pomiń Redisa)
- Nie masz dojrzałości operacyjnej, żeby monitorować i debugować problemy z cache
Phil Karlton słynnie powiedział, że w informatyce są tylko dwie trudne rzeczy: inwalidacja cache i nazewnictwo. Miał rację co do obu, ale to inwalidacja cache budzi cię w nocy.
Konfiguracja ioredis#
Zanim zanurzymy się we wzorce, ustalmy połączenie. Wszędzie używam ioredis — to najbardziej dojrzały klient Redis dla Node.js, z właściwym wsparciem TypeScript, trybem klastra, wsparciem Sentinel i skryptowaniem Lua.
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: Number(process.env.REDIS_DB) || 0,
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 200, 5000);
return delay;
},
lazyConnect: true,
enableReadyCheck: true,
connectTimeout: 10000,
});
redis.on("error", (err) => {
console.error("[Redis] Connection error:", err.message);
});
redis.on("connect", () => {
console.log("[Redis] Connected");
});
export default redis;Kilka rzeczy warto zauważyć. lazyConnect: true oznacza, że połączenie nie jest nawiązywane, dopóki faktycznie nie uruchomisz komendy, co jest przydatne podczas testowania i inicjalizacji. retryStrategy implementuje exponential backoff ograniczony do 5 sekund — bez tego awaria Redisa powoduje, że twoja aplikacja spamuje próbami ponownego połączenia. A maxRetriesPerRequest: 3 zapewnia, że poszczególne komendy szybko kończą się błędem zamiast zawieszać się w nieskończoność.
Wzorzec Cache-Aside#
To wzorzec, którego będziesz używać w 80% przypadków. Nazywa się też "lazy loading" lub "look-aside". Przepływ jest prosty:
- Aplikacja otrzymuje żądanie
- Sprawdź Redis pod kątem zcachowanej wartości
- Jeśli znaleziona (cache hit), zwróć ją
- Jeśli nie znaleziona (cache miss), odpytaj bazę danych
- Zapisz wynik w Redis
- Zwróć wynik
Oto implementacja z typami:
import redis from "./redis";
interface CacheOptions {
ttl?: number; // seconds
prefix?: string;
}
async function cacheAside<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions = {}
): Promise<T> {
const { ttl = 3600, prefix = "cache" } = options;
const cacheKey = `${prefix}:${key}`;
// Step 1: Try to read from cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
return JSON.parse(cached) as T;
} catch {
// Corrupted cache entry, delete it and fall through
await redis.del(cacheKey);
}
}
// Step 2: Cache miss — fetch from source
const result = await fetcher();
// Step 3: Store in cache (don't await — fire and forget)
redis
.set(cacheKey, JSON.stringify(result), "EX", ttl)
.catch((err) => {
console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
});
return result;
}Użycie wygląda tak:
interface User {
id: string;
name: string;
email: string;
plan: "free" | "pro" | "enterprise";
}
async function getUser(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{ ttl: 1800 } // 30 minutes
);
}Zauważ, że wywołanie redis.set jest fire-and-forget. To celowe. Jeśli Redis jest wyłączony lub wolny, żądanie i tak się wykona. Cache to optymalizacja, nie wymóg. Jeśli zapis do cache się nie powiedzie, następne żądanie po prostu trafi do bazy danych. Żaden problem.
Jest subtelny bug w wielu implementacjach cache-aside, który ludzie pomijają: cachowanie wartości null. Jeśli użytkownik nie istnieje i nie cachujesz tego faktu, każde żądanie o tego użytkownika trafia do bazy danych. Atakujący może to wykorzystać, żądając losowych ID użytkowników, zamieniając twój cache w no-op. Zawsze cachuj też negatywny wynik — po prostu z krótszym TTL.
async function getUserSafe(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{
// Shorter TTL for null results to limit memory usage
// but long enough to absorb repeated misses
ttl: row ? 1800 : 300,
}
);
}Właściwie pozwól, że przestruktururyzuję to, żeby dynamiczny TTL działał poprawnie:
async function getUserWithDynamicTTL(userId: string): Promise<User | null> {
const cacheKey = `cache:user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as User | null;
}
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
const user: User | null = row[0] ?? null;
// Cache exists results for 30 min, null results for 5 min
const ttl = user ? 1800 : 300;
await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
return user;
}Write-Through i Write-Behind#
Cache-aside świetnie działa dla obciążeń z dużą ilością odczytów, ale ma problem ze spójnością: jeśli inny serwis lub proces aktualizuje bazę danych bezpośrednio, twój cache jest nieaktualny, dopóki nie wygaśnie TTL. Tu wchodzą wzorce write-through i write-behind.
Write-Through#
W write-through każdy zapis przechodzi przez warstwę cache. Cache jest aktualizowany pierwszy, potem baza danych. To gwarantuje, że cache jest zawsze spójny z bazą danych (zakładając, że zapisy zawsze przechodzą przez twoją aplikację).
async function updateUser(
userId: string,
updates: Partial<User>
): Promise<User> {
// Step 1: Update the database
const updated = await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *",
[userId, updates.name, updates.email]
);
const user: User = updated[0];
// Step 2: Update the cache immediately
const cacheKey = `cache:user:${userId}`;
await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
return user;
}Kluczowa różnica od cache-aside: zapisujemy do cache przy każdym zapisie, nie tylko przy odczytach. To oznacza, że cache jest zawsze ciepły dla ostatnio aktualizowanych danych.
Kompromis: latencja zapisu rośnie, ponieważ każdy zapis teraz dotyka zarówno bazy danych, jak i Redisa. Jeśli Redis jest wolny, twoje zapisy są wolne. W większości aplikacji odczyty znacznie przewyższają zapisy, więc ten kompromis się opłaca.
Write-Behind (Write-Back)#
Write-behind odwraca scenariusz: zapisy trafiają najpierw do Redisa, a baza danych jest aktualizowana asynchronicznie. To daje ekstremalnie szybkie zapisy kosztem potencjalnej utraty danych, jeśli Redis padnie przed utrwaleniem danych.
async function updateUserWriteBehind(
userId: string,
updates: Partial<User>
): Promise<User> {
const cacheKey = `cache:user:${userId}`;
// Read current state
const current = await redis.get(cacheKey);
const user = current ? JSON.parse(current) as User : null;
if (!user) throw new Error("User not in cache");
// Update cache immediately
const updated = { ...user, ...updates };
await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
// Queue database write for async processing
await redis.rpush(
"write_behind:users",
JSON.stringify({ userId, updates, timestamp: Date.now() })
);
return updated;
}Potem miałbyś osobny worker drenujący tę kolejkę:
async function processWriteBehindQueue(): Promise<void> {
while (true) {
const item = await redis.blpop("write_behind:users", 5);
if (item) {
const { userId, updates } = JSON.parse(item[1]);
try {
await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1",
[userId, updates.name, updates.email]
);
} catch (err) {
// Re-queue on failure with retry count
console.error("[WriteBehind] Failed:", err);
await redis.rpush("write_behind:users:dlq", item[1]);
}
}
}
}Rzadko używam write-behind w praktyce. Ryzyko utraty danych jest realne — jeśli Redis padnie, zanim worker przetworzy kolejkę, te zapisy przepadają. Używaj tego tylko dla danych, gdzie eventual consistency jest naprawdę akceptowalna, jak liczniki wyświetleń, zdarzenia analityczne czy niekrytyczne preferencje użytkownika.
Strategia TTL#
Właściwe ustawienie TTL jest bardziej złożone, niż się wydaje. Sztywne 1-godzinne TTL na wszystko jest łatwe do implementacji i prawie zawsze złe.
Poziomy zmienności danych#
Kategoryzuję dane na trzy poziomy i przypisuję TTL odpowiednio:
const TTL = {
// Tier 1: Rarely changes, expensive to compute
// Examples: product catalog, site config, feature flags
STATIC: 86400, // 24 hours
// Tier 2: Changes occasionally, moderate cost
// Examples: user profiles, team settings, permissions
MODERATE: 1800, // 30 minutes
// Tier 3: Changes frequently, cheap to compute but called often
// Examples: feed data, notification counts, session info
VOLATILE: 300, // 5 minutes
// Tier 4: Ephemeral, used for rate limiting and locks
EPHEMERAL: 60, // 1 minute
// Null results: always short-lived
NOT_FOUND: 120, // 2 minutes
} as const;TTL Jitter: Zapobieganie Thundering Herd#
Oto scenariusz, który mnie ugryzł: wdrażasz swoją aplikację, cache jest pusty i 10 000 żądań cachuje te same dane z 1-godzinnym TTL. Godzinę później wszystkie 10 000 kluczy wygasa jednocześnie. Wszystkie 10 000 żądań uderza w bazę danych naraz. Baza dusi się. Widziałem, jak to kładzie produkcyjną instancję Postgresa.
Rozwiązaniem jest jitter — dodanie losowości do wartości TTL:
function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
const jitter = baseTtl * jitterPercent;
const offset = Math.random() * jitter * 2 - jitter;
return Math.max(1, Math.round(baseTtl + offset));
}
// Instead of: redis.set(key, value, "EX", 3600)
// Use: redis.set(key, value, "EX", ttlWithJitter(3600))
// 3600 ± 10% = random value between 3240 and 3960To rozkłada wygasanie na okno czasowe, więc zamiast 10 000 kluczy wygasających w tej samej sekundzie, wygasają w ciągu 12-minutowego okna. Baza danych widzi stopniowy wzrost ruchu, nie urwisko.
Dla ścieżek krytycznych idę dalej i używam 20% jittera:
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 secondsPrzesuwane wygasanie (Sliding Expiry)#
Dla danych sesyjnych, gdzie TTL powinien się resetować przy każdym dostępie, użyj GETEX (Redis 6.2+):
async function getWithSlidingExpiry<T>(
key: string,
ttl: number
): Promise<T | null> {
// GETEX atomically gets the value AND resets the TTL
const value = await redis.getex(key, "EX", ttl);
if (value === null) return null;
return JSON.parse(value) as T;
}Jeśli korzystasz ze starszej wersji Redisa, użyj pipeline:
async function getWithSlidingExpiryCompat<T>(
key: string,
ttl: number
): Promise<T | null> {
const pipeline = redis.pipeline();
pipeline.get(key);
pipeline.expire(key, ttl);
const results = await pipeline.exec();
if (!results || !results[0] || results[0][1] === null) return null;
return JSON.parse(results[0][1] as string) as T;
}Cache Stampede (Thundering Herd)#
TTL jitter pomaga z masowym wygasaniem, ale nie rozwiązuje stampede na pojedynczym kluczu: gdy popularny klucz wygasa i setki współbieżnych żądań próbuje go jednocześnie zregenerować.
Wyobraź sobie, że cachujesz feed strony głównej z 5-minutowym TTL. Wygasa. Pięćdziesiąt współbieżnych żądań widzi cache miss. Wszystkie pięćdziesiąt uderza w bazę danych tym samym kosztownym zapytaniem. Właśnie sam na siebie zrobiłeś DDoS.
Rozwiązanie 1: Mutex Lock#
Tylko jedno żądanie regeneruje cache. Wszyscy inni czekają.
async function cacheAsideWithMutex<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const lockKey = `lock:${key}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as T;
}
// Try to acquire lock (NX = only if not exists, EX = auto-expire)
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (acquired) {
try {
// We got the lock — fetch and cache
const result = await fetcher();
await redis.set(
cacheKey,
JSON.stringify(result),
"EX",
ttlWithJitter(ttl)
);
return result;
} finally {
// Release lock
await redis.del(lockKey);
}
}
// Another request holds the lock — wait and retry
await sleep(100);
const retried = await redis.get(cacheKey);
if (retried !== null) {
return JSON.parse(retried) as T;
}
// Still no cache — fall through to database
// (this handles the case where the lock holder failed)
return fetcher();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}Jest subtelny race condition w powyższym zwalnianiu locka. Jeśli posiadacz locka potrzebuje więcej niż 10 sekund (TTL locka), inne żądanie przejmuje lock, a potem pierwsze żądanie usuwa lock drugiego żądania. Właściwa poprawka to użycie unikalnego tokenu:
import { randomUUID } from "crypto";
async function acquireLock(
lockKey: string,
ttl: number
): Promise<string | null> {
const token = randomUUID();
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
return acquired ? token : null;
}
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
// Lua script ensures atomic check-and-delete
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, 1, lockKey, token);
return result === 1;
}To jest zasadniczo uproszczony Redlock. Dla jednoinstancyjnego Redisa to wystarczające. Dla konfiguracji Redis Cluster lub Sentinel przyjrzyj się pełnemu algorytmowi Redlock — ale szczerze, do zapobiegania cache stampede ta prosta wersja działa dobrze.
Rozwiązanie 2: Probabilistyczne wczesne wygasanie#
To moje ulubione podejście. Zamiast czekać na wygaśnięcie klucza, losowo regenerujesz go tuż przed wygaśnięciem. Pomysł pochodzi z pracy Vattaniego, Chierichettiego i Lowensteina.
interface CachedValue<T> {
data: T;
cachedAt: number;
ttl: number;
}
async function cacheWithEarlyExpiration<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T> {
const cacheKey = `cache:${key}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
const entry = JSON.parse(cached) as CachedValue<T>;
const age = (Date.now() - entry.cachedAt) / 1000;
const remaining = entry.ttl - age;
// XFetch algorithm: probabilistically regenerate as expiry approaches
// beta * Math.log(Math.random()) produces a negative number
// that grows larger (more negative) as expiry approaches
const beta = 1; // tuning parameter, 1 works well
const shouldRegenerate =
remaining - beta * Math.log(Math.random()) * -1 <= 0;
if (!shouldRegenerate) {
return entry.data;
}
// Fall through to regenerate
console.log(`[Cache] Early regeneration triggered for ${key}`);
}
const data = await fetcher();
const entry: CachedValue<T> = {
data,
cachedAt: Date.now(),
ttl,
};
// Set with extra buffer so Redis doesn't expire before we can regenerate
await redis.set(
cacheKey,
JSON.stringify(entry),
"EX",
Math.round(ttl * 1.1)
);
return data;
}Piękno tego podejścia: w miarę zmniejszania się pozostałego TTL klucza, prawdopodobieństwo regeneracji rośnie. Przy 1000 współbieżnych żądań może jedno lub dwa wyzwolą regenerację, podczas gdy reszta kontynuuje serwowanie zcachowanych danych. Żadnych locków, żadnej koordynacji, żadnego czekania.
Rozwiązanie 3: Stale-While-Revalidate#
Serwuj nieaktualną wartość podczas regeneracji w tle. To daje najlepszą latencję, ponieważ żadne żądanie nigdy nie czeka na fetcher.
async function staleWhileRevalidate<T>(
key: string,
fetcher: () => Promise<T>,
options: {
freshTtl: number; // how long the data is "fresh"
staleTtl: number; // how long stale data can be served
}
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const metaKey = `meta:${key}`;
const [cached, meta] = await redis.mget(cacheKey, metaKey);
if (cached !== null) {
const parsedMeta = meta ? JSON.parse(meta) : null;
const isFresh =
parsedMeta && Date.now() - parsedMeta.cachedAt < options.freshTtl * 1000;
if (!isFresh) {
// Data is stale — serve it but trigger background refresh
revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
}
return JSON.parse(cached) as T;
}
// Complete cache miss — must fetch synchronously
return fetchAndCache(key, cacheKey, metaKey, fetcher, options);
}
async function fetchAndCache<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): Promise<T> {
const data = await fetcher();
const totalTtl = options.freshTtl + options.staleTtl;
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(data), "EX", totalTtl);
pipeline.set(
metaKey,
JSON.stringify({ cachedAt: Date.now() }),
"EX",
totalTtl
);
await pipeline.exec();
return data;
}
function revalidateInBackground<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): void {
// Use a lock to prevent multiple background refreshes
const lockKey = `revalidate_lock:${key}`;
redis
.set(lockKey, "1", "EX", 30, "NX")
.then((acquired) => {
if (!acquired) return;
return fetchAndCache(key, cacheKey, metaKey, fetcher, options)
.finally(() => redis.del(lockKey));
})
.catch((err) => {
console.error(`[SWR] Background revalidation failed for ${key}:`, err);
});
}Użycie:
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
freshTtl: 300, // 5 minutes fresh
staleTtl: 3600, // serve stale for up to 1 hour while revalidating
});Używam tego wzorca do wszystkiego skierowanego do użytkownika, gdzie latencja jest ważniejsza niż absolutna aktualność. Dane dashboardu, strony profili, listy produktów — wszystko idealni kandydaci.
Inwalidacja cache#
Phil Karlton nie żartował. Inwalidacja to miejsce, gdzie cachowanie przechodzi z "łatwej optymalizacji" w "problem systemów rozproszonych".
Prosta inwalidacja oparta na kluczach#
Najłatwiejszy przypadek: gdy aktualizujesz użytkownika, usuń jego klucz cache.
async function updateUserAndInvalidate(
userId: string,
updates: Partial<User>
): Promise<User> {
const user = await db.query(
"UPDATE users SET name = $2 WHERE id = $1 RETURNING *",
[userId, updates.name]
);
// Invalidate the cache
await redis.del(`cache:user:${userId}`);
return user[0];
}To działa, dopóki dane użytkownika nie pojawiają się w innych zcachowanych wynikach. Może są osadzone w liście członków zespołu. Może są w wynikach wyszukiwania. Może są w 14 różnych zcachowanych odpowiedziach API. Teraz musisz śledzić, które klucze cache zawierają które encje.
Inwalidacja oparta na tagach#
Taguj swoje wpisy cache encjami, które zawierają, a potem inwaliduj po tagu.
async function setWithTags<T>(
key: string,
value: T,
ttl: number,
tags: string[]
): Promise<void> {
const pipeline = redis.pipeline();
// Store the value
pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
// Add the key to each tag's set
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, `cache:${key}`);
pipeline.expire(`tag:${tag}`, ttl + 3600); // Tag sets live longer than values
}
await pipeline.exec();
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}Użycie:
// When caching team data, tag it with all member IDs
const team = await fetchTeam(teamId);
await setWithTags(
`team:${teamId}`,
team,
1800,
[
`entity:team:${teamId}`,
...team.members.map((m) => `entity:user:${m.id}`),
]
);
// When user 42 updates their profile, invalidate everything that contains them
await invalidateByTag("entity:user:42");Inwalidacja sterowana zdarzeniami#
Dla większych systemów użyj Redis Pub/Sub do rozgłaszania zdarzeń inwalidacji:
// Publisher (in your API service)
async function publishInvalidation(
entityType: string,
entityId: string
): Promise<void> {
await redis.publish(
"cache:invalidate",
JSON.stringify({ entityType, entityId, timestamp: Date.now() })
);
}
// Subscriber (in each app instance)
const subscriber = new Redis(/* same config */);
subscriber.subscribe("cache:invalidate", (err) => {
if (err) console.error("[PubSub] Subscribe error:", err);
});
subscriber.on("message", async (_channel, message) => {
const { entityType, entityId } = JSON.parse(message);
await invalidateByTag(`entity:${entityType}:${entityId}`);
console.log(`[Cache] Invalidated ${entityType}:${entityId}`);
});To jest krytyczne w deploymentach wieloinstancyjnych. Jeśli masz 4 serwery aplikacji za load balancerem, inwalidacja na serwerze 1 musi się propagować do wszystkich serwerów. Pub/Sub obsługuje to automatycznie.
Inwalidacja oparta na wzorcach (ostrożnie)#
Czasami musisz zinwalidować wszystkie klucze pasujące do wzorca. Nigdy nie używaj KEYS na produkcji. Blokuje serwer Redis podczas skanowania całej przestrzeni kluczy. Przy milionach kluczy może to blokować przez sekundy — wieczność w terminach Redisa.
Użyj SCAN zamiast tego:
async function invalidateByPattern(pattern: string): Promise<number> {
let cursor = "0";
let deletedCount = 0;
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
deletedCount += keys.length;
}
} while (cursor !== "0");
return deletedCount;
}
// Invalidate all cached data for a specific team
await invalidateByPattern("cache:team:42:*");SCAN iteruje inkrementalnie — nigdy nie blokuje serwera. Podpowiedź COUNT sugeruje, ile kluczy zwrócić na iterację (to podpowiedź, nie gwarancja). Dla dużych przestrzeni kluczy to jedyne bezpieczne podejście.
Powiedziawszy to, inwalidacja oparta na wzorcach to code smell. Jeśli skanujesz często, przeprojektuj strukturę kluczy lub użyj tagów. SCAN jest O(N) po przestrzeni kluczy i jest przeznaczony do operacji konserwacyjnych, nie do gorących ścieżek.
Struktury danych poza stringami#
Większość developerów traktuje Redisa jako key-value store do stringów JSON. To jak kupienie scyzoryka szwajcarskiego i używanie tylko otwieracza do butelek. Redis ma bogate struktury danych, a wybór właściwej może wyeliminować całe kategorie złożoności.
Hashe do obiektów#
Zamiast serializować cały obiekt jako JSON, przechowuj go jako Redis Hash. To pozwala odczytywać i aktualizować poszczególne pola bez deserializacji całości.
// Store user as a hash
async function setUserHash(user: User): Promise<void> {
const key = `user:${user.id}`;
await redis.hset(key, {
name: user.name,
email: user.email,
plan: user.plan,
updatedAt: Date.now().toString(),
});
await redis.expire(key, 1800);
}
// Read specific fields
async function getUserPlan(userId: string): Promise<string | null> {
return redis.hget(`user:${userId}`, "plan");
}
// Update a single field
async function upgradeUserPlan(
userId: string,
plan: string
): Promise<void> {
await redis.hset(`user:${userId}`, "plan", plan);
}
// Read entire hash as object
async function getUserHash(userId: string): Promise<User | null> {
const data = await redis.hgetall(`user:${userId}`);
if (!data || Object.keys(data).length === 0) return null;
return {
id: userId,
name: data.name,
email: data.email,
plan: data.plan as User["plan"],
};
}Hashe są wydajne pamięciowo dla małych obiektów (Redis używa kompaktowego kodowania ziplist pod spodem) i unikają narzutu serialize/deserialize. Kompromis: tracisz możliwość przechowywania zagnieżdżonych obiektów bez wcześniejszego spłaszczenia ich.
Sorted Sets do rankingów i rate limitingu#
Sorted Sets to najbardziej niedoceniana struktura danych Redisa. Każdy element ma score, a zbiór jest zawsze posortowany po score. To czyni je idealnymi do rankingów, klasyfikacji i sliding window rate limitingu.
// Leaderboard
async function addScore(
leaderboard: string,
userId: string,
score: number
): Promise<void> {
await redis.zadd(leaderboard, score, userId);
}
async function getTopPlayers(
leaderboard: string,
count: number = 10
): Promise<Array<{ userId: string; score: number }>> {
const results = await redis.zrevrange(
leaderboard,
0,
count - 1,
"WITHSCORES"
);
const players: Array<{ userId: string; score: number }> = [];
for (let i = 0; i < results.length; i += 2) {
players.push({
userId: results[i],
score: parseFloat(results[i + 1]),
});
}
return players;
}
async function getUserRank(
leaderboard: string,
userId: string
): Promise<number | null> {
const rank = await redis.zrevrank(leaderboard, userId);
return rank !== null ? rank + 1 : null; // 0-indexed to 1-indexed
}Do sliding window rate limitingu:
async function slidingWindowRateLimit(
identifier: string,
windowMs: number,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number }> {
const key = `ratelimit:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
// Remove entries outside the window
pipeline.zremrangebyscore(key, 0, windowStart);
// Add current request
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// Count requests in window
pipeline.zcard(key);
// Set expiry on the whole key
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results?.[2]?.[1] as number;
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
};
}To jest dokładniejsze niż podejście z licznikiem stałego okna i nie cierpi na problem graniczny, gdzie seria żądań na końcu jednego okna i na początku następnego efektywnie podwaja twój rate limit.
Listy do kolejek#
Redis Lists z LPUSH/BRPOP tworzą doskonałe lekkie kolejki zadań:
interface Job {
id: string;
type: string;
payload: Record<string, unknown>;
createdAt: number;
}
// Producer
async function enqueueJob(
queue: string,
type: string,
payload: Record<string, unknown>
): Promise<string> {
const job: Job = {
id: randomUUID(),
type,
payload,
createdAt: Date.now(),
};
await redis.lpush(`queue:${queue}`, JSON.stringify(job));
return job.id;
}
// Consumer (blocks until a job is available)
async function dequeueJob(
queue: string,
timeout: number = 5
): Promise<Job | null> {
const result = await redis.brpop(`queue:${queue}`, timeout);
if (!result) return null;
return JSON.parse(result[1]) as Job;
}Do czegokolwiek bardziej złożonego niż podstawowe kolejkowanie (ponowne próby, dead letter queues, priorytety, opóźnione zadania) użyj BullMQ, który bazuje na Redisie, ale obsługuje wszystkie edge case'y.
Sety do śledzenia unikalności#
Potrzebujesz śledzić unikalnych odwiedzających, deduplikować zdarzenia albo sprawdzać przynależność? Sety mają O(1) dla dodawania, usuwania i sprawdzania przynależności.
// Track unique visitors per day
async function trackVisitor(
page: string,
visitorId: string
): Promise<boolean> {
const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
const isNew = await redis.sadd(key, visitorId);
// Auto-expire after 48 hours
await redis.expire(key, 172800);
return isNew === 1; // 1 = new member, 0 = already existed
}
// Get unique visitor count
async function getUniqueVisitors(page: string, date: string): Promise<number> {
return redis.scard(`visitors:${page}:${date}`);
}
// Check if user has already performed an action
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}Dla bardzo dużych setów (miliony elementów) rozważ HyperLogLog. Używa tylko 12KB pamięci niezależnie od kardynalności, kosztem ~0,81% błędu standardowego:
// HyperLogLog for approximate unique counts
async function trackVisitorApprox(
page: string,
visitorId: string
): Promise<void> {
const key = `hll:visitors:${page}:${new Date().toISOString().split("T")[0]}`;
await redis.pfadd(key, visitorId);
await redis.expire(key, 172800);
}
async function getApproxUniqueVisitors(
page: string,
date: string
): Promise<number> {
return redis.pfcount(`hll:visitors:${page}:${date}`);
}Serializacja: JSON vs MessagePack#
JSON to domyślny wybór do serializacji w Redisie. Jest czytelny, uniwersalny i wystarczająco dobry w większości przypadków. Ale w systemach o dużej przepustowości narzut serialize/deserialize się kumuluje.
Problem z JSON#
const user = {
id: "usr_abc123",
name: "Ahmet Kousa",
email: "ahmet@example.com",
plan: "pro",
preferences: {
theme: "dark",
language: "tr",
notifications: true,
},
};
// JSON: 189 bytes
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
// JSON.parse on a hot path: ~0.02ms per call
// At 10,000 requests/sec: 200ms total CPU time per secondAlternatywa MessagePack#
MessagePack to binarny format serializacji, który jest mniejszy i szybszy od JSON:
npm install msgpackrimport { pack, unpack } from "msgpackr";
// MessagePack: ~140 bytes (25% smaller)
const packed = pack(user);
console.log(packed.length); // ~140
// Store as Buffer
await redis.set("user:123", packed);
// Read as Buffer
const raw = await redis.getBuffer("user:123");
if (raw) {
const data = unpack(raw);
}Zwróć uwagę na użycie getBuffer zamiast get — to kluczowe. get zwraca string i skorumpowałoby dane binarne.
Kompresja dla dużych wartości#
Dla dużych zcachowanych wartości (odpowiedzi API z setkami elementów, wyrenderowany HTML) dodaj kompresję:
import { promisify } from "util";
import { gzip, gunzip } from "zlib";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
async function setCompressed<T>(
key: string,
value: T,
ttl: number
): Promise<void> {
const json = JSON.stringify(value);
// Only compress if larger than 1KB (compression overhead isn't worth it for small values)
if (Buffer.byteLength(json) > 1024) {
const compressed = await gzipAsync(json);
await redis.set(key, compressed, "EX", ttl);
} else {
await redis.set(key, json, "EX", ttl);
}
}
async function getCompressed<T>(key: string): Promise<T | null> {
const raw = await redis.getBuffer(key);
if (!raw) return null;
try {
// Try to decompress first
const decompressed = await gunzipAsync(raw);
return JSON.parse(decompressed.toString()) as T;
} catch {
// Not compressed, parse as regular JSON
return JSON.parse(raw.toString()) as T;
}
}W moich testach kompresja gzip zazwyczaj zmniejsza rozmiar payloadu JSON o 70-85%. Odpowiedź API o 50KB staje się 8KB. To ma znaczenie, gdy płacisz za pamięć Redisa — mniej pamięci na klucz oznacza więcej kluczy w tej samej instancji.
Kompromis: kompresja dodaje 1-3ms czasu CPU na operację. W większości aplikacji to pomijalne. Dla ścieżek z ultra-niską latencją pomiń to.
Moja rekomendacja#
Używaj JSON, chyba że profilowanie pokaże, że to wąskie gardło. Czytelność i debugowalność JSON w Redisie (możesz zrobić redis-cli GET key i faktycznie odczytać wartość) przeważa nad zyskiem wydajności MessagePack w 95% aplikacji. Dodawaj kompresję tylko dla wartości większych niż 1KB.
Redis w Next.js#
Next.js ma swoją własną historię cachowania (Data Cache, Full Route Cache itp.), ale Redis wypełnia luki, których wbudowane cachowanie nie obsługuje — zwłaszcza gdy potrzebujesz współdzielić cache między wieloma instancjami lub utrzymywać cache między deploymentami.
Cachowanie odpowiedzi API Route#
// app/api/products/route.ts
import { NextResponse } from "next/server";
import redis from "@/lib/redis";
export async function GET(request: Request) {
const url = new URL(request.url);
const category = url.searchParams.get("category") || "all";
const cacheKey = `api:products:${category}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return NextResponse.json(JSON.parse(cached), {
headers: {
"X-Cache": "HIT",
"Cache-Control": "public, s-maxage=60",
},
});
}
// Fetch from database
const products = await db.products.findMany({
where: category !== "all" ? { category } : undefined,
orderBy: { createdAt: "desc" },
take: 50,
});
// Cache for 5 minutes with jitter
await redis.set(
cacheKey,
JSON.stringify(products),
"EX",
ttlWithJitter(300)
);
return NextResponse.json(products, {
headers: {
"X-Cache": "MISS",
"Cache-Control": "public, s-maxage=60",
},
});
}Nagłówek X-Cache jest nieoceniony do debugowania. Gdy latencja rośnie, szybki curl -I mówi ci, czy cache działa.
Przechowywanie sesji#
Next.js z Redisem do sesji bije JWT dla aplikacji stanowych:
// lib/session.ts
import { randomUUID } from "crypto";
import redis from "./redis";
interface Session {
userId: string;
role: string;
createdAt: number;
data: Record<string, unknown>;
}
const SESSION_TTL = 86400; // 24 hours
const SESSION_PREFIX = "session:";
export async function createSession(
userId: string,
role: string
): Promise<string> {
const sessionId = randomUUID();
const session: Session = {
userId,
role,
createdAt: Date.now(),
data: {},
};
await redis.set(
`${SESSION_PREFIX}${sessionId}`,
JSON.stringify(session),
"EX",
SESSION_TTL
);
return sessionId;
}
export async function getSession(
sessionId: string
): Promise<Session | null> {
const key = `${SESSION_PREFIX}${sessionId}`;
// Use GETEX to refresh TTL on every access (sliding expiry)
const raw = await redis.getex(key, "EX", SESSION_TTL);
if (!raw) return null;
return JSON.parse(raw) as Session;
}
export async function destroySession(sessionId: string): Promise<void> {
await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
// Destroy all sessions for a user (useful for "logout everywhere")
export async function destroyAllUserSessions(
userId: string
): Promise<void> {
// This requires maintaining a user->sessions index
const sessionIds = await redis.smembers(`user_sessions:${userId}`);
if (sessionIds.length > 0) {
const pipeline = redis.pipeline();
for (const sid of sessionIds) {
pipeline.del(`${SESSION_PREFIX}${sid}`);
}
pipeline.del(`user_sessions:${userId}`);
await pipeline.exec();
}
}Middleware Rate Limiting#
// middleware.ts (or a helper used by middleware)
import redis from "@/lib/redis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
export async function rateLimit(
identifier: string,
limit: number = 60,
windowSeconds: number = 60
): Promise<RateLimitResult> {
const key = `rate:${identifier}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
// Lua script for atomic rate limiting
const script = `
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
local count = redis.call('ZCARD', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return count
`;
const count = (await redis.eval(
script,
1,
key,
windowStart,
now,
`${now}:${Math.random()}`,
windowSeconds
)) as number;
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt: now + windowSeconds,
};
}Skrypt Lua jest tu ważny. Bez niego sekwencja ZREMRANGEBYSCORE + ZADD + ZCARD nie jest atomowa i przy dużej współbieżności licznik może być niedokładny. Skrypty Lua wykonują się atomowo w Redisie — nie mogą być przeplatane z innymi komendami.
Distributed Locks dla Next.js#
Gdy masz wiele instancji Next.js i musisz zapewnić, że tylko jedna przetwarza zadanie (jak wysyłanie zaplanowanego maila lub uruchamianie zadania czyszczącego):
// lib/distributed-lock.ts
import { randomUUID } from "crypto";
import redis from "./redis";
export async function withLock<T>(
lockName: string,
fn: () => Promise<T>,
options: { ttl?: number; retryDelay?: number; maxRetries?: number } = {}
): Promise<T | null> {
const { ttl = 30, retryDelay = 200, maxRetries = 10 } = options;
const token = randomUUID();
const lockKey = `dlock:${lockName}`;
// Try to acquire lock
for (let attempt = 0; attempt < maxRetries; attempt++) {
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
if (acquired) {
try {
// Extend lock automatically for long-running tasks
const extender = setInterval(async () => {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else
return 0
end
`;
await redis.eval(script, 1, lockKey, token, ttl);
}, (ttl * 1000) / 3);
const result = await fn();
clearInterval(extender);
return result;
} finally {
// Release lock only if we still own it
const releaseScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(releaseScript, 1, lockKey, token);
}
}
// Wait before retrying
await new Promise((r) => setTimeout(r, retryDelay));
}
// Could not acquire lock after all retries
return null;
}Użycie:
// In a cron-triggered API route
export async function POST() {
const result = await withLock("daily-report", async () => {
// Only one instance runs this
const report = await generateDailyReport();
await sendReportEmail(report);
return report;
});
if (result === null) {
return NextResponse.json(
{ message: "Another instance is already processing" },
{ status: 409 }
);
}
return NextResponse.json({ success: true });
}Interwał rozszerzania locka przy ttl/3 jest ważny. Bez niego, jeśli twoje zadanie trwa dłużej niż TTL locka, lock wygasa i inna instancja go przejmuje. Extender utrzymuje lock żywy tak długo, jak zadanie działa.
Monitoring i debugowanie#
Redis jest szybki, dopóki nie jest. Gdy problemy uderzą, potrzebujesz widoczności.
Współczynnik trafień cache (Cache Hit Ratio)#
Najważniejsza metryka. Śledź ją w swojej aplikacji:
// lib/cache-metrics.ts
import redis from "./redis";
const METRICS_KEY = "metrics:cache";
export async function recordCacheHit(): Promise<void> {
await redis.hincrby(METRICS_KEY, "hits", 1);
}
export async function recordCacheMiss(): Promise<void> {
await redis.hincrby(METRICS_KEY, "misses", 1);
}
export async function getCacheStats(): Promise<{
hits: number;
misses: number;
hitRate: number;
}> {
const stats = await redis.hgetall(METRICS_KEY);
const hits = parseInt(stats.hits || "0", 10);
const misses = parseInt(stats.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? hits / total : 0,
};
}
// Reset metrics daily
export async function resetCacheStats(): Promise<void> {
await redis.del(METRICS_KEY);
}Zdrowy współczynnik trafień cache to powyżej 90%. Jeśli jesteś poniżej 80%, albo twoje TTL są za krótkie, albo twoje klucze cache są zbyt specyficzne, albo twoje wzorce dostępu są bardziej losowe, niż myślałeś.
Komenda INFO#
Komenda INFO to wbudowany dashboard zdrowia Redisa:
redis-cli INFO memory# Memory
used_memory:1234567
used_memory_human:1.18M
used_memory_peak:2345678
used_memory_peak_human:2.24M
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23
Kluczowe metryki do monitorowania:
- used_memory vs maxmemory: Czy zbliżasz się do limitu?
- mem_fragmentation_ratio: Powyżej 1.5 oznacza, że Redis używa znacznie więcej RSS niż pamięci logicznej. Rozważ restart.
- evicted_keys: Jeśli jest niezerowe, a nie zamierzałeś eviction, brakuje ci pamięci.
redis-cli INFO statsObserwuj:
- keyspace_hits / keyspace_misses: Współczynnik trafień na poziomie serwera
- total_commands_processed: Przepustowość
- instantaneous_ops_per_sec: Bieżąca przepustowość
MONITOR (używaj z ekstremalną ostrożnością)#
MONITOR streamuje każdą komendę wykonywaną na serwerze Redis w czasie rzeczywistym. Jest niesamowicie przydatny do debugowania i niesamowicie niebezpieczny na produkcji.
# NEVER leave this running in production
# It adds significant overhead and can log sensitive data
redis-cli MONITOR1614556800.123456 [0 127.0.0.1:52340] "SET" "cache:user:123" "{\"name\":\"Ahmet\"}" "EX" "1800"
1614556800.234567 [0 127.0.0.1:52340] "GET" "cache:user:456"
Używam MONITOR dokładnie do dwóch rzeczy: debugowania problemów z nazewnictwem kluczy podczas developmentu i weryfikacji, że konkretna ścieżka kodu trafia do Redisa zgodnie z oczekiwaniami. Nigdy dłużej niż 30 sekund. Nigdy na produkcji, chyba że wyczerpałeś inne opcje debugowania.
Keyspace Notifications#
Chcesz wiedzieć, kiedy klucze wygasają lub są usuwane? Redis może publikować zdarzenia:
# Enable keyspace notifications for expired and evicted events
redis-cli CONFIG SET notify-keyspace-events Exconst subscriber = new Redis(/* config */);
// Listen for key expiration events
subscriber.subscribe("__keyevent@0__:expired", (err) => {
if (err) console.error("Subscribe error:", err);
});
subscriber.on("message", (_channel, expiredKey) => {
console.log(`Key expired: ${expiredKey}`);
// Proactively regenerate important keys
if (expiredKey.startsWith("cache:homepage")) {
regenerateHomepageCache().catch(console.error);
}
});To jest przydatne do proaktywnego rozgrzewania cache — zamiast czekać, aż użytkownik wyzwoli cache miss, regenerujesz krytyczne wpisy w momencie ich wygaśnięcia.
Analiza pamięci#
Gdy pamięć Redisa rośnie niespodziewanie, musisz znaleźć, które klucze zużywają najwięcej:
# Sample 10 largest keys
redis-cli --bigkeys# Scanning the entire keyspace to find biggest keys
[00.00%] Biggest string found so far '"cache:search:electronics"' with 524288 bytes
[25.00%] Biggest zset found so far '"leaderboard:global"' with 150000 members
[50.00%] Biggest hash found so far '"session:abc123"' with 45 fields
Dla bardziej szczegółowej analizy:
# Memory usage of a specific key (in bytes)
redis-cli MEMORY USAGE "cache:search:electronics"// Programmatic memory analysis
async function analyzeMemory(pattern: string): Promise<void> {
let cursor = "0";
const stats: Array<{ key: string; bytes: number }> = [];
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
for (const key of keys) {
const bytes = await redis.memory("USAGE", key);
if (bytes) {
stats.push({ key, bytes: bytes as number });
}
}
} while (cursor !== "0");
// Sort by size descending
stats.sort((a, b) => b.bytes - a.bytes);
console.log("Top 20 keys by memory usage:");
for (const { key, bytes } of stats.slice(0, 20)) {
const mb = (bytes / 1024 / 1024).toFixed(2);
console.log(` ${key}: ${mb} MB`);
}
}Polityki eviction#
Jeśli twoja instancja Redis ma limit maxmemory (powinna mieć), skonfiguruj politykę eviction:
# In redis.conf or via CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lruDostępne polityki:
- noeviction: Zwraca błąd, gdy pamięć jest pełna (domyślna, najgorsza do cachowania)
- allkeys-lru: Wyrzuca najdawniej używany klucz (najlepszy wybór ogólnego przeznaczenia do cachowania)
- allkeys-lfu: Wyrzuca najrzadziej używany klucz (lepszy, jeśli niektóre klucze są dostępne w seriach)
- volatile-lru: Wyrzuca tylko klucze z ustawionym TTL (przydatne, jeśli mieszasz cache z danymi trwałymi)
- allkeys-random: Losowa eviction (zaskakująco przyzwoita, bez narzutu)
Dla czystych obciążeń cachowania allkeys-lfu jest zazwyczaj najlepszym wyborem. Utrzymuje często dostępne klucze w pamięci, nawet jeśli nie były ostatnio dostępne.
Składamy wszystko razem: Produkcyjny moduł cache#
Oto kompletny moduł cache, którego używam na produkcji, łączący wszystko, co omówiliśmy:
// lib/cache.ts
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 200, 5000);
},
});
// TTL tiers
const TTL = {
STATIC: 86400,
MODERATE: 1800,
VOLATILE: 300,
EPHEMERAL: 60,
NOT_FOUND: 120,
} as const;
type TTLTier = keyof typeof TTL;
function ttlWithJitter(base: number, jitter = 0.1): number {
const offset = base * jitter * (Math.random() * 2 - 1);
return Math.max(1, Math.round(base + offset));
}
// Core cache-aside with stampede protection
async function get<T>(
key: string,
fetcher: () => Promise<T>,
options: {
tier?: TTLTier;
ttl?: number;
tags?: string[];
swr?: { freshTtl: number; staleTtl: number };
} = {}
): Promise<T> {
const { tier = "MODERATE", tags } = options;
const baseTtl = options.ttl ?? TTL[tier];
const cacheKey = `c:${key}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
const parsed = JSON.parse(cached);
recordHit();
return parsed as T;
} catch {
await redis.del(cacheKey);
}
}
recordMiss();
// Acquire lock to prevent stampede
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (!acquired) {
// Another process is fetching — wait briefly and retry cache
await new Promise((r) => setTimeout(r, 150));
const retried = await redis.get(cacheKey);
if (retried) return JSON.parse(retried) as T;
}
try {
const result = await fetcher();
const ttl = ttlWithJitter(baseTtl);
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
// Store tag associations
if (tags) {
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, cacheKey);
pipeline.expire(`tag:${tag}`, ttl + 3600);
}
}
await pipeline.exec();
return result;
} finally {
await redis.del(lockKey);
}
}
// Invalidation
async function invalidate(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
await redis.del(...keys.map((k) => `c:${k}`));
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}
// Metrics
function recordHit(): void {
redis.hincrby("metrics:cache", "hits", 1).catch(() => {});
}
function recordMiss(): void {
redis.hincrby("metrics:cache", "misses", 1).catch(() => {});
}
async function stats(): Promise<{
hits: number;
misses: number;
hitRate: string;
}> {
const raw = await redis.hgetall("metrics:cache");
const hits = parseInt(raw.hits || "0", 10);
const misses = parseInt(raw.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "N/A",
};
}
export const cache = {
get,
invalidate,
invalidateByTag,
stats,
redis,
TTL,
};Użycie w całej aplikacji:
import { cache } from "@/lib/cache";
// Simple cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
tier: "VOLATILE",
tags: ["entity:products"],
});
// With custom TTL
const config = await cache.get("app:config", fetchAppConfig, {
ttl: 43200, // 12 hours
});
// After updating a product
await cache.invalidateByTag("entity:products");
// Check health
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);Typowe błędy, które popełniłem (żebyś ty nie musiał)#
1. Nieustawianie maxmemory. Redis z radością zużyje całą dostępną pamięć, dopóki OS go nie ubije. Zawsze ustawiaj limit.
2. Używanie KEYS na produkcji. Blokuje serwer. Używaj SCAN. Nauczyłem się tego, gdy wywołanie KEYS * ze skryptu monitorującego spowodowało 3 sekundy downtime'u.
3. Zbyt agresywne cachowanie. Nie wszystko musi być zcachowane. Jeśli twoje zapytanie do bazy trwa 2ms i jest wywoływane 10 razy na minutę, cachowanie dodaje złożoność za pomijalny zysk.
4. Ignorowanie kosztów serializacji. Kiedyś zcachowałem 2MB blob JSON i byłem zdziwiony, dlaczego odczyty z cache są wolne. Narzut serializacji był większy niż zapytanie do bazy, które miał oszczędzić.
5. Brak graceful degradation. Gdy Redis padnie, twoja aplikacja powinna nadal działać — tylko wolniej. Opakuj każde wywołanie cache w try/catch, które wraca do bazy danych. Nigdy nie pozwól, żeby awaria cache stała się błędem widocznym dla użytkownika.
async function resilientGet<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
try {
return await cache.get(key, fetcher);
} catch (err) {
console.error(`[Cache] Degraded mode for ${key}:`, err);
return fetcher(); // Bypass cache entirely
}
}6. Niemonitorowanie evictions. Jeśli Redis wyrzuca klucze, albo masz za mało zasobów, albo cachujesz za dużo. Tak czy inaczej, musisz o tym wiedzieć.
7. Współdzielenie instancji Redis między cachowaniem a danymi trwałymi. Używaj osobnych instancji (lub przynajmniej osobnych baz danych). Polityka eviction cache, która kasuje wpisy z kolejki zadań, to zły dzień dla wszystkich.
Podsumowanie#
Cachowanie z Redisem nie jest trudne, ale łatwo jest to zrobić źle. Zacznij od cache-aside, dodaj TTL jitter od pierwszego dnia, monitoruj współczynnik trafień i opieraj się pokusie cachowania wszystkiego.
Najlepsza strategia cachowania to taka, nad którą możesz logicznie rozumować o 3 w nocy, gdy coś się psuje. Trzymaj to prosto, trzymaj to obserwowalne i pamiętaj, że każda zcachowana wartość to kłamstwo, które powiedziałeś swoim użytkownikom o stanie twoich danych — twoim zadaniem jest utrzymywać to kłamstwo tak małe i krótkotrwałe, jak to możliwe.