Caching artinya menyimpan salinan jawaban di tengah jalan, supaya pertanyaan yang sama gak perlu sampai ke sumber aslinya. Bayangin toko roti dengan banyak cabang. Roti tawar yang sama untuk semua orang dipajang di etalase cabang dekat rumahmu, jadi kamu gak perlu ke pabrik. Di pintu dapur pabrik ada rak berisi roti yang baru keluar oven, buat kurir yang datang beberapa detik kemudian. Di kulkas dapur ada adonan siap panggang, jadi gak tiap roti mulai dari tepung. Tiap roti punya tanggal kedaluwarsa, kue ulang tahun bertuliskan namamu gak pernah dipajang, dan saat resep berubah, roti lama ditarik dari etalase. catatan-api punya layer yang sama persis: CDN (etalase cabang), nginx proxy_cache di web-1 (rak di pintu dapur), Redis (kulkas), dan Postgres (gudang tepung).
🧪 Tebakan cepat: CDN sudah menyala di depan catatan-api dan
/catatan/populerboleh disimpan 60 detik. Dalam satu menit ada 60 klik dari Facebook ke/catatan/populer?fbclid=..., dan tiap klik punyafbclidsendiri. Berapa dari 60 request itu yang sampai keweb-1? Tebak dulu, jawabannya ada di bagian "CDN: s-maxage, Cache Rule, dan cache key".
Empat layer di depan catatan-api
Request berjalan dari luar ke dalam, dan berhenti di layer pertama yang punya salinannya:
| Layer | Di mana | Menyimpan apa | Berapa lama |
|---|---|---|---|
| CDN (Cloudflare) | edge Jakarta, Surabaya, Singapura | jawaban publik yang sama untuk semua orang | sesuai s-maxage dari header |
nginx proxy_cache |
disk web-1 |
jawaban dari Node yang boleh dibagi | 10 detik (di config) |
| Redis | memori, di samping app | data hasil query, per key | EX 60 per key |
| Postgres | disk database | data asli | selamanya |
Sebelum CDN masih ada cache browser, di perangkat pengunjung sendiri (dibahas di pelajaran HTTP Caching: Cache-Control, ETag, Validation). Dari layer yang kamu kelola di server, CDN yang paling dekat dengan user: secara jarak dan secara urutan.
Satu menit traffic catatan-api
Lab-nya mengirim 10 request per detik selama 1 menit, jadi 600 request: 4 ke /assets/app.3f9a1c.js, 3 ke /catatan/populer, 1 ke /catatan/populer?fbclid=..., dan 2 ke /catatan (daftar catatan milik user yang login, 20 user bergantian). Pengunjung masuk lewat 3 edge. Waktunya contoh: edge 15 ms, edge ke web-1 30 ms, Redis 2 ms, query populer 150 ms. Hasilnya:
| Yang menyala | Dijawab edge | Sampai ke web-1 |
Sampai ke Node | Query Postgres | Rata-rata |
|---|---|---|---|---|---|
| belum ada cache | 0 (0%) | 600 | 360 | 360 | 97 ms |
| CDN | 414 (69%) | 186 | 183 | 183 | 45,6 ms |
| CDN + nginx | 414 (69%) | 186 | 126 | 126 | 30,9 ms |
| CDN + nginx + Redis | 414 (69%) | 186 | 126 | 21 | 26,6 ms |
| semua + cache key tanpa query | 474 (79%) | 126 | 121 | 21 | 23,6 ms |
Hit ratio adalah persen request yang dijawab dari cache: hit / total × 100. 414 dari 600 jadi 69%. Tiap layer menangkap yang lolos dari layer sebelumnya, jadi Postgres turun dari 360 query jadi 21.
CDN: s-maxage, Cache Rule, dan cache key
CDN menurut pada header Cache-Control yang dikirim app:
app.get("/catatan/populer", async (req, res) => {
res.set("Cache-Control", "public, max-age=0, s-maxage=60");
res.json(await ambilPopuler());
});
app.get("/catatan", wajibLogin, async (req, res) => {
res.set("Cache-Control", "private, no-store");
res.json(await ambilCatatanUser(req.user.id));
});
public, max-age=0, s-maxage=60: browser selalu bertanya lagi (max-age=0), tapi shared cache seperti CDN dan nginx boleh menyimpan 60 detik (s-maxage).private, no-store: data per user. CDN meneruskannya keweb-1tanpa menyimpan apa pun.- Aset di
/assets/dikirim nginx langsung dari disk (tanpa lewat Node) dengan headerpublic, max-age=31536000, immutable: simpan setahun, gak usah tanya lagi. Aman karena isi baru = nama file baru (app.8b21d4.js).
Di Cloudflare, path tanpa ekstensi file statis seperti /catatan/populer gak di-cache sama sekali (statusnya DYNAMIC) sampai kamu membuat Cache Rule yang menandainya eligible for cache.
Cache key adalah nama yang dipakai CDN untuk memutuskan dua request itu "file yang sama". Bawaannya URL lengkap, termasuk query string. Jadi /catatan/populer?fbclid=IwAR1000 dan ?fbclid=IwAR1001 adalah dua key berbeda, dan tiap key baru selalu MISS. Ke-60 request itu sampai ke web-1 semua. Itu jawaban tebakannya. Atur cache key path ini supaya mengabaikan query string, dan hit ratio-nya naik dari 69% jadi 79%. Sisanya 20% adalah /catatan, yang memang gak boleh disimpan di CDN.
nginx proxy_cache di web-1
CDN punya banyak edge, dan tiap edge MISS sendiri-sendiri. nginx di web-1 (dari pelajaran Reverse Proxy & Load Balancing) menyimpan satu salinan untuk semuanya:
# /etc/nginx/sites-available/catatan-api
proxy_cache_path /var/cache/nginx/catatan levels=1:2 keys_zone=catatan:10m max_size=100m inactive=10m;
server {
server_name catatan.example.com;
location /catatan/populer {
proxy_pass http://127.0.0.1:3000;
proxy_cache catatan;
proxy_cache_key $scheme$host$uri;
proxy_cache_valid 200 10s;
proxy_ignore_headers Cache-Control;
add_header X-Cache-Status $upstream_cache_status;
}
}
proxy_cache_pathmenyiapkan folder cache dankeys_zone=catatan:10m, memori untuk daftar key.proxy_cache_key $scheme$host$uri:$uritanpa query string, jadi semua?fbclid=...berbagi satu salinan di sini.proxy_cache_valid 200 10splusproxy_ignore_headers Cache-Control: nginx menyimpan jawaban 200 selama 10 detik, apa pun header-nya. Pakai hanya di location yang publik.X-Cache-StatusmemperlihatkanHIT,MISS, atauEXPIREDdi response saat dicek dengancurl -I.
Hasilnya, 63 request populer yang lolos dari CDN tinggal 6 yang sampai ke Node: satu tiap 10 detik. App PHP (Laravel lewat PHP-FPM) memakai pasangannya, fastcgi_cache, dengan cara yang sama.
Redis: cache-aside untuk data aplikasi
Request yang sampai ke Node masih bisa gak menyentuh Postgres. Pola paling umum namanya cache-aside (atau lazy loading): cek cache dulu, kalau gak ada (miss) ambil dari database lalu simpan ke cache untuk request berikutnya.
async function ambilCatatanUser(userId) {
const key = `catatan:user:${userId}`;
const simpanan = await redis.get(key);
if (simpanan !== null) return JSON.parse(simpanan); // HIT
const { rows } = await db.query("SELECT id, judul, isi FROM catatan WHERE user_id = $1", [userId]);
await redis.set(key, JSON.stringify(rows), { EX: 60 }); // MISS: simpan 60 detik
return rows;
}
- Key-nya per user (
catatan:user:42), jadi data user lain gak pernah tertukar. Inilah tempat data private di-cache, bukan CDN. { EX: 60 }memberi TTL (time to live) 60 detik: lewat dari itu key-nya hilang sendiri.ambilPopuler()memakai pola yang sama dengan keycatatan:populer. Di Lab, 120 request/catatandari 20 user cuma butuh 20 query: satu per user per menit. Perintah Redis lainnya ada di pelajaran Redis Basics.
Deploy: purge dari dalam ke luar
Bagian tersulit dari caching adalah cache invalidation: memastikan salinan dihapus atau diganti begitu data aslinya berubah. Ada tiga cara: tunggu TTL habis, hapus saat data berubah (event-based, misalnya redis.del di route POST /catatan), atau ganti nama file (versioning, seperti app.8b21d4.js).
Saat deploy, CDN bisa dikosongkan lebih awal dengan purge. Di Lab, deploy versi baru setelah 30 detik tanpa purge membuat 192 jawaban di menit berikutnya masih versi lama, karena edge mengisi ulang salinannya dari nginx dan Redis yang juga masih menyimpan versi lama. Dengan purge, angkanya 0. Urutannya dari dalam ke luar:
# bagian akhir deploy.sh
redis-cli DEL catatan:populer
sudo find /var/cache/nginx/catatan -type f -delete
curl -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"files":["https://catatan.example.com/catatan/populer"]}'
Aset gak perlu di-purge, karena nama file barunya belum pernah ada di cache mana pun.
Di latihan: membaca header Cache-Control
parseCacheControl(header) mengurai header jadi object, seperti yang dilakukan CDN. Pecah di koma, trim() tiap bagian, lalu: bagian yang mengandung = dipecah jadi key dan angka (Number), sisanya jadi true. Urutan key mengikuti urutan di header:
| Panggilan | Hasil |
|---|---|
parseCacheControl("public, max-age=86400") |
{ public: true, "max-age": 86400 } |
parseCacheControl("private, no-cache, no-store") |
{ private: true, "no-cache": true, "no-store": true } |
parseCacheControl("public, max-age=3600, s-maxage=7200") |
{ public: true, "max-age": 3600, "s-maxage": 7200 } |
⚠️ Jebakan yang sering ketemu
- Data per user masuk shared cache. Lupa
privatedi/catatan, dan nginx atau CDN bisa menyimpan catatan user 3 lalu memberikannya ke user 7, karena key-nya cuma URL. Data per user di-cache di Redis dengan key per user. - Query string acak memecah cache.
fbclid,utm_source, atau?t=yang selalu beda membuat tiap request MISS. Atur cache key-nya. - Purge dari luar ke dalam. Purge CDN dulu, dan edge langsung mengambil salinan lama dari nginx atau Redis. Kosongkan Redis, lalu nginx, baru CDN.
- Aset tanpa hash diberi
immutable.app.jsyang isinya berubah tetap dipakai browser lama sampai setahun, dan purge CDN gak menjangkau browser.immutablehanya untuk nama file ber-hash.
TL;DR: Cache menyimpan salinan supaya request berhenti sebelum sampai ke sumber. Untuk catatan-api urutannya CDN (paling dekat dengan user), nginx proxy_cache di web-1, Redis di samping app, lalu Postgres; di Lab, 360 query per menit turun jadi 21. Hit ratio = hit / total × 100. CDN menurut Cache-Control: public, max-age=31536000, immutable untuk aset ber-hash, s-maxage untuk shared cache, private, no-store untuk data per user. Cache key bawaannya URL lengkap, jadi query string acak membuat MISS. Redis memakai cache-aside (cek cache, miss ambil dari database, simpan dengan EX). Masalah utama caching adalah cache invalidation: pakai TTL, hapus saat data berubah, atau ganti nama file, dan saat deploy purge dari dalam ke luar.