İçeriğe geç
·30 dk okuma

Üretimde Gerçekten İşe Yarayan Redis Önbellekleme Stratejileri

Cache-aside, write-through, cache stampede koruması, TTL stratejileri ve invalidasyon kalıpları. Gerçek kod örnekleriyle üretim Node.js uygulamalarında kullandığım Redis desenleri.

Paylaş:X / TwitterLinkedIn

Herkes API'n yavaş olduğunda "Redis ekle" diyor. Ama kimse altı ay sonra cache'in bayat veri sunduğunda, invalidasyon mantığının 40 dosyaya dağıldığında ve bir deploy'un veritabanını hiç önbellek kullanmamışsın gibi çökerten bir cache stampede'e neden olduğunda ne olacağını söylemiyor.

Redis'i yıllardır üretimde çalıştırıyorum. Bir oyuncak olarak değil, bir eğitim projesinde değil — önbelleklemeyi yanlış yapmanın sabah 3'te alarm anlamına geldiği, gerçek trafik alan sistemlerde. Bu yazıda doğru yapma konusunda öğrendiğim her şeyi paylaşacağım.

Neden Önbellek?#

Bariz olanla başlayalım: veritabanları belleğe göre yavaştır. 15ms süren bir PostgreSQL sorgusu, veritabanı standartlarına göre hızlıdır. Ama bu sorgu her API isteğinde çalışıyorsa ve saniyede 1.000 istek karşılıyorsan, saniyede 15.000ms'lik toplam veritabanı süresi demek bu. Bağlantı havuzun tükenir. p99 gecikmen tavanı deler. Kullanıcılar yükleniyor animasyonlarına bakıp kalır.

Redis çoğu okumayu 1ms'nin altında sunar. Aynı veri önbelleğe alındığında 15ms'lik işlem 0.3ms'lik işleme dönüşür. Bu bir mikro-optimizasyon değil. 4 veritabanı replikasına ihtiyaç duymak ile sıfıra ihtiyaç duymak arasındaki farktır.

Ama önbellekleme bedava değil. Karmaşıklık ekler, tutarlılık sorunları ortaya çıkarır ve tamamen yeni bir hata sınıfı yaratır. Herhangi bir şeyi önbelleğe almadan önce kendine sor:

Önbellek ne zaman faydalı:

  • Veri yazılmaktan çok daha sık okunuyorsa (10:1 oranı veya daha fazla)
  • Altta yatan sorgu pahalıysa (join'ler, aggregation'lar, harici API çağrıları)
  • Hafif bayatlık kabul edilebilirse (ürün kataloğu, kullanıcı profilleri, yapılandırma)
  • Öngörülebilir erişim kalıpların varsa (aynı key'ler tekrar tekrar isteniyor)

Önbellek ne zaman zarar verir:

  • Veri sürekli değişiyorsa ve güncel olmalıysa (gerçek zamanlı hisse fiyatları, canlı skorlar)
  • Her istek benzersizse (çok parametreli arama sorguları)
  • Veri setin küçükse (her şey uygulamanın belleğine sığıyorsa Redis'i atla)
  • Önbellek sorunlarını izleyip hata ayıklayacak operasyonel olgunluğun yoksa

Phil Karlton bilgisayar biliminde sadece iki zor şey olduğunu söylemişti: cache invalidation ve isimlendirme. İkisinde de haklıydı, ama seni gecenin bir yarısı uyandıran cache invalidation.

ioredis Kurulumu#

Kalıplara dalmadan önce bağlantıyı kuralım. Her yerde ioredis kullanıyorum — Node.js için en olgun Redis istemcisi; düzgün TypeScript desteği, cluster modu, Sentinel desteği ve Lua scripting ile birlikte geliyor.

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;

Birkaç şeye dikkat çekmek isterim. lazyConnect: true, bağlantının bir komut çalıştırana kadar kurulmadığı anlamına gelir — test ve başlatma sırasında kullanışlıdır. retryStrategy, 5 saniyeyle sınırlı üstel geri çekilme uygular — bu olmadan bir Redis kesintisi uygulamanın yeniden bağlanma denemelerini yağdırmasına neden olur. Ve maxRetriesPerRequest: 3, tekil komutların sonsuza kadar beklemek yerine hızlıca başarısız olmasını sağlar.

Cache-Aside Kalıbı#

Bu, zamanın %80'inde kullanacağın kalıptır. "Lazy loading" veya "look-aside" olarak da adlandırılır. Akış basittir:

  1. Uygulama bir istek alır
  2. Redis'te önbelleğe alınmış değeri kontrol et
  3. Bulunduysa (cache hit), döndür
  4. Bulunamadıysa (cache miss), veritabanını sorgula
  5. Sonucu Redis'te sakla
  6. Sonucu döndür

İşte tipli bir uygulama:

typescript
import redis from "./redis";
 
interface CacheOptions {
  ttl?: number;       // saniye
  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}`;
 
  // Adım 1: Önbellekten okumayı dene
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      return JSON.parse(cached) as T;
    } catch {
      // Bozuk önbellek girdisi, sil ve devam et
      await redis.del(cacheKey);
    }
  }
 
  // Adım 2: Cache miss — kaynaktan getir
  const result = await fetcher();
 
  // Adım 3: Önbellekte sakla (await etme — ateşle ve unut)
  redis
    .set(cacheKey, JSON.stringify(result), "EX", ttl)
    .catch((err) => {
      console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
    });
 
  return result;
}

Kullanımı şöyle görünü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 dakika
  );
}

redis.set çağrısını ateşle-ve-unut yaptığıma dikkat et. Bu kasıtlı. Redis çökmüşse veya yavaşsa istek yine de tamamlanır. Önbellek bir optimizasyon, bir gereklilik değil. Önbelleğe yazma başarısız olursa, bir sonraki istek veritabanına gidecektir. Önemli değil.

Birçok cache-aside implementasyonunda insanların kaçırdığı ince bir hata var: null değerleri önbelleğe alma. Bir kullanıcı yoksa ve bu gerçeği önbelleğe almazsan, o kullanıcı için her istek veritabanına gider. Bir saldırgan rastgele kullanıcı ID'leri isteyerek bunu sömürebilir ve cache'ini işlevsiz hale getirebilir. Negatif sonucu da her zaman önbelleğe al — sadece daha kısa bir TTL ile.

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;
    },
    {
      // Null sonuçlar için daha kısa TTL — bellek kullanımını sınırlar
      // ama tekrarlanan miss'leri absorbe edecek kadar uzun
      ttl: row ? 1800 : 300,
    }
  );
}

Aslında dinamik TTL'nin düzgün çalışması için şöyle yeniden yapılandırayım:

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;
 
  // Var olan sonuçları 30 dk, null sonuçları 5 dk önbelleğe al
  const ttl = user ? 1800 : 300;
  await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
 
  return user;
}

Write-Through ve Write-Behind#

Cache-aside okuma ağırlıklı iş yükleri için harika çalışır, ama bir tutarlılık sorunu var: başka bir servis veya süreç veritabanını doğrudan güncellerse, TTL sona erene kadar önbelleğin bayat kalır. Write-through ve write-behind kalıpları burada devreye girer.

Write-Through#

Write-through'da her yazma önbellek katmanından geçer. Önce önbellek güncellenir, sonra veritabanı. Bu, önbelleğin her zaman veritabanıyla tutarlı olmasını garanti eder (yazmaların her zaman uygulamandan geçtiğini varsayarak).

typescript
async function updateUser(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  // Adım 1: Veritabanını güncelle
  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];
 
  // Adım 2: Önbelleği hemen güncelle
  const cacheKey = `cache:user:${userId}`;
  await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
 
  return user;
}

Cache-aside'dan temel fark: sadece okumalarda değil, her yazmada önbelleğe yazarız. Bu, son güncellenen veriler için önbelleğin her zaman sıcak olduğu anlamına gelir.

Denge: Yazma gecikmesi artar çünkü her yazma artık hem veritabanına hem Redis'e dokunur. Redis yavaşsa, yazmaların yavaşlar. Çoğu uygulamada okumalar yazmaları açık ara geçer, bu yüzden bu denge genellikle buna değer.

Write-Behind (Write-Back)#

Write-behind senaryoyu tersine çevirir: yazmalar önce Redis'e gider ve veritabanı asenkron olarak güncellenir. Veri Redis'te kalıcılaştırılmadan önce Redis çökerse potansiyel veri kaybı pahasına son derece hızlı yazmalar sağlar.

typescript
async function updateUserWriteBehind(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const cacheKey = `cache:user:${userId}`;
 
  // Mevcut durumu oku
  const current = await redis.get(cacheKey);
  const user = current ? JSON.parse(current) as User : null;
  if (!user) throw new Error("User not in cache");
 
  // Önbelleği hemen güncelle
  const updated = { ...user, ...updates };
  await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
 
  // Veritabanı yazmasını asenkron işleme için kuyruğa al
  await redis.rpush(
    "write_behind:users",
    JSON.stringify({ userId, updates, timestamp: Date.now() })
  );
 
  return updated;
}

Ardından bu kuyruğu boşaltan ayrı bir worker'ın olur:

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) {
        // Başarısızlıkta yeniden kuyruğa al (retry sayısıyla)
        console.error("[WriteBehind] Failed:", err);
        await redis.rpush("write_behind:users:dlq", item[1]);
      }
    }
  }
}

Pratikte write-behind'ı nadiren kullanırım. Veri kaybı riski gerçek — worker kuyruğu işlemeden Redis çökerse o yazmalar gider. Bunu sadece nihai tutarlılığın gerçekten kabul edilebilir olduğu veriler için kullan: görüntülenme sayıları, analitik olayları veya kritik olmayan kullanıcı tercihleri gibi.

TTL Stratejisi#

TTL'yi doğru ayarlamak göründüğünden daha incelikli. Her şeye sabit 1 saatlik TTL koymak uygulaması kolay ve neredeyse her zaman yanlış.

Veri Değişkenliği Katmanları#

Verileri üç katmana ayırıp TTL'leri buna göre atarım:

typescript
const TTL = {
  // Katman 1: Nadiren değişir, hesaplaması pahalı
  // Örnekler: ürün kataloğu, site yapılandırması, feature flag'ler
  STATIC: 86400,       // 24 saat
 
  // Katman 2: Ara sıra değişir, orta maliyet
  // Örnekler: kullanıcı profilleri, takım ayarları, izinler
  MODERATE: 1800,      // 30 dakika
 
  // Katman 3: Sık değişir, hesaplaması ucuz ama sık çağrılır
  // Örnekler: akış verisi, bildirim sayıları, oturum bilgisi
  VOLATILE: 300,       // 5 dakika
 
  // Katman 4: Geçici, rate limiting ve lock'lar için
  EPHEMERAL: 60,       // 1 dakika
 
  // Null sonuçlar: her zaman kısa ömürlü
  NOT_FOUND: 120,      // 2 dakika
} as const;

TTL Jitter: Thundering Herd'ü Önleme#

Beni yakalayan bir senaryo: uygulamanı deploy ediyorsun, önbellek boş ve 10.000 istek aynı veriyi 1 saatlik TTL ile önbelleğe alıyor. Bir saat sonra 10.000 key aynı anda sona erer. 10.000 isteğin hepsi aynı anda veritabanına gider. Veritabanı boğulur. Bunun bir üretim Postgres instance'ını çökerttiğini gördüm.

Çözüm jitter — TTL değerlerine rastgelelik eklemek:

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));
}
 
// Bunun yerine: redis.set(key, value, "EX", 3600)
// Bunu kullan:  redis.set(key, value, "EX", ttlWithJitter(3600))
 
// 3600 ± %10 = 3240 ile 3960 arasında rastgele değer

Bu, sona ermeyi bir pencereye yayar, böylece 10.000 key aynı saniyede sona ermek yerine 12 dakikalık bir pencere boyunca sona erer. Veritabanı uçurum yerine kademeli bir trafik artışı görür.

Kritik yollar için daha ileri gidip %20 jitter kullanırım:

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

Kayan Sona Erme#

TTL'nin her erişimde sıfırlanması gereken oturum benzeri veriler için GETEX (Redis 6.2+) kullan:

typescript
async function getWithSlidingExpiry<T>(
  key: string,
  ttl: number
): Promise<T | null> {
  // GETEX atomik olarak değeri alır VE TTL'yi sıfırlar
  const value = await redis.getex(key, "EX", ttl);
  if (value === null) return null;
  return JSON.parse(value) as T;
}

Daha eski bir Redis sürümündeysen pipeline kullan:

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 toplu sona ermeye yardımcı olur, ama tek key stampede'ini çözmez: popüler bir key sona erdiğinde ve yüzlerce eşzamanlı isteğin hepsi aynı anda yeniden oluşturmaya çalıştığında.

Ana sayfa akışını 5 dakikalık TTL ile önbelleğe aldığını düşün. Süresi dolar. Elli eşzamanlı istek cache miss görür. Ellisi de aynı pahalı sorguyla veritabanına gider. Kendi kendine DDoS yapmış olursun.

Çözüm 1: Mutex Lock#

Sadece bir istek önbelleği yeniden oluşturur. Diğerleri bekler.

typescript
async function cacheAsideWithMutex<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T | null> {
  const cacheKey = `cache:${key}`;
  const lockKey = `lock:${key}`;
 
  // Önce önbelleği dene
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as T;
  }
 
  // Lock almaya çalış (NX = sadece yoksa, EX = otomatik sona erme)
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (acquired) {
    try {
      // Lock'u aldık — getir ve önbelleğe al
      const result = await fetcher();
      await redis.set(
        cacheKey,
        JSON.stringify(result),
        "EX",
        ttlWithJitter(ttl)
      );
      return result;
    } finally {
      // Lock'u serbest bırak
      await redis.del(lockKey);
    }
  }
 
  // Başka bir istek lock'u tutuyor — bekle ve tekrar dene
  await sleep(100);
 
  const retried = await redis.get(cacheKey);
  if (retried !== null) {
    return JSON.parse(retried) as T;
  }
 
  // Hâlâ önbellek yok — veritabanına düş
  // (lock sahibinin başarısız olduğu durumu ele alır)
  return fetcher();
}
 
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Yukarıdaki lock serbest bırakmada ince bir yarış koşulu var. Lock sahibi 10 saniyeden (lock TTL'i) fazla sürerse, başka bir istek lock'u alır ve sonra ilk istek ikinci isteğin lock'unu siler. Doğru düzeltme benzersiz bir token kullanmak:

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 atomik kontrol-ve-sil garantisi sağlar
  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;
}

Bu temelde basitleştirilmiş bir Redlock. Tek instance Redis için yeterli. Redis Cluster veya Sentinel kurulumları için tam Redlock algoritmasına bak — ama dürüst olmak gerekirse, cache stampede koruması için bu basit sürüm gayet iyi çalışır.

Çözüm 2: Olasılıksal Erken Sona Erme#

Bu en sevdiğim yaklaşım. Key'in sona ermesini beklemek yerine, sona ermeden hemen önce rastgele yeniden oluştur. Fikir Vattani, Chierichetti ve Lowenstein'ın bir makalesinden geliyor.

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 algoritması: sona erme yaklaştıkça olasılıksal yeniden oluşturma
    // beta * Math.log(Math.random()) negatif bir sayı üretir
    // sona erme yaklaştıkça daha büyük (daha negatif) olur
    const beta = 1; // ayar parametresi, 1 iyi çalışır
    const shouldRegenerate =
      remaining - beta * Math.log(Math.random()) * -1 <= 0;
 
    if (!shouldRegenerate) {
      return entry.data;
    }
 
    // Yeniden oluşturmak için devam et
    console.log(`[Cache] Early regeneration triggered for ${key}`);
  }
 
  const data = await fetcher();
  const entry: CachedValue<T> = {
    data,
    cachedAt: Date.now(),
    ttl,
  };
 
  // Redis yeniden oluşturmadan önce sona ermesin diye ekstra tamponla ayarla
  await redis.set(
    cacheKey,
    JSON.stringify(entry),
    "EX",
    Math.round(ttl * 1.1)
  );
 
  return data;
}

Bu yaklaşımın güzelliği: key'in kalan TTL'si azaldıkça yeniden oluşturma olasılığı artar. 1.000 eşzamanlı istekle belki bir veya ikisi yeniden oluşturmayı tetiklerken geri kalanı önbelleğe alınmış veriyi sunmaya devam eder. Lock yok, koordinasyon yok, bekleme yok.

Çözüm 3: Stale-While-Revalidate#

Arka planda yeniden oluştururken bayat değeri sun. Hiçbir istek fetcher'ı beklemediği için en iyi gecikmeyi sağlar.

typescript
async function staleWhileRevalidate<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    freshTtl: number;   // verinin ne kadar "taze" olduğu
    staleTtl: number;   // bayat verinin ne kadar sunulabileceği
  }
): 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) {
      // Veri bayat — sun ama arka plan yenilemeyi tetikle
      revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
    }
 
    return JSON.parse(cached) as T;
  }
 
  // Tam cache miss — senkron getirmek gerekir
  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 {
  // Birden fazla arka plan yenilemeyi önlemek için lock kullan
  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);
    });
}

Kullanım:

typescript
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
  freshTtl: 300,     // 5 dakika taze
  staleTtl: 3600,    // yeniden doğrularken 1 saate kadar bayat sun
});

Bu kalıbı gecikmenin mutlak tazelikten daha önemli olduğu, kullanıcıya dönük her şeyde kullanırım. Dashboard verileri, profil sayfaları, ürün listeleri — hepsi mükemmel adaylar.

Cache Invalidation#

Phil Karlton şaka yapmıyordu. Invalidation, önbelleklemenin "kolay optimizasyon"dan "dağıtık sistem problemi"ne dönüştüğü yerdir.

Basit Key Tabanlı Invalidation#

En kolay durum: bir kullanıcıyı güncellediğinde cache key'ini sil.

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]
  );
 
  // Önbelleği geçersiz kıl
  await redis.del(`cache:user:${userId}`);
 
  return user[0];
}

Bu, kullanıcı verisi başka önbelleğe alınmış sonuçlarda görünene kadar çalışır. Belki bir takım üyeleri listesine gömülü. Belki bir arama sonucunda. Belki 14 farklı önbelleğe alınmış API yanıtında. Artık hangi cache key'lerinin hangi varlıkları içerdiğini takip etmen gerekiyor.

Tag Tabanlı Invalidation#

Önbellek girdilerini içerdikleri varlıklarla etiketle, sonra etikete göre geçersiz kıl.

typescript
async function setWithTags<T>(
  key: string,
  value: T,
  ttl: number,
  tags: string[]
): Promise<void> {
  const pipeline = redis.pipeline();
 
  // Değeri sakla
  pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
 
  // Key'i her etiketin set'ine ekle
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, `cache:${key}`);
    pipeline.expire(`tag:${tag}`, ttl + 3600); // Tag set'leri değerlerden daha uzun yaşar
  }
 
  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;
}

Kullanım:

typescript
// Takım verisini önbelleğe alırken tüm üye ID'leriyle etiketle
const team = await fetchTeam(teamId);
await setWithTags(
  `team:${teamId}`,
  team,
  1800,
  [
    `entity:team:${teamId}`,
    ...team.members.map((m) => `entity:user:${m.id}`),
  ]
);
 
// Kullanıcı 42 profilini güncellediğinde, onu içeren her şeyi geçersiz kıl
await invalidateByTag("entity:user:42");

Olay Tabanlı Invalidation#

Daha büyük sistemlerde invalidasyon olaylarını yayınlamak için Redis Pub/Sub kullan:

typescript
// Yayıncı (API servisinde)
async function publishInvalidation(
  entityType: string,
  entityId: string
): Promise<void> {
  await redis.publish(
    "cache:invalidate",
    JSON.stringify({ entityType, entityId, timestamp: Date.now() })
  );
}
 
// Abone (her uygulama instance'ında)
const subscriber = new Redis(/* aynı yapılandırma */);
 
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}`);
});

Bu, çoklu instance deploy'larında kritiktir. Bir load balancer arkasında 4 uygulama sunucun varsa, sunucu 1'deki bir invalidasyon tüm sunuculara yayılmalıdır. Pub/Sub bunu otomatik olarak halleder.

Kalıp Tabanlı Invalidation (Dikkatle)#

Bazen bir kalıba uyan tüm key'leri geçersiz kılman gerekir. Üretimde asla KEYS kullanma. Tüm key alanını tararken Redis sunucusunu bloklar. Milyonlarca key ile bu saniyeler sürebilir — Redis'te bir ömür.

Bunun yerine SCAN kullan:

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;
}
 
// Belirli bir takımın tüm önbelleğe alınmış verisini geçersiz kıl
await invalidateByPattern("cache:team:42:*");

SCAN artımlı olarak iterasyon yapar — sunucuyu asla bloklamaz. COUNT ipucu iterasyon başına kaç key döndürüleceğini önerir (bir ipucu, garanti değil). Büyük key alanları için tek güvenli yaklaşım budur.

Bununla birlikte, kalıp tabanlı invalidasyon bir kod kokusu. Sık sık tarama yapıyorsan, key yapını yeniden tasarla veya etiketler kullan. SCAN key alanı üzerinde O(N)'dir ve sıcak yollar için değil bakım işlemleri için tasarlanmıştır.

String'lerin Ötesinde Veri Yapıları#

Çoğu geliştirici Redis'i JSON string'leri için bir key-value deposu olarak ele alır. Bu, İsviçre çakısı alıp sadece şişe açacağını kullanmak gibidir. Redis'in zengin veri yapıları var ve doğru olanı seçmek bütün karmaşıklık kategorilerini ortadan kaldırabilir.

Nesneler İçin Hash'ler#

Tüm bir nesneyi JSON olarak serileştirmek yerine Redis Hash olarak sakla. Bu, tüm şeyi deserialize etmeden tekil alanları okumanı ve güncellemenı sağlar.

typescript
// Kullanıcıyı hash olarak sakla
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);
}
 
// Belirli alanları oku
async function getUserPlan(userId: string): Promise<string | null> {
  return redis.hget(`user:${userId}`, "plan");
}
 
// Tek bir alanı güncelle
async function upgradeUserPlan(
  userId: string,
  plan: string
): Promise<void> {
  await redis.hset(`user:${userId}`, "plan", plan);
}
 
// Tüm hash'i nesne olarak oku
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"],
  };
}

Hash'ler küçük nesneler için bellek açısından verimlidir (Redis arka planda kompakt ziplist kodlaması kullanır) ve serileştirme/deserileştirme yükünden kaçınır. Denge: iç içe nesneleri önce düzleştirmeden saklama yeteneğini kaybedersin.

Sıralı Listeler ve Rate Limiting İçin Sorted Set'ler#

Sorted Set'ler Redis'in en az takdir edilen veri yapısıdır. Her üyenin bir skoru vardır ve set her zaman skora göre sıralıdır. Bu onları liderlik tabloları, sıralama ve kayan pencere rate limiting için mükemmel kılar.

typescript
// Liderlik tablosu
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-indeksli'den 1-indeksli'ye
}

Kayan pencere rate limiting için:

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();
 
  // Pencere dışındaki girdileri kaldır
  pipeline.zremrangebyscore(key, 0, windowStart);
 
  // Mevcut isteği ekle
  pipeline.zadd(key, now, `${now}:${Math.random()}`);
 
  // Penceredeki istekleri say
  pipeline.zcard(key);
 
  // Tüm key'e sona erme ayarla
  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),
  };
}

Bu, sabit pencere sayacı yaklaşımından daha doğrudur ve bir pencerenin sonundaki ile bir sonrakinin başındaki patlamanın rate limit'ini fiilen ikiye katladığı sınır sorununu yaşamaz.

Kuyruklar İçin List'ler#

Redis List'leri LPUSH/BRPOP ile mükemmel hafif iş kuyrukları oluşturur:

typescript
interface Job {
  id: string;
  type: string;
  payload: Record<string, unknown>;
  createdAt: number;
}
 
// Üretici
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;
}
 
// Tüketici (bir iş müsait olana kadar bloklar)
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;
}

Temel kuyruğun ötesinde daha karmaşık şeyler için (yeniden denemeler, dead letter kuyrukları, öncelik, gecikmeli işler) BullMQ kullan — Redis üzerine inşa edilmiştir ama tüm edge case'leri halleder.

Benzersiz Takip İçin Set'ler#

Benzersiz ziyaretçileri takip etmen, olayları tekilleştirmen veya üyeliği kontrol etmen mi gerekiyor? Set'ler ekleme, silme ve üyelik kontrolü için O(1)'dir.

typescript
// Gün başına benzersiz ziyaretçileri takip et
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);
 
  // 48 saat sonra otomatik sona er
  await redis.expire(key, 172800);
 
  return isNew === 1; // 1 = yeni üye, 0 = zaten vardı
}
 
// Benzersiz ziyaretçi sayısını al
async function getUniqueVisitors(page: string, date: string): Promise<number> {
  return redis.scard(`visitors:${page}:${date}`);
}
 
// Kullanıcının zaten bir eylem yapıp yapmadığını kontrol et
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
  return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}

Çok büyük set'ler (milyonlarca üye) için bunun yerine HyperLogLog'u düşün. Kardinaliteden bağımsız olarak sadece 12KB bellek kullanır, ~%0.81 standart hata pahasına:

typescript
// Yaklaşık benzersiz sayımlar için HyperLogLog
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}`);
}

Serileştirme: JSON vs MessagePack#

JSON, Redis serileştirmesi için varsayılan tercihtir. Okunabilir, evrensel ve çoğu durum için yeterince iyi. Ama yüksek aktarımlı sistemlerde serileştirme/deserileştirme yükü birikir.

JSON'ın Sorunu#

typescript
const user = {
  id: "usr_abc123",
  name: "Ahmet Kousa",
  email: "ahmet@example.com",
  plan: "pro",
  preferences: {
    theme: "dark",
    language: "tr",
    notifications: true,
  },
};
 
// JSON: 189 bayt
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
 
// Sıcak yolda JSON.parse: çağrı başına ~0.02ms
// Saniyede 10.000 istekte: saniyede 200ms toplam CPU süresi

MessagePack Alternatifi#

MessagePack, JSON'dan daha küçük ve daha hızlı bir ikili serileştirme formatıdır:

bash
npm install msgpackr
typescript
import { pack, unpack } from "msgpackr";
 
// MessagePack: ~140 bayt (%25 daha küçük)
const packed = pack(user);
console.log(packed.length); // ~140
 
// Buffer olarak sakla
await redis.set("user:123", packed);
 
// Buffer olarak oku
const raw = await redis.getBuffer("user:123");
if (raw) {
  const data = unpack(raw);
}

get yerine getBuffer kullanımına dikkat et — bu kritik. get bir string döndürür ve ikili veriyi bozar.

Büyük Değerler İçin Sıkıştırma#

Büyük önbellek değerleri için (yüzlerce öğeli API yanıtları, render edilmiş HTML) sıkıştırma ekle:

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);
 
  // Sadece 1KB'dan büyükse sıkıştır (küçük değerler için sıkıştırma yükü buna değmez)
  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 {
    // Önce sıkıştırmayı açmayı dene
    const decompressed = await gunzipAsync(raw);
    return JSON.parse(decompressed.toString()) as T;
  } catch {
    // Sıkıştırılmamış, normal JSON olarak parse et
    return JSON.parse(raw.toString()) as T;
  }
}

Testlerimde gzip sıkıştırma genellikle JSON payload boyutunu %70-85 azaltır. 50KB'lık bir API yanıtı 8KB olur. Redis belleği için ödeme yaparken bu önemlidir — key başına daha az bellek, aynı instance'ta daha fazla key demektir.

Denge: sıkıştırma işlem başına 1-3ms CPU süresi ekler. Çoğu uygulama için bu ihmal edilebilir. Ultra düşük gecikmeli yollar için atla.

Tavsiyem#

Profilleme bir darboğaz gösterene kadar JSON kullan. JSON'ın Redis'teki okunabilirliği ve hata ayıklanabilirliği (key'e redis-cli GET yapıp değeri gerçekten okuyabilirsin) uygulamaların %95'i için MessagePack'in performans kazancından ağır basar. Sıkıştırmayı sadece 1KB'dan büyük değerler için ekle.

Next.js'te Redis#

Next.js'in kendi önbellekleme hikayesi var (Data Cache, Full Route Cache, vb.), ama Redis yerleşik önbelleklemenin karşılayamadığı boşlukları doldurur — özellikle birden fazla instance arasında önbellek paylaşman veya deploy'lar arasında önbelleği kalıcı kılman gerektiğinde.

API Route Yanıtlarını Önbelleğe Alma#

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}`;
 
  // Önbelleği kontrol et
  const cached = await redis.get(cacheKey);
  if (cached) {
    return NextResponse.json(JSON.parse(cached), {
      headers: {
        "X-Cache": "HIT",
        "Cache-Control": "public, s-maxage=60",
      },
    });
  }
 
  // Veritabanından getir
  const products = await db.products.findMany({
    where: category !== "all" ? { category } : undefined,
    orderBy: { createdAt: "desc" },
    take: 50,
  });
 
  // 5 dakika jitter ile önbelleğe al
  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 header'ı hata ayıklama için çok değerli. Gecikme artışlarında hızlı bir curl -I önbelleğin çalışıp çalışmadığını söyler.

Oturum Depolama#

Redis ile Next.js oturumları, durumlu uygulamalar için JWT'yi yener:

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 saat
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}`;
 
  // Her erişimde TTL'yi yenilemek için GETEX kullan (kayan sona erme)
  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}`);
}
 
// Bir kullanıcının tüm oturumlarını yok et ("her yerden çıkış yap" için kullanışlı)
export async function destroyAllUserSessions(
  userId: string
): Promise<void> {
  // Bu, bir kullanıcı->oturumlar indeksi tutmayı gerektirir
  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 (veya middleware tarafından kullanılan bir yardımcı)
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;
 
  // Atomik rate limiting için Lua script
  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 script burada önemli. O olmadan ZREMRANGEBYSCORE + ZADD + ZCARD dizisi atomik değil ve yüksek eşzamanlılıkta sayım hatalı olabilir. Lua script'leri Redis'te atomik olarak çalışır — diğer komutlarla araya girilemez.

Next.js İçin Dağıtık Lock'lar#

Birden fazla Next.js instance'ın varsa ve sadece birinin bir görevi işlemesini sağlaman gerekiyorsa (zamanlanmış e-posta gönderme veya temizlik işi çalıştırma gibi):

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}`;
 
  // Lock almayı dene
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
 
    if (acquired) {
      try {
        // Uzun süren görevler için lock'u otomatik uzat
        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 {
        // Lock'u sadece hâlâ bize aitse serbest bırak
        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);
      }
    }
 
    // Tekrar denemeden önce bekle
    await new Promise((r) => setTimeout(r, retryDelay));
  }
 
  // Tüm denemelerden sonra lock alınamadı
  return null;
}

Kullanım:

typescript
// Cron tetiklemeli bir API route'unda
export async function POST() {
  const result = await withLock("daily-report", async () => {
    // Sadece bir instance bunu çalıştırır
    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 });
}

ttl/3 aralığındaki lock uzatma önemlidir. O olmadan, görevin lock TTL'inden daha uzun sürerse lock sona erer ve başka bir instance onu kapar. Uzatıcı, görev çalıştığı sürece lock'u canlı tutar.

İzleme ve Hata Ayıklama#

Redis olmadığı ana kadar hızlıdır. Sorunlar geldiğinde görünürlüğe ihtiyacın var.

Cache Hit Oranı#

En önemli tek metrik. Uygulamanda takip et:

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,
  };
}
 
// Metrikleri günlük sıfırla
export async function resetCacheStats(): Promise<void> {
  await redis.del(METRICS_KEY);
}

Sağlıklı bir cache hit oranı %90'ın üzerindedir. %80'in altındaysan, ya TTL'lerin çok kısa, ya cache key'lerin çok spesifik, ya da erişim kalıpların düşündüğünden daha rastgele.

INFO Komutu#

INFO komutu Redis'in yerleşik sağlık panosu:

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

İzlenecek temel metrikler:

  • used_memory vs maxmemory: Limite yaklaşıyor musun?
  • mem_fragmentation_ratio: 1.5'in üzerindeyse Redis mantıksal bellekten önemli ölçüde daha fazla RSS kullanıyor. Yeniden başlatmayı düşün.
  • evicted_keys: Sıfır değilse ve eviction kasıtlı değilse, belleğin dolmuş.
bash
redis-cli INFO stats

Dikkat et:

  • keyspace_hits / keyspace_misses: Sunucu düzeyinde hit oranı
  • total_commands_processed: Aktarım hızı
  • instantaneous_ops_per_sec: Anlık aktarım hızı

MONITOR (Son Derece Dikkatli Kullan)#

MONITOR Redis sunucusunda yürütülen her komutu gerçek zamanlı olarak akıtır. Hata ayıklama için inanılmaz derecede kullanışlı ve üretimde inanılmaz derecede tehlikeli.

bash
# Üretimde bunu ASLA çalışır bırakma
# Önemli yük ekler ve hassas verileri logleyebilir
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"

MONITOR'u tam olarak iki şey için kullanırım: geliştirme sırasında key isimlendirme sorunlarını ayıklamak ve belirli bir kod yolunun Redis'e beklendiği gibi vurduğunu doğrulamak. Asla 30 saniyeden fazla. Diğer hata ayıklama seçeneklerini tüketmedikçe asla üretimde.

Keyspace Bildirimleri#

Key'lerin ne zaman sona erdiğini veya silindiğini bilmek ister misin? Redis olaylar yayınlayabilir:

bash
# Sona erme ve eviction olayları için keyspace bildirimlerini etkinleştir
redis-cli CONFIG SET notify-keyspace-events Ex
typescript
const subscriber = new Redis(/* yapılandırma */);
 
// Key sona erme olaylarını dinle
subscriber.subscribe("__keyevent@0__:expired", (err) => {
  if (err) console.error("Subscribe error:", err);
});
 
subscriber.on("message", (_channel, expiredKey) => {
  console.log(`Key expired: ${expiredKey}`);
 
  // Önemli key'leri proaktif olarak yeniden oluştur
  if (expiredKey.startsWith("cache:homepage")) {
    regenerateHomepageCache().catch(console.error);
  }
});

Bu, proaktif cache ısındırma için kullanışlı — kullanıcının bir cache miss tetiklemesini beklemek yerine kritik girdileri sona erdikleri anda yeniden oluşturursun.

Bellek Analizi#

Redis belleği beklenmedik şekilde büyüdüğünde hangi key'lerin en çok tükettiğini bulmak gerekir:

bash
# En büyük 10 key'i örnekle
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

Daha detaylı analiz için:

bash
# Belirli bir key'in bellek kullanımı (bayt cinsinden)
redis-cli MEMORY USAGE "cache:search:electronics"
typescript
// Programatik bellek analizi
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");
 
  // Boyuta göre azalan sırala
  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 Politikaları#

Redis instance'ının bir maxmemory limiti varsa (olmalı), bir eviction politikası yapılandır:

bash
# redis.conf'ta veya CONFIG SET ile
maxmemory 512mb
maxmemory-policy allkeys-lru

Mevcut politikalar:

  • noeviction: Bellek dolduğunda hata döndürür (varsayılan, önbellekleme için en kötüsü)
  • allkeys-lru: En son kullanılmayanı çıkar (önbellekleme için en iyi genel amaçlı seçim)
  • allkeys-lfu: En az sıklıkla kullanılanı çıkar (bazı key'ler patlamalar halinde erişiliyorsa daha iyi)
  • volatile-lru: Sadece TTL'si olan key'leri çıkar (önbellek ve kalıcı veriyi karıştırıyorsan kullanışlı)
  • allkeys-random: Rastgele eviction (şaşırtıcı derecede iyi, yük yok)

Saf önbellekleme iş yükleri için allkeys-lfu genellikle en iyi seçimdir. Son zamanlarda erişilmemiş olsalar bile sık erişilen key'leri bellekte tutar.

Her Şeyi Bir Araya Getirmek: Üretim Cache Modülü#

İşte tartıştığımız her şeyi birleştiren, üretimde kullandığım tam cache modülü:

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 katmanları
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));
}
 
// Stampede korumalı temel cache-aside
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}`;
 
  // Önbelleği kontrol et
  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();
 
  // Stampede'i önlemek için lock al
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (!acquired) {
    // Başka bir süreç getiriyor — kısa bir süre bekle ve önbelleği tekrar dene
    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);
 
    // Tag ilişkilerini sakla
    if (tags) {
      for (const tag of tags) {
        pipeline.sadd(`tag:${tag}`, cacheKey);
        pipeline.expire(`tag:${tag}`, ttl + 3600);
      }
    }
 
    await pipeline.exec();
    return result;
  } finally {
    await redis.del(lockKey);
  }
}
 
// Invalidation
async function invalidate(...keys: string[]): Promise<void> {
  if (keys.length === 0) return;
  await redis.del(...keys.map((k) => `c:${k}`));
}
 
async function invalidateByTag(tag: string): Promise<number> {
  const keys = await redis.smembers(`tag:${tag}`);
  if (keys.length === 0) return 0;
 
  const pipeline = redis.pipeline();
  for (const key of keys) {
    pipeline.del(key);
  }
  pipeline.del(`tag:${tag}`);
  await pipeline.exec();
  return keys.length;
}
 
// Metrikler
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,
};

Uygulamada kullanım:

typescript
import { cache } from "@/lib/cache";
 
// Basit cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
  tier: "VOLATILE",
  tags: ["entity:products"],
});
 
// Özel TTL ile
const config = await cache.get("app:config", fetchAppConfig, {
  ttl: 43200, // 12 saat
});
 
// Bir ürünü güncelledikten sonra
await cache.invalidateByTag("entity:products");
 
// Sağlığı kontrol et
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);

Yaptığım Yaygın Hatalar (Sen Yapma Diye)#

1. maxmemory ayarlamamak. Redis, işletim sistemi onu öldürene kadar tüm kullanılabilir belleği seve seve kullanır. Her zaman bir limit koy.

2. Üretimde KEYS kullanmak. Sunucuyu bloklar. SCAN kullan. Bunu bir izleme scriptindeki KEYS * çağrısının 3 saniyelik kesintiye neden olduğu zaman öğrendim.

3. Aşırı agresif önbelleğe alma. Her şeyin önbelleğe alınması gerekmez. Veritabanı sorgun 2ms sürüyorsa ve dakikada 10 kez çağrılıyorsa, önbellekleme ihmal edilebilir fayda için karmaşıklık ekler.

4. Serileştirme maliyetlerini görmezden gelmek. Bir keresinde 2MB'lık bir JSON blob'u önbelleğe aldım ve cache okumalarının neden yavaş olduğuna şaşırdım. Serileştirme yükü, tasarruf etmesi gereken veritabanı sorgusundan büyüktü.

5. Zarif bozulma yok. Redis çöktüğünde uygulamanın hâlâ çalışmalı — sadece daha yavaş. Her cache çağrısını veritabanına geri dönen bir try/catch'e sar. Bir cache hatasının asla kullanıcıya görünen bir hataya dönüşmesine izin verme.

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(); // Önbelleği tamamen atla
  }
}

6. Eviction'ları izlememek. Redis key çıkarıyorsa, ya yetersiz kaynak ayırmışsın ya da çok fazla önbelleğe alıyorsun. Her iki durumda da bilmen gerekiyor.

7. Bir Redis instance'ını önbellekleme ve kalıcı veri arasında paylaşmak. Ayrı instance'lar (veya en azından ayrı veritabanları) kullan. İş kuyruğu girdilerini silen bir cache eviction politikası herkes için kötü bir gündür.

Toparlayalım#

Redis önbellekleme zor değil, ama yanlış yapmak kolay. Cache-aside ile başla, birinci günden TTL jitter ekle, hit oranını izle ve her şeyi önbelleğe alma dürtüsüne diren.

En iyi önbellekleme stratejisi, bir şeyler bozulduğunda sabah 3'te üzerinde düşünebildiğin stratejidir. Basit tut, gözlemlenebilir tut ve unutma: her önbelleğe alınmış değer, kullanıcılarına verinin durumu hakkında söylediğin bir yalandır — senin görevin bu yalanı olabildiğince küçük ve kısa ömürlü tutmak.

İlgili Yazılar