Hasil query yang sama dihitung berulang kali untuk data yang tidak berubah adalah pemborosan yang paling mudah diperbaiki. Caching menyimpan hasil pekerjaan mahal — query basis data, respons API, hasil komputasi — di tempat yang murah dijangkau, lalu menyajikannya kembali untuk permintaan berikutnya. Chapter ini membangun cache di atas Map langkah demi langkah: mulai dari hit dan miss, masa berlaku (TTL), sampai perlindungan dari cache stampede. Kita juga akan melihat posisi cache di lapisan-lapisan lain mulai dari header HTTP hingga cache terdistribusi.
Hit, Miss, dan Sumber #
Dua kata ini adalah kosakata wajib cache. Permintaan yang dilayani dari simpanan disebut hit; permintaan yang memaksa kita memuat dari sumber asli (basis data, API eksternal) disebut miss. Rasio keduanya menjadi ukuran kesehatan cache: semakin tinggi proporsi hit, semakin banyak pekerjaan mahal yang berhasil dihindari. Implementasi pertama kita menyimpan hasil di sebuah Map dan menghitung statistik tiap aksesnya:
// Cache sederhana di atas Map: ambil() mengembalikan dari
// simpanan bila ada (hit), atau memuat dari sumber (miss).
function buatCache(muatDariSumber) {
const simpanan = new Map();
let hit = 0;
let miss = 0;
return {
async ambil(kunci) {
if (simpanan.has(kunci)) {
hit += 1;
return simpanan.get(kunci);
}
miss += 1;
const nilai = await muatDariSumber(kunci);
simpanan.set(kunci, nilai);
return nilai;
},
statistik() {
return { hit, miss, ukuran: simpanan.size };
},
};
}
let panggilanSumber = 0;
async function muatProduk(id) {
panggilanSumber += 1;
// simulasi query basis data: dua tahap microtask
await Promise.resolve();
await Promise.resolve();
return `data produk ${id}`;
}
const cache = buatCache(muatProduk);
const permintaan = ["p-1", "p-2", "p-1", "p-3", "p-2", "p-1"];
for (const id of permintaan) {
await cache.ambil(id);
}
console.log("urutan permintaan :", permintaan.join(" "));
console.log("panggilan sumber :", panggilanSumber);
console.log("statistik cache :", JSON.stringify(cache.statistik()));Output yang Dihasilkan:
urutan permintaan : p-1 p-2 p-1 p-3 p-2 p-1
panggilan sumber : 3
statistik cache : {"hit":3,"miss":3,"ukuran":3}Mari kita bedah baris-per-barisnya:
- Baris 3-6 (
buatCachemembuka simpanan): function pabrik menerimamuatDariSumbersebagai parameter — sumber data disuntikkan, bukan diimpor langsung, sehingga cache ini bisa dipasangkan dengan sumber apa pun dan mudah diuji (pola yang sudah kita kenal dari Section Architecture dan Design). - Baris 10-13 (jalur hit):
simpanan.has(kunci)terpenuhi berarti nilai sudah ada; hitunganhitnaik dan nilai langsung dikembalikan tanpa menyentuh sumber. - Baris 14-18 (jalur miss): nilai dimuat lewat
await, disimpan, lalu dikembalikan. Dari enam permintaan pada contoh, hanya tiga yang sampai ke sumber — sisanya adalah hit. - Baris 27-33 (
muatProduk): sumber palsu menghitung setiap pemanggilannya; dua tahapawait Promise.resolve()mensimulasikan query asynchronous tanpa timer sungguhan, sehingga output tetap identik di setiap eksekusi.
Satu kelemahan jelas dari versi ini: entri tidak pernah tua. Data yang berhenti valid — harga berubah, stok habis — akan terus disajikan selama proses hidup. Inilah pintu masuk TTL.
Masa Berlaku dengan TTL #
TTL (time to live) memberi tiap entri umur: selama umurnya belum terlampaui entri dianggap segar, dan setelah itu entri dibuang pada akses berikutnya. Kuncinya ada pada keputusan desain kecil yang penting: jangan memanggil Date.now() di dalam cache. Suntikkan jam dari luar, maka perilaku cache bisa diuji dengan jam palsu yang kita majukan sendiri:
// Cache dengan masa berlaku (TTL). Jam disuntikkan dari luar
// supaya urutan hit/miss/kedaluwarsa dapat dijalankan ulang identik.
function buatJamPalsu() {
let waktu = 0;
return {
sekarang: () => waktu,
maju: (ms) => {
waktu += ms;
},
};
}
function buatCacheTtl(ttlMs, jam) {
const simpanan = new Map();
return {
simpan(kunci, nilai) {
simpanan.set(kunci, { nilai, dibuatPada: jam.sekarang() });
},
ambil(kunci) {
const entri = simpanan.get(kunci);
if (!entri) return "miss";
if (jam.sekarang() - entri.dibuatPada >= ttlMs) {
simpanan.delete(kunci);
return "kedaluwarsa";
}
return "hit";
},
ukuran: () => simpanan.size,
};
}
const jam = buatJamPalsu();
const cache = buatCacheTtl(5000, jam);
function lakukan(keterangan, aksi) {
console.log(`t=${String(jam.sekarang()).padStart(5)} ms ${keterangan.padEnd(34)} ${aksi()}`);
}
cache.simpan("produk-1", "Kopi Gayo");
lakukan("ambil produk-1", () => `-> ${cache.ambil("produk-1")}`);
jam.maju(3000);
lakukan("3 detik kemudian, ambil produk-1", () => `-> ${cache.ambil("produk-1")}`);
jam.maju(3000);
lakukan("6 detik, ambil produk-1", () => `-> ${cache.ambil("produk-1")}`);
lakukan("ukuran cache", () => `-> ${cache.ukuran()}`);
cache.simpan("produk-1", "Kopi Gayo");
lakukan("simpan ulang, lalu ambil", () => `-> ${cache.ambil("produk-1")}`);
lakukan("ukuran cache", () => `-> ${cache.ukuran()}`);Output yang Dihasilkan:
t= 0 ms ambil produk-1 -> hit
t= 3000 ms 3 detik kemudian, ambil produk-1 -> hit
t= 6000 ms 6 detik, ambil produk-1 -> kedaluwarsa
t= 6000 ms ukuran cache -> 0
t= 6000 ms simpan ulang, lalu ambil -> hit
t= 6000 ms ukuran cache -> 1Bedah baris-per-barisnya:
- Baris 3-11 (
buatJamPalsu): jam palsu menyimpan angka milidetik mulai dari 0;maju()menambah waktunya. Cache tidak tahu dan tidak peduli jamnya palsu — untuk produksi kita menyuntikkan pembungkusDate.now()dan seluruh logika TTL tetap sama. - Baris 17-19 (
simpan): setiap entri membawa nilai plus stempeldibuatPadadari jam yang disuntikkan. - Baris 20-28 (
ambil): tiga kemungkinan hasil. Entri absen berartimiss; entri berumur lebih dari atau sama denganttlMsberartikedaluwarsa— sekaligus dihapus agar memori tidak menumpuk; selebihnyahit. - Baris 40-52 (skenario): dengan TTL 5.000 ms, akses pada detik ke-0 dan ke-3 masih hit, akses pada detik ke-6 jatuh kedaluwarsa, lalu simpan ulang membuat entri baru yang segar kembali.
Pembersihan pada akses seperti di atas disebut lazy expiration — entri tua dibuang saat disentuh. Alternatifnya pembersihan berkala lewat setTimeout/setInterval, yang sepadan untuk cache besar agar entri tak lagi diakses pun akhirnya membebaskan memori; ingat saja untuk membersihkan timernya saat cache dimusnahkan, agar tidak menjadi kebocoran listener dari chapter sebelumnya.
Invalidasi dan Bahaya Data Basi #
TTL menjawab “entri berumur berapa lama”, tetapi ada kalanya kita tahu lebih cepat bahwa data sudah berubah — misalnya tepat setelah operasi tulis. Invalidasi adalah tindakan menghapus atau memperbarui entri sebelum umurnya habis. Tiga strategi umumnya:
| Strategi | Caranya | Konsekuensi |
|---|---|---|
| Hapus saat tulis | Operasi tulis menghapus kunci terkait dari cache | Sederhana; request berikutnya otomatis miss lalu memuat data segar |
| Perbarui saat tulis | Operasi tulis menulis nilai baru ke cache dan sumber sekaligus | Data selalu segar, tetapi tulis jadi lebih mahal dan rawan balapan antar penulis |
| TTL pendek sebagai jaring pengaman | Biarkan entri tua mati sendiri | Sederhana; data basi dibatasi umur, bukan dihilangkan |
Cache Stampede dan Single-Flight #
Saat entri populer jatuh kedaluwarsa tepat ketika ratusan request datang serentak, semua request itu menemukan cache kosong dan semuanya menyerang sumber asli bersamaan — inilah cache stampede. Basis data yang sehat bisa lumpuh hanya karena satu kunci favorit mati. Penawarnya adalah single-flight: peminta pertama memulai pemuaatan, dan peminta lain menunggu promise yang sama alih-alih memulai pemuaatan sendiri:
// Single-flight: permintaan serentak untuk kunci yang sama
// berbagi satu promise, sehingga sumber asli hanya dipanggil sekali.
function buatCacheSingleFlight(muatDariSumber) {
const simpanan = new Map();
const sedangBerjalan = new Map();
return {
async ambil(kunci) {
if (simpanan.has(kunci)) {
return simpanan.get(kunci);
}
if (sedangBerjalan.has(kunci)) {
return sedangBerjalan.get(kunci);
}
const janji = muatDariSumber(kunci).then((nilai) => {
simpanan.set(kunci, nilai);
sedangBerjalan.delete(kunci);
return nilai;
});
sedangBerjalan.set(kunci, janji);
return janji;
},
};
}
let panggilanSumber = 0;
async function muatProduk(id) {
panggilanSumber += 1;
// simulasi query lambat: hasil baru tersedia setelah beberapa tahap
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
return `data ${id}`;
}
const cache = buatCacheSingleFlight(muatProduk);
const [a, b, c] = await Promise.all([
cache.ambil("produk-1"),
cache.ambil("produk-1"),
cache.ambil("produk-1"),
]);
console.log("tiga permintaan serentak, hasilnya:");
console.log(" a:", a);
console.log(" b:", b);
console.log(" c:", c);
console.log("panggilan ke sumber:", panggilanSumber);
const d = await cache.ambil("produk-1");
console.log("permintaan keempat:", d);
console.log("total panggilan ke sumber:", panggilanSumber);Output yang Dihasilkan:
tiga permintaan serentak, hasilnya:
a: data produk-1
b: data produk-1
c: data produk-1
panggilan ke sumber: 1
permintaan keempat: data produk-1
total panggilan ke sumber: 1Bedah baris-per-barisnya:
- Baris 4-5 (dua
Map):simpananmenampung hasil jadi;sedangBerjalanmenampung promise yang masih dikerjakan — keduanya harus terpisah karena isinya benda yang berbeda (nilai vs janji nilai). - Baris 8-14 (tiga jalur
ambil): entri jadi langsung dikembalikan; bila sedang dimuat, peminta baru menerima promise yang sedang berjalan itu; bila belum ada keduanya, peminta inilah yang memulai pemuaatan. - Baris 15-21 (janji bersama):
.then()menyimpan hasil kesimpanan, menghapus jejak darisedangBerjalan, lalu mengembalikan nilai — semua penunggu menerima nilai yang sama dari satu promise. - Baris 39-43 (
Promise.all): tigaambil()dieksekusi hampir bersamaan; karenamuatProdukbaru selesai setelah beberapa tahap microtask, pemanggil kedua dan ketiga masih menemukan janji yang belum selesai dan ikut menunggu — sumber hanya dipanggil sekali. - Baris 50-52 (permintaan keempat): dilayani dari
simpanantanpa menyentuh sumber, sehingga total pemanggilan tetap satu.
Cache di Lapisan Lain #
Cache yang kita bangun tinggal di memori satu proses: cepat, sederhana, tetapi ikut hilang saat proses restart dan tidak dibagi antar proses. Di ekosistem nyata, cache hidup berlapis:
| Lapisan | Tempat hidup | Kapan dipakai |
|---|---|---|
In-process (Map) | Memori satu proses Node.js | Data kecil, privat per proses, boleh hilang kapan saja |
| Terdistribusi (misalnya Redis) | Proses terpisah di jaringan | Data dibagi banyak instance; Redis sendiri tidak terpasang di lingkungan Course ini, jadi kami membahasnya secara konsep |
| HTTP caching | Browser dan CDN, diatur dari server lewat header | Respons publik yang sama untuk banyak klien |
Untuk lapisan HTTP, server ikut mengatur perilaku cache klien lewat header respons. Contoh berikut menandai respons boleh disimpan 60 detik dan boleh disajikan basi selama 30 detik berikutnya sambil dimuat ulang di latar belakang:
res.writeHead(200, {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=60, stale-while-revalidate=30",
});
res.end(JSON.stringify(daftarProduk));Kombinasi arahan max-age dan stale-while-revalidate di atas adalah padanan HTTP dari single-flight: klien memakai salinan lama sementara versi baru dimuat sekali. Rincian seluruh arahan header ini dibahas di dokumentasi Cache-Control di MDN
, dan gambaran besar berlapisnya ada di panduan HTTP caching MDN
.
Satu proses dengan cache yang sehat kini melayani jauh lebih banyak request per detik. Pertanyaan berikutnya lazim muncul: bagaimana kalau satu proses saja tidak cukup lagi? CPU dengan banyak core menunggu untuk dipakai penuh — dan node:cluster adalah cara resmi Node.js membagi satu port ke banyak proses. Itulah topik chapter Clustering.