Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.
01Apa cara terbaik untuk invalidate cache?
Tidak ada satu cara terbaik; pilih metode sesuai seberapa stale data boleh terbaca. Pasang TTL di setiap key sebagai pengaman, hapus atau beri versi pada key setelah commit database bila data harus segar, dan tambahkan jitter serta single-flight agar tidak terjadi stampede. Kebanyakan sistem produksi memakai ketiganya sekaligus.
02Sebaiknya pakai TTL atau invalidation berbasis event?
Pakai TTL bila data sedikit stale masih bisa diterima, karena staleness terburuk sama dengan TTL dan tidak ada yang bisa gagal. Pakai invalidation berbasis event bila data stale merugikan uang, seperti harga atau jumlah stok, tetapi tetap pertahankan TTL sebagai pengaman bila purge hilang.
03Apa itu cache stampede dan bagaimana mencegahnya?
Cache stampede terjadi saat key populer expired dan banyak request miss bersamaan, semuanya menjalankan query mahal yang sama. Cegah dengan single-flight agar caller bersamaan berbagi satu rebuild, lock seperti SET NX PX di Redis, jitter pada TTL, atau probabilistic early expiration.
04Sebaiknya hapus key cache atau update saat data berubah?
Hapus setelah commit database adalah default yang lebih aman, karena read berikutnya membangun ulang dari source of truth dan Anda tidak menulis nilai dari update yang belum selesai. Update langsung hanya masuk akal bila Anda bisa menulis nilai baru yang tepat dalam langkah yang sama. Jangan pernah purge sebelum commit.
05Bagaimana menghapus banyak key Redis berdasarkan pola tanpa memblokir?
Hindari KEYS yang menelusuri seluruh keyspace dalam satu perintah. Pakai redis-cli --scan dengan pola lalu alirkan hasilnya ke UNLINK per batch, karena UNLINK mengembalikan memori di thread lain. Lebih baik lagi, taruh version counter di key agar satu INCR mematikan seluruh grup.
Strategi Cache Invalidation: TTL, Event, dan Versioned Key
Cara invalidate cache dengan benar: kenapa data jadi stale, beda TTL, event-driven purge, dan versioned key, serta jitter dan single-flight untuk cegah stampede.
Invalidate cache dengan memilih metode sesuai seberapa stale data boleh terbaca. Pasang TTL sebagai pengaman, hapus atau beri versi pada key setelah commit database bila data harus segar, lalu tambahkan jitter dan single-flight agar satu expiry hanya memicu satu rebuild, bukan stampede. Satu teknik saja tidak cukup untuk ketiga masalah itu.
Bayangkan ERP carwash tempat owner menaikkan harga paket cuci dari Rp10.000 menjadi Rp12.000. Database sudah mencatat 12.000, tetapi tablet kasir masih menampilkan 10.000, dan tidak ada yang tahu sampai kapan. Selisih antara source of truth dan salinannya itulah inti cache invalidation.
Saya membangun sistem ERP dan POS dengan NestJS, Postgres, dan Redis, jadi artikel ini memakai stack tersebut. Artikel ini menjawab pertanyaan yang sering dicari: bagaimana cara invalidate cache dengan benar memakai TTL, event, atau versioned key? Setiap angka dihitung langsung di teks, dan setiap perilaku Redis dirujuk ke dokumentasi resmi di bagian akhir.
Kenapa cache mengembalikan data stale setelah database berubah?
Cache adalah salinan kedua, dan dua salinan akan berbeda setiap kali salah satunya berubah sendirian. Solusi yang paling jelas, hapus key saat menulis, tetap menyisakan celah. Pada pola cache-aside, reader yang mulai sebelum write bisa selesai setelah delete lalu menaruh nilai lama kembali. Berikut urutan kejadiannya dengan TTL 60 detik.
t0 reader A: GET product:42 -> miss
t1 reader A: SELECT price ... -> 10000 (old row)
t2 writer B: UPDATE price = 12000; COMMIT
t3 writer B: DEL product:42 -> key did not exist yet, nothing to delete
t4 reader A: SET product:42 10000 EX 60 -> the old price is cached again
# Result: every reader sees 10000 until t4 + 60 s, although the database
# says 12000 and the invalidation "worked".
Jadi purge saat write tidak menjamin data segar dengan sendirinya. Purge hanya mempersempit jendela stale dari seluruh TTL menjadi interleaving langka di atas, itu pun hanya bila purge terjadi setelah commit. TTL pada setiap key yang membatasi kerusakan ketika interleaving itu terjadi, karena itu event-driven invalidation dan TTL adalah pasangan, bukan pesaing.
Jangan menganggap delete-on-write sebagai bukti reader selalu mendapat data segar. Reader yang memuat row sebelum commit Anda bisa menyimpannya lagi ke cache setelah delete, dan nilai stale itu hidup selama TTL penuh. Pasang TTL pada setiap key, bahkan key yang Anda purge secara eksplisit.
Bagaimana cara kerja TTL dan berapa lama sebaiknya?
TTL adalah invalidation paling sederhana: set key dengan expiry, misalnya SET key value EX 60 di Redis, dan entri hilang sendiri. Staleness terburuk sama dengan TTL. Bila update datang di waktu acak, rata-rata umur nilai yang salah sekitar setengahnya, yaitu 30 detik pada TTL 60 detik. Pilih TTL dari tingkat staleness yang bisa diterima bisnis, bukan dari angka yang terasa rapi.
Harganya adalah kerja rebuild. Key yang dibaca terus-menerus dibangun ulang sekali per TTL: 3600 dibagi 60 menghasilkan 60 rebuild per jam pada TTL 60 detik, dan 3600 dibagi 5 menghasilkan 720 per jam pada TTL 5 detik, dua belas kali lipat beban database untuk key yang sama. Lalu ada expiry yang serempak. Bila 10.000 key di-warm bersamaan dengan TTL 300 detik, semuanya expired di detik yang sama. Jitter plus minus 10 persen menyebarkannya ke rentang 270 sampai 330 detik, jendela 60 detik, sehingga lonjakan menjadi sekitar 10.000 dibagi 60, kira-kira 167 rebuild per detik.
Apa itu cache stampede dan bagaimana single-flight dan jitter mencegahnya?
Stampede, disebut juga thundering herd atau dogpile, terjadi saat key populer expired dan banyak request miss bersamaan, masing-masing menjalankan query mahal yang sama. Ambil key yang dibaca 200 kali per detik dengan rebuild 400 ms. Selama rebuild datang 200 kali 0,4 sama dengan 80 request, sehingga 80 query identik menghantam Postgres, bukan 1. Bila pool berisi 10 koneksi, yang merupakan asumsi untuk contoh ini, 70 di antaranya antre dan rebuild yang lambat menjadi makin lambat. Wikipedia menyebut tiga mitigasi: locking, external recomputation, dan probabilistic early expiration. Kode di bawah menggabungkan yang pertama dengan jitter.
import Redis from "ioredis";
import { randomUUID } from "node:crypto";
const redis = new Redis();
const inflight = new Map<string, Promise<string>>();
const BASE_TTL = 300; // seconds
const JITTER = 0.1; // +/- 10 percent -> 270..330 s
const ttlWithJitter = () =>
Math.round(BASE_TTL * (1 - JITTER + Math.random() * 2 * JITTER));
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// Delete the lock only if it is still ours (the pattern from the Redis SET docs).
const UNLOCK = `if redis.call("get",KEYS[1]) == ARGV[1]
then return redis.call("del",KEYS[1]) else return 0 end`;
export async function getCached(
key: string,
load: () => Promise<string>,
): Promise<string> {
const hit = await redis.get(key);
if (hit !== null) return hit;
// Layer 1: single-flight inside this process. 80 callers, one promise.
const pending = inflight.get(key);
if (pending) return pending;
const run = (async () => {
// Layer 2: one rebuilder across every process that shares this Redis.
const lockKey = "lock:" + key;
const token = randomUUID();
const got = await redis.set(lockKey, token, "PX", 5000, "NX");
if (got !== "OK") {
await sleep(50); // another process is rebuilding; look again shortly
const again = await redis.get(key);
if (again !== null) return again;
// Still empty: load anyway. A slow rebuilder must not fail every reader.
}
try {
const value = await load();
await redis.set(key, value, "EX", ttlWithJitter());
return value;
} finally {
if (got === "OK") await redis.eval(UNLOCK, 1, lockKey, token);
}
})().finally(() => inflight.delete(key));
inflight.set(key, run);
return run;
}
Dua lapisan punya tugas berbeda. Map in-process adalah single-flight: caller yang bersamaan dalam satu proses Node berbagi satu promise, jadi 80 caller menjadi satu. Lock Redis, SET dengan NX dan expiry PX, memilih satu rebuilder di seluruh proses. Lock membawa token acak dan dilepas oleh script yang hanya menghapusnya bila token masih cocok, pola yang ditampilkan di dokumentasi Redis SET, sehingga client yang lambat tidak menghapus lock milik client lain. Redis menyebut lock SET NX EX polos kurang dianjurkan dibanding Redlock bila butuh jaminan lebih kuat, dan untuk rebuild cache, lock yang hilang hanya berakibat satu query ganda.
Alternatifnya adalah menghindari miss sama sekali. Probabilistic early expiration, disebut XFetch di Wikipedia, membuat tiap reader recompute lebih awal ketika now dikurangi delta dikali beta dikali ln(U) sudah mencapai atau melewati expiry, dengan delta adalah waktu rebuild terakhir, U bilangan acak antara 0 dan 1, dan beta default 1. Dengan rebuild 0,4 detik, reader me-refresh rata-rata sekitar 0,4 detik sebelum expiry, karena rata-rata minus ln(U) adalah 1. Tidak perlu lock, tetapi Anda harus menyimpan delta di samping nilainya.
Kapan sebaiknya invalidate dengan event, bukan menunggu TTL?
Pakai event-driven invalidation bila data stale punya biaya yang tidak bisa dibatasi TTL: harga, jumlah stok, hak akses. Aturannya soal urutan. Tulis ke database, commit, baru hapus key. Purge sebelum commit membuat reader mengisi ulang row lama di celah itu, dan nilai salah bertahan sampai TTL-nya habis.
// Wrong: purge first, write second. A reader refills the OLD row in between.
await redis.del("product:" + id);
await db.query("UPDATE products SET price = $1 WHERE id = $2", [price, id]);
// Right: the purge is the LAST step, after the commit is durable.
await db.query("BEGIN");
await db.query("UPDATE products SET price = $1 WHERE id = $2", [price, id]);
await db.query("COMMIT");
// UNLINK frees memory in another thread; DEL does it on the main one.
await redis.unlink("product:" + id);
// Optional: tell other app instances to drop their in-process copy.
// Pub/Sub is fire and forget, so this is a hint. The TTL stays as the backstop.
await redis.publish("cache:purge", "product:" + id);
Ada dua detail penting. UNLINK menghapus key seperti DEL tetapi mengembalikan memori di thread lain, jadi value besar tidak memblokir server. Selain itu Redis Pub/Sub bersifat fire and forget: halaman keyspace notifications menyatakan event yang dipublikasikan saat subscriber terputus akan hilang. Jadi broadcast purge hanyalah optimasi untuk salinan in-process, bukan satu-satunya pertahanan. Bila purge yang terlewat tidak boleh terjadi, tulis purge ke tabel outbox dalam transaksi yang sama dan biarkan worker mengulangnya.
Di ERP, jalur write biasanya hanya beberapa method service yang dikenal. Taruh invalidation di method yang sama dengan commit, di balik satu helper, jangan menyebar panggilan DEL di controller. Satu tempat untuk diaudit lebih baik daripada empat puluh.
Apa itu versioned key dan kapan lebih baik daripada menghapus?
Versioned key menaruh counter di nama key: product:42:v7. Untuk invalidate, naikkan counter. Entri lama tidak akan dibaca lagi dan habis sendiri lewat TTL, jadi tidak ada yang perlu dicari dan dihapus. Cara ini juga menutup race di bagian pertama, asalkan reader mengambil versi sebelum membaca database dan writer menaikkan versi setelah commit. Reader yang memegang versi 7 dan memuat row lama menulis ke product:42:v7, key yang tidak akan diminta siapa pun setelah counter menjadi 8.
// The version counter is the only thing a writer touches.
const verKey = (id: string) => "ver:product:" + id;
export async function readProduct(id: string) {
// Capture the version BEFORE the database read, never after.
const v = (await redis.get(verKey(id))) ?? "0";
const key = "product:" + id + ":v" + v;
const hit = await redis.get(key);
if (hit !== null) return JSON.parse(hit);
const row = await loadProductFromPostgres(id);
await redis.set(key, JSON.stringify(row), "EX", 3600);
return row;
}
export async function onProductCommitted(id: string) {
// Run after COMMIT. Old keys are unreachable at once and expire by TTL.
await redis.incr(verKey(id));
}
Harganya satu round trip tambahan per read untuk mengambil versi, dan key mati memakai memori sampai TTL-nya habis, jadi atur TTL dan kebijakan maxmemory. Sebagai gantinya satu INCR pada namespace seperti ver:products mematikan semua key list dan detail yang dibangun di atasnya, itulah cara invalidate sekelompok key tanpa scan. Untuk hot path, simpan versi di variabel in-process berumur pendek dan terima lag beberapa detik.
Bagaimana cara invalidate banyak key Redis tanpa memblokir server?
Tahan godaan untuk purge berdasarkan pola. KEYS menelusuri seluruh keyspace dalam satu perintah, hal yang salah dijalankan di Redis yang juga melayani session dan rate limit Anda. SCAN mengembalikan key per batch, dan UNLINK menghapus tanpa memblokir, jadi versi aman mengalirkan hasil 100 key sekali jalan.
# Wrong: KEYS walks the whole keyspace in one command, then DEL frees on the main thread.
redis-cli KEYS 'product:*' | xargs redis-cli DEL
# Right: SCAN in batches, UNLINK 100 keys at a time, reclaim memory off-thread.
redis-cli --scan --pattern 'product:*' | xargs -n 100 redis-cli UNLINK
# Better still: no scan at all. One INCR retires every key under a namespace.
redis-cli INCR ver:products
Bahkan versi aman itu adalah batch job, bukan strategi invalidation. Di antara scan dan UNLINK terakhir, reader bisa mengisi ulang key yang sudah Anda purge, dan pada Redis Cluster key tersebar di node berbeda. Bila Anda rutin perlu membuang sekelompok key, itu tanda untuk merancang version counter ke dalam key sejak awal.
Strategi cache invalidation mana yang sebaiknya dipilih?
Lima opsi di bawah bukan alternatif untuk diperingkat. Kebanyakan sistem menggabungkan TTL, satu mekanisme kesegaran, dan pengaman stampede. Tabel ini menyatakan apa yang benar-benar dijamin masing-masing.
Strategi
Staleness terburuk
Kegagalan utama
Paling cocok untuk
TTL saja
Seluruh TTL
Expiry serempak dan stampede
Data referensi yang jarang berubah, seperti katalog layanan
TTL dengan jitter dan single-flight
Seluruh TTL
Lock habis sebelum rebuild selesai sehingga terjadi query ganda
Hot key dengan rebuild mahal
Delete setelah commit
Hampir nol, tetapi seluruh TTL bila reader menyimpan ulang row lama
Delete hilang atau berjalan sebelum commit
Harga, stok, dan hak akses yang mahal bila stale
Versioned key
Hampir nol, tanpa race refill
Satu read tambahan per request dan key mati memakai memori sampai TTL
Sekelompok key yang harus dimatikan bersama
Stale-while-revalidate
TTL ditambah jendela stale
Pengguna sesaat melihat data lama, memang disengaja
Dashboard dan feed yang mengutamakan kecepatan daripada ketepatan
Stale-while-revalidate distandarkan untuk cache HTTP di RFC 5861: Cache-Control max-age=600, stale-while-revalidate=30 berarti respons segar selama 600 detik dan boleh disajikan stale 30 detik lagi sementara refresh berjalan di background. Ide yang sama bisa dipakai di kode aplikasi. Lalu jalani checklist ini untuk setiap key.
Tulis tingkat staleness yang diterima bisnis dalam detik, dan set TTL paling besar sama dengan angka itu.
Tambahkan jitter 10 persen pada TTL setiap key yang di-warm secara massal.
Bila staleness di atas beberapa detik merugikan uang, delete atau beri versi pada key setelah commit, dan pertahankan TTL sebagai pengaman.
Pasang single-flight atau lock Redis pada key yang rebuild-nya lebih dari beberapa puluh milidetik dan sering dibaca.
Cache invalidation sulit karena tiga masalah bersembunyi di satu nama: seberapa stale data boleh terbaca, berapa rebuild yang dipicu satu expiry, dan cara mematikan sekelompok key. Selesaikan masing-masing dengan sengaja. TTL membatasi staleness, delete-after-commit atau version counter membuat data segar, dan jitter dengan single-flight mencegah satu expiry menjadi delapan puluh query.