π Topik: Strategi Caching β Cache-Aside, Write-Through, dan Proteksi Stampede Kategori: Performance Β· Level: intermediateβadvanced
π― Tujuan Belajar
Setelah sesi ini, keputusan berikut jadi lebih terdefinisi:
- Bisa memilih pola baca/tulis cache berdasarkan karakteristik konsistensi yang benar-benar dibutuhkan, bukan berdasarkan kebiasaan.
- Bisa menjelaskan mengapa cache-aside + delete-on-write tidak linearizable, dan tahu mitigasi apa yang realistis.
- Bisa mendesain proteksi stampede berlapis (in-process β distributed β probabilistic) dan tahu kapan cukup satu lapis.
- Bisa mengenali kapan tidak menambahkan cache sama sekali β karena cache adalah state tambahan yang bisa divergen, bukan sekadar "bikin cepat".
π§ Penjelasan Inti
Cache menyelesaikan satu masalah: jarak antara kecepatan compute/network dan kecepatan storage, plus biaya (query DB mahal, IOPS terbatas, egress berbayar).
Tapi cache memperkenalkan masalah baru yang tidak ada sebelumnya:
| Masalah baru | Sumber |
|---|---|
| Staleness | Salinan data berbeda dari sumber kebenaran |
| Invalidation | Tidak ada cara murah memberi tahu semua salinan "data berubah" |
| Stampede / thundering herd | Banyak request miss bersamaan menghantam backend |
| Penetration | Query untuk key yang tidak ada selalu miss β selalu ke DB |
| Avalanche | Banyak key expire bersamaan atau cache cluster mati |
| Hot key | Satu key menerima trafik mayoritas β satu shard/node jenuh |
| Split-brain state | Cache jadi de-facto source of truth saat DB failover |
Prinsip dasar yang sering dilupakan: cache bukan database. Cache adalah materialized view opsional yang boleh hilang kapan saja. Kalau sistem tidak bisa hidup dengan cache kosong, itu bukan cache β itu primary store dengan durabilitas palsu.
Klasifikasi penting: setiap strategi cache harus dijawab pada 3 sumbu: 1. Siapa yang mengisi cache? (app / cache layer / CDC pipeline) 2. Kapan ditulis? (saat read miss / saat write / async) 3. Berapa lama boleh divergen? (bounded staleness dalam detik/menit β ini angka SLO, bukan feeling)
βοΈ Cara Kerja Internal
1. Cache-Aside (Lazy Loading)
Aplikasi yang mengurus cache. Cache tidak tahu apa-apa soal database.
read(key):
v = cache.get(key)
if v != MISS: return v # hit
v = db.query(key) # miss β backend
cache.set(key, v, ttl=TTL + jitter)
return v
write(key, val):
db.update(key, val) # commit dulu
cache.delete(key) # lalu invalidate (bukan update)
Mengapa delete, bukan set nilai baru?
- set pada write bersamaan bisa menulis nilai lebih lama dari yang sudah ada (write A dan B, urutan tulis ke cache bisa terbalik dari urutan commit DB).
- Delete bersifat idempotent β aman diulang, aman out-of-order.
- Delete membuat cache hanya berisi data yang benar-benar dibaca (tidak mem-polut data yang tak pernah diakses).
Sifat: hanya data yang dipakai yang di-cache (efisien memori), degradasi anggun saat cache mati (langsung ke DB, lambat tapi benar), tapi consistency-nya eventual dan miss path menanggung latensi tiga lompatan (appβcache, appβDB, appβcache set).
2. Read-Through
Bedanya dengan cache-aside cuma siapa yang memanggil DB: library cache (mis. Caffeine dengan CacheLoader, atau cache proxy di sisi infrastruktur) yang melakukan load. Aplikasi cukup cache.get(key).
Keuntungan: satu titik implementasi, tidak ada duplikasi logika "miss handling" di 40 tempat. Kerugian: model konsistensi jadi tersembunyi di library; sulit melakukan join/agregasi kompleks di dalam loader; error handling loader harus eksplisit (kalau loader throw, apakah di-cache sebagai negative? jangan).
3. Write-Through
write(key, val):
cache.set(key, val) # sinkron
db.update(key, val) # sinkron, dalam request yang sama
Cache selalu "hangat" untuk data yang baru ditulis, dan pembaca tidak pernah melihat stale untuk key itu. Tapi:
- Latensi write = max(cache, db) + overhead, bukan min. Write jadi lebih lambat tanpa manfaat langsung kecuali read/write ratio tinggi pada key yang sama.
- Cache pollution: menulis data yang mungkin tidak akan pernah dibaca.
- Bukan jaminan konsistensi: kalau ada jalur lain menulis ke DB (batch job, service lain, DBA manual), cache tidak tahu. Write-through hanya konsisten terhadap jalur tulis yang melewatinya.
- Kalau cache set berhasil tapi DB gagal β cache memuat nilai yang tidak pernah ada. Urutan penting: commit DB dulu, lalu tulis cache, dan kalau langkah kedua gagal, invalidate.
4. Write-Behind / Write-Back
write(key, val):
cache.set(key, val) # return ke client segera
queue.publish(key, val) # flush async ke DB
Latensi write sangat rendah dan bisa batching (koalesensi banyak update pada key sama jadi satu UPDATE). Tapi: kehilangan data pada kegagalan node cache, ordering tidak dijamin tanpa mekanisme tambahan, dan debugging jadi jauh lebih sulit karena DB tertinggal dari kebenaran yang dilihat user. Dipakai untuk kasus di mana kehilangan beberapa detik data dapat ditoleransi (counter metrics, view count, presence, rate limiter) β bukan untuk saldo, stok, atau state yang mengikat uang.
5. Write-Around
Tulis langsung ke DB, tidak menyentuh cache (atau invalidate saja). Cocok untuk data write-once-read-rarely (log, arsip, dokumen besar). Menghindari pollution; konsekuensinya read pertama selalu miss.
Tabel perbandingan
| Pola | Konsistensi vs DB | Latensi write | Risiko data hilang | Cocok untuk |
|---|---|---|---|---|
| Cache-aside | eventual (TTL-bounded) | normal + 1 delete | tidak (cache boleh hilang) | read-heavy, data umum |
| Read-through | eventual (TTL-bounded) | normal | tidak | sama, tapi ingin 1 titik implementasi |
| Write-through | kuat hanya untuk jalur yg lewat cache | naik | tidak | read/write ratio tinggi pada key sama |
| Write-behind | lemah (DB tertinggal) | sangat rendah | ya, pada crash node | counter, telemetry, non-kritis |
| Write-around | eventual | normal | tidak | write-once, read-rarely |
π‘οΈ Proteksi Stampede β Tiga Masalah Berbeda
Banyak engineer menyebut semuanya "stampede". Sebenarnya ada tiga pola serangan yang butuh pertahanan berbeda.
(a) Cache Stampede / Dog-pile β key populer expire, N request menembak DB
Pertahanan 1 β Single-flight (in-process). Koalesensi hanya di dalam satu proses. Murah (nol round-trip jaringan), tapi dengan 50 pod, tetap ada 50 query ke DB.
inflight: Map[key] -> Future
get(key):
if v := cache.get(key); v != MISS: return v
f, ok := inflight[key]
if !ok:
f = async { v := db.query(key); cache.set(key, v, ttl+jitter); return v }
inflight[key] = f
f.onDone { delete inflight[key] }
return f.await(timeout=50ms, fallback=staleOrError)
Pertahanan 2 β Distributed lock. SET key:lock <token> NX PX 3000. Satu node saja yang recompute; sisanya menunggu atau menyajikan stale.
Biaya nyata distributed lock: satu round-trip tambahan di jalur kritis, lock bisa nyangkut (holder crash β harus TTL, dan TTL terlalu panjang = antrean panjang), dan lock bukan konsensus β Redis failover bisa menghasilkan dua holder. Untuk kebanyakan kasus single-flight per-proses + stale-while-revalidate sudah cukup; lock terdistribusi baru layak kalau biaya recompute benar-benar besar (query agregasi 30 detik, panggilan API berbayar).
Pertahanan 3 β Stale-while-revalidate. Sajikan nilai lama (masih dianggap valid untuk beberapa detik) dan refresh di background. Ini mengubah stampede jadi satu refresh asinkron. Standar HTTP-nya: Cache-Control: max-age=60, stale-while-revalidate=30, stale-if-error=600 (RFC 5861). Trade-off: pembaca menerima data basi secara eksplisit β harus diterima produk, bukan diputuskan diam-diam oleh engineer.
Pertahanan 4 β Probabilistic early expiration (XFetch). Refresh sebelum expire dengan probabilitas naik mendekati deadline, sehingga tidak ada lonjakan serentak:
shouldRefresh(now, expiry, recomputeCost, betaβ1.0):
# makin dekat expiry, makin besar peluang refresh
return now - recomputeCost * beta * ln(random()) >= expiry
Pertahanan 5 β Jitter TTL. Selalu ttl = base + rand(0, base*0.1). Ini pertahanan termurah dan paling sering dilupakan; mencegah ribuan key expire pada detik yang sama setelah mass warm-up.
(b) Cache Penetration β query untuk key yang TIDAK ADA
Setiap request miss dan DB juga tidak punya datanya β tidak ada yang bisa di-cache β setiap request menembus ke DB. Skenario klasik: enumerasi ID acak, bot, atau GET /user/99999999.
- Negative cache: simpan sentinel (
NULL) dengan TTL pendek (5β60 detik). Pendek karena begitu data dibuat, pembaca harus cepat melihatnya. - Bloom filter di depan: tolak key yang pasti tidak ada tanpa menyentuh cache maupun DB. Trade-off: false positive (masih boleh lewat, aman), tidak bisa delete tanpa counting Bloom / rotasi, dan harus dibangun dari sumber yang tahu seluruh key space.
- Validasi format di edge: ID harus UUID valid / angka dalam rentang β tolak 90% bot sebelum menyentuh apa pun.
(c) Cache Avalanche β cache mati atau kehilangan banyak key sekaligus
Restart Redis, failover, deploy, atau flush β seluruh trafik jatuh ke DB sekaligus. DB yang semula melayani 2% trafik harus melayani 100%.
- Multi-tier cache: L1 in-process (Caffeine/
sync.Mapdengan TTL pendek) menyerap lonjakan awal; L2 Redis. - Circuit breaker + rate limit di depan DB, bukan hanya di depan API. Lebih baik menolak sebagian request dengan 503 cepat daripada DB kolaps dan semua request timeout.
- Warm-up terkontrol: isi ulang cache bertahap (rate-limited) saat start, jangan biarkan trafik user mengisi.
stale-if-error: kalau backend error, sajikan stale lebih lama. Mengorbankan freshness untuk ketersediaan β keputusan produk.- Replika baca sebagai bantalan, dengan catatan replika bisa tertinggal (lihat bagian failure mode).
ποΈ Diagram: jalur baca lengkap dengan pertahanan berlapis
client
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β L1 in-process (TTL 1β5s, capacity-bounded) β β serap hot key & lonjakan
β hit β return β
βββββββββββββββββββββββββββββββββββββββββββββββ
β miss
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β L2 Redis: value + logical expiry metadata β
β fresh β return β
β stale-but-within-grace β return + async β β stale-while-revalidate
β refresh (singleflight) β
βββββββββββββββββββββββββββββββββββββββββββββββ
β miss / refresh
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β Single-flight (per proses) β
β β optional distributed lock (SET NX PX) β
βββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β DB / backing service β
β circuit breaker Β· concurrency limit Β· β
β timeout Β· negative cache on empty result β
βββββββββββββββββββββββββββββββββββββββββββββββ
Prinsip: setiap lapis harus punya cara untuk gagal tanpa menjatuhkan lapis di bawahnya. Cache adalah penyangga, bukan bagian dari jalur kebenaran.
βοΈ Trade-off & Kapan JANGAN Pakai Cache
Ini bagian yang membedakan pemula dari senior.
Kapan JANGAN menambahkan cache
- Dataset muat di working set DB dan DB sudah punya buffer pool. Postgres/MySQL sudah menyimpan halaman panas di shared buffers; menambah Redis bisa menambah hop jaringan tanpa mengurangi biaya disk. Ukur dulu
hit ratioDB. - Read-your-write wajib dan strict. Kalau user harus langsung melihat efek tulisnya (form wizard, workflow approval, saldo), cache-aside dengan TTL menciptakan bug yang tidak reproducible. Pilihan: bypass cache untuk pembacaan pasca-write (sticky ke primary), atau session-level read-your-write token.
- Write sangat dominan dengan key nyaris tidak dibaca ulang. Cache hanya menambah biaya invalidasi.
- Data keuangan/ledger tanpa toleransi divergensi. Jangan pernah jadikan cache sebagai sumber kebenaran saldo; cache boleh hanya untuk view (mis. total yang bisa direkonsiliasi).
- Beban trafik rendah dan latensi DB sudah dalam SLO. Cache menambah satu sistem yang harus dioperasikan, dimonitor, di-upgrade, dan bisa jadi titik kegagalan baru. Kompleksitas operasional adalah biaya nyata.
- Kebutuhan sebenarnya adalah indexing atau query yang lebih baik. Cache sering dipakai untuk menutupi
SELECTyang salah desain. Perbaiki query/index dulu β itu menghilangkan masalah, cache hanya menyembunyikannya.
Trade-off utama per keputusan
| Keputusan | Yang didapat | Yang dibayar |
|---|---|---|
| TTL panjang | hit ratio tinggi, backend lega | jendela staleness lebar, invalidation kurang penting |
| TTL pendek | freshness tinggi | hit ratio turun, biaya miss naik |
| Delete-on-write | cache tidak pernah menyimpan nilai usang lama | ada race yang bisa menyimpan stale (lihat bawah) |
| Set-on-write | pembaca langsung hangat | risiko urutan tulis terbalik, pollution |
| Distributed lock | koalesensi lintas node | round-trip tambahan, risiko lock stuck, bukan konsensus |
| In-process cache (L1) | latensi ~mikrodetik, tanpa jaringan | tidak bisa di-invalidate dari luar proses; hanya bisa TTL pendek |
| Write-behind | latensi write terbaik | kehilangan data, ordering, debugging sulit |
| CDC-based invalidation | invalidation benar walau ada jalur tulis lain | infrastruktur tambahan (Debezium/outbox), lag replikasi |
Race yang wajib diketahui: cache-aside tidak linearizable
T1 (reader): GET miss β db.read β dapat v_lama ββββββββββββββ
T2 (writer): db.update(v_baru) β cache.delete βββ β
β β
T1: β ββ cache.set(v_lama)
Hasil: cache menyimpan v_lama tanpa batas waktu (kalau TTL panjang/absen) padahal DB sudah v_baru. Ini bukan bug implementasi β ini sifat pola.
Mitigasi yang realistis, dalam urutan preferensi:
1. TTL sebagai backstop β selalu ada TTL, bahkan untuk data yang "tidak pernah berubah". Ini pagar keselamatan yang murah.
2. Delayed double delete β commit, delete, lalu delete lagi setelah ~500ms. Menutup jendela race dengan biaya trade-off kecil tapi bukan bukti formal.
3. Versioning / CAS β simpan version dari DB di value cache; tolak set dengan version lebih lama.
4. Invalidation lewat CDC/log (outbox + Debezium) β cache di-update oleh consumer stream, bukan oleh aplikasi. Konsisten terhadap semua jalur tulis, dengan lag yang terukur.
5. Read-through dengan loader berversi β loader membaca snapshot dengan LSN/version, cache menyimpan metadata versi.
π Contoh Produksi & Failure Mode
1. Hot key di Redis Cluster. Satu key viral (mis. halaman produk flash sale) masuk ke satu slot β satu node. Node itu mencapai CPU limit sementara node lain idle. Gejala: p99 latency naik hanya pada sebagian key, metrik cluster terlihat "normal" secara agregat. Mitigasi: L1 in-process di depan, atau replikasi key dengan suffix acak (key:{0..9}) yang dibaca acak, atau read dari replika.
2. Big key / O(N) command. SMEMBERS pada set 5 juta anggota, atau KEYS * di produksi, memblokir event loop Redis (single-threaded untuk eksekusi command). Efeknya bukan satu query lambat β semua klien Redis ikut melambat. Mitigasi: SCAN dengan COUNT, pisahkan struktur besar, batasi ukuran value, larang KEYS lewat ACL Redis.
3. Eviction tak terduga. Redis dengan maxmemory-policy noeviction akan mengembalikan error tulis saat penuh β aplikasi yang menganggap cache.set selalu berhasil akan gagal di jalur yang tidak diuji. Dengan allkeys-lru, key yang "seharusnya" ada bisa hilang kapan saja, memicu miss storm. Mitigasi: pilih policy secara sadar (volatile-lru kalau semua key punya TTL), perlakukan set sebagai best-effort, siapkan alarm pada evicted_keys.
4. Fallback yang tidak diuji. Cache mati β semua trafik ke DB β DB connection pool habis β semua endpoint mati, termasuk yang tidak memakai cache (karena pool bersama). Ini kegagalan klasik "cache membuat sistem lebih rapuh, bukan lebih tahan". Mitigasi: pool terpisah untuk jalur cache-miss, concurrency limit, circuit breaker, load test dengan cache dimatikan (chaos test wajib: "matikan Redis, sistem harus degradasi, bukan mati").
5. Stale setelah failover DB. Primary gagal β replika dipromosikan, tapi replika tertinggal beberapa detik β aplikasi membaca dari replika, mengisi cache dengan data lama, dan TTL panjang menyegelnya. Divergensi bertahan jauh lebih lama dari outage-nya. Mitigasi: TTL pendek untuk data yang sering berubah, invalidate namespace saat failover, atau baca dari primary untuk key kritis.
6. Negative cache yang terlalu panjang. Bug dibuat, user dibuat, lalu tidak muncul selama TTL. Terlihat seperti "data hilang". Mitigasi: TTL negative cache pendek (β€60s) dan hapus entri saat create sukses.
7. Serialisasi sebagai bottleneck. Value besar (JSON 500KB) membuat biaya CPU serialize/deserialize + transfer melebihi biaya query DB itu sendiri. Cache jadi lebih lambat dari sumbernya. Mitigasi: ukur ukuran value, kompresi (LZ4/zstd), simpan hanya field yang dipakai, atau cache fragmen bukan dokumen penuh.
β οΈ Miskonsepsi Umum
| Miskonsepsi | Koreksi |
|---|---|
| "Cache bikin sistem lebih cepat" | Cache mempercepat read yang hit, memperlambat write (invalidasi), dan menambah satu titik kegagalan. Net effect harus diukur. |
| "Cache-aside + delete on write = konsisten" | Eventual, dengan race yang bisa menyimpan stale tanpa batas jika TTL tidak ada. |
| "TTL panjang lebih baik karena hit ratio tinggi" | Hit ratio bukan tujuan; staleness tolerance yang jadi batasan. Hit ratio tinggi dengan data salah = kegagalan lebih cepat. |
| "Write-through menjamin DB dan cache selalu sama" | Hanya untuk jalur tulis yang melewatinya. Jalur lain (batch, service lain, DBA) tetap membuat divergensi. |
| "Redis itu single-threaded jadi pasti bottleneck" | Eksekusi command single-threaded, tapi I/O multi-threaded sejak Redis 6 dan CPU bukan bottleneck untuk command O(1). Yang membunuh adalah command O(N) dan big key. |
| "Stampede cukup diatasi dengan lock" | Lock tanpa TTL = deadlock; lock dengan TTL panjang = antrean; single-flight per-proses tidak koalesensi lintas pod. Butuh kombinasi. |
| "Cache invalidation itu masalah penamaan" | Ini masalah ordering dan visibility β dua operasi pada dua sistem tanpa transaksi bersama. |
| "Kalau cache mati, sistem tetap jalan" | Hanya kalau jalur fallback diuji dengan beban produksi. Tanpa itu, cache mati = outage. |
| "Pakai LFU selalu lebih baik dari LRU" | LFU butuh biaya tracking dan punya masalah "cache pollution by history" (key yang dulu populer tetap tinggal). W-TinyLFU (Caffeine) menyeimbangkan keduanya, tapi tetap butuh tuning. |
β Cek Pemahaman
-
Sistem Anda punya endpoint
GET /invoice/{id}yang membaca data yang baru berubah saat pembayaran dikonfirmasi (lewat webhook dari payment gateway yang menulis langsung ke DB, tidak lewat service Anda). Anda memakai cache-aside dengandeletepada write di service Anda. Jelaskan mengapa invalidation Anda tidak akan pernah menangkap perubahan dari webhook, dan rancang minimal dua perbaikan berbeda (satu tanpa infrastruktur baru, satu dengan). -
Anda mengukur hit ratio 96% dan memutuskan menaikkan TTL dari 60s ke 1 jam untuk mencapai 99%. Sebutkan tiga metrik tambahan yang harus Anda lihat sebelum menyetujui, dan satu skenario di mana keputusan itu secara diam-diam merusak correctness.
-
Cache Redis Anda mati total pada jam sibuk. Rancang urutan kegagalan yang Anda harapkan terjadi (degradasi) versus urutan yang paling mungkin terjadi tanpa persiapan (kaskade), dan sebutkan dua kontrol konkret yang memisahkan keduanya.
π Poin Kunci
- Cache = state tambahan yang bisa divergen. Setiap cache adalah keputusan tentang bounded staleness, bukan tentang kecepatan.
- Delete, jangan set, saat invalidasi β idempotent dan aman terhadap urutan.
- Selalu pasang TTL, bahkan untuk data yang "tidak pernah berubah". TTL adalah pagar keselamatan terakhir untuk bug invalidasi yang tidak terduga.
- Jitter TTL adalah pertahanan stampede termurah; single-flight per-proses menutup sisanya untuk sebagian besar sistem; distributed lock hanya kalau biaya recompute benar-benar besar.
- Stale-while-revalidate mengubah stampede jadi satu refresh asinkron β tapi ini keputusan produk (pembaca menerima data basi), bukan keputusan implementasi.
- Tiga masalah berbeda: stampede (key panas expire), penetration (key tidak ada), avalanche (banyak key hilang sekaligus). Pertahanannya berbeda.
- Uji dengan cache dimatikan. Kalau sistem tidak bisa degrade, Anda tidak punya cache β Anda punya single point of failure.
- Cache tidak memperbaiki query buruk. Index dan skema dulu, cache kemudian.
β‘οΈ Eksplorasi Lanjut
- Consistency model formal: baca tentang read-your-writes, monotonic reads, dan bagaimana session token / logical clock dipakai untuk menegakkan keduanya di atas cache eventual.
- Invalidation berbasis log: pola transactional outbox + CDC (Debezium) sebagai pengganti invalidasi aplikatif β bandingkan lag, kompleksitas, dan jaminan ordering-nya.
- Cache di tingkat edge/CDN:
s-maxage,stale-if-error, purging by tag/surrogate key β masalah yang sama, primitif berbeda. - Eviction & admission: LRU vs LFU vs W-TinyLFU (Caffeine), dan mengapa admission policy (TinyLFU sketch) sering lebih berdampak daripada eviction policy itu sendiri.
- Ukur, jangan asumsikan: instrumen
hit_ratio,miss_cost_p99,evicted_keys,keyspace_hits/misses, danused_memory_fragmentation_ratioβ lalu hitung effective latency = hit_ratio Γ hit_latency + (1βhit_ratio) Γ miss_latency.