Gå till innehåll
·31 min läsning

Redis-cachningstrategier som faktiskt fungerar i produktion

Cache-aside, write-through, förebyggande av cache stampede, TTL-strategier och invalideringsmönster. Redis-mönstren jag använt i Node.js-produktionsappar med riktiga kodexempel.

Dela:X / TwitterLinkedIn

Alla säger att du ska "bara lägga till Redis" när ditt API är långsamt. Ingen berättar vad som händer sex månader senare när din cache serverar inaktuella data, din invalideringslogik är utspridd över 40 filer, och en deploy orsakar en cache stampede som tar ner din databas hårdare än om du aldrig hade cachat överhuvudtaget.

Jag har kört Redis i produktion i åratal. Inte som en leksak, inte i en tutorial — i system som hanterar riktig trafik där felaktig cachning innebär larm klockan tre på natten. Det som följer är allt jag lärt mig om att göra det rätt.

Varför cacha?#

Låt oss börja med det uppenbara: databaser är långsamma jämfört med minne. En PostgreSQL-fråga som tar 15ms är snabb enligt databasstandarder. Men om den frågan körs på varje enskild API-förfrågan, och du hanterar 1 000 förfrågningar per sekund, är det 15 000ms kumulativ databastid per sekund. Din anslutningspool är uttömd. Din p99-latens går genom taket. Användarna stirrar på spinnare.

Redis serverar de flesta läsningar på under 1ms. Samma data, cachad, förvandlar en 15ms-operation till en 0.3ms-operation. Det är ingen mikrooptimering. Det är skillnaden mellan att behöva 4 databasreplikor och att behöva noll.

Men cachning är inte gratis. Det lägger till komplexitet, introducerar konsistensproblem och skapar en helt ny klass av felscenarier. Innan du cachar något, fråga dig själv:

När cachning hjälper:

  • Data läses mycket oftare än det skrivs (10:1-förhållande eller högre)
  • Den underliggande frågan är dyr (joins, aggregeringar, externa API-anrop)
  • Viss inaktualitet är acceptabel (produktkatalog, användarprofiler, konfiguration)
  • Du har förutsägbara åtkomstmönster (samma nycklar träffas upprepade gånger)

När cachning skadar:

  • Data ändras konstant och måste vara färsk (realtidsaktiekurser, livescorer)
  • Varje förfrågan är unik (sökfrågor med många parametrar)
  • Ditt dataset är litet (om allt ryms i din apps minne, hoppa över Redis)
  • Du har inte den operativa mognaden att övervaka och felsöka cachningsproblem

Phil Karlton sa berömt att det bara finns två svåra saker inom datavetenskap: cacheinvalidering och att namnge saker. Han hade rätt om båda, men cacheinvalidering är den som väcker dig på natten.

Konfigurera ioredis#

Innan vi dyker ner i mönster, låt oss etablera anslutningen. Jag använder ioredis överallt — det är den mest mogna Redis-klienten för Node.js, med ordentligt TypeScript-stöd, klusterläge, Sentinel-stöd och Lua-skriptning.

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

Några saker värda att notera. lazyConnect: true innebär att anslutningen inte etableras förrän du faktiskt kör ett kommando, vilket är användbart under testning och initialisering. retryStrategy implementerar exponentiell backoff begränsad till 5 sekunder — utan detta spammar din app återanslutningsförsök vid ett Redis-avbrott. Och maxRetriesPerRequest: 3 säkerställer att individuella kommandon misslyckas snabbt istället för att hänga för evigt.

Cache-Aside-mönstret#

Det här är mönstret du kommer använda 80% av tiden. Det kallas också "lazy loading" eller "look-aside." Flödet är enkelt:

  1. Applikationen tar emot en förfrågan
  2. Kontrollera Redis efter det cachade värdet
  3. Om det finns (cache hit), returnera det
  4. Om det inte finns (cache miss), fråga databasen
  5. Lagra resultatet i Redis
  6. Returnera resultatet

Här är en typad implementation:

typescript
import redis from "./redis";
 
interface CacheOptions {
  ttl?: number;       // sekunder
  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}`;
 
  // Steg 1: Försök läsa från cache
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      return JSON.parse(cached) as T;
    } catch {
      // Korrupt cache-post, radera den och fortsätt
      await redis.del(cacheKey);
    }
  }
 
  // Steg 2: Cache miss — hämta från källa
  const result = await fetcher();
 
  // Steg 3: Lagra i cache (vänta inte — fire and forget)
  redis
    .set(cacheKey, JSON.stringify(result), "EX", ttl)
    .catch((err) => {
      console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
    });
 
  return result;
}

Användning ser ut så här:

typescript
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 minuter
  );
}

Notera att jag fire-and-forget redis.set-anropet. Det är avsiktligt. Om Redis är nere eller långsamt slutförs förfrågan ändå. Cachen är en optimering, inte ett krav. Om skrivning till cache misslyckas träffar nästa förfrågan bara databasen igen. Ingen stor sak.

Det finns en subtil bugg i många cache-aside-implementationer som folk missar: cachning av null-värden. Om en användare inte existerar och du inte cachar det faktum, träffar varje förfrågan för den användaren databasen. En angripare kan utnyttja detta genom att begära slumpmässiga användar-ID:n, vilket förvandlar din cache till en no-op. Cacha alltid det negativa resultatet också — bara med en kortare TTL.

typescript
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;
    },
    {
      // Kortare TTL för null-resultat för att begränsa minnesanvändning
      // men tillräckligt lång för att absorbera upprepade missar
      ttl: row ? 1800 : 300,
    }
  );
}

Egentligen, låt mig omstrukturera det för att få den dynamiska TTL:en att fungera korrekt:

typescript
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;
 
  // Cacha existerande resultat i 30 min, null-resultat i 5 min
  const ttl = user ? 1800 : 300;
  await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
 
  return user;
}

Write-Through och Write-Behind#

Cache-aside fungerar utmärkt för läsintensiva arbetsbelastningar, men det har ett konsistensproblem: om en annan tjänst eller process uppdaterar databasen direkt är din cache inaktuell tills TTL:en löper ut. Här kommer write-through- och write-behind-mönstren in.

Write-Through#

I write-through går varje skrivning genom cache-lagret. Cachen uppdateras först, sedan databasen. Detta garanterar att cachen alltid är konsistent med databasen (förutsatt att skrivningar alltid går genom din applikation).

typescript
async function updateUser(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  // Steg 1: Uppdatera databasen
  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];
 
  // Steg 2: Uppdatera cachen omedelbart
  const cacheKey = `cache:user:${userId}`;
  await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
 
  return user;
}

Den avgörande skillnaden från cache-aside: vi skriver till cachen vid varje skrivning, inte bara vid läsningar. Det betyder att cachen alltid är varm för nyligen uppdaterad data.

Avvägningen: skrivlatensen ökar eftersom varje skrivning nu berör både databasen och Redis. Om Redis är långsamt blir dina skrivningar långsamma. I de flesta applikationer överträffar läsningar skrivningar med råge, så denna avvägning är värd det.

Write-Behind (Write-Back)#

Write-behind vänder på steken: skrivningar går till Redis först, och databasen uppdateras asynkront. Det ger extremt snabba skrivningar till kostnaden av potentiell dataförlust om Redis går ner innan datan har persisteras.

typescript
async function updateUserWriteBehind(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const cacheKey = `cache:user:${userId}`;
 
  // Läs aktuellt tillstånd
  const current = await redis.get(cacheKey);
  const user = current ? JSON.parse(current) as User : null;
  if (!user) throw new Error("User not in cache");
 
  // Uppdatera cache omedelbart
  const updated = { ...user, ...updates };
  await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
 
  // Köa databasskrivning för asynkron bearbetning
  await redis.rpush(
    "write_behind:users",
    JSON.stringify({ userId, updates, timestamp: Date.now() })
  );
 
  return updated;
}

Du skulle sedan ha en separat worker som tömmer den kön:

typescript
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) {
        // Lägg tillbaka i kön vid misslyckande med antal omförsök
        console.error("[WriteBehind] Failed:", err);
        await redis.rpush("write_behind:users:dlq", item[1]);
      }
    }
  }
}

Jag använder sällan write-behind i praktiken. Risken för dataförlust är verklig — om Redis kraschar innan workern bearbetar kön är dessa skrivningar borta. Använd detta bara för data där eventuell konsistens genuint är acceptabel, som visningsräkningar, analyshändelser eller icke-kritiska användarpreferenser.

TTL-strategi#

Att få TTL rätt är mer nyanserat än det verkar. En fast 1-timmes TTL på allt är lätt att implementera och nästan alltid fel.

Datavolatilitetsnivåer#

Jag kategoriserar data i tre nivåer och tilldelar TTL:er därefter:

typescript
const TTL = {
  // Nivå 1: Ändras sällan, dyrt att beräkna
  // Exempel: produktkatalog, webbplatskonfiguration, feature flags
  STATIC: 86400,       // 24 timmar
 
  // Nivå 2: Ändras ibland, måttlig kostnad
  // Exempel: användarprofiler, teaminställningar, behörigheter
  MODERATE: 1800,      // 30 minuter
 
  // Nivå 3: Ändras ofta, billigt att beräkna men anropas ofta
  // Exempel: flödesdata, notifieringsräknare, sessionsinformation
  VOLATILE: 300,       // 5 minuter
 
  // Nivå 4: Efemärt, används för hastighetsbegränsning och lås
  EPHEMERAL: 60,       // 1 minut
 
  // Null-resultat: alltid kortlivade
  NOT_FOUND: 120,      // 2 minuter
} as const;

TTL-jitter: Förebyggande av åskvädersflocken#

Här är ett scenario som har bitit mig: du deployar din app, cachen är tom, och 10 000 förfrågningar cachar alla samma data med en 1-timmes TTL. En timme senare löper alla 10 000 nycklar ut samtidigt. Alla 10 000 förfrågningar träffar databasen på en gång. Databasen kvävs. Jag har sett detta ta ner en PostgreSQL-produktionsinstans.

Lösningen är jitter — att lägga till slumpmässighet i TTL-värden:

typescript
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));
}
 
// Istället för: redis.set(key, value, "EX", 3600)
// Använd:      redis.set(key, value, "EX", ttlWithJitter(3600))
 
// 3600 ± 10% = slumpmässigt värde mellan 3240 och 3960

Detta sprider utgången över ett fönster, så istället för att 10 000 nycklar löper ut samma sekund, löper de ut under ett 12-minutersfönster. Databasen ser en gradvis ökning av trafik, inte en klippa.

För kritiska vägar går jag längre och använder 20% jitter:

typescript
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 sekunder

Glidande utgång#

För sessionsliknande data där TTL:en ska återställas vid varje åtkomst, använd GETEX (Redis 6.2+):

typescript
async function getWithSlidingExpiry<T>(
  key: string,
  ttl: number
): Promise<T | null> {
  // GETEX hämtar atomärt värdet OCH återställer TTL:en
  const value = await redis.getex(key, "EX", ttl);
  if (value === null) return null;
  return JSON.parse(value) as T;
}

Om du kör en äldre Redis-version, använd en pipeline:

typescript
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 (åskvädersflocken)#

TTL-jitter hjälper med massutgång, men det löser inte stampeden för en enskild nyckel: när en populär nyckel löper ut och hundratals samtidiga förfrågningar alla försöker regenerera den simultant.

Tänk dig att du cachar din hemsidas flöde med en 5-minuters TTL. Den löper ut. Femtio samtidiga förfrågningar ser cache-missen. Alla femtio träffar databasen med samma dyra fråga. Du har i praktiken DDoS:at dig själv.

Lösning 1: Mutex-lås#

Bara en förfrågan regenererar cachen. Alla andra väntar.

typescript
async function cacheAsideWithMutex<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T | null> {
  const cacheKey = `cache:${key}`;
  const lockKey = `lock:${key}`;
 
  // Försök cache först
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as T;
  }
 
  // Försök skaffa lås (NX = bara om det inte finns, EX = auto-utgång)
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (acquired) {
    try {
      // Vi fick låset — hämta och cacha
      const result = await fetcher();
      await redis.set(
        cacheKey,
        JSON.stringify(result),
        "EX",
        ttlWithJitter(ttl)
      );
      return result;
    } finally {
      // Släpp lås
      await redis.del(lockKey);
    }
  }
 
  // En annan förfrågan håller låset — vänta och försök igen
  await sleep(100);
 
  const retried = await redis.get(cacheKey);
  if (retried !== null) {
    return JSON.parse(retried) as T;
  }
 
  // Fortfarande ingen cache — faller igenom till databasen
  // (detta hanterar fallet där låshållaren misslyckades)
  return fetcher();
}
 
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Det finns en subtil race condition i låssläppet ovan. Om låshållaren tar längre tid än 10 sekunder (låsets TTL), får en annan förfrågan låset, och sedan raderar den första förfrågan den andra förfrågans lås. Den korrekta lösningen är att använda en unik token:

typescript
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-skript säkerställer atomär kontrollera-och-radera
  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;
}

Detta är i grunden ett förenklat Redlock. För enstaka Redis-instanser räcker det. För Redis Cluster- eller Sentinel-uppsättningar, titta på hela Redlock-algoritmen — men ärligt talat, för cachestampedeförebyggande fungerar denna enkla version utmärkt.

Lösning 2: Probabilistisk tidig utgång#

Det här är mitt favoritangreppssätt. Istället för att vänta på att nyckeln ska löpa ut, regenerera den slumpmässigt strax före utgången. Idén kommer från ett papper av Vattani, Chierichetti och Lowenstein.

typescript
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-algoritm: regenerera probabilistiskt när utgången närmar sig
    // beta * Math.log(Math.random()) producerar ett negativt tal
    // som växer sig större (mer negativt) när utgången närmar sig
    const beta = 1; // justeringsparameter, 1 fungerar bra
    const shouldRegenerate =
      remaining - beta * Math.log(Math.random()) * -1 <= 0;
 
    if (!shouldRegenerate) {
      return entry.data;
    }
 
    // Faller igenom för att regenerera
    console.log(`[Cache] Early regeneration triggered for ${key}`);
  }
 
  const data = await fetcher();
  const entry: CachedValue<T> = {
    data,
    cachedAt: Date.now(),
    ttl,
  };
 
  // Sätt med extra buffert så Redis inte löper ut innan vi kan regenerera
  await redis.set(
    cacheKey,
    JSON.stringify(entry),
    "EX",
    Math.round(ttl * 1.1)
  );
 
  return data;
}

Det fina med detta angreppssätt: när nyckelns återstående TTL minskar, ökar sannolikheten för regenerering. Med 1 000 samtidiga förfrågningar kanske en eller två utlöser regenerering medan resten fortsätter servera cachad data. Inga lås, ingen koordinering, ingen väntan.

Lösning 3: Stale-While-Revalidate#

Servera det inaktuella värdet medan du regenererar i bakgrunden. Detta ger den bästa latensen eftersom ingen förfrågan någonsin väntar på hämtaren.

typescript
async function staleWhileRevalidate<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    freshTtl: number;   // hur länge datan är "färsk"
    staleTtl: number;   // hur länge inaktuell data kan serveras
  }
): 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) {
      // Datan är inaktuell — servera den men utlös bakgrundsuppdatering
      revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
    }
 
    return JSON.parse(cached) as T;
  }
 
  // Komplett cache-miss — måste hämta synkront
  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 {
  // Använd ett lås för att förhindra flera bakgrundsuppdateringar
  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);
    });
}

Användning:

typescript
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
  freshTtl: 300,     // 5 minuter färsk
  staleTtl: 3600,    // servera inaktuellt i upp till 1 timme medan revalidering pågår
});

Jag använder detta mönster för allt användarriktat där latens spelar mer roll än absolut färskhet. Dashboarddata, profilsidor, produktlistningar — alla perfekta kandidater.

Cacheinvalidering#

Phil Karlton skämtade inte. Invalidering är där cachning går från "enkel optimering" till "distribuerat systemproblem."

Enkel nyckelbaserad invalidering#

Det enklaste fallet: när du uppdaterar en användare, radera deras cachenyckel.

typescript
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]
  );
 
  // Invalidera cachen
  await redis.del(`cache:user:${userId}`);
 
  return user[0];
}

Detta fungerar tills användardata dyker upp i andra cachade resultat. Kanske är den inbäddad i en teammedlemslistning. Kanske är den i ett sökresultat. Kanske är den i 14 olika cachade API-svar. Nu måste du spåra vilka cachenycklar som innehåller vilka entiteter.

Taggbaserad invalidering#

Tagga dina cacheposter med entiteterna de innehåller, sedan invalidera efter tagg.

typescript
async function setWithTags<T>(
  key: string,
  value: T,
  ttl: number,
  tags: string[]
): Promise<void> {
  const pipeline = redis.pipeline();
 
  // Lagra värdet
  pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
 
  // Lägg till nyckeln i varje taggs uppsättning
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, `cache:${key}`);
    pipeline.expire(`tag:${tag}`, ttl + 3600); // Tagguppsättningar lever längre än värden
  }
 
  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;
}

Användning:

typescript
// När du cachar teamdata, tagga den med alla medlems-ID:n
const team = await fetchTeam(teamId);
await setWithTags(
  `team:${teamId}`,
  team,
  1800,
  [
    `entity:team:${teamId}`,
    ...team.members.map((m) => `entity:user:${m.id}`),
  ]
);
 
// När användare 42 uppdaterar sin profil, invalidera allt som innehåller dem
await invalidateByTag("entity:user:42");

Händelsedriven invalidering#

För större system, använd Redis Pub/Sub för att sända invalideringshändelser:

typescript
// Publicerare (i din API-tjänst)
async function publishInvalidation(
  entityType: string,
  entityId: string
): Promise<void> {
  await redis.publish(
    "cache:invalidate",
    JSON.stringify({ entityType, entityId, timestamp: Date.now() })
  );
}
 
// Prenumerant (i varje appinstans)
const subscriber = new Redis(/* samma konfiguration */);
 
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}`);
});

Detta är kritiskt i flerinstansdistributioner. Om du har 4 appservrar bakom en lastbalanserare måste en invalidering på server 1 spridas till alla servrar. Pub/Sub hanterar detta automatiskt.

Mönsterbaserad invalidering (försiktigt)#

Ibland behöver du invalidera alla nycklar som matchar ett mönster. Använd aldrig KEYS i produktion. Det blockerar Redis-servern medan hela nyckelrymden skannas. Med miljontals nycklar kan detta blockera i sekunder — en evighet i Redis-termer.

Använd SCAN istället:

typescript
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;
}
 
// Invalidera all cachad data för ett specifikt team
await invalidateByPattern("cache:team:42:*");

SCAN itererar inkrementellt — det blockerar aldrig servern. COUNT-hinten föreslår hur många nycklar som ska returneras per iteration (det är en hint, inte en garanti). För stora nyckelrymder är detta det enda säkra tillvägagångssättet.

Med det sagt är mönsterbaserad invalidering en kodlukt. Om du skannar ofta, designa om din nyckelstruktur eller använd taggar. SCAN är O(N) över nyckelrymden och är avsett för underhållsoperationer, inte heta vägar.

Datastrukturer bortom strängar#

De flesta utvecklare behandlar Redis som en nyckel-värde-lagring för JSON-strängar. Det är som att köpa en schweizisk armékniv och bara använda flasköppnaren. Redis har rika datastrukturer, och att välja rätt kan eliminera hela kategorier av komplexitet.

Hashar för objekt#

Istället för att serialisera ett helt objekt som JSON, lagra det som en Redis Hash. Detta låter dig läsa och uppdatera individuella fält utan att deserialisera hela objektet.

typescript
// Lagra användare som en 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);
}
 
// Läs specifika fält
async function getUserPlan(userId: string): Promise<string | null> {
  return redis.hget(`user:${userId}`, "plan");
}
 
// Uppdatera ett enskilt fält
async function upgradeUserPlan(
  userId: string,
  plan: string
): Promise<void> {
  await redis.hset(`user:${userId}`, "plan", plan);
}
 
// Läs hela hashen som objekt
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"],
  };
}

Hashar är minneseffektiva för små objekt (Redis använder en kompakt ziplist-kodning under huven) och undviker serialisering/deserialisering-overhead. Avvägningen: du förlorar möjligheten att lagra nästlade objekt utan att platta ut dem först.

Sorterade mängder för topplistor och hastighetsbegränsning#

Sorterade mängder är Redis mest underuppskattade datastruktur. Varje medlem har en poäng, och mängden är alltid sorterad efter poäng. Det gör dem perfekta för topplistor, rankning och glidande fönster-hastighetsbegränsning.

typescript
// Topplista
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-indexerat till 1-indexerat
}

För glidande fönster-hastighetsbegränsning:

typescript
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();
 
  // Ta bort poster utanför fönstret
  pipeline.zremrangebyscore(key, 0, windowStart);
 
  // Lägg till aktuell förfrågan
  pipeline.zadd(key, now, `${now}:${Math.random()}`);
 
  // Räkna förfrågningar i fönstret
  pipeline.zcard(key);
 
  // Sätt utgång på hela nyckeln
  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),
  };
}

Detta är mer exakt än tillvägagångssättet med fast fönsterräknare och lider inte av gränsproblematiken där en skur i slutet av ett fönster och början av nästa effektivt fördubblar din hastighetsgräns.

Listor för köer#

Redis-listor med LPUSH/BRPOP utgör utmärkta lätta jobbköer:

typescript
interface Job {
  id: string;
  type: string;
  payload: Record<string, unknown>;
  createdAt: number;
}
 
// Producent
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;
}
 
// Konsument (blockerar tills ett jobb är tillgängligt)
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;
}

För allt mer komplext än enkel köhantering (omförsök, dead letter-köer, prioritet, fördröjda jobb), använd BullMQ som bygger på Redis men hanterar alla specialfall.

Mängder för unik spårning#

Behöver du spåra unika besökare, deduplicera händelser eller kontrollera medlemskap? Mängder är O(1) för tillägg, borttagning och medlemskapskontroller.

typescript
// Spåra unika besökare per dag
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-utgång efter 48 timmar
  await redis.expire(key, 172800);
 
  return isNew === 1; // 1 = ny medlem, 0 = fanns redan
}
 
// Hämta antal unika besökare
async function getUniqueVisitors(page: string, date: string): Promise<number> {
  return redis.scard(`visitors:${page}:${date}`);
}
 
// Kontrollera om användare redan har utfört en åtgärd
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
  return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}

För mycket stora mängder (miljontals medlemmar), överväg HyperLogLog istället. Det använder bara 12KB minne oavsett kardinalitet, till kostnaden av ~0.81% standardfel:

typescript
// HyperLogLog för ungefärliga unika räkningar
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}`);
}

Serialisering: JSON vs MessagePack#

JSON är standardvalet för Redis-serialisering. Det är läsbart, universellt och tillräckligt bra för de flesta fall. Men för system med hög genomströmning ackumuleras serialiserings-/deserialiseringsoverhead.

Problemet med JSON#

typescript
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 på en het väg: ~0.02ms per anrop
// Vid 10 000 förfrågningar/sek: 200ms total CPU-tid per sekund

MessagePack-alternativet#

MessagePack är ett binärt serialiseringsformat som är mindre och snabbare än JSON:

bash
npm install msgpackr
typescript
import { pack, unpack } from "msgpackr";
 
// MessagePack: ~140 bytes (25% mindre)
const packed = pack(user);
console.log(packed.length); // ~140
 
// Lagra som Buffer
await redis.set("user:123", packed);
 
// Läs som Buffer
const raw = await redis.getBuffer("user:123");
if (raw) {
  const data = unpack(raw);
}

Notera användningen av getBuffer istället för get — detta är kritiskt. get returnerar en sträng och skulle korrumpera binär data.

Komprimering för stora värden#

För stora cachade värden (API-svar med hundratals poster, renderad HTML), lägg till komprimering:

typescript
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);
 
  // Komprimera bara om större än 1KB (komprimeringens overhead är inte värt det för små värden)
  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 {
    // Försök dekomprimera först
    const decompressed = await gunzipAsync(raw);
    return JSON.parse(decompressed.toString()) as T;
  } catch {
    // Inte komprimerad, parsa som vanlig JSON
    return JSON.parse(raw.toString()) as T;
  }
}

I mina tester reducerar gzip-komprimering typiskt JSON-nyttolastens storlek med 70-85%. Ett 50KB API-svar blir 8KB. Detta spelar roll när du betalar för Redis-minne — mindre minne per nyckel innebär fler nycklar i samma instans.

Avvägningen: komprimering lägger till 1-3ms CPU-tid per operation. För de flesta applikationer är detta försumbart. För ultrasnabba vägar, hoppa över det.

Min rekommendation#

Använd JSON om inte profilering visar att det är en flaskhals. Läsbarheten och felsökbarheten hos JSON i Redis (du kan köra redis-cli GET key och faktiskt läsa värdet) överväger prestandavinsten av MessagePack för 95% av applikationerna. Lägg bara till komprimering för värden större än 1KB.

Redis i Next.js#

Next.js har sin egen cachningshistoria (Data Cache, Full Route Cache, etc.), men Redis fyller luckor som den inbyggda cachningen inte kan hantera — särskilt när du behöver dela cache mellan flera instanser eller bevara cache mellan deployer.

Cachning av API-ruttsvar#

typescript
// 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}`;
 
  // Kontrollera 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",
      },
    });
  }
 
  // Hämta från databas
  const products = await db.products.findMany({
    where: category !== "all" ? { category } : undefined,
    orderBy: { createdAt: "desc" },
    take: 50,
  });
 
  // Cacha i 5 minuter med 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",
    },
  });
}

X-Cache-headern är ovärderlig för felsökning. När latensen stiger berättar en snabb curl -I om cachen fungerar.

Sessionslagring#

Next.js med Redis för sessioner slår JWT för tillståndsbaserade applikationer:

typescript
// 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 timmar
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}`;
 
  // Använd GETEX för att uppdatera TTL vid varje åtkomst (glidande utgång)
  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}`);
}
 
// Förstör alla sessioner för en användare (användbart för "logga ut överallt")
export async function destroyAllUserSessions(
  userId: string
): Promise<void> {
  // Detta kräver att man underhåller ett användare->sessioner-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();
  }
}

Hastighetsbegränsning i middleware#

typescript
// middleware.ts (eller en hjälpfunktion som används av 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-skript för atomär hastighetsbegränsning
  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-skriptet är viktigt här. Utan det är ZREMRANGEBYSCORE + ZADD + ZCARD-sekvensen inte atomär, och under hög samtidighet kan räkningen bli felaktig. Lua-skript körs atomärt i Redis — de kan inte interfolieras med andra kommandon.

Distribuerade lås för Next.js#

När du har flera Next.js-instanser och behöver säkerställa att bara en bearbetar en uppgift (som att skicka ett schemalagt e-post eller köra ett rensningsjobb):

typescript
// 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}`;
 
  // Försök skaffa lås
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
 
    if (acquired) {
      try {
        // Förläng lås automatiskt för långvariga uppgifter
        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 {
        // Släpp lås bara om vi fortfarande äger det
        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);
      }
    }
 
    // Vänta innan omförsök
    await new Promise((r) => setTimeout(r, retryDelay));
  }
 
  // Kunde inte skaffa lås efter alla omförsök
  return null;
}

Användning:

typescript
// I en cron-utlöst API-rutt
export async function POST() {
  const result = await withLock("daily-report", async () => {
    // Bara en instans kör detta
    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 });
}

Låsförlängningsintervallet på ttl/3 är viktigt. Utan det, om din uppgift tar längre tid än låsets TTL, löper låset ut och en annan instans tar det. Förlängaren håller låset levande så länge uppgiften körs.

Övervakning och felsökning#

Redis är snabbt tills det inte är det. När problem uppstår behöver du insyn.

Cacheträffkvot#

Det enskilt viktigaste måttet. Spåra det i din applikation:

typescript
// 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,
  };
}
 
// Återställ mätningar dagligen
export async function resetCacheStats(): Promise<void> {
  await redis.del(METRICS_KEY);
}

En hälsosam cacheträffkvot ligger över 90%. Om du är under 80% är antingen dina TTL:er för korta, dina cachenycklar för specifika, eller dina åtkomstmönster mer slumpmässiga än du trodde.

INFO-kommandot#

INFO-kommandot är Redis inbyggda hälsoinstrumentpanel:

bash
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

Viktiga mått att övervaka:

  • used_memory vs maxmemory: Närmar du dig gränsen?
  • mem_fragmentation_ratio: Över 1.5 innebär att Redis använder betydligt mer RSS än logiskt minne. Överväg en omstart.
  • evicted_keys: Om detta inte är noll och du inte avsåg eviction, har du slut på minne.
bash
redis-cli INFO stats

Titta efter:

  • keyspace_hits / keyspace_misses: Servernivå-träffkvot
  • total_commands_processed: Genomströmning
  • instantaneous_ops_per_sec: Aktuell genomströmning

MONITOR (använd med extrem försiktighet)#

MONITOR strömmar varje kommando som körs på Redis-servern i realtid. Det är otroligt användbart för felsökning och otroligt farligt i produktion.

bash
# Lämna ALDRIG detta igång i produktion
# Det lägger till betydande overhead och kan logga känslig data
redis-cli MONITOR
1614556800.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"

Jag använder MONITOR för exakt två saker: felsökning av nyckelnamnsproblem under utveckling och verifiering av att en specifik kodväg träffar Redis som förväntat. Aldrig i mer än 30 sekunder. Aldrig i produktion om du inte redan har uttömt andra felsökningsalternativ.

Nyckelrymdnotifieringar#

Vill du veta när nycklar löper ut eller raderas? Redis kan publicera händelser:

bash
# Aktivera nyckelrymdnotifieringar för utgångna och evictade händelser
redis-cli CONFIG SET notify-keyspace-events Ex
typescript
const subscriber = new Redis(/* konfiguration */);
 
// Lyssna efter nyckelutgångshändelser
subscriber.subscribe("__keyevent@0__:expired", (err) => {
  if (err) console.error("Subscribe error:", err);
});
 
subscriber.on("message", (_channel, expiredKey) => {
  console.log(`Key expired: ${expiredKey}`);
 
  // Proaktivt regenerera viktiga nycklar
  if (expiredKey.startsWith("cache:homepage")) {
    regenerateHomepageCache().catch(console.error);
  }
});

Detta är användbart för proaktiv cache-uppvärmning — istället för att vänta på att en användare utlöser en cache-miss, regenererar du kritiska poster i samma ögonblick som de löper ut.

Minnesanalys#

När Redis-minnet växer oväntat behöver du hitta vilka nycklar som förbrukar mest:

bash
# Sampla 10 största nycklar
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

För mer detaljerad analys:

bash
# Minnesanvändning för en specifik nyckel (i bytes)
redis-cli MEMORY USAGE "cache:search:electronics"
typescript
// Programmatisk minnesanalys
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");
 
  // Sortera efter storlek fallande
  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`);
  }
}

Evictionpolicyer#

Om din Redis-instans har en maxmemory-gräns (det borde den ha), konfigurera en evictionpolicy:

bash
# I redis.conf eller via CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lru

Tillgängliga policyer:

  • noeviction: Returnerar fel när minnet är fullt (standard, sämst för cachning)
  • allkeys-lru: Evicta minst nyligen använda nyckel (bästa generella valet för cachning)
  • allkeys-lfu: Evicta minst frekvent använda nyckel (bättre om vissa nycklar nås i skurar)
  • volatile-lru: Evicta bara nycklar med TTL satt (användbart om du blandar cache och persistent data)
  • allkeys-random: Slumpmässig eviction (förvånansvärt bra, ingen overhead)

För rena cachningsarbetsbelastningar är allkeys-lfu vanligtvis det bästa valet. Det behåller ofta använda nycklar i minnet även om de inte har nåtts nyligen.

Att sätta ihop allt: En produktionscachemodul#

Här är den kompletta cachemodulen jag använder i produktion, som kombinerar allt vi diskuterat:

typescript
// 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-nivåer
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));
}
 
// Kärnan: cache-aside med stampedeskydd
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}`;
 
  // Kontrollera 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();
 
  // Skaffa lås för att förhindra stampede
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (!acquired) {
    // En annan process hämtar — vänta kort och försök cache igen
    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);
 
    // Lagra taggassociationer
    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);
  }
}
 
// Invalidering
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;
}
 
// Mätningar
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,
};

Användning i hela applikationen:

typescript
import { cache } from "@/lib/cache";
 
// Enkel cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
  tier: "VOLATILE",
  tags: ["entity:products"],
});
 
// Med anpassad TTL
const config = await cache.get("app:config", fetchAppConfig, {
  ttl: 43200, // 12 timmar
});
 
// Efter uppdatering av en produkt
await cache.invalidateByTag("entity:products");
 
// Kontrollera hälsa
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);

Vanliga misstag jag gjort (så att du inte behöver)#

1. Att inte sätta maxmemory. Redis kommer gladeligen använda allt tillgängligt minne tills operativsystemet dödar det. Sätt alltid en gräns.

2. Att använda KEYS i produktion. Det blockerar servern. Använd SCAN. Jag lärde mig detta när ett KEYS *-anrop från ett övervakningsskript orsakade 3 sekunders driftstopp.

3. Att cacha för aggressivt. Inte allt behöver cachas. Om din databasfråga tar 2ms och anropas 10 gånger per minut, lägger cachning till komplexitet för försumbar nytta.

4. Att ignorera serialiseringskostnader. Jag cachade en gång en 2MB JSON-blob och undrade varför cache-läsningar var långsamma. Serialiseringsoverheaden var större än databasfrågan den var tänkt att spara.

5. Ingen graceful degradation. När Redis går ner ska din app fortfarande fungera — bara långsammare. Omslut varje cache-anrop i en try/catch som faller tillbaka till databasen. Låt aldrig ett cache-fel bli ett användarsynligt fel.

typescript
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(); // Kringgå cache helt
  }
}

6. Att inte övervaka evictions. Om Redis evictar nycklar är du antingen underdimensionerad eller cachar för mycket. Oavsett behöver du veta.

7. Att dela en Redis-instans mellan cachning och persistent data. Använd separata instanser (eller åtminstone separata databaser). En cache-evictionpolicy som raderar dina jobbköposter är en dålig dag för alla.

Avslutning#

Redis-cachning är inte svårt, men det är lätt att göra fel. Börja med cache-aside, lägg till TTL-jitter från dag ett, övervaka din träffkvot och motstå frestelsen att cacha allt.

Den bästa cachningsstrategin är den du kan resonera kring klockan tre på natten när något går sönder. Håll det enkelt, håll det observerbart, och kom ihåg att varje cachat värde är en lögn du berättat för dina användare om tillståndet på din data — ditt jobb är att hålla den lögnen så liten och kortlivad som möjligt.

Relaterade inlägg