Ga naar inhoud
·32 min leestijd

Redis Cachingstrategieën Die Echt Werken in Productie

Cache-aside, write-through, cache stampede preventie, TTL-strategieën en invalidatiepatronen. De Redis-patronen die ik heb gebruikt in productie Node.js-apps met echte codevoorbeelden.

Delen:X / TwitterLinkedIn

Iedereen zegt "voeg gewoon Redis toe" wanneer je API traag is. Niemand vertelt je wat er zes maanden later gebeurt wanneer je cache verouderde data serveert, je invalidatielogica verspreid is over 40 bestanden, en een deploy een cache stampede veroorzaakt die je database harder neerlegt dan wanneer je helemaal niet gecacht had.

Ik draai Redis in productie al jaren. Niet als speelgoed, niet in een tutorial — in systemen die echt verkeer verwerken waar caching fout laten gaan betekent pager-alerts om 3 uur 's nachts. Wat volgt is alles wat ik heb geleerd over het goed doen.

Waarom Cachen?#

Laten we beginnen met het voor de hand liggende: databases zijn traag vergeleken met geheugen. Een PostgreSQL-query die 15ms duurt is snel naar databasemaatstaven. Maar als die query op elk API-verzoek draait, en je 1.000 verzoeken per seconde verwerkt, is dat 15.000ms cumulatieve databasetijd per seconde. Je connection pool is uitgeput. Je p99-latentie gaat door het dak. Gebruikers staren naar spinners.

Redis serveert de meeste reads in minder dan 1ms. Diezelfde data, gecacht, maakt van een operatie van 15ms een operatie van 0,3ms. Dat is geen micro-optimalisatie. Dat is het verschil tussen 4 database-replica's nodig hebben en nul.

Maar caching is niet gratis. Het voegt complexiteit toe, introduceert consistentieproblemen en creëert een geheel nieuwe klasse van faalscenario's. Voordat je iets cachet, stel jezelf de vraag:

Wanneer caching helpt:

  • Data wordt veel vaker gelezen dan geschreven (10:1 verhouding of hoger)
  • De onderliggende query is duur (joins, aggregaties, externe API-calls)
  • Lichte veroudering is acceptabel (productcatalogus, gebruikersprofielen, configuratie)
  • Je hebt voorspelbare toegangspatronen (dezelfde keys worden herhaaldelijk geraakt)

Wanneer caching pijn doet:

  • Data verandert voortdurend en moet vers zijn (realtime aandelenkoersen, live scores)
  • Elk verzoek is uniek (zoekopdrachten met veel parameters)
  • Je dataset is klein (als het geheel in het geheugen van je app past, sla Redis over)
  • Je hebt niet de operationele volwassenheid om cacheproblemen te monitoren en debuggen

Phil Karlton zei beroemd dat er maar twee moeilijke dingen in de informatica zijn: cache-invalidatie en het benoemen van dingen. Hij had gelijk over beide, maar cache-invalidatie is degene die je 's nachts wakker maakt.

ioredis Opzetten#

Voordat we in patronen duiken, laten we de verbinding opzetten. Ik gebruik overal ioredis — het is de meest volwassen Redis-client voor Node.js, met goede TypeScript-ondersteuning, cluster mode, Sentinel-ondersteuning en Lua-scripting.

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;

Een paar dingen die het vermelden waard zijn. lazyConnect: true betekent dat de verbinding pas wordt opgezet wanneer je daadwerkelijk een commando uitvoert, wat handig is tijdens testen en initialisatie. retryStrategy implementeert exponential backoff begrensd op 5 seconden — zonder dit zorgt een Redis-storing ervoor dat je app reconnectie-pogingen spamt. En maxRetriesPerRequest: 3 zorgt ervoor dat individuele commando's snel falen in plaats van eeuwig te hangen.

Cache-Aside Patroon#

Dit is het patroon dat je 80% van de tijd zult gebruiken. Het wordt ook wel "lazy loading" of "look-aside" genoemd. De flow is simpel:

  1. Applicatie ontvangt een verzoek
  2. Controleer Redis op de gecachte waarde
  3. Indien gevonden (cache hit), geef het terug
  4. Indien niet gevonden (cache miss), query de database
  5. Sla het resultaat op in Redis
  6. Geef het resultaat terug

Hier is een getypte implementatie:

typescript
import redis from "./redis";
 
interface CacheOptions {
  ttl?: number;       // seconden
  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}`;
 
  // Stap 1: Probeer uit cache te lezen
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      return JSON.parse(cached) as T;
    } catch {
      // Corrupte cache-entry, verwijder het en ga door
      await redis.del(cacheKey);
    }
  }
 
  // Stap 2: Cache miss — haal uit bron
  const result = await fetcher();
 
  // Stap 3: Sla op in cache (niet awaiten — fire and forget)
  redis
    .set(cacheKey, JSON.stringify(result), "EX", ttl)
    .catch((err) => {
      console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
    });
 
  return result;
}

Gebruik ziet er zo uit:

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 minuten
  );
}

Merk op dat ik de redis.set-aanroep fire-and-forget doe. Dit is met opzet. Als Redis down of traag is, wordt het verzoek nog steeds voltooid. De cache is een optimalisatie, geen vereiste. Als schrijven naar de cache faalt, raakt het volgende verzoek gewoon de database opnieuw. Geen ramp.

Er zit een subtiele bug in veel cache-aside implementaties die mensen missen: null-waarden cachen. Als een gebruiker niet bestaat en je dat feit niet cachet, raakt elk verzoek voor die gebruiker de database. Een aanvaller kan dit exploiteren door willekeurige gebruikers-ID's op te vragen, waardoor je cache een no-op wordt. Cache altijd ook het negatieve resultaat — maar met een kortere 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;
    },
    {
      // Kortere TTL voor null-resultaten om geheugengebruik te beperken
      // maar lang genoeg om herhaalde misses op te vangen
      ttl: row ? 1800 : 300,
    }
  );
}

Laat me dat eigenlijk herstructureren zodat de dynamische TTL goed werkt:

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

Write-Through en Write-Behind#

Cache-aside werkt prima voor read-heavy workloads, maar het heeft een consistentieprobleem: als een andere service of proces de database rechtstreeks bijwerkt, is je cache verouderd tot de TTL verloopt. Hier komen write-through en write-behind patronen.

Write-Through#

Bij write-through gaat elke schrijfoperatie via de cachelaag. De cache wordt eerst bijgewerkt, dan de database. Dit garandeert dat de cache altijd consistent is met de database (ervan uitgaande dat schrijfoperaties altijd via je applicatie gaan).

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

Het belangrijkste verschil met cache-aside: we schrijven naar de cache bij elke schrijfoperatie, niet alleen bij reads. Dit betekent dat de cache altijd warm is voor recent bijgewerkte data.

De trade-off: schrijflatentie neemt toe omdat elke schrijfoperatie nu zowel de database als Redis raakt. Als Redis traag is, zijn je writes traag. In de meeste applicaties zijn reads veel talrijker dan writes, dus deze trade-off is het waard.

Write-Behind (Write-Back)#

Write-behind draait het script om: writes gaan eerst naar Redis, en de database wordt asynchroon bijgewerkt. Dit geeft je extreem snelle writes ten koste van mogelijk dataverlies als Redis uitvalt voordat de data is opgeslagen.

typescript
async function updateUserWriteBehind(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const cacheKey = `cache:user:${userId}`;
 
  // Lees huidige staat
  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 direct
  const updated = { ...user, ...updates };
  await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
 
  // Zet database-write in de wachtrij voor async verwerking
  await redis.rpush(
    "write_behind:users",
    JSON.stringify({ userId, updates, timestamp: Date.now() })
  );
 
  return updated;
}

Je hebt dan een aparte worker die die queue leegtrekt:

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) {
        // Zet opnieuw in de wachtrij bij falen met retry-telling
        console.error("[WriteBehind] Failed:", err);
        await redis.rpush("write_behind:users:dlq", item[1]);
      }
    }
  }
}

Ik gebruik write-behind zelden in de praktijk. Het risico op dataverlies is reëel — als Redis crasht voordat de worker de queue verwerkt, zijn die writes weg. Gebruik dit alleen voor data waar eventual consistency daadwerkelijk acceptabel is, zoals weergavetellingen, analytics events of niet-kritieke gebruikersvoorkeuren.

TTL-Strategie#

TTL goed instellen is genuanceerder dan het lijkt. Een vaste TTL van 1 uur op alles is makkelijk te implementeren en bijna altijd fout.

Datavolatiliteitsniveaus#

Ik categoriseer data in drie niveaus en wijs TTL's dienovereenkomstig toe:

typescript
const TTL = {
  // Niveau 1: Verandert zelden, duur om te berekenen
  // Voorbeelden: productcatalogus, siteconfiguratie, feature flags
  STATIC: 86400,       // 24 uur
 
  // Niveau 2: Verandert af en toe, gemiddelde kosten
  // Voorbeelden: gebruikersprofielen, teaminstellingen, permissies
  MODERATE: 1800,      // 30 minuten
 
  // Niveau 3: Verandert vaak, goedkoop te berekenen maar vaak aangeroepen
  // Voorbeelden: feed-data, notificatietellingen, sessie-info
  VOLATILE: 300,       // 5 minuten
 
  // Niveau 4: Kortstondig, gebruikt voor rate limiting en locks
  EPHEMERAL: 60,       // 1 minuut
 
  // Null-resultaten: altijd kortstondig
  NOT_FOUND: 120,      // 2 minuten
} as const;

TTL Jitter: De Thundering Herd Voorkomen#

Hier is een scenario dat me heeft gebeten: je deployt je app, de cache is leeg, en 10.000 verzoeken cachen allemaal dezelfde data met een TTL van 1 uur. Een uur later verlopen alle 10.000 keys tegelijkertijd. Alle 10.000 verzoeken raken de database tegelijk. De database stikt erin. Ik heb dit een productie-Postgres-instantie zien neerleggen.

De oplossing is jitter — willekeurigheid toevoegen aan TTL-waarden:

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));
}
 
// In plaats van: redis.set(key, value, "EX", 3600)
// Gebruik:      redis.set(key, value, "EX", ttlWithJitter(3600))
 
// 3600 ± 10% = willekeurige waarde tussen 3240 en 3960

Dit spreidt de verlooptijd over een venster, zodat in plaats van 10.000 keys die op dezelfde seconde verlopen, ze verlopen over een venster van 12 minuten. De database ziet een geleidelijke toename in verkeer, geen klif.

Voor kritieke paden ga ik verder en gebruik 20% jitter:

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

Sliding Expiry#

Voor sessie-achtige data waarbij de TTL bij elke toegang gereset moet worden, gebruik GETEX (Redis 6.2+):

typescript
async function getWithSlidingExpiry<T>(
  key: string,
  ttl: number
): Promise<T | null> {
  // GETEX haalt atomisch de waarde op EN reset de TTL
  const value = await redis.getex(key, "EX", ttl);
  if (value === null) return null;
  return JSON.parse(value) as T;
}

Als je op een oudere Redis-versie zit, gebruik een 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 (Thundering Herd)#

TTL-jitter helpt bij massale verlooptijd, maar lost de single-key stampede niet op: wanneer een populaire key verloopt en honderden gelijktijdige verzoeken allemaal proberen hem tegelijk te regenereren.

Stel je voor dat je je homepage-feed cachet met een TTL van 5 minuten. Het verloopt. Vijftig gelijktijdige verzoeken zien de cache miss. Alle vijftig raken de database met dezelfde dure query. Je hebt effectief jezelf DDoS'd.

Oplossing 1: Mutex Lock#

Slechts één verzoek regenereert de cache. Alle anderen wachten.

typescript
async function cacheAsideWithMutex<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T | null> {
  const cacheKey = `cache:${key}`;
  const lockKey = `lock:${key}`;
 
  // Probeer cache eerst
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as T;
  }
 
  // Probeer lock te verkrijgen (NX = alleen als niet bestaat, EX = auto-expire)
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (acquired) {
    try {
      // Wij hebben de lock — haal op en cache
      const result = await fetcher();
      await redis.set(
        cacheKey,
        JSON.stringify(result),
        "EX",
        ttlWithJitter(ttl)
      );
      return result;
    } finally {
      // Geef lock vrij
      await redis.del(lockKey);
    }
  }
 
  // Een ander verzoek houdt de lock — wacht en probeer opnieuw
  await sleep(100);
 
  const retried = await redis.get(cacheKey);
  if (retried !== null) {
    return JSON.parse(retried) as T;
  }
 
  // Nog steeds geen cache — val terug naar database
  // (dit handelt het geval af waar de lockhouder faalde)
  return fetcher();
}
 
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Er zit een subtiele race condition in de lock-release hierboven. Als de lockhouder langer duurt dan 10 seconden (de lock-TTL), verkrijgt een ander verzoek de lock, en dan verwijdert het eerste verzoek de lock van het tweede verzoek. De juiste oplossing is om een uniek token te gebruiken:

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-script garandeert atomische 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;
}

Dit is in essentie een vereenvoudigde Redlock. Voor single-instance Redis is het voldoende. Voor Redis Cluster of Sentinel-opstellingen, kijk naar het volledige Redlock-algoritme — maar eerlijk gezegd, voor cache stampede preventie werkt deze simpele versie prima.

Oplossing 2: Probabilistische Vroege Verlooptijd#

Dit is mijn favoriete aanpak. In plaats van te wachten tot de key verloopt, regenereer hem willekeurig iets voor de verlooptijd. Het idee komt uit een paper van Vattani, Chierichetti en 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-algoritme: regenereer probabilistisch naarmate verlooptijd nadert
    // beta * Math.log(Math.random()) produceert een negatief getal
    // dat groter wordt (negatiever) naarmate verlooptijd nadert
    const beta = 1; // tuning parameter, 1 werkt goed
    const shouldRegenerate =
      remaining - beta * Math.log(Math.random()) * -1 <= 0;
 
    if (!shouldRegenerate) {
      return entry.data;
    }
 
    // Val door naar regeneratie
    console.log(`[Cache] Early regeneration triggered for ${key}`);
  }
 
  const data = await fetcher();
  const entry: CachedValue<T> = {
    data,
    cachedAt: Date.now(),
    ttl,
  };
 
  // Zet met extra buffer zodat Redis niet verloopt voordat we kunnen regenereren
  await redis.set(
    cacheKey,
    JSON.stringify(entry),
    "EX",
    Math.round(ttl * 1.1)
  );
 
  return data;
}

De schoonheid van deze aanpak: naarmate de resterende TTL van de key afneemt, neemt de kans op regeneratie toe. Met 1.000 gelijktijdige verzoeken zullen misschien een of twee regeneratie triggeren terwijl de rest gecachte data blijft serveren. Geen locks, geen coördinatie, geen wachten.

Oplossing 3: Stale-While-Revalidate#

Serveer de verouderde waarde terwijl je op de achtergrond regenereert. Dit geeft de beste latentie omdat geen enkel verzoek ooit wacht op de fetcher.

typescript
async function staleWhileRevalidate<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    freshTtl: number;   // hoe lang de data "vers" is
    staleTtl: number;   // hoe lang verouderde data geserveerd mag worden
  }
): 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 verouderd — serveer het maar trigger achtergrondverversing
      revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
    }
 
    return JSON.parse(cached) as T;
  }
 
  // Volledige cache miss — moet synchroon ophalen
  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 {
  // Gebruik een lock om meervoudige achtergrondverversingen te voorkomen
  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);
    });
}

Gebruik:

typescript
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
  freshTtl: 300,     // 5 minuten vers
  staleTtl: 3600,    // serveer verouderd tot 1 uur tijdens hervalidatie
});

Ik gebruik dit patroon voor alles wat gebruikersgericht is waar latentie meer uitmaakt dan absolute versheid. Dashboarddata, profielpagina's, productoverzichten — allemaal perfecte kandidaten.

Cache-Invalidatie#

Phil Karlton maakte geen grapje. Invalidatie is waar caching gaat van "makkelijke optimalisatie" naar "distributed systems probleem."

Simpele Key-Gebaseerde Invalidatie#

Het makkelijkste geval: wanneer je een gebruiker bijwerkt, verwijder hun cache-key.

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

Dit werkt totdat de gebruikersdata in andere gecachte resultaten verschijnt. Misschien zit het ingebed in een teamledenlijst. Misschien zit het in een zoekresultaat. Misschien zit het in 14 verschillende gecachte API-responses. Nu moet je bijhouden welke cache-keys welke entiteiten bevatten.

Tag-Gebaseerde Invalidatie#

Tag je cache-entries met de entiteiten die ze bevatten, en invalideer dan op tag.

typescript
async function setWithTags<T>(
  key: string,
  value: T,
  ttl: number,
  tags: string[]
): Promise<void> {
  const pipeline = redis.pipeline();
 
  // Sla de waarde op
  pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
 
  // Voeg de key toe aan elke tag-set
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, `cache:${key}`);
    pipeline.expire(`tag:${tag}`, ttl + 3600); // Tag-sets leven langer dan waarden
  }
 
  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;
}

Gebruik:

typescript
// Bij het cachen van teamdata, tag het met alle lid-ID's
const team = await fetchTeam(teamId);
await setWithTags(
  `team:${teamId}`,
  team,
  1800,
  [
    `entity:team:${teamId}`,
    ...team.members.map((m) => `entity:user:${m.id}`),
  ]
);
 
// Wanneer gebruiker 42 zijn profiel bijwerkt, invalideer alles dat hen bevat
await invalidateByTag("entity:user:42");

Event-Driven Invalidatie#

Voor grotere systemen, gebruik Redis Pub/Sub om invalidatie-events te broadcasten:

typescript
// Publisher (in je 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 elke app-instantie)
const subscriber = new Redis(/* zelfde 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}`);
});

Dit is cruciaal bij multi-instance deployments. Als je 4 app-servers achter een load balancer hebt, moet een invalidatie op server 1 naar alle servers worden doorgegeven. Pub/Sub handelt dit automatisch af.

Patroon-Gebaseerde Invalidatie (Voorzichtig)#

Soms moet je alle keys invalideren die aan een patroon voldoen. Gebruik nooit KEYS in productie. Het blokkeert de Redis-server terwijl het de hele keyspace scant. Met miljoenen keys kan dit seconden blokkeren — een eeuwigheid in Redis-termen.

Gebruik SCAN in plaats daarvan:

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;
}
 
// Invalideer alle gecachte data voor een specifiek team
await invalidateByPattern("cache:team:42:*");

SCAN itereert incrementeel — het blokkeert de server nooit. De COUNT-hint suggereert hoeveel keys per iteratie te retourneren (het is een hint, geen garantie). Voor grote keyspaces is dit de enige veilige aanpak.

Dat gezegd hebbende, patroon-gebaseerde invalidatie is een code smell. Als je merkt dat je vaak scant, herontwerp je key-structuur of gebruik tags. SCAN is O(N) over de keyspace en is bedoeld voor onderhoudsoperaties, niet voor hot paths.

Datastructuren Voorbij Strings#

De meeste developers behandelen Redis als een key-value store voor JSON strings. Dat is alsof je een Zwitsers zakmes koopt en alleen de flesopener gebruikt. Redis heeft rijke datastructuren, en de juiste kiezen kan hele categorieën complexiteit elimineren.

Hashes voor Objecten#

In plaats van een heel object als JSON te serialiseren, sla het op als een Redis Hash. Hierdoor kun je individuele velden lezen en bijwerken zonder het geheel te deserialiseren.

typescript
// Sla gebruiker op als 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);
}
 
// Lees specifieke velden
async function getUserPlan(userId: string): Promise<string | null> {
  return redis.hget(`user:${userId}`, "plan");
}
 
// Update een enkel veld
async function upgradeUserPlan(
  userId: string,
  plan: string
): Promise<void> {
  await redis.hset(`user:${userId}`, "plan", plan);
}
 
// Lees hele hash als 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"],
  };
}

Hashes zijn geheugenefficiënt voor kleine objecten (Redis gebruikt intern een compacte ziplist-encoding) en vermijden de serialize/deserialize overhead. De trade-off: je verliest de mogelijkheid om geneste objecten op te slaan zonder ze eerst plat te maken.

Sorted Sets voor Leaderboards en Rate Limiting#

Sorted Sets zijn Redis' meest ondergewaardeerde datastructuur. Elk lid heeft een score, en de set is altijd gesorteerd op score. Dit maakt ze perfect voor leaderboards, ranking en sliding window rate limiting.

typescript
// 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 naar 1-indexed
}

Voor sliding window rate limiting:

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();
 
  // Verwijder entries buiten het venster
  pipeline.zremrangebyscore(key, 0, windowStart);
 
  // Voeg huidig verzoek toe
  pipeline.zadd(key, now, `${now}:${Math.random()}`);
 
  // Tel verzoeken in venster
  pipeline.zcard(key);
 
  // Zet verlooptijd op de hele 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),
  };
}

Dit is nauwkeuriger dan de fixed-window counter-aanpak en heeft niet het grensgebiedprobleem waarbij een burst aan het einde van het ene venster en het begin van het volgende je rate limit effectief verdubbelt.

Lists voor Queues#

Redis Lists met LPUSH/BRPOP vormen uitstekende lichtgewicht job queues:

typescript
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 (blokkeert tot een job beschikbaar is)
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;
}

Voor alles complexer dan basale queuing (retries, dead letter queues, prioriteit, uitgestelde jobs), gebruik BullMQ dat op Redis bouwt maar alle edge cases afhandelt.

Sets voor Unieke Tracking#

Moet je unieke bezoekers bijhouden, events dedupliceren, of lidmaatschap controleren? Sets zijn O(1) voor toevoegen, verwijderen en lidmaatschapscontroles.

typescript
// Track unieke bezoekers 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);
 
  // Verloopt automatisch na 48 uur
  await redis.expire(key, 172800);
 
  return isNew === 1; // 1 = nieuw lid, 0 = bestond al
}
 
// Haal uniek bezoekersaantal op
async function getUniqueVisitors(page: string, date: string): Promise<number> {
  return redis.scard(`visitors:${page}:${date}`);
}
 
// Controleer of gebruiker al een actie heeft uitgevoerd
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
  return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}

Voor zeer grote sets (miljoenen leden), overweeg HyperLogLog in plaats daarvan. Het gebruikt slechts 12KB geheugen ongeacht de cardinaliteit, ten koste van ~0,81% standaardfout:

typescript
// HyperLogLog voor benaderde unieke tellingen
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}`);
}

Serialisatie: JSON vs MessagePack#

JSON is de standaardkeuze voor Redis-serialisatie. Het is leesbaar, universeel en goed genoeg voor de meeste gevallen. Maar voor high-throughput systemen telt de serialize/deserialize overhead op.

Het Probleem met 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 op een hot path: ~0.02ms per aanroep
// Bij 10.000 requests/sec: 200ms totale CPU-tijd per seconde

MessagePack Alternatief#

MessagePack is een binair serialisatieformaat dat kleiner en sneller is dan JSON:

bash
npm install msgpackr
typescript
import { pack, unpack } from "msgpackr";
 
// MessagePack: ~140 bytes (25% kleiner)
const packed = pack(user);
console.log(packed.length); // ~140
 
// Sla op als Buffer
await redis.set("user:123", packed);
 
// Lees als Buffer
const raw = await redis.getBuffer("user:123");
if (raw) {
  const data = unpack(raw);
}

Let op het gebruik van getBuffer in plaats van get — dit is cruciaal. get retourneert een string en zou binaire data corrumperen.

Compressie voor Grote Waarden#

Voor grote gecachte waarden (API-responses met honderden items, gerenderde HTML), voeg compressie toe:

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);
 
  // Comprimeer alleen als groter dan 1KB (compressie-overhead is niet de moeite voor kleine waarden)
  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 {
    // Probeer eerst te decomprimeren
    const decompressed = await gunzipAsync(raw);
    return JSON.parse(decompressed.toString()) as T;
  } catch {
    // Niet gecomprimeerd, parse als gewone JSON
    return JSON.parse(raw.toString()) as T;
  }
}

In mijn tests vermindert gzip-compressie de JSON-payloadgrootte doorgaans met 70-85%. Een API-response van 50KB wordt 8KB. Dit doet ertoe wanneer je betaalt voor Redis-geheugen — minder geheugen per key betekent meer keys in dezelfde instantie.

De trade-off: compressie voegt 1-3ms CPU-tijd toe per operatie. Voor de meeste applicaties is dit verwaarloosbaar. Voor ultra-lage-latentie paden, sla het over.

Mijn Aanbeveling#

Gebruik JSON tenzij profiling laat zien dat het een bottleneck is. De leesbaarheid en debugbaarheid van JSON in Redis (je kunt redis-cli GET key uitvoeren en de waarde daadwerkelijk lezen) weegt zwaarder dan de prestatieverbetering van MessagePack voor 95% van de applicaties. Voeg compressie alleen toe voor waarden groter dan 1KB.

Redis in Next.js#

Next.js heeft zijn eigen cachingverhaal (Data Cache, Full Route Cache, enz.), maar Redis vult gaten die de ingebouwde caching niet kan opvullen — vooral wanneer je cache moet delen tussen meerdere instanties of cache moet behouden over deploys heen.

API Route Responses Cachen#

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}`;
 
  // Controleer 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",
      },
    });
  }
 
  // Haal uit database
  const products = await db.products.findMany({
    where: category !== "all" ? { category } : undefined,
    orderBy: { createdAt: "desc" },
    take: 50,
  });
 
  // Cache voor 5 minuten met 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",
    },
  });
}

De X-Cache-header is onmisbaar voor debuggen. Bij latentiepieken vertelt een snelle curl -I je of de cache werkt.

Sessieopslag#

Next.js met Redis voor sessies is beter dan JWT voor stateful applicaties:

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 uur
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}`;
 
  // Gebruik GETEX om TTL te vernieuwen bij elke toegang (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}`);
}
 
// Vernietig alle sessies voor een gebruiker (handig voor "overal uitloggen")
export async function destroyAllUserSessions(
  userId: string
): Promise<void> {
  // Dit vereist het bijhouden van een gebruiker->sessies 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#

typescript
// middleware.ts (of een helper gebruikt door 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 voor atomische 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,
  };
}

Het Lua-script is hier belangrijk. Zonder zou de ZREMRANGEBYSCORE + ZADD + ZCARD-reeks niet atomisch zijn, en onder hoge concurrency kan de telling onnauwkeurig zijn. Lua-scripts worden atomisch uitgevoerd in Redis — ze kunnen niet worden afgewisseld met andere commando's.

Distributed Locks voor Next.js#

Wanneer je meerdere Next.js-instanties hebt en wilt garanderen dat slechts één een taak verwerkt (zoals het verzenden van een geplande e-mail of het uitvoeren van een opschoningstask):

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}`;
 
  // Probeer lock te verkrijgen
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
 
    if (acquired) {
      try {
        // Verleng lock automatisch voor langlopende taken
        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 {
        // Geef lock alleen vrij als wij het nog bezitten
        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);
      }
    }
 
    // Wacht voor opnieuw proberen
    await new Promise((r) => setTimeout(r, retryDelay));
  }
 
  // Kon lock niet verkrijgen na alle pogingen
  return null;
}

Gebruik:

typescript
// In een cron-getriggerde API route
export async function POST() {
  const result = await withLock("daily-report", async () => {
    // Slechts één instantie voert dit uit
    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 });
}

Het lock-verlengingsinterval op ttl/3 is belangrijk. Zonder dit, als je taak langer duurt dan de lock-TTL, verloopt de lock en pakt een andere instantie hem. De extender houdt de lock in leven zolang de taak draait.

Monitoring en Debugging#

Redis is snel totdat het dat niet meer is. Wanneer er problemen optreden, heb je zichtbaarheid nodig.

Cache Hit Ratio#

De belangrijkste metric. Volg het in je applicatie:

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,
  };
}
 
// Reset metrics dagelijks
export async function resetCacheStats(): Promise<void> {
  await redis.del(METRICS_KEY);
}

Een gezonde cache hit ratio is boven de 90%. Als je onder de 80% zit, zijn je TTL's ofwel te kort, je cache-keys te specifiek, of je toegangspatronen meer willekeurig dan je dacht.

INFO Commando#

Het INFO-commando is Redis' ingebouwde health dashboard:

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

Belangrijke metrics om te monitoren:

  • used_memory vs maxmemory: Nader je de limiet?
  • mem_fragmentation_ratio: Boven 1,5 betekent dat Redis significant meer RSS gebruikt dan logisch geheugen. Overweeg een herstart.
  • evicted_keys: Als dit niet-nul is en je geen eviction bedoelde, zit je zonder geheugen.
bash
redis-cli INFO stats

Let op:

  • keyspace_hits / keyspace_misses: Hit ratio op serverniveau
  • total_commands_processed: Throughput
  • instantaneous_ops_per_sec: Huidige throughput

MONITOR (Gebruik met Extreme Voorzichtigheid)#

MONITOR streamt elk commando dat op de Redis-server wordt uitgevoerd in realtime. Het is ongelooflijk nuttig voor debuggen en ongelooflijk gevaarlijk in productie.

bash
# NOOIT aan laten staan in productie
# Het voegt significante overhead toe en kan gevoelige data loggen
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"

Ik gebruik MONITOR voor precies twee dingen: debuggen van key-naamgevingsproblemen tijdens development, en verifiëren dat een specifiek codepad Redis raakt zoals verwacht. Nooit langer dan 30 seconden. Nooit in productie tenzij je andere debugopties al hebt uitgeput.

Keyspace Notifications#

Wil je weten wanneer keys verlopen of worden verwijderd? Redis kan events publiceren:

bash
# Schakel keyspace-notificaties in voor verlopen en evicted events
redis-cli CONFIG SET notify-keyspace-events Ex
typescript
const subscriber = new Redis(/* config */);
 
// Luister naar 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}`);
 
  // Regenereer belangrijke keys proactief
  if (expiredKey.startsWith("cache:homepage")) {
    regenerateHomepageCache().catch(console.error);
  }
});

Dit is nuttig voor proactieve cache warming — in plaats van te wachten tot een gebruiker een cache miss triggert, regenereer je kritieke entries op het moment dat ze verlopen.

Geheugenanalyse#

Wanneer Redis-geheugen onverwacht groeit, moet je achterhalen welke keys het meeste verbruiken:

bash
# Sample 10 grootste 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

Voor gedetailleerdere analyse:

bash
# Geheugengebruik van een specifieke key (in bytes)
redis-cli MEMORY USAGE "cache:search:electronics"
typescript
// Programmatische geheugenanalyse
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");
 
  // Sorteer op grootte aflopend
  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 Policies#

Als je Redis-instantie een maxmemory-limiet heeft (dat zou moeten), configureer dan een eviction policy:

bash
# In redis.conf of via CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lru

Beschikbare policies:

  • noeviction: Retourneert een fout wanneer geheugen vol is (standaard, slechtste voor caching)
  • allkeys-lru: Verwijder de minst recent gebruikte key (beste algemene keuze voor caching)
  • allkeys-lfu: Verwijder de minst frequent gebruikte key (beter als sommige keys in bursts worden benaderd)
  • volatile-lru: Verwijder alleen keys met een TTL (nuttig als je cache en persistente data mixt)
  • allkeys-random: Willekeurige verwijdering (verrassend degelijk, geen overhead)

Voor pure caching workloads is allkeys-lfu meestal de beste keuze. Het houdt vaak benaderde keys in het geheugen, zelfs als ze niet recent zijn benaderd.

Alles Samenvoegen: Een Productie Cache Module#

Hier is de complete cache-module die ik in productie gebruik, met alles wat we besproken hebben gecombineerd:

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-niveaus
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 met stampede-bescherming
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}`;
 
  // Controleer 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();
 
  // Verkrijg lock om stampede te voorkomen
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (!acquired) {
    // Een ander proces is aan het ophalen — wacht kort en probeer cache opnieuw
    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);
 
    // Sla tag-associaties op
    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);
  }
}
 
// Invalidatie
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,
};

Gebruik door de hele applicatie:

typescript
import { cache } from "@/lib/cache";
 
// Simpele cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
  tier: "VOLATILE",
  tags: ["entity:products"],
});
 
// Met aangepaste TTL
const config = await cache.get("app:config", fetchAppConfig, {
  ttl: 43200, // 12 uur
});
 
// Na het bijwerken van een product
await cache.invalidateByTag("entity:products");
 
// Controleer gezondheid
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);

Veelgemaakte Fouten (Zodat Jij Ze Niet Hoeft te Maken)#

1. Geen maxmemory instellen. Redis zal vrolijk al het beschikbare geheugen gebruiken totdat het besturingssysteem het killt. Stel altijd een limiet in.

2. KEYS gebruiken in productie. Het blokkeert de server. Gebruik SCAN. Ik heb dit geleerd toen een KEYS *-aanroep vanuit een monitoringscript 3 seconden downtime veroorzaakte.

3. Te agressief cachen. Niet alles hoeft gecacht te worden. Als je databasequery 2ms duurt en 10 keer per minuut wordt aangeroepen, voegt caching complexiteit toe voor verwaarloosbaar voordeel.

4. Serialisatiekosten negeren. Ik heb ooit een JSON-blob van 2MB gecacht en was verbaasd waarom cache-reads traag waren. De serialisatie-overhead was groter dan de databasequery die het zou moeten besparen.

5. Geen graceful degradation. Wanneer Redis uitvalt, moet je app nog steeds werken — alleen langzamer. Wrap elke cache-aanroep in een try/catch die terugvalt naar de database. Laat een cachefout nooit een gebruikerszichtbare fout worden.

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(); // Omzeil cache volledig
  }
}

6. Evictions niet monitoren. Als Redis keys aan het verwijderen is, ben je ofwel te weinig gericht of je cachet te veel. Hoe dan ook, je moet het weten.

7. Een Redis-instantie delen tussen caching en persistente data. Gebruik aparte instanties (of minstens aparte databases). Een cache eviction policy die je job queue entries verwijdert is een slechte dag voor iedereen.

Afronding#

Redis-caching is niet moeilijk, maar het is makkelijk om het fout te doen. Begin met cache-aside, voeg TTL-jitter toe vanaf dag één, monitor je hit rate, en weersta de drang om alles te cachen.

De beste cachingstrategie is degene waar je om 3 uur 's nachts over kunt redeneren wanneer iets kapotgaat. Houd het simpel, houd het observeerbaar, en onthoud dat elke gecachte waarde een leugen is die je je gebruikers vertelt over de staat van je data — jouw taak is om die leugen zo klein en kortstondig mogelijk te houden.

Gerelateerde artikelen