← Semua pembelajaran / Astro Nol → Portal Berita
Fase 3 · Jalur API

Cache berlapis — dari edge sampai memori proses

Cache yang benar bukan satu lapis, melainkan empat — dan yang paling sering salah bukan cara mengisinya, melainkan apa yang terjadi tepat saat ia kedaluwarsa.

Intisari

  • Empat lapis: Cloudflare edge → cache proses → Redis/ElastiCache → database.
  • Lapis yang paling menguntungkan jauh di atas yang lain adalah edge. Perbaiki itu dulu.
  • Cache di memori proses gratis dan sangat cepat, tapi tidak konsisten antar task — hanya untuk data yang boleh basi.
  • Thundering herd: saat entri populer kedaluwarsa, ribuan request menyerbu database bersamaan. Kunci per-key mencegahnya.
  • Selalu simpan versi basi lebih lama dari TTL-nya, supaya kamu punya sesuatu untuk disajikan saat sumbernya mati.

Empat lapis dan porsinya

LapisLatensiDibagi antar task?Porsi trafik yang diserap
Cloudflare edge~5–30 ms ke pembacaGlobal90–95%
Memori proses (Map)< 0,01 msTidak~3%
Redis / ElastiCache~0,5–2 msYa~1,5%
MySQL~1–50 ms—< 1%

Perhatikan porsinya sebelum memutuskan di mana bekerja. Menaikkan cache hit ratio edge dari 60% ke 93% memangkas beban origin lima sampai enam kali lipat — itu perbaikan terbesar yang tersedia untukmu, dan seluruhnya konfigurasi, tanpa infrastruktur baru. Menambah ElastiCache sebelum memperbaiki edge berarti membayar bulanan untuk mengoptimalkan 1,5% terakhir.

Lapis 2: cache di memori proses

// src/lib/cache-memori.ts
type Entri<T> = { nilai: T; kedaluwarsa: number; basiSampai: number };

const peta = new Map<string, Entri<unknown>>();
const sedangJalan = new Map<string, Promise<unknown>>();

export async function cache<T>(
  kunci: string,
  ttlDetik: number,
  ambil: () => Promise<T>,
): Promise<T> {
  const now = Date.now();
  const entri = peta.get(kunci) as Entri<T> | undefined;

  if (entri && now < entri.kedaluwarsa) {
    return entri.nilai;
  }

  // Cegah thundering herd: hanya SATU pengambilan per kunci.
  const berjalan = sedangJalan.get(kunci) as Promise<T> | undefined;
  if (berjalan) {
    // Kalau ada versi basi, pakai itu daripada ikut menunggu.
    if (entri && now < entri.basiSampai) return entri.nilai;
    return berjalan;
  }

  const p = ambil()
    .then((nilai) => {
      peta.set(kunci, {
        nilai,
        kedaluwarsa: Date.now() + ttlDetik * 1000,
        basiSampai: Date.now() + ttlDetik * 10 * 1000,
      });
      return nilai;
    })
    .catch((e) => {
      // Sumber mati: sajikan yang basi kalau ada.
      if (entri && Date.now() < entri.basiSampai) return entri.nilai;
      throw e;
    })
    .finally(() => sedangJalan.delete(kunci));

  sedangJalan.set(kunci, p);
  return p;
}

Dua puluh baris ini menyelesaikan tiga masalah sekaligus: cache, thundering herd, dan penyajian basi saat sumber mati. Untuk sebagian besar kebutuhan portal berita, ini sudah cukup dan tidak butuh infrastruktur apa pun.

export const ambilKurs = () =>
  cache("kurs", 300, () => ambilJson<Kurs>(URL_KURS));

Batasnya jelas dan harus dihormati: cache ini per-task. Dua belas task ECS berarti dua belas salinan yang bisa berbeda isi, dan tiap deploy mengosongkan semuanya. Jangan pakai untuk apa pun yang harus konsisten — status langganan, kuota, penghitung. Pakai untuk data yang boleh basi dan sama untuk semua orang: kurs, daftar kategori, pengaturan situs, hasil API vendor.

Batasi ukurannya

const MAKS = 500;

// setelah peta.set(...)
if (peta.size > MAKS) {
  // Map di JavaScript mempertahankan urutan penyisipan:
  // kunci pertama adalah yang paling lama.
  const tertua = peta.keys().next().value;
  if (tertua !== undefined) peta.delete(tertua);
}

Tanpa batas, cache dengan kunci dinamis — artikel:${slug} — akan tumbuh sampai container di-OOM-kill oleh kernel, tanpa satu pun baris log yang menjelaskan. Ini kebocoran memori yang paling umum di aplikasi Node yang memakai Map sebagai cache.

Lapis 3: Redis / ElastiCache

// src/lib/redis.ts
import { createClient } from "redis";

export const redis = createClient({
  url: process.env.REDIS_URL,
  socket: { connectTimeout: 2000, reconnectStrategy: (n) => Math.min(n * 100, 3000) },
});

redis.on("error", (e) => console.error(JSON.stringify({ level: "error", redis: String(e) })));
await redis.connect();
export async function cacheRedis<T>(
  kunci: string,
  ttlDetik: number,
  ambil: () => Promise<T>,
): Promise<T> {
  try {
    const ada = await redis.get(kunci);
    if (ada) return JSON.parse(ada) as T;
  } catch {
    // Redis mati bukan alasan halaman mati.
  }

  const nilai = await ambil();

  try {
    await redis.setEx(kunci, ttlDetik, JSON.stringify(nilai));
  } catch { /* abaikan */ }

  return nilai;
}

try/catch di kedua sisi itu wajib, bukan kehati-hatian berlebihan. Cache adalah optimasi. Kalau Redis-mu mati dan aplikasimu ikut mati, kamu baru saja menambah satu titik kegagalan tunggal untuk mendapatkan kecepatan. Cache yang tidak bisa dilewati bukan cache — ia database.

Kapan Redis benar-benar dibutuhkan

KebutuhanCukup memori proses?
Kurs, kategori, pengaturan situsYa
Hasil API vendor yang sama untuk semuaYa
Rate limiting per IPTidak — harus dibagi antar task
Idempotensi webhook MidtransTidak — harus dibagi, dan tahan restart
Sesi (kalau tidak di database)Tidak
Penghitung hits yang dikumpulkanTidak

Perhatikan bahwa alasan Redis dibutuhkan hampir selalu state yang dibagi, bukan kecepatan. Kalau kebutuhanmu murni kecepatan, memori proses lebih cepat dan gratis.

Anti-pola yang mahal

Anti-polaAkibatnya
Kunci cache tanpa ID pembaca untuk data personalData satu member tersaji ke member lain
TTL sama untuk semua entriSemua kedaluwarsa bersamaan; origin diserbu serempak
Menyimpan null tanpa membedakannya dari "belum ada"Kueri yang selalu miss untuk data yang memang kosong
Map tanpa batas ukuranContainer di-OOM-kill tanpa jejak
Menyimpan objek besar (isi artikel penuh)Memori habis lebih cepat dari yang kamu kira
Cache yang kegagalannya menjatuhkan halamanTitik kegagalan tunggal baru
// Sebar TTL supaya tidak kedaluwarsa serempak
const ttl = 300 + Math.floor(Math.random() * 60);

Baris satu ini menyelesaikan baris kedua di tabel. Kalau seribu entri diisi bersamaan saat deploy — dan semuanya ber-TTL 300 detik — maka lima menit kemudian seribu entri kedaluwarsa dalam detik yang sama. Menambahkan jitter 0–60 detik menyebarkannya.

Latihan: implementasikan cache() di atas lengkap dengan pencegahan thundering herd dan batas ukuran. Uji dengan fungsi yang tidur 500 ms dan mencatat tiap kali dipanggil, lalu panggil cache() 50 kali bersamaan dengan Promise.all. Fungsi asalnya harus terpanggil tepat satu kali. Lalu buat versi tanpa sedangJalan dan bandingkan — kamu akan melihat 50 panggilan.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.