Lompat ke konten
·31 menit membaca

Strategi Caching Redis yang Benar-Benar Bekerja di Produksi

Cache-aside, write-through, pencegahan cache stampede, strategi TTL, dan pola invalidasi. Pola Redis yang saya gunakan di aplikasi Node.js produksi dengan contoh kode nyata.

Bagikan:X / TwitterLinkedIn

Semua orang bilang "tambahkan Redis saja" saat API-mu lambat. Tidak ada yang bilang apa yang terjadi enam bulan kemudian saat cache-mu menyajikan data basi, logika invalidasi tersebar di 40 file, dan deploy menyebabkan cache stampede yang menumbangkan database-mu lebih parah dari kalau kamu tidak pernah caching sama sekali.

Saya sudah menjalankan Redis di produksi bertahun-tahun. Bukan sebagai mainan, bukan di tutorial — di sistem yang menangani trafik nyata di mana kesalahan caching berarti alert pager jam 3 pagi. Berikut adalah semua yang saya pelajari tentang melakukannya dengan benar.

Mengapa Caching?#

Mari mulai dari yang jelas: database itu lambat relatif terhadap memori. Query PostgreSQL yang butuh 15ms itu cepat menurut standar database. Tapi kalau query itu berjalan di setiap API request, dan kamu menangani 1.000 request per detik, itu 15.000ms waktu database kumulatif per detik. Connection pool-mu habis. Latensi p99-mu menembus langit-langit. Pengguna menatap spinner.

Redis menyajikan sebagian besar read dalam waktu di bawah 1ms. Data yang sama, ter-cache, mengubah operasi 15ms menjadi operasi 0.3ms. Itu bukan micro-optimization. Itu perbedaan antara butuh 4 database replica dan butuh nol.

Tapi caching tidak gratis. Ia menambah kompleksitas, memperkenalkan masalah konsistensi, dan membuat kelas kegagalan yang sepenuhnya baru. Sebelum kamu meng-cache apa pun, tanyakan pada dirimu:

Kapan caching membantu:

  • Data dibaca jauh lebih sering daripada ditulis (rasio 10:1 atau lebih tinggi)
  • Query yang mendasarinya mahal (join, agregasi, panggilan API eksternal)
  • Sedikit kedaluwarsa bisa diterima (katalog produk, profil pengguna, config)
  • Kamu punya pola akses yang bisa diprediksi (key yang sama diakses berulang kali)

Kapan caching merugikan:

  • Data berubah terus-menerus dan harus fresh (harga saham real-time, skor langsung)
  • Setiap request unik (query pencarian dengan banyak parameter)
  • Dataset-mu kecil (kalau semuanya muat di memori aplikasi, lewati Redis)
  • Kamu belum punya kematangan operasional untuk memonitor dan debug masalah cache

Phil Karlton terkenal mengatakan hanya ada dua hal sulit dalam ilmu komputer: cache invalidation dan penamaan. Dia benar tentang keduanya, tapi cache invalidation yang membangunkanmu di malam hari.

Setup ioredis#

Sebelum kita masuk ke pola-pola, mari siapkan koneksinya. Saya menggunakan ioredis di mana-mana — ini client Redis paling matang untuk Node.js, dengan dukungan TypeScript yang tepat, mode cluster, dukungan Sentinel, dan 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;

Beberapa hal yang patut dicatat. lazyConnect: true berarti koneksi tidak dibuat sampai kamu benar-benar menjalankan perintah, yang berguna saat testing dan inisialisasi. retryStrategy mengimplementasikan exponential backoff yang dibatasi 5 detik — tanpa ini, outage Redis menyebabkan aplikasimu mengirim spam percobaan koneksi ulang. Dan maxRetriesPerRequest: 3 memastikan perintah individual gagal cepat alih-alih menggantung selamanya.

Pola Cache-Aside#

Ini pola yang akan kamu gunakan 80% waktu. Juga disebut "lazy loading" atau "look-aside." Alurnya sederhana:

  1. Aplikasi menerima request
  2. Cek Redis untuk nilai yang ter-cache
  3. Jika ditemukan (cache hit), kembalikan
  4. Jika tidak ditemukan (cache miss), query database
  5. Simpan hasilnya di Redis
  6. Kembalikan hasilnya

Ini implementasi bertipe:

typescript
import redis from "./redis";
 
interface CacheOptions {
  ttl?: number;       // detik
  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}`;
 
  // Langkah 1: Coba baca dari cache
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      return JSON.parse(cached) as T;
    } catch {
      // Entri cache rusak, hapus dan lanjutkan
      await redis.del(cacheKey);
    }
  }
 
  // Langkah 2: Cache miss — ambil dari sumber
  const result = await fetcher();
 
  // Langkah 3: Simpan di cache (jangan await — fire and forget)
  redis
    .set(cacheKey, JSON.stringify(result), "EX", ttl)
    .catch((err) => {
      console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
    });
 
  return result;
}

Penggunaan seperti ini:

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

Perhatikan saya fire-and-forget pemanggilan redis.set. Ini disengaja. Kalau Redis down atau lambat, request tetap selesai. Cache adalah optimasi, bukan kebutuhan. Kalau menulis ke cache gagal, request berikutnya tinggal hit database lagi. Tidak masalah.

Ada bug halus di banyak implementasi cache-aside yang orang lewatkan: meng-cache nilai null. Kalau user tidak ada dan kamu tidak meng-cache fakta itu, setiap request untuk user itu menghantam database. Penyerang bisa mengeksploitasi ini dengan meminta ID user acak, mengubah cache-mu menjadi sia-sia. Selalu cache hasil negatif juga — hanya dengan TTL lebih pendek.

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;
    },
    {
      // TTL lebih pendek untuk hasil null untuk membatasi penggunaan memori
      // tapi cukup lama untuk menyerap miss berulang
      ttl: row ? 1800 : 300,
    }
  );
}

Sebenarnya, izinkan saya merestruktur itu agar TTL dinamis bekerja dengan benar:

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 hasil ada selama 30 menit, hasil null selama 5 menit
  const ttl = user ? 1800 : 300;
  await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
 
  return user;
}

Write-Through dan Write-Behind#

Cache-aside bekerja baik untuk workload read-heavy, tapi punya masalah konsistensi: kalau service atau proses lain mengupdate database langsung, cache-mu basi sampai TTL habis. Masuk write-through dan write-behind.

Write-Through#

Di write-through, setiap write melewati layer cache. Cache diupdate dulu, lalu database. Ini menjamin cache selalu konsisten dengan database (mengasumsikan write selalu melewati aplikasimu).

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

Perbedaan kunci dari cache-aside: kita menulis ke cache di setiap write, bukan hanya di read. Ini berarti cache selalu warm untuk data yang baru diupdate.

Trade-off-nya: latensi write meningkat karena setiap write sekarang menyentuh database dan Redis. Kalau Redis lambat, write-mu lambat. Di kebanyakan aplikasi, read jauh lebih banyak dari write, jadi trade-off ini layak.

Write-Behind (Write-Back)#

Write-behind membalik naskah: write pergi ke Redis dulu, dan database diupdate secara asinkron. Ini memberi write yang sangat cepat dengan biaya potensi kehilangan data jika Redis down sebelum data dipersist.

typescript
async function updateUserWriteBehind(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const cacheKey = `cache:user:${userId}`;
 
  // Baca state saat ini
  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 segera
  const updated = { ...user, ...updates };
  await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
 
  // Antrekan write database untuk pemrosesan async
  await redis.rpush(
    "write_behind:users",
    JSON.stringify({ userId, updates, timestamp: Date.now() })
  );
 
  return updated;
}

Lalu kamu punya worker terpisah yang menguras antrian itu:

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) {
        // Re-queue saat gagal dengan retry count
        console.error("[WriteBehind] Failed:", err);
        await redis.rpush("write_behind:users:dlq", item[1]);
      }
    }
  }
}

Saya jarang menggunakan write-behind dalam praktik. Risiko kehilangan data itu nyata — kalau Redis crash sebelum worker memproses antrian, write-write itu hilang. Gunakan ini hanya untuk data di mana eventual consistency benar-benar bisa diterima, seperti view count, event analytics, atau preferensi pengguna non-kritis.

Strategi TTL#

Mendapatkan TTL yang tepat lebih bernuansa dari kelihatannya. TTL tetap 1 jam untuk semuanya mudah diimplementasikan dan hampir selalu salah.

Tier Volatilitas Data#

Saya mengkategorikan data ke tiga tier dan menetapkan TTL sesuai:

typescript
const TTL = {
  // Tier 1: Jarang berubah, mahal untuk dihitung
  // Contoh: katalog produk, config situs, feature flag
  STATIC: 86400,       // 24 jam
 
  // Tier 2: Berubah kadang-kadang, biaya sedang
  // Contoh: profil pengguna, pengaturan tim, permission
  MODERATE: 1800,      // 30 menit
 
  // Tier 3: Sering berubah, murah dihitung tapi sering dipanggil
  // Contoh: data feed, jumlah notifikasi, info sesi
  VOLATILE: 300,       // 5 menit
 
  // Tier 4: Ephemeral, digunakan untuk rate limiting dan lock
  EPHEMERAL: 60,       // 1 menit
 
  // Hasil null: selalu berumur pendek
  NOT_FOUND: 120,      // 2 menit
} as const;

TTL Jitter: Mencegah Thundering Herd#

Ini skenario yang pernah menggigit saya: kamu deploy aplikasi, cache kosong, dan 10.000 request semua meng-cache data yang sama dengan TTL 1 jam. Satu jam kemudian, semua 10.000 key expired bersamaan. Semua 10.000 request menghantam database sekaligus. Database kewalahan. Saya pernah melihat ini menumbangkan instance Postgres produksi.

Solusinya adalah jitter — menambahkan keacakan ke nilai TTL:

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));
}
 
// Alih-alih: redis.set(key, value, "EX", 3600)
// Gunakan:   redis.set(key, value, "EX", ttlWithJitter(3600))
 
// 3600 ± 10% = nilai acak antara 3240 dan 3960

Ini menyebarkan expiration dalam sebuah jendela, jadi alih-alih 10.000 key expired di detik yang sama, mereka expired dalam jendela 12 menit. Database melihat peningkatan trafik bertahap, bukan tebing.

Untuk path kritis, saya lebih jauh dan menggunakan 20% jitter:

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

Sliding Expiry#

Untuk data mirip sesi di mana TTL harus reset di setiap akses, gunakan GETEX (Redis 6.2+):

typescript
async function getWithSlidingExpiry<T>(
  key: string,
  ttl: number
): Promise<T | null> {
  // GETEX secara atomik mendapatkan nilai DAN mereset TTL
  const value = await redis.getex(key, "EX", ttl);
  if (value === null) return null;
  return JSON.parse(value) as T;
}

Kalau kamu di versi Redis yang lebih lama, gunakan 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 membantu dengan mass expiration, tapi tidak menyelesaikan stampede single-key: saat key populer expired dan ratusan request bersamaan semua mencoba me-regenerate secara bersamaan.

Bayangkan kamu meng-cache feed homepage dengan TTL 5 menit. Expired. Lima puluh request bersamaan melihat cache miss. Kelima puluhnya menghantam database dengan query mahal yang sama. Kamu secara efektif DDoS dirimu sendiri.

Solusi 1: Mutex Lock#

Hanya satu request yang me-regenerate cache. Sisanya menunggu.

typescript
async function cacheAsideWithMutex<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T | null> {
  const cacheKey = `cache:${key}`;
  const lockKey = `lock:${key}`;
 
  // Coba cache dulu
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as T;
  }
 
  // Coba acquire lock (NX = hanya jika belum ada, EX = auto-expire)
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (acquired) {
    try {
      // Kita dapat lock — fetch dan cache
      const result = await fetcher();
      await redis.set(
        cacheKey,
        JSON.stringify(result),
        "EX",
        ttlWithJitter(ttl)
      );
      return result;
    } finally {
      // Release lock
      await redis.del(lockKey);
    }
  }
 
  // Request lain memegang lock — tunggu dan retry
  await sleep(100);
 
  const retried = await redis.get(cacheKey);
  if (retried !== null) {
    return JSON.parse(retried) as T;
  }
 
  // Masih belum ada cache — fall through ke database
  // (ini menangani kasus di mana pemegang lock gagal)
  return fetcher();
}
 
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Ada race condition halus di release lock di atas. Kalau pemegang lock butuh lebih dari 10 detik (TTL lock), request lain acquire lock, dan kemudian request pertama menghapus lock request kedua. Perbaikan yang tepat adalah menggunakan token unik:

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 memastikan check-and-delete yang atomik
  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;
}

Ini pada dasarnya Redlock yang disederhanakan. Untuk Redis single-instance, ini cukup. Untuk setup Redis Cluster atau Sentinel, lihat algoritma Redlock lengkap — tapi jujur, untuk pencegahan stampede caching, versi sederhana ini bekerja baik.

Solusi 2: Probabilistic Early Expiration#

Ini pendekatan favorit saya. Alih-alih menunggu key expired, secara acak regenerate sedikit sebelum expiration. Idenya berasal dari paper oleh Vattani, Chierichetti, dan 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;
 
    // Algoritma XFetch: secara probabilistik regenerate saat expiry mendekat
    // beta * Math.log(Math.random()) menghasilkan angka negatif
    // yang semakin besar (lebih negatif) saat expiry mendekat
    const beta = 1; // parameter tuning, 1 bekerja baik
    const shouldRegenerate =
      remaining - beta * Math.log(Math.random()) * -1 <= 0;
 
    if (!shouldRegenerate) {
      return entry.data;
    }
 
    // Fall through untuk regenerate
    console.log(`[Cache] Early regeneration triggered for ${key}`);
  }
 
  const data = await fetcher();
  const entry: CachedValue<T> = {
    data,
    cachedAt: Date.now(),
    ttl,
  };
 
  // Set dengan buffer ekstra agar Redis tidak expire sebelum kita bisa regenerate
  await redis.set(
    cacheKey,
    JSON.stringify(entry),
    "EX",
    Math.round(ttl * 1.1)
  );
 
  return data;
}

Keindahan pendekatan ini: saat sisa TTL key menurun, probabilitas regeneration meningkat. Dengan 1.000 request bersamaan, mungkin satu atau dua yang memicu regeneration sementara sisanya terus menyajikan data ter-cache. Tanpa lock, tanpa koordinasi, tanpa menunggu.

Solusi 3: Stale-While-Revalidate#

Sajikan nilai basi sambil me-regenerate di background. Ini memberi latensi terbaik karena tidak ada request yang pernah menunggu fetcher.

typescript
async function staleWhileRevalidate<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    freshTtl: number;   // berapa lama data "fresh"
    staleTtl: number;   // berapa lama data basi bisa disajikan
  }
): 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 basi — sajikan tapi trigger background refresh
      revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
    }
 
    return JSON.parse(cached) as T;
  }
 
  // Cache miss total — harus fetch secara sinkron
  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 {
  // Gunakan lock untuk mencegah multiple background refresh
  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);
    });
}

Penggunaan:

typescript
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
  freshTtl: 300,     // 5 menit fresh
  staleTtl: 3600,    // sajikan basi sampai 1 jam sambil revalidasi
});

Saya menggunakan pola ini untuk apa pun yang menghadap pengguna di mana latensi lebih penting dari kesegaran absolut. Data dashboard, halaman profil, listing produk — semua kandidat sempurna.

Cache Invalidation#

Phil Karlton tidak bercanda. Invalidation adalah di mana caching berubah dari "optimasi mudah" menjadi "masalah sistem terdistribusi."

Invalidation Berbasis Key Sederhana#

Kasus paling mudah: saat kamu mengupdate user, hapus cache key-nya.

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

Ini bekerja sampai data user muncul di hasil cache lain. Mungkin ia tertanam di daftar anggota tim. Mungkin di hasil pencarian. Mungkin di 14 respons API ter-cache berbeda. Sekarang kamu perlu melacak cache key mana yang berisi entitas mana.

Invalidation Berbasis Tag#

Tag entri cache-mu dengan entitas yang dikandungnya, lalu invalidate berdasarkan tag.

typescript
async function setWithTags<T>(
  key: string,
  value: T,
  ttl: number,
  tags: string[]
): Promise<void> {
  const pipeline = redis.pipeline();
 
  // Simpan nilainya
  pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
 
  // Tambahkan key ke set setiap tag
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, `cache:${key}`);
    pipeline.expire(`tag:${tag}`, ttl + 3600); // Set tag hidup lebih lama dari nilai
  }
 
  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;
}

Penggunaan:

typescript
// Saat meng-cache data tim, tag dengan semua ID anggota
const team = await fetchTeam(teamId);
await setWithTags(
  `team:${teamId}`,
  team,
  1800,
  [
    `entity:team:${teamId}`,
    ...team.members.map((m) => `entity:user:${m.id}`),
  ]
);
 
// Saat user 42 mengupdate profilnya, invalidate semua yang mengandungnya
await invalidateByTag("entity:user:42");

Invalidation Berbasis Event#

Untuk sistem yang lebih besar, gunakan Redis Pub/Sub untuk menyiarkan event invalidation:

typescript
// Publisher (di API service-mu)
async function publishInvalidation(
  entityType: string,
  entityId: string
): Promise<void> {
  await redis.publish(
    "cache:invalidate",
    JSON.stringify({ entityType, entityId, timestamp: Date.now() })
  );
}
 
// Subscriber (di setiap instance aplikasi)
const subscriber = new Redis(/* config sama */);
 
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}`);
});

Ini kritis di deployment multi-instance. Kalau kamu punya 4 app server di belakang load balancer, invalidation di server 1 perlu menyebar ke semua server. Pub/Sub menangani ini otomatis.

Invalidation Berbasis Pola (Hati-hati)#

Kadang kamu perlu invalidate semua key yang cocok dengan pola. Jangan pernah gunakan KEYS di produksi. Ia memblokir server Redis saat memindai seluruh keyspace. Dengan jutaan key, ini bisa memblokir selama detik — keabadian dalam istilah Redis.

Gunakan SCAN sebagai gantinya:

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;
}
 
// Invalidate semua data ter-cache untuk tim tertentu
await invalidateByPattern("cache:team:42:*");

SCAN iterasi secara inkremental — ia tidak pernah memblokir server. Hint COUNT menyarankan berapa key untuk dikembalikan per iterasi (ini hint, bukan jaminan). Untuk keyspace besar, ini satu-satunya pendekatan yang aman.

Meskipun demikian, invalidation berbasis pola adalah code smell. Kalau kamu sering melakukan scan, redesain struktur key-mu atau gunakan tag. SCAN itu O(N) atas keyspace dan dimaksudkan untuk operasi maintenance, bukan hot path.

Struktur Data di Luar String#

Kebanyakan developer memperlakukan Redis sebagai key-value store untuk string JSON. Itu seperti membeli pisau Swiss army dan hanya menggunakan pembuka botol. Redis punya struktur data yang kaya, dan memilih yang tepat bisa menghilangkan seluruh kategori kompleksitas.

Hash untuk Objek#

Alih-alih menyerialisasi seluruh objek sebagai JSON, simpan sebagai Redis Hash. Ini memungkinkan membaca dan mengupdate field individual tanpa mendeserialisasi semuanya.

typescript
// Simpan user sebagai 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);
}
 
// Baca field tertentu
async function getUserPlan(userId: string): Promise<string | null> {
  return redis.hget(`user:${userId}`, "plan");
}
 
// Update satu field
async function upgradeUserPlan(
  userId: string,
  plan: string
): Promise<void> {
  await redis.hset(`user:${userId}`, "plan", plan);
}
 
// Baca seluruh hash sebagai objek
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 efisien memori untuk objek kecil (Redis menggunakan encoding ziplist yang compact di balik layar) dan menghindari overhead serialize/deserialize. Trade-off-nya: kamu kehilangan kemampuan menyimpan objek bersarang tanpa meratakan mereka dulu.

Sorted Set untuk Leaderboard dan Rate Limiting#

Sorted Set adalah struktur data Redis yang paling kurang dihargai. Setiap member punya score, dan set selalu terurut berdasarkan score. Ini membuat mereka sempurna untuk leaderboard, ranking, dan 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 ke 1-indexed
}

Untuk 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();
 
  // Hapus entri di luar window
  pipeline.zremrangebyscore(key, 0, windowStart);
 
  // Tambahkan request saat ini
  pipeline.zadd(key, now, `${now}:${Math.random()}`);
 
  // Hitung request di window
  pipeline.zcard(key);
 
  // Set expiry pada seluruh 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),
  };
}

Ini lebih akurat dari pendekatan fixed-window counter dan tidak menderita masalah boundary di mana burst di akhir satu window dan awal window berikutnya secara efektif menggandakan rate limit-mu.

List untuk Queue#

Redis List dengan LPUSH/BRPOP membuat job queue ringan yang bagus:

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 (memblokir sampai ada job tersedia)
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;
}

Untuk apa pun yang lebih kompleks dari queuing dasar (retry, dead letter queue, prioritas, delayed job), gunakan BullMQ yang dibangun di atas Redis tapi menangani semua edge case.

Set untuk Pelacakan Unik#

Perlu melacak pengunjung unik, mendeduplikasi event, atau mengecek keanggotaan? Set itu O(1) untuk add, remove, dan pengecekan keanggotaan.

typescript
// Lacak pengunjung unik per hari
async function trackVisitor(
  page: string,
  visitorId: string
): Promise<boolean> {
  const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
  const isNew = await redis.sadd(key, visitorId);
 
  // Auto-expire setelah 48 jam
  await redis.expire(key, 172800);
 
  return isNew === 1; // 1 = anggota baru, 0 = sudah ada
}
 
// Dapatkan jumlah pengunjung unik
async function getUniqueVisitors(page: string, date: string): Promise<number> {
  return redis.scard(`visitors:${page}:${date}`);
}
 
// Cek apakah user sudah melakukan aksi
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
  return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}

Untuk set yang sangat besar (jutaan member), pertimbangkan HyperLogLog sebagai gantinya. Ia hanya menggunakan 12KB memori terlepas dari kardinalitas, dengan biaya ~0.81% standar error:

typescript
// HyperLogLog untuk perkiraan jumlah unik
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}`);
}

Serialisasi: JSON vs MessagePack#

JSON adalah pilihan default untuk serialisasi Redis. Mudah dibaca, universal, dan cukup baik untuk kebanyakan kasus. Tapi untuk sistem throughput tinggi, overhead serialize/deserialize menumpuk.

Masalah dengan 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 byte
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
 
// JSON.parse di hot path: ~0.02ms per panggilan
// Pada 10.000 request/detik: 200ms total waktu CPU per detik

Alternatif MessagePack#

MessagePack adalah format serialisasi biner yang lebih kecil dan lebih cepat dari JSON:

bash
npm install msgpackr
typescript
import { pack, unpack } from "msgpackr";
 
// MessagePack: ~140 byte (25% lebih kecil)
const packed = pack(user);
console.log(packed.length); // ~140
 
// Simpan sebagai Buffer
await redis.set("user:123", packed);
 
// Baca sebagai Buffer
const raw = await redis.getBuffer("user:123");
if (raw) {
  const data = unpack(raw);
}

Perhatikan penggunaan getBuffer alih-alih get — ini kritis. get mengembalikan string dan akan merusak data biner.

Kompresi untuk Nilai Besar#

Untuk nilai cache besar (respons API dengan ratusan item, HTML yang dirender), tambahkan kompresi:

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);
 
  // Hanya kompres jika lebih besar dari 1KB (overhead kompresi tidak layak untuk nilai kecil)
  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 {
    // Coba dekompresi dulu
    const decompressed = await gunzipAsync(raw);
    return JSON.parse(decompressed.toString()) as T;
  } catch {
    // Tidak terkompresi, parse sebagai JSON biasa
    return JSON.parse(raw.toString()) as T;
  }
}

Dalam pengujian saya, kompresi gzip biasanya mengurangi ukuran payload JSON 70-85%. Respons API 50KB menjadi 8KB. Ini penting saat kamu membayar untuk memori Redis — memori lebih sedikit per key berarti lebih banyak key di instance yang sama.

Trade-off-nya: kompresi menambahkan 1-3ms waktu CPU per operasi. Untuk kebanyakan aplikasi, ini bisa diabaikan. Untuk path ultra-low-latency, lewati.

Rekomendasi Saya#

Gunakan JSON kecuali profiling menunjukkan itu bottleneck. Keterbacaan dan kemampuan debug JSON di Redis (redis-cli GET key dan benar-benar bisa baca nilainya) lebih berharga dari gain performa MessagePack untuk 95% aplikasi. Tambahkan kompresi hanya untuk nilai lebih besar dari 1KB.

Redis di Next.js#

Next.js punya cerita caching sendiri (Data Cache, Full Route Cache, dll.), tapi Redis mengisi celah yang caching bawaan tidak bisa tangani — terutama saat kamu perlu berbagi cache antar multiple instance atau mempertahankan cache antar deployment.

Caching Respons API Route#

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}`;
 
  // Cek 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",
      },
    });
  }
 
  // Ambil dari database
  const products = await db.products.findMany({
    where: category !== "all" ? { category } : undefined,
    orderBy: { createdAt: "desc" },
    take: 50,
  });
 
  // Cache selama 5 menit dengan 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",
    },
  });
}

Header X-Cache tak ternilai untuk debugging. Saat latensi melonjak, curl -I cepat memberi tahu apakah cache bekerja.

Penyimpanan Sesi#

Next.js dengan Redis untuk sesi mengalahkan JWT untuk aplikasi stateful:

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 jam
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}`;
 
  // Gunakan GETEX untuk refresh TTL di setiap akses (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}`);
}
 
// Hancurkan semua sesi untuk user (berguna untuk "logout everywhere")
export async function destroyAllUserSessions(
  userId: string
): Promise<void> {
  // Ini butuh memelihara index user->sessions
  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 (atau helper yang digunakan 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 untuk rate limiting atomik
  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 penting di sini. Tanpanya, urutan ZREMRANGEBYSCORE + ZADD + ZCARD tidak atomik, dan di bawah concurrency tinggi, count-nya bisa tidak akurat. Lua script dieksekusi secara atomik di Redis — mereka tidak bisa diselingi oleh perintah lain.

Distributed Lock untuk Next.js#

Saat kamu punya beberapa instance Next.js dan perlu memastikan hanya satu yang memproses task (seperti mengirim email terjadwal atau menjalankan cleanup job):

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}`;
 
  // Coba acquire lock
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
 
    if (acquired) {
      try {
        // Perpanjang lock otomatis untuk task yang berjalan lama
        const extender = setInterval(async () => {
          const script = `
            if redis.call("get", KEYS[1]) == ARGV[1] then
              return redis.call("expire", KEYS[1], ARGV[2])
            else
              return 0
            end
          `;
          await redis.eval(script, 1, lockKey, token, ttl);
        }, (ttl * 1000) / 3);
 
        const result = await fn();
        clearInterval(extender);
        return result;
      } finally {
        // Release lock hanya jika kita masih memilikinya
        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);
      }
    }
 
    // Tunggu sebelum retry
    await new Promise((r) => setTimeout(r, retryDelay));
  }
 
  // Tidak bisa acquire lock setelah semua retry
  return null;
}

Penggunaan:

typescript
// Di API route yang dipicu cron
export async function POST() {
  const result = await withLock("daily-report", async () => {
    // Hanya satu instance yang menjalankan ini
    const report = await generateDailyReport();
    await sendReportEmail(report);
    return report;
  });
 
  if (result === null) {
    return NextResponse.json(
      { message: "Another instance is already processing" },
      { status: 409 }
    );
  }
 
  return NextResponse.json({ success: true });
}

Interval perpanjangan lock di ttl/3 penting. Tanpanya, kalau task-mu butuh lebih lama dari TTL lock, lock expired dan instance lain mengambilnya. Extender menjaga lock tetap hidup selama task berjalan.

Monitoring dan Debugging#

Redis itu cepat sampai tidak. Saat masalah muncul, kamu butuh visibilitas.

Cache Hit Ratio#

Metrik paling penting. Lacak di aplikasimu:

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 metrik harian
export async function resetCacheStats(): Promise<void> {
  await redis.del(METRICS_KEY);
}

Cache hit ratio yang sehat adalah di atas 90%. Kalau kamu di bawah 80%, entah TTL-mu terlalu pendek, cache key-mu terlalu spesifik, atau pola aksesmu lebih acak dari yang kamu kira.

Perintah INFO#

Perintah INFO adalah dashboard kesehatan bawaan Redis:

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

Metrik kunci yang dipantau:

  • used_memory vs maxmemory: Apakah kamu mendekati limit?
  • mem_fragmentation_ratio: Di atas 1.5 berarti Redis menggunakan RSS jauh lebih banyak dari memori logis. Pertimbangkan restart.
  • evicted_keys: Kalau ini non-zero dan kamu tidak bermaksud eviction, kamu kehabisan memori.
bash
redis-cli INFO stats

Yang diperhatikan:

  • keyspace_hits / keyspace_misses: Hit rate level server
  • total_commands_processed: Throughput
  • instantaneous_ops_per_sec: Throughput saat ini

MONITOR (Gunakan dengan Sangat Hati-hati)#

MONITOR melakukan stream setiap perintah yang dieksekusi di server Redis secara real-time. Sangat berguna untuk debugging dan sangat berbahaya di produksi.

bash
# JANGAN PERNAH biarkan ini berjalan di produksi
# Ia menambahkan overhead signifikan dan bisa mencatat data sensitif
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"

Saya menggunakan MONITOR untuk tepat dua hal: debugging masalah penamaan key selama development, dan memverifikasi bahwa code path tertentu menghantam Redis sesuai harapan. Tidak pernah lebih dari 30 detik. Tidak pernah di produksi kecuali kamu sudah menghabiskan opsi debugging lain.

Keyspace Notification#

Ingin tahu kapan key expired atau dihapus? Redis bisa mempublikasikan event:

bash
# Aktifkan keyspace notification untuk event expired dan evicted
redis-cli CONFIG SET notify-keyspace-events Ex
typescript
const subscriber = new Redis(/* config */);
 
// Dengarkan event expiration key
subscriber.subscribe("__keyevent@0__:expired", (err) => {
  if (err) console.error("Subscribe error:", err);
});
 
subscriber.on("message", (_channel, expiredKey) => {
  console.log(`Key expired: ${expiredKey}`);
 
  // Secara proaktif regenerate key penting
  if (expiredKey.startsWith("cache:homepage")) {
    regenerateHomepageCache().catch(console.error);
  }
});

Ini berguna untuk cache warming proaktif — alih-alih menunggu pengguna memicu cache miss, kamu me-regenerate entri kritis begitu expired.

Analisis Memori#

Saat memori Redis tumbuh tidak terduga, kamu perlu menemukan key mana yang mengonsumsi paling banyak:

bash
# Sampel 10 key terbesar
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

Untuk analisis lebih detail:

bash
# Penggunaan memori key tertentu (dalam byte)
redis-cli MEMORY USAGE "cache:search:electronics"
typescript
// Analisis memori programatik
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");
 
  // Urutkan berdasarkan ukuran menurun
  stats.sort((a, b) => b.bytes - a.bytes);
 
  console.log("Top 20 key berdasarkan penggunaan memori:");
  for (const { key, bytes } of stats.slice(0, 20)) {
    const mb = (bytes / 1024 / 1024).toFixed(2);
    console.log(`  ${key}: ${mb} MB`);
  }
}

Kebijakan Eviction#

Kalau instance Redis-mu punya limit maxmemory (seharusnya), konfigurasi kebijakan eviction:

bash
# Di redis.conf atau via CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lru

Kebijakan yang tersedia:

  • noeviction: Mengembalikan error saat memori penuh (default, terburuk untuk caching)
  • allkeys-lru: Evict key yang paling jarang digunakan belakangan ini (pilihan serba guna terbaik untuk caching)
  • allkeys-lfu: Evict key yang paling jarang digunakan secara frekuensi (lebih baik jika beberapa key diakses dalam burst)
  • volatile-lru: Hanya evict key dengan TTL yang di-set (berguna jika kamu mencampur cache dan data persisten)
  • allkeys-random: Eviction acak (mengejutkan layak, tanpa overhead)

Untuk workload caching murni, allkeys-lfu biasanya pilihan terbaik. Ia menyimpan key yang sering diakses di memori bahkan jika belum diakses baru-baru ini.

Menyatukan Semuanya: Modul Cache Produksi#

Ini modul cache lengkap yang saya gunakan di produksi, menggabungkan semua yang telah kita bahas:

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 tier
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 dengan perlindungan stampede
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}`;
 
  // Cek cache
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      const parsed = JSON.parse(cached);
      recordHit();
      return parsed as T;
    } catch {
      await redis.del(cacheKey);
    }
  }
 
  recordMiss();
 
  // Acquire lock untuk mencegah stampede
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (!acquired) {
    // Proses lain sedang fetching — tunggu sebentar dan retry cache
    await new Promise((r) => setTimeout(r, 150));
    const retried = await redis.get(cacheKey);
    if (retried) return JSON.parse(retried) as T;
  }
 
  try {
    const result = await fetcher();
    const ttl = ttlWithJitter(baseTtl);
 
    const pipeline = redis.pipeline();
    pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
 
    // Simpan asosiasi tag
    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;
}
 
// Metrik
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,
};

Penggunaan di seluruh aplikasi:

typescript
import { cache } from "@/lib/cache";
 
// Cache-aside sederhana
const products = await cache.get("products:featured", fetchFeaturedProducts, {
  tier: "VOLATILE",
  tags: ["entity:products"],
});
 
// Dengan TTL kustom
const config = await cache.get("app:config", fetchAppConfig, {
  ttl: 43200, // 12 jam
});
 
// Setelah mengupdate produk
await cache.invalidateByTag("entity:products");
 
// Cek kesehatan
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);

Kesalahan Umum yang Pernah Saya Buat (Agar Kamu Tidak Perlu)#

1. Tidak mengatur maxmemory. Redis dengan senang hati menggunakan semua memori yang tersedia sampai OS membunuhnya. Selalu set limit.

2. Menggunakan KEYS di produksi. Ia memblokir server. Gunakan SCAN. Saya belajar ini saat panggilan KEYS * dari script monitoring menyebabkan 3 detik downtime.

3. Caching terlalu agresif. Tidak semua perlu di-cache. Kalau query database-mu butuh 2ms dan dipanggil 10 kali per menit, caching menambah kompleksitas untuk manfaat yang tidak signifikan.

4. Mengabaikan biaya serialisasi. Saya pernah meng-cache blob JSON 2MB dan bingung kenapa cache read lambat. Overhead serialisasi lebih besar dari query database yang seharusnya dihemat.

5. Tidak ada degradasi yang elegan. Saat Redis down, aplikasimu seharusnya tetap bekerja — hanya lebih lambat. Bungkus setiap panggilan cache dalam try/catch yang fallback ke database. Jangan pernah biarkan kegagalan cache menjadi error yang dilihat pengguna.

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(); // Bypass cache sepenuhnya
  }
}

6. Tidak memonitor eviction. Kalau Redis melakukan evict key, kamu entah under-provisioned atau meng-cache terlalu banyak. Either way, kamu perlu tahu.

7. Berbagi instance Redis antara caching dan data persisten. Gunakan instance terpisah (atau setidaknya database terpisah). Kebijakan eviction cache yang menghapus entri job queue-mu adalah hari yang buruk untuk semua orang.

Penutup#

Caching Redis tidak sulit, tapi mudah salah. Mulai dengan cache-aside, tambahkan TTL jitter dari hari pertama, monitor hit rate-mu, dan tahan godaan untuk meng-cache semuanya.

Strategi caching terbaik adalah yang bisa kamu nalarkan jam 3 pagi saat ada yang rusak. Jaga tetap sederhana, jaga bisa diamati, dan ingat bahwa setiap nilai ter-cache adalah kebohongan yang kamu ceritakan pada pengguna tentang keadaan datamu — tugasmu adalah menjaga kebohongan itu sekecil dan sependek mungkin.

Artikel Terkait