Cache stampede atau thundering herd terjadi ketika key cache bernilai tinggi (hot key) kedaluwarsa secara mendadak di bawah beban traffic masif. Secara simultan, ratusan hingga ribuan worker goroutine mengalami cache miss dan secara bersamaan mengeksekusi query lambat ke database backend. Lonjakan I/O ini memicu kehabisan connection pool database, lonjakan latensi p99, hingga kegagalan kaskade (cascading failure) pada sistem downstream.
Akar Masalah dan Limitasi Mutex Lokal
Solusi umum yang kerap diambil secara naif adalah membungkus pembacaan database dengan sync.Mutex standar. Pendekatan ini memiliki sejumlah masalah struktural:
- Lock Contention Global: Menggunakan satu mutex global akan menyerialisasi seluruh request lintas entitas, menghancurkan throughput Go Fiber yang berjalan di atas event-driven pool
valyala/fasthttp. - Memory Leak pada Mutex per Key: Menggunakan map berisi mutex individual untuk setiap cache key membutuhkan sinkronisasi kompleks (seperti
sync.Map) serta strategi garbage collection manual untuk menghapus mutex yang sudah tidak terpakai, rentan terhadap kebocoran memori. - Starvation: Mutex tidak mendistribusikan hasil evaluasi ke thread lain yang antre. Goroutine yang memegang lock menyelesaikan query, tetapi goroutine berikutnya yang mendapatkan giliran tetap akan menjalankan query ulang jika validasi cache di luar lock tidak presisi.
Solusi standar Go runtime untuk pola ini adalah request coalescing via package golang.org/x/sync/singleflight. Mekanisme ini memastikan hanya satu proses eksekusi upstream yang berjalan untuk satu key identik pada saat bersamaan, sementara request konkuren lainnya menunggu dan menerima hasil yang sama.
Tantangan Arsitektur Fiber dan fasthttp
Mengintegrasikan singleflight ke dalam Go Fiber membutuhkan perhatian khusus terhadap lifecycle runtime Fasthttp:
- Buffer Reuse & Memory Safety: Fasthttp mendaur ulang struct
fasthttp.RequestCtxmenggunakansync.Poolsegera setelah handler Fiber kembali. Slice byte dari database atau serialisasi JSON yang disimpan atau dibagikan ke multiple goroutine tidak boleh meminjam buffer memory internal Fasthttp tanpa disalin (cloned). - Context Cancellation Pitfall: Jika request pertama yang memicu eksekusi
singleflight.Group.Domembatalkan koneksinya (misal klien menutup koneksi HTTP), membatalkan context query akan menggagalkan seluruh caller lain yang sedang menunggu eksekusi yang sama.
Implementasi Kode: Fiber Handler dengan singleflight
Gunakan singleflight.Group.DoChan untuk memisahkan lifecycle context klien individual dari context eksekusi upstream. Implementasikan fungsi berikut:
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
type Product struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
type ProductService struct {
db *sql.DB
rdb *redis.Client
sfg singleflight.Group
}
func NewProductService(db *sql.DB, rdb *redis.Client) *ProductService {
return &ProductService{
db: db,
rdb: rdb,
}
}
func (s *ProductService) GetProductHandler(c *fiber.Ctx) error {
productID := c.Params("id")
cacheKey := fmt.Sprintf("cache:product:%s", productID)
// 1. Cek Redis Cache
cachedData, err := s.rdb.Get(c.UserContext(), cacheKey).Bytes()
if err == nil {
c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON)
return c.Send(cachedData)
} else if !errors.Is(err, redis.Nil) {
// Redis error non-fatal: log dan fallback ke upstream
}
// 2. Eksekusi upstream via singleflight.DoChan
// Gunakan context terpisah dari request context untuk eksekusi DB
execCtx, cancelExec := context.WithTimeout(context.Background(), 3*time.Second)
ch := s.sfg.DoChan(cacheKey, func() (interface{}, error) {
defer cancelExec()
prod, fetchErr := s.fetchProductFromDB(execCtx, productID)
if fetchErr != nil {
return nil, fetchErr
}
payload, jsonErr := json.Marshal(prod)
if jsonErr != nil {
return nil, jsonErr
}
// Simpan ke Redis cache asinkron atau sinkron
_ = s.rdb.Set(context.Background(), cacheKey, payload, 5*time.Minute).Err()
// Return copy payload bytes agar aman dari memory reuse
return bytes.Clone(payload), nil
})
// 3. Isolasi timeout per caller HTTP
select {
case <-c.UserContext().Done():
// Caller menutup koneksi lebih awal
return c.Status(fiber.StatusRequestTimeout).JSON(fiber.Map{
"error": "request canceled by client",
})
case res := <-ch:
if res.Err != nil {
// Cegah caching error jangka panjang jika terjadi transient failure
s.sfg.Forget(cacheKey)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "failed to retrieve data",
})
}
// res.Val bertipe []byte yang aman
data, ok := res.Val.([]byte)
if !ok {
return c.Status(fiber.StatusInternalServerError).SendString("internal assertion error")
}
// res.Shared menandakan apakah hasil dibagi dengan goroutine lain
if res.Shared {
c.Set("X-Cache-Coalesced", "true")
}
c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON)
return c.Send(data)
}
}
func (s *ProductService) fetchProductFromDB(ctx context.Context, id string) (*Product, error) {
// Simulasi pemanggilan DB
query := "SELECT id, name, price FROM products WHERE id = $1"
var p Product
err := s.db.QueryRowContext(ctx, query, id).Scan(&p.ID, &p.Name, &p.Price)
if err != nil {
return nil, err
}
return &p, nil
}
Mitigasi Hang dan Upstream Deadlock
Penggunaan singleflight yang keliru dapat menyebabkan deadlock virtual di mana caller terkunci tanpa batas waktu jika goroutine eksekutor menggantung. Terapkan proteksi berikut:
- Detached Timeout Context: Jangan menggunakan
c.UserContext()di dalam closure fungsisfg.DoChan. Buat context independen turunancontext.Background()dengan timeout eksplisit (misal 3-5 detik). Hal ini mencegah query dibatalkan prematur akibat pemutusan koneksi oleh satu klien acak saat puluhan klien lain masih menunggu. - Call
Forget()pada Error: Panggils.sfg.Forget(cacheKey)di dalam penanganan error. Secara default,singleflightmenahan status in-flight sampai fungsi kembali. Jika upstream menghasilkan error yang berulang, panggilForgetsesegera mungkin agar request berikutnya dapat mencoba ulang alih-alih menerima error yang sama terus-menerus. - Select Timeout di Sisi Handler: Handler Fiber harus menggunakan blok
selectantara channelDoChandan context request caller (c.UserContext().Done()) agar resource web server segera dilepaskan ketika klien putus.
Observabilitas: Metrik Prometheus
Untuk mendeteksi lonjakan thundering herd dan mengukur efektivitas singleflight, instrumentasikan metrik Prometheus pada handler:
var (
singleflightSharedCounter = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "fiber_singleflight_shared_total",
Help: "Total number of singleflight requests shared across callers",
},
[]string{"key_pattern"},
)
)
// Di dalam handling response channel
if res.Shared {
singleflightSharedCounter.WithLabelValues("products").Inc()
}
Jika metrik fiber_singleflight_shared_total melonjak tajam secara berkala, ini menandakan TTL key cache telah habis dan thundering herd berhasil dicegah. Sebaliknya, jika latensi downstream meningkat tanpa diiringi kenaikan metrik ini, terjadi variasi key parameter (key cardinality terlalu tinggi) yang membuat coalescing tidak efektif.
Strategi Pelengkap: Cache Revalidation
Meskipun singleflight melindungi upstream DB dari replikasi query bersamaan pada satu instance, beban jaringan antarmesin tetap ada di lingkungan cluster multi-node. Terapkan strategi pendukung berikut pada layer caching:
- TTL Jitter: Tambahkan variasi acak (misal:
TTL = baseTTL + rand(0..30s)) pada setiap key untuk mencegah kedaluwarsa massal pada kumpulan record yang dibuat pada waktu bersamaan. - Probabilistic Early Expiration (XFetch): Hitung probabilitas refresh cache sebelum key benar-benar expired berdasarkan durasi eksekusi upstream dan sisa waktu TTL. Jika tercapai, picu background worker untuk memperbarui data ke Redis.
- Stale-While-Revalidate: Sajikan data stale dari Redis selama 5-10 detik tambahan sementara goroutine background memperbarui cache via
singleflight.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!