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
| Lapis | Latensi | Dibagi antar task? | Porsi trafik yang diserap |
|---|---|---|---|
| Cloudflare edge | ~5–30 ms ke pembaca | Global | 90–95% |
| Memori proses (Map) | < 0,01 ms | Tidak | ~3% |
| Redis / ElastiCache | ~0,5–2 ms | Ya | ~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
| Kebutuhan | Cukup memori proses? |
|---|---|
| Kurs, kategori, pengaturan situs | Ya |
| Hasil API vendor yang sama untuk semua | Ya |
| Rate limiting per IP | Tidak — harus dibagi antar task |
| Idempotensi webhook Midtrans | Tidak — harus dibagi, dan tahan restart |
| Sesi (kalau tidak di database) | Tidak |
| Penghitung hits yang dikumpulkan | Tidak |
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-pola | Akibatnya |
|---|---|
| Kunci cache tanpa ID pembaca untuk data personal | Data satu member tersaji ke member lain |
| TTL sama untuk semua entri | Semua kedaluwarsa bersamaan; origin diserbu serempak |
Menyimpan null tanpa membedakannya dari "belum ada" | Kueri yang selalu miss untuk data yang memang kosong |
Map tanpa batas ukuran | Container di-OOM-kill tanpa jejak |
| Menyimpan objek besar (isi artikel penuh) | Memori habis lebih cepat dari yang kamu kira |
| Cache yang kegagalannya menjatuhkan halaman | Titik 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.