Strategie cachování s Redis, které skutečně fungují v produkci
Cache-aside, write-through, prevence cache stampede, strategie TTL a invalidační vzory. Redis vzory, které jsem používal v produkčních Node.js aplikacích s reálnými příklady kódu.
Každý vám řekne, abyste „prostě přidali Redis", když je vaše API pomalé. Nikdo vám ale neřekne, co se stane o šest měsíců později, když váš cache servíruje zastaralá data, vaše invalidační logika je rozházená ve 40 souborech a deploy způsobí cache stampede, který položí vaši databázi ještě tvrději, než kdybyste nikdy necachovali vůbec.
Provozuji Redis v produkci už léta. Ne jako hračku, ne v tutoriálu — v systémech zpracovávajících reálný provoz, kde špatné cachování znamená pagery ve 3 ráno. Co následuje, je vše, co jsem se naučil o tom, jak to dělat správně.
Proč cachovat?#
Začněme tím zřejmým: databáze jsou pomalé ve srovnání s pamětí. PostgreSQL dotaz, který trvá 15ms, je rychlý podle databázových standardů. Ale pokud ten dotaz běží na každém jednotlivém API požadavku a vy zpracováváte 1 000 požadavků za sekundu, to je 15 000ms kumulativního databázového času za sekundu. Váš connection pool je vyčerpaný. Vaše p99 latence letí. Uživatelé zírají na spinnery.
Redis obslouží většinu čtení pod 1ms. Stejná data, uložená v cache, mění 15ms operaci na 0,3ms operaci. To není mikrooptimalizace. To je rozdíl mezi potřebou 4 databázových replik a potřebou nuly.
Ale cachování není zadarmo. Přidává složitost, zavádí problémy s konzistencí a vytváří zcela novou třídu selhání. Než začnete cokoli cachovat, položte si otázku:
Kdy cachování pomáhá:
- Data se čtou mnohem častěji, než se zapisují (poměr 10:1 nebo vyšší)
- Podkladový dotaz je drahý (joiny, agregace, volání externích API)
- Mírná zastaralost je přijatelná (produktový katalog, uživatelské profily, konfigurace)
- Máte předvídatelné přístupové vzory (stejné klíče se opakovaně dotazují)
Kdy cachování škodí:
- Data se neustále mění a musí být čerstvá (ceny akcií v reálném čase, živé skóre)
- Každý požadavek je unikátní (vyhledávací dotazy s mnoha parametry)
- Váš dataset je malý (pokud se celý vejde do paměti vaší aplikace, Redis přeskočte)
- Nemáte provozní vyspělost na to, abyste monitorovali a ladili problémy s cache
Phil Karlton slavně prohlásil, že v informatice existují jen dva těžké problémy: invalidace cache a pojmenovávání věcí. Měl pravdu v obou případech, ale invalidace cache je ta, která vás budí v noci.
Nastavení ioredis#
Než se ponoříme do vzorů, pojďme nastavit připojení. Používám ioredis všude — je to nejzralejší Redis klient pro Node.js, s řádnou podporou TypeScriptu, cluster režimem, Sentinel podporou a Lua skriptováním.
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;Pár věcí stojí za zmínku. lazyConnect: true znamená, že připojení se nenavazuje, dokud skutečně nespustíte příkaz, což je užitečné při testování a inicializaci. retryStrategy implementuje exponenciální backoff s limitem 5 sekund — bez toho by výpadek Redisu způsobil, že vaše aplikace spamuje pokusy o opětovné připojení. A maxRetriesPerRequest: 3 zajistí, že jednotlivé příkazy selžou rychle místo aby visely donekonečna.
Vzor Cache-Aside#
Toto je vzor, který budete používat v 80 % případů. Říká se mu také „lazy loading" nebo „look-aside". Postup je jednoduchý:
- Aplikace přijme požadavek
- Zkontroluje Redis na cachovanou hodnotu
- Pokud je nalezena (cache hit), vrátí ji
- Pokud není nalezena (cache miss), dotáže se databáze
- Uloží výsledek do Redisu
- Vrátí výsledek
Zde je typovaná implementace:
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;
}Použití vypadá takto:
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
);
}Všimněte si, že volání redis.set posílám jako fire-and-forget. To je záměrné. Pokud je Redis nedostupný nebo pomalý, požadavek se stejně dokončí. Cache je optimalizace, nikoli požadavek. Pokud zápis do cache selže, další požadavek jednoduše znovu zavolá databázi. Žádný problém.
V mnoha implementacích cache-aside je nenápadná chyba, kterou lidé přehlížejí: cachování null hodnot. Pokud uživatel neexistuje a tuto skutečnost necachujete, každý požadavek na tohoto uživatele zasáhne databázi. Útočník toho může zneužít požadováním náhodných uživatelských ID, čímž váš cache změní v noop. Vždy cachujte i negativní výsledek — jen s kratším 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,
}
);
}Vlastně, dovolte mi to přestrukturovat, aby dynamické TTL fungovalo správně:
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 a Write-Behind#
Cache-aside funguje skvěle pro zátěže s převahou čtení, ale má problém s konzistencí: pokud jiná služba nebo proces aktualizuje databázi přímo, váš cache je zastaralý, dokud nevyprší TTL. Vstupte do vzorů write-through a write-behind.
Write-Through#
U write-through každý zápis prochází vrstvou cache. Cache se aktualizuje nejdříve, pak databáze. To zaručuje, že cache je vždy konzistentní s databází (za předpokladu, že zápisy vždy procházejí vaší aplikací).
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;
}Klíčový rozdíl oproti cache-aside: do cache zapisujeme při každém zápisu, nejen při čtení. To znamená, že cache je vždy zahřátý pro nedávno aktualizovaná data.
Kompromis: latence zápisů se zvyšuje, protože každý zápis nyní zasáhne jak databázi, tak Redis. Pokud je Redis pomalý, vaše zápisy jsou pomalé. Ve většině aplikací čtení výrazně převyšují zápisy, takže tento kompromis stojí za to.
Write-Behind (Write-Back)#
Write-behind obrací scénář: zápisy jdou nejdříve do Redisu a databáze se aktualizuje asynchronně. To vám dává extrémně rychlé zápisy za cenu potenciální ztráty dat, pokud Redis spadne před persistencí dat.
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;
}Pak byste měli separátní worker, který tu frontu zpracovává:
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]);
}
}
}
}V praxi write-behind používám zřídka. Riziko ztráty dat je reálné — pokud Redis spadne předtím, než worker frontu zpracuje, ty zápisy jsou pryč. Používejte to pouze pro data, kde je eventuální konzistence skutečně přijatelná, jako počty zobrazení, analytické události nebo nekritické uživatelské preference.
Strategie TTL#
Správné nastavení TTL je nuancovanější, než se zdá. Pevné TTL 1 hodina na všechno je snadné implementovat a téměř vždy špatné.
Úrovně volatility dat#
Data kategorizuji do tří úrovní a podle toho přiřazuji TTL:
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: Prevence Thundering Herd#
Tady je scénář, který mě už kousl: nasadíte aplikaci, cache je prázdný a 10 000 požadavků všechny cachuje stejná data s 1hodinovým TTL. O hodinu později vyprší všech 10 000 klíčů současně. Všech 10 000 požadavků zasáhne databázi najednou. Databáze se dusí. Viděl jsem, jak to shodilo produkční Postgres instanci.
Řešením je jitter — přidání náhodnosti k hodnotám 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 rozloží expirace přes okno, takže místo 10 000 klíčů expirujících ve stejnou sekundu expirují přes 12minutové okno. Databáze vidí postupný nárůst provozu, ne útes.
Pro kritické cesty jdu dál a používám 20% jitter:
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 secondsKlouzavá expirace#
Pro data typu session, kde by se TTL mělo resetovat při každém přístupu, použijte 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;
}Pokud jste na starší verzi Redisu, použijte 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 pomáhá s hromadnou expirací, ale neřeší stampede jednoho klíče: když populární klíč vyprší a stovky souběžných požadavků se ho všechny pokusí regenerovat současně.
Představte si, že cachujete feed vaší homepage s 5minutovým TTL. Vyprší. Padesát souběžných požadavků vidí cache miss. Všech padesát zasáhne databázi se stejným drahým dotazem. Efektivně jste si udělali DDoS sami na sebe.
Řešení 1: Mutex Lock#
Pouze jeden požadavek regeneruje cache. Všichni ostatní čekají.
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));
}V uvolnění zámku výše je nenápadný race condition. Pokud držitel zámku trvá déle než 10 sekund (TTL zámku), jiný požadavek získá zámek, a pak první požadavek smaže zámek druhého požadavku. Správné řešení je použít unikátní token:
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;
}Toto je v podstatě zjednodušený Redlock. Pro jednoinstanční Redis to stačí. Pro Redis Cluster nebo Sentinel sestavení se podívejte na plný Redlock algoritmus — ale upřímně, pro prevenci cache stampede tato jednoduchá verze funguje dobře.
Řešení 2: Pravděpodobnostní brzká expirace#
Toto je můj oblíbený přístup. Místo čekání na expiraci klíče ho náhodně regenerujeme mírně před vypršením. Myšlenka pochází z článku od Vattaniho, Chierichettiho a 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;
}Krása tohoto přístupu: jak klesá zbývající TTL klíče, pravděpodobnost regenerace roste. Při 1 000 souběžných požadavcích třeba jeden nebo dva spustí regeneraci, zatímco zbytek pokračuje v servírování cachovaných dat. Žádné zámky, žádná koordinace, žádné čekání.
Řešení 3: Stale-While-Revalidate#
Servírujte zastaralou hodnotu, zatímco na pozadí regenerujete. To dává nejlepší latenci, protože žádný požadavek nikdy nečeká 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);
});
}Použití:
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
freshTtl: 300, // 5 minutes fresh
staleTtl: 3600, // serve stale for up to 1 hour while revalidating
});Tento vzor používám pro cokoli orientovaného na uživatele, kde na latenci záleží víc než na absolutní čerstvosti. Data dashboardu, profilové stránky, seznamy produktů — to vše jsou perfektní kandidáti.
Invalidace cache#
Phil Karlton nežertoval. Invalidace je místo, kde se cachování mění z „snadné optimalizace" na „problém distribuovaných systémů".
Jednoduchá invalidace na základě klíče#
Nejjednodušší případ: když aktualizujete uživatele, smažte jeho cache klíč.
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 funguje, dokud se uživatelská data neobjeví v jiných cachovaných výsledcích. Třeba jsou vložena v seznamu členů týmu. Třeba jsou ve výsledku vyhledávání. Třeba jsou ve 14 různých cachovaných API odpovědích. Teď potřebujete sledovat, které cache klíče obsahují které entity.
Invalidace na základě tagů#
Označte cache záznamy entitami, které obsahují, a pak invalidujte podle 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;
}Použití:
// 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");Událostmi řízená invalidace#
Pro větší systémy použijte Redis Pub/Sub k vysílání invalidačních událostí:
// 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}`);
});Toto je kritické v nasazeních s více instancemi. Pokud máte 4 aplikační servery za load balancerem, invalidace na serveru 1 se musí propagovat na všechny servery. Pub/Sub to řeší automaticky.
Invalidace na základě vzoru (opatrně)#
Někdy potřebujete invalidovat všechny klíče odpovídající vzoru. Nikdy nepoužívejte KEYS v produkci. Blokuje Redis server při skenování celého keyspace. S miliony klíčů to může blokovat sekundy — v Redisových pojmech věčnost.
Použijte místo toho SCAN:
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 inkrementálně — nikdy neblokuje server. Nápověda COUNT naznačuje, kolik klíčů vrátit na jednu iteraci (je to nápověda, ne záruka). Pro velké keyspace je to jediný bezpečný přístup.
To řečeno, invalidace na základě vzoru je code smell. Pokud se přistihnete, že skenujete často, přenavrhněte strukturu klíčů nebo použijte tagy. SCAN je O(N) přes keyspace a je určen pro údržbové operace, ne pro hot paths.
Datové struktury mimo řetězce#
Většina vývojářů zachází s Redisem jako s key-value úložištěm pro JSON řetězce. To je jako koupit si švýcarský nůž a používat jen otvírák na lahve. Redis má bohaté datové struktury a výběr té správné může eliminovat celé kategorie složitosti.
Hashe pro objekty#
Místo serializace celého objektu jako JSON ho uložte jako Redis Hash. To vám umožní číst a aktualizovat jednotlivá pole bez deserializace celé vě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 jsou paměťově efektivní pro malé objekty (Redis interně používá kompaktní ziplist kódování) a vyhýbají se overhead serializace/deserializace. Kompromis: ztrácíte možnost ukládat vnořené objekty bez jejich zploštění.
Sorted Sets pro žebříčky a rate limiting#
Sorted Sets jsou nejvíce nedoceněná datová struktura Redisu. Každý člen má skóre a množina je vždy seřazena podle skóre. To je dělá perfektními pro žebříčky, řazení a sliding window rate limiting.
// 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
}Pro sliding window rate limiting:
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 je přesnější než přístup s fixním oknem a netrpí hraničním problémem, kde nával na konci jednoho okna a začátku dalšího efektivně zdvojnásobí váš rate limit.
Seznamy pro fronty#
Redis Seznamy s LPUSH/BRPOP tvoří výborné odlehčené fronty úloh:
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;
}Pro cokoli složitějšího než základní řazení do fronty (opakování, dead letter fronty, priority, zpožděné úlohy) použijte BullMQ, který staví na Redisu, ale řeší všechny edge cases.
Množiny pro sledování unikátních hodnot#
Potřebujete sledovat unikátní návštěvníky, deduplikovat události nebo kontrolovat členství? Množiny jsou O(1) pro přidání, odebrání a kontrolu členství.
// 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;
}Pro velmi velké množiny (miliony členů) zvažte HyperLogLog místo toho. Používá pouze 12KB paměti bez ohledu na kardinalitu, za cenu ~0,81% standardní chyby:
// 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}`);
}Serializace: JSON vs MessagePack#
JSON je výchozí volba pro serializaci v Redisu. Je čitelný, univerzální a pro většinu případů dostatečný. Ale pro systémy s vysokou propustností se overhead serializace/deserializace sčítá.
Problém s 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 secondAlternativa MessagePack#
MessagePack je binární serializační formát, který je menší a rychlejší než 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);
}Všimněte si použití getBuffer místo get — to je kritické. get vrací řetězec a poškodilo by binární data.
Komprese pro velké hodnoty#
Pro velké cachované hodnoty (API odpovědi se stovkami položek, renderované HTML) přidejte kompresi:
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;
}
}V mém testování gzip komprese typicky zmenšuje velikost JSON payloadu o 70–85 %. 50KB API odpověď se změní na 8KB. To má význam, když platíte za Redis paměť — méně paměti na klíč znamená více klíčů ve stejné instanci.
Kompromis: komprese přidává 1–3ms CPU času na operaci. Pro většinu aplikací je to zanedbatelné. Pro ultra nízkolatencové cesty to přeskočte.
Moje doporučení#
Používejte JSON, pokud profilování neukáže, že je úzkým hrdlem. Čitelnost a laditelnost JSON v Redisu (můžete redis-cli GET key a hodnotu skutečně přečíst) převažuje nad výkonovým ziskem MessagePacku pro 95 % aplikací. Kompresi přidávejte pouze pro hodnoty větší než 1KB.
Redis v Next.js#
Next.js má svůj vlastní příběh cachování (Data Cache, Full Route Cache atd.), ale Redis vyplňuje mezery, které vestavěné cachování nezvládne — zejména když potřebujete sdílet cache mezi více instancemi nebo persistovat cache přes deploymenty.
Cachování odpovědí 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",
},
});
}Hlavička X-Cache je neocenitelná pro ladění. Když latence vyskočí, rychlý curl -I vám řekne, jestli cache funguje.
Úložiště sessions#
Next.js s Redisem pro sessions překonává JWT pro stavové aplikace:
// 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();
}
}Rate Limiting Middleware#
// 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,
};
}Lua skript je zde důležitý. Bez něj sekvence ZREMRANGEBYSCORE + ZADD + ZCARD není atomická a při vysoké souběžnosti by počet mohl být nepřesný. Lua skripty se v Redisu vykonávají atomicky — nemohou být prokládány s jinými příkazy.
Distribuované zámky pro Next.js#
Když máte více instancí Next.js a potřebujete zajistit, aby pouze jedna zpracovávala úlohu (jako odeslání plánovaného emailu nebo spuštění čisticí úlohy):
// 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;
}Použití:
// 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 });
}Interval prodlužování zámku na ttl/3 je důležitý. Bez něj, pokud vaše úloha trvá déle než TTL zámku, zámek vyprší a jiná instance ho převezme. Prodlužovač udržuje zámek aktivní, dokud úloha běží.
Monitoring a ladění#
Redis je rychlý, dokud není. Když nastanou problémy, potřebujete viditelnost.
Poměr zásahu cache#
Jediná nejdůležitější metrika. Sledujte ji ve vaší aplikaci:
// 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);
}Zdravý poměr zásahu cache je nad 90 %. Pokud jste pod 80 %, buď jsou vaše TTL příliš krátká, vaše cache klíče příliš specifické, nebo vaše přístupové vzory jsou náhodnější, než jste mysleli.
Příkaz INFO#
Příkaz INFO je vestavěný zdravotní dashboard Redisu:
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
Klíčové metriky ke sledování:
- used_memory vs maxmemory: Blížíte se k limitu?
- mem_fragmentation_ratio: Nad 1.5 znamená, že Redis používá výrazně více RSS než logické paměti. Zvažte restart.
- evicted_keys: Pokud je nenulové a nechtěli jste eviction, docházíte paměť.
redis-cli INFO statsSledujte:
- keyspace_hits / keyspace_misses: Poměr zásahů na úrovni serveru
- total_commands_processed: Propustnost
- instantaneous_ops_per_sec: Aktuální propustnost
MONITOR (používejte s maximální opatrností)#
MONITOR streamuje každý příkaz provedený na Redis serveru v reálném čase. Je neuvěřitelně užitečný pro ladění a neuvěřitelně nebezpečný v produkci.
# 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"
MONITOR používám přesně na dvě věci: ladění problémů s pojmenováním klíčů během vývoje a ověření, že konkrétní cesta kódu zasahuje Redis podle očekávání. Nikdy déle než 30 sekund. Nikdy v produkci, pokud jste nevyčerpali ostatní možnosti ladění.
Keyspace notifikace#
Chcete vědět, kdy klíče expirují nebo jsou smazány? Redis může publikovat události:
# 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 je užitečné pro proaktivní zahřívání cache — místo čekání, až uživatel vyvolá cache miss, regenerujete kritické záznamy v okamžiku, kdy expirují.
Analýza paměti#
Když paměť Redisu nečekaně roste, potřebujete zjistit, které klíče spotřebovávají nejvíce:
# 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
Pro podrobnější analýzu:
# 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`);
}
}Eviction politiky#
Pokud má vaše Redis instance maxmemory limit (měla by), nakonfigurujte eviction politiku:
# In redis.conf or via CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lruDostupné politiky:
- noeviction: Vrací chybu, když je paměť plná (výchozí, nejhorší pro cachování)
- allkeys-lru: Eviktuje nejméně nedávno použitý klíč (nejlepší obecná volba pro cachování)
- allkeys-lfu: Eviktuje nejméně často používaný klíč (lepší, pokud jsou některé klíče přistupovány ve vlnách)
- volatile-lru: Eviktuje pouze klíče s nastaveným TTL (užitečné, pokud mícháte cache a persistentní data)
- allkeys-random: Náhodná eviction (překvapivě slušné, bez overhead)
Pro čistě cachovací zátěže je allkeys-lfu obvykle nejlepší volba. Udržuje často přistupované klíče v paměti, i když nebyly nedávno použity.
Dáme to dohromady: produkční cache modul#
Zde je kompletní cache modul, který používám v produkci, kombinující vše, co jsme probírali:
// 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,
};Použití napříč aplikací:
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}`);Běžné chyby, které jsem udělal (abyste nemuseli vy)#
1. Nenastavení maxmemory. Redis bude vesele používat veškerou dostupnou paměť, dokud ho OS nezabije. Vždy nastavte limit.
2. Použití KEYS v produkci. Blokuje server. Používejte SCAN. Naučil jsem se to, když volání KEYS * z monitorovacího skriptu způsobilo 3 sekundy výpadku.
3. Příliš agresivní cachování. Ne všechno je potřeba cachovat. Pokud váš databázový dotaz trvá 2ms a je volaný 10krát za minutu, cachování přidává složitost pro zanedbatelný přínos.
4. Ignorování nákladů serializace. Jednou jsem cachoval 2MB JSON blob a divil se, proč jsou čtení z cache pomalá. Overhead serializace byl větší než databázový dotaz, který měl ušetřit.
5. Žádná elegantní degradace. Když Redis spadne, vaše aplikace by měla stále fungovat — jen pomaleji. Obalte každé volání cache do try/catch, které padne zpět na databázi. Nikdy nedovolte, aby selhání cache se stalo chybou viditelnou uživateli.
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. Nemonitorování evictions. Pokud Redis eviktuje klíče, buď máte nedostatečné zdroje, nebo cachujete příliš mnoho. V obou případech to potřebujete vědět.
7. Sdílení Redis instance mezi cachováním a persistentními daty. Používejte separátní instance (nebo alespoň separátní databáze). Eviction politika cache, která maže záznamy vaší fronty úloh, je špatný den pro všechny.
Shrnutí#
Cachování s Redisem není těžké, ale je snadné to udělat špatně. Začněte s cache-aside, přidejte TTL jitter od prvního dne, monitorujte poměr zásahů a odolejte nutkání cachovat vše.
Nejlepší strategie cachování je ta, o které dokážete přemýšlet ve 3 ráno, když se něco rozbije. Udržujte to jednoduché, udržujte to pozorovatelné a pamatujte, že každá cachovaná hodnota je lež, kterou jste řekli uživatelům o stavu vašich dat — vaším úkolem je udržet tu lež co nejmenší a nejkratší.