Cache stampede terjadi saat banyak request datang bersamaan untuk data yang sama, lalu cache untuk data itu kedaluwarsa pada waktu yang hampir sama. Akibatnya, semua request serentak menembus cache, menghantam database, latency naik, connection pool cepat penuh, dan worker aplikasi ikut tersandera menunggu I/O.
Di aplikasi Go Fiber, masalah ini umumnya tidak selesai hanya dengan “pakai Redis”. Anda perlu kombinasi beberapa teknik: cache-aside, TTL yang tepat, jitter TTL, stale-while-revalidate, dan singleflight untuk menggabungkan request identik agar hanya satu proses yang benar-benar memuat ulang data. Jika aplikasi berjalan di banyak instance, Redis berperan sebagai shared cache, sementara singleflight menahan duplikasi kerja di level proses.
Masalah operasional yang biasanya terlihat
Gejala cache stampede biasanya muncul lebih dulu di observability daripada di kode. Beberapa pola yang sering terlihat:
- Lonjakan query ke database tepat setelah key populer expired.
- P95/P99 latency naik tajam walau rata-rata traffic tidak berubah drastis.
- Goroutine dan worker sibuk menunggu Redis, database, atau downstream service.
- Connection pool database habis, lalu request berikutnya ikut antre.
- Error berantai: timeout dari DB memicu retry, lalu beban makin berat.
Masalah ini paling sering terjadi pada endpoint read-heavy seperti katalog produk, profil pengguna, konfigurasi global, halaman landing yang banyak diakses, atau agregasi data yang mahal dihitung ulang.
Fondasi solusi: cache-aside, TTL, dan kenapa expired bersamaan itu berbahaya
Cache-aside
Pola cache-aside adalah alur paling umum:
- Aplikasi cek data di cache.
- Jika hit, langsung kembalikan hasil.
- Jika miss, ambil dari database.
- Simpan hasil ke cache dengan TTL.
- Kembalikan hasil ke klien.
Pola ini sederhana dan fleksibel, tetapi masalah muncul ketika banyak request mengalami miss untuk key yang sama di waktu yang sama.
TTL dan expired serempak
TTL menentukan berapa lama data dianggap segar. Jika semua key diberi TTL identik, terutama untuk data yang ditulis pada waktu berdekatan, banyak key bisa kedaluwarsa hampir bersamaan. Untuk key yang populer, ini menjadi pemicu stampede.
Jitter TTL
Jitter TTL berarti menambahkan variasi acak pada masa berlaku cache, misalnya 5 menit ditambah 0–30 detik acak. Tujuannya bukan memperpanjang cache sembarangan, tetapi menyebar waktu expired agar tidak terkonsentrasi pada satu momen.
Prinsipnya sederhana: jangan biarkan semua data populer bangun minta dimuat ulang pada detik yang sama.
Menggabungkan Singleflight dan Redis di Go Fiber
Kapan singleflight membantu
singleflight di Go berguna untuk memastikan hanya satu eksekusi berjalan untuk pekerjaan dengan key yang sama dalam satu proses. Jika 100 request ke product:123 datang bersamaan dan cache miss, hanya satu goroutine yang pergi ke database; sisanya menunggu hasil yang sama.
Ini sangat efektif untuk:
- Mengurangi query duplikat saat cache miss bersamaan.
- Menahan lonjakan kerja CPU atau I/O untuk perhitungan/ambil data yang sama.
- Menurunkan tekanan ke database dan downstream service.
Namun, perlu dipahami batasnya: singleflight bersifat lokal per instance. Jika Anda menjalankan 10 instance aplikasi, masing-masing instance tetap bisa menjalankan satu load untuk key yang sama. Jadi Redis tetap dibutuhkan sebagai cache bersama, dan jika koordinasi lintas instance benar-benar penting, Anda mungkin butuh mekanisme lock/distributed coordination tambahan.
Arsitektur alur request
Alur yang umum dan aman untuk endpoint read-heavy adalah:
- Request masuk ke handler Go Fiber.
- Service membentuk cache key, misalnya
product:{id}. - Cek Redis.
- Jika hit fresh, kembalikan data.
- Jika miss, gunakan singleflight berdasarkan key itu.
- Di dalam fungsi singleflight, cek Redis sekali lagi untuk menghindari race.
- Jika tetap miss, ambil data dari database.
- Simpan ke Redis dengan TTL + jitter.
- Kembalikan hasil ke seluruh requester yang menunggu.
Pengecekan Redis dua kali terlihat berulang, tetapi ini berguna karena saat request menunggu singleflight, request lain mungkin sudah lebih dulu mengisi cache.
Contoh implementasi service di Go Fiber
Contoh berikut fokus pada service layer agar bisa dipakai dari handler Fiber mana pun. Kodenya sengaja dibuat ringkas tetapi tetap realistis.
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"math/rand"
"time"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
type Product struct {
ID int64 `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
type ProductService struct {
rdb *redis.Client
db *sql.DB
group singleflight.Group
}
func NewProductService(rdb *redis.Client, db *sql.DB) *ProductService {
return &ProductService{rdb: rdb, db: db}
}
func cacheKeyProduct(id int64) string {
return fmt.Sprintf("product:%d", id)
}
func ttlWithJitter(base time.Duration, maxJitter time.Duration) time.Duration {
if maxJitter <= 0 {
return base
}
return base + time.Duration(rand.Int63n(int64(maxJitter)))
}
func (s *ProductService) GetProduct(ctx context.Context, id int64) (*Product, error) {
key := cacheKeyProduct(id)
// 1) First read from Redis
if val, err := s.rdb.Get(ctx, key).Result(); err == nil {
var p Product
if json.Unmarshal([]byte(val), &p) == nil {
return &p, nil
}
// Jika payload rusak, hapus lalu lanjut ke reload
_ = s.rdb.Del(ctx, key).Err()
} else if err != nil && !errors.Is(err, redis.Nil) {
// Redis error: jangan langsung gagal total, lanjut ke DB lewat singleflight
}
// 2) De-duplicate concurrent misses in this process
v, err, _ := s.group.Do(key, func() (interface{}, error) {
// 2a) Double-check Redis after entering singleflight
if val, err := s.rdb.Get(ctx, key).Result(); err == nil {
var p Product
if json.Unmarshal([]byte(val), &p) == nil {
return &p, nil
}
_ = s.rdb.Del(ctx, key).Err()
}
// 2b) Load from DB
p, err := s.getProductFromDB(ctx, id)
if err != nil {
return nil, err
}
// 2c) Populate Redis with TTL + jitter
payload, err := json.Marshal(p)
if err == nil {
ttl := ttlWithJitter(5*time.Minute, 30*time.Second)
_ = s.rdb.Set(ctx, key, payload, ttl).Err()
}
return p, nil
})
if err != nil {
return nil, err
}
return v.(*Product), nil
}
func (s *ProductService) getProductFromDB(ctx context.Context, id int64) (*Product, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, name, price FROM products WHERE id = ?`, id)
var p Product
if err := row.Scan(&p.ID, &p.Name, &p.Price); err != nil {
return nil, err
}
return &p, nil
}
func main() {
app := fiber.New()
// Inisialisasi redis dan db disederhanakan.
var rdb *redis.Client
var db *sql.DB
svc := NewProductService(rdb, db)
app.Get("/products/:id", func(c *fiber.Ctx) error {
id, err := c.ParamsInt("id")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid id")
}
p, err := svc.GetProduct(c.UserContext(), int64(id))
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(p)
})
_ = app.Listen(":3000")
}Beberapa hal penting dari contoh di atas:
- Redis dibaca sebelum dan sesudah masuk singleflight.
- Redis error tidak langsung menjatuhkan request; aplikasi tetap bisa membaca dari DB.
- TTL diberi jitter untuk mengurangi expired serempak.
- singleflight dipasang di service, bukan di middleware generik, karena key cache biasanya bergantung pada domain data.
Stale-While-Revalidate untuk menahan lonjakan saat cache hampir/baru expired
Stale-while-revalidate adalah strategi menyajikan data yang sedikit basi untuk waktu singkat sambil memicu refresh di belakang layar. Ini berguna ketika freshness absolut tidak wajib per request, misalnya katalog, profil publik, atau data agregasi yang berubah tidak setiap detik.
Cara kerja praktis
Alih-alih menyimpan hanya payload mentah, simpan metadata seperti:
datafresh_untilstale_until
Logikanya:
- Jika sekarang <
fresh_until, kembalikan sebagai fresh. - Jika
fresh_untilterlewati tapi masih <stale_until, kembalikan data stale dan trigger refresh async. - Jika
stale_untiljuga terlewati, anggap miss dan reload sinkron.
Kombinasi ini menurunkan kemungkinan banyak request menunggu refresh bersamaan, terutama untuk key yang sangat panas.
type CacheEnvelope struct {
Data json.RawMessage `json:"data"`
FreshUntil time.Time `json:"fresh_until"`
StaleUntil time.Time `json:"stale_until"`
}
func isFresh(e CacheEnvelope) bool {
return time.Now().Before(e.FreshUntil)
}
func isStaleAllowed(e CacheEnvelope) bool {
now := time.Now()
return now.After(e.FreshUntil) && now.Before(e.StaleUntil)
}Untuk refresh async, tetap gunakan singleflight agar banyak trigger dalam satu instance tidak melakukan reload yang sama berulang-ulang.
Trade-off utamanya jelas: Anda menukar freshness dengan stabilitas dan latency. Untuk data yang sangat sensitif terhadap konsistensi, stale-while-revalidate mungkin tidak cocok.
Strategi invalidasi cache yang realistis
Mencegah stampede bukan hanya soal read path. Anda juga perlu memikirkan kapan cache harus dibuang atau diperbarui.
1. TTL-only
Paling sederhana: data dibiarkan hidup sampai expired. Cocok untuk data yang toleran terhadap stale dan tidak terlalu sering berubah.
2. Delete on write
Saat data diperbarui di database, hapus key Redis terkait. Request berikutnya akan memuat ulang dari sumber data.
func (s *ProductService) UpdateProduct(ctx context.Context, p *Product) error {
// update DB dulu
// ...
key := cacheKeyProduct(p.ID)
if err := s.rdb.Del(ctx, key).Err(); err != nil {
// log warning, jangan sampai operasi utama gagal hanya karena invalidasi cache gagal
}
return nil
}Ini pendekatan umum untuk cache-aside. Kekurangannya, tepat setelah invalidasi bisa muncul burst miss jika key sangat populer. Karena itu singleflight tetap penting.
3. Write-through atau explicit refresh
Setelah update DB berhasil, aplikasi juga menulis versi terbaru ke Redis. Ini menurunkan miss setelah write, tetapi menambah kompleksitas dan risiko inkonsistensi jika urutan operasi tidak dijaga baik.
4. Event-driven invalidation
Untuk sistem yang lebih besar, invalidasi bisa dipicu melalui event bus atau pub/sub. Ini berguna jika banyak service membaca data yang sama, tetapi overhead operasionalnya lebih tinggi.
Fallback saat Redis lambat atau down
Redis adalah akselerator, bukan sumber kebenaran utama. Jika Redis lambat atau tidak tersedia, aplikasi sebaiknya degradasi secara terkontrol, bukan ikut kolaps.
Prinsip fallback
- Jangan hard-fail request read hanya karena cache gagal, kecuali memang data wajib hanya tersedia dari cache.
- Batasi timeout Redis agar worker tidak terlalu lama menunggu cache.
- Tetap pakai singleflight agar fallback ke DB tidak memicu duplikasi load dalam satu instance.
- Pertimbangkan stale local memory untuk data sangat penting jika Redis sesekali terganggu.
Kesalahan yang sering terjadi
- Timeout Redis lebih lama dari timeout DB, sehingga cache justru jadi bottleneck pertama.
- Retry berlapis ke Redis dan DB tanpa batas yang jelas.
- Semua error cache diperlakukan fatal.
Jika Redis benar-benar down dan traffic tinggi, sistem masih berisiko overload ke DB. Di titik ini, singleflight lokal membantu, tetapi hanya per instance. Untuk beban lintas banyak instance, Anda mungkin perlu:
- rate limiting untuk endpoint tertentu,
- response stale dari memory lokal,
- lock terdistribusi secara selektif, atau
- pre-warming cache untuk key populer.
Kapan singleflight lokal cukup, dan kapan perlu koordinasi lintas instance
Singleflight lokal cukup jika:
- Aplikasi hanya berjalan di satu instance atau sedikit instance.
- Key panas tersebar merata dan burst tidak terlalu ekstrem.
- Redis hit rate tinggi sehingga miss bersamaan jarang terjadi.
- Database masih cukup kuat menangani satu miss per instance.
Perlu koordinasi lintas instance jika:
- Ada banyak replica aplikasi.
- Beberapa key sangat populer dan mahal dimuat ulang.
- Expiry atau invalidasi bisa memicu burst besar ke sumber data.
- Downstream system sensitif terhadap lonjakan kecil sekalipun.
Koordinasi lintas instance biasanya memakai distributed lock berbasis Redis atau mekanisme lease. Tetapi ini menambah kompleksitas: lock timeout, lock orphaned, fairness, dan risiko deadlock logis jika implementasi buruk. Untuk banyak kasus, kombinasi Redis shared cache + jitter TTL + stale-while-revalidate + singleflight lokal sudah cukup baik tanpa lock global.
Metrik yang perlu dipantau
Tanpa metrik, cache stampede sering baru ketahuan saat database sudah tercekik. Pantau minimal:
Metrik cache
- Cache hit ratio per endpoint atau per key pattern.
- Cache miss rate dan lonjakannya.
- Redis latency untuk GET, SET, DEL.
- Jumlah error Redis dan timeout.
Metrik singleflight dan load source
- Jumlah request yang share hasil singleflight untuk key panas.
- Durasi load dari DB saat miss.
- Jumlah concurrent loader per instance.
Metrik aplikasi dan database
- P95/P99 latency endpoint terkait.
- Goroutine count dan saturation worker.
- DB connection in use / wait count.
- Query rate ke tabel yang sering dicache.
Jika memungkinkan, tambahkan label seperti endpoint, cache key family, dan hasil cache (hit, miss, stale, error) agar investigasi lebih cepat.
Trade-off consistency vs freshness
Tidak ada strategi cache yang gratis. Anda selalu memilih kompromi antara konsistensi, freshness, latency, dan ketahanan saat burst.
- TTL pendek: data lebih segar, tetapi miss lebih sering.
- TTL panjang: hit ratio lebih tinggi, tetapi data lebih lama basi.
- Stale-while-revalidate: latency stabil, tetapi user bisa menerima data sedikit usang.
- Delete-on-write: lebih konsisten setelah update, tetapi bisa memicu burst miss.
- Distributed lock: mengurangi duplikasi lintas instance, tetapi menambah kompleksitas dan failure mode baru.
Karena itu, pilih strategi per domain data, bukan satu aturan untuk semua endpoint.
Contoh alur request yang disarankan
- Handler Fiber memanggil service domain.
- Service cek Redis.
- Jika fresh hit, return.
- Jika stale masih boleh dipakai, return stale dan trigger refresh async dengan singleflight.
- Jika miss total, gunakan singleflight lalu load dari DB.
- Simpan hasil ke Redis dengan TTL + jitter.
- Saat write, lakukan invalidasi atau refresh terkontrol.
Alur ini menyeimbangkan freshness, latency, dan perlindungan terhadap burst.
Checklist anti-pattern
- Memberi TTL identik untuk semua key populer tanpa jitter.
- Mengandalkan Redis saja tanpa de-duplication saat miss.
- Menganggap singleflight lintas instance, padahal hanya lokal per proses.
- Menghapus cache pada write tetapi tidak siap menghadapi burst miss sesudahnya.
- Membuat timeout Redis terlalu longgar sehingga worker terblokir.
- Tidak melakukan double-check cache di dalam callback singleflight.
- Menyimpan payload tanpa metadata padahal butuh stale-while-revalidate.
- Tidak punya metrik hit/miss, Redis timeout, dan DB wait.
- Meng-cache data yang sangat sensitif tanpa memikirkan konsistensi.
- Menggunakan distributed lock untuk semua key, padahal overhead-nya tidak selalu sepadan.
Panduan uji beban sederhana
Untuk membuktikan solusi bekerja, uji dengan skenario yang memang memicu stampede.
Skenario yang layak diuji
- Pilih satu endpoint dengan key populer, misalnya
/products/123. - Set TTL pendek di environment pengujian agar expiry sering terjadi.
- Kirim burst request bersamaan tepat sebelum dan sesudah expiry.
- Bandingkan 3 kondisi: tanpa singleflight, dengan singleflight, dan dengan singleflight + stale-while-revalidate.
Hal yang diamati
- Jumlah query DB untuk key yang sama.
- P95/P99 latency selama expiry window.
- Jumlah timeout Redis/DB.
- Penggunaan koneksi DB dan antreannya.
- Berapa banyak request yang benar-benar memicu reload.
Contoh pendekatan dengan tool HTTP load test
Anda bisa memakai tool seperti hey, wrk, atau k6. Intinya bukan memilih tool tertentu, melainkan memastikan request benar-benar menabrak key yang sama secara paralel.
# Contoh ide pengujian burst ke satu resource yang sama
hey -n 2000 -c 200 http://localhost:3000/products/123Jika solusi berjalan baik, Anda seharusnya melihat hit ke DB jauh lebih sedikit saat miss bersamaan, latency tail lebih terkendali, dan worker tidak menumpuk menunggu operasi yang sama berulang-ulang.
Penutup
Untuk mencegah cache stampede di Go Fiber, Redis saja tidak cukup. Pendekatan yang paling praktis adalah menggabungkan cache-aside sebagai pola dasar, TTL dengan jitter untuk menyebar expiry, singleflight untuk menahan duplikasi kerja per instance, dan stale-while-revalidate jika domain datanya mengizinkan sedikit stale demi stabilitas.
Mulailah dari solusi yang sederhana tetapi disiplin: ukur hit/miss, pasang singleflight di service untuk key panas, tambahkan jitter TTL, lalu evaluasi apakah Anda benar-benar perlu koordinasi lintas instance. Dengan begitu, Anda bisa menurunkan lonjakan request ke database saat cache expired bersamaan tanpa menambah kompleksitas lebih cepat dari kebutuhan sistem Anda.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!