Distributed Caching di Go: Cache-Aside, Invalidation, dan Singleflight
"Strategi distributed caching di Go menggunakan Redis, singleflight untuk mencegah cache stampede, dan teknik cache invalidation."
Distributed Caching di Go: Cache-Aside, Invalidation, dan Singleflight
Dalam arsitektur high-traffic microservices, database relasional sering menjadi bottleneck utama. Penggunaan distributed cache seperti Redis membantu memangkas beban query database dan memangkas waktu respon endpoint dari ratusan milidetik menjadi sub-milidetik.
Namun, mengelola distributed cache menghadirkan tantangan serius: Cache Stampede (Thundering Herd Problem), Cache Penetration, serta kompleksitas Cache Invalidation. Artikel ini membahas strategi implementasi distributed caching profesional di Go.
1. Pola Cache-Aside (Lazy Loading)
Cache-Aside adalah pola paling umum:
Aplikasi mencari data di Cache (Redis).
Cache Hit: Data langsung dikembalikan ke caller.
Cache Miss: Aplikasi membaca data dari database relasional, menulis data tersebut ke Redis dengan TTL (Time To Live), lalu mengembalikan respon ke caller.
2. Mengatasi Thundering Herd dengan golang.org/x/sync/singleflight
Masalah Cache Stampede terjadi ketika sebuah key yang populer (misal detail produk Flash Sale) mengalami masa kedaluwarsa (cache expired). Jika ada 10.000 concurrent request masuk dalam satu milidetik yang sama, seluruh 10.000 request akan mendapati cache miss dan secara simultan membanjiri database dengan query berat yang sama. Database dapat mengalami crash seketika.
Library bawaan Go singleflight menyelesaikan masalah ini dengan menduplikasi pemanggilan fungsi konkuren ke satu eksekusi tunggal:
package product
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"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"`
Stock int `json:"stock"`
}
type Service struct {
db *sql.DB
rdb *redis.Client
group singleflight.Group
cacheTTL time.Duration
}
func NewService(db *sql.DB, rdb *redis.Client) *Service {
return &Service{
db: db,
rdb: rdb,
cacheTTL: 15 * time.Minute,
}
}
func (s *Service) GetProduct(ctx context.Context, id string) (*Product, error) {
cacheKey := fmt.Sprintf("product:%s", id)
// 1. Coba baca dari Redis cache
cachedVal, err := s.rdb.Get(ctx, cacheKey).Bytes()
if err == nil {
var p Product
if err := json.Unmarshal(cachedVal, &p); err == nil {
return &p, nil
}
} else if !errors.Is(err, redis.Nil) {
// Redis error logging (jangan biarkan Redis down menggagalkan aplikasi)
fmt.Printf("Redis error: %v\n", err)
}
// 2. Gunakan singleflight untuk mencegah stampede ke database
val, err, _ := s.group.Do(cacheKey, func() (interface{}, error) {
// Double check cache di dalam flight runner (opsional)
p, dbErr := s.fetchFromDB(ctx, id)
if dbErr != nil {
return nil, dbErr
}
// Simpan ke Redis secara asynchronous agar tidak memblokir response
go func(prod *Product) {
data, _ := json.Marshal(prod)
// Berikan jitter pada TTL untuk mencegah multi-key expired bersamaan
ttlWithJitter := s.cacheTTL + time.Duration(time.Now().UnixNano()%30)*time.Second
_ = s.rdb.Set(context.Background(), cacheKey, data, ttlWithJitter).Err()
}(p)
return p, nil
})
if err != nil {
return nil, err
}
return val.(*Product), nil
}
func (s *Service) fetchFromDB(ctx context.Context, id string) (*Product, error) {
query := `SELECT id, name, price, stock FROM products WHERE id = $1`
row := s.db.QueryRowContext(ctx, query, id)
var p Product
if err := row.Scan(&p.ID, &p.Name, &p.Price, &p.Stock); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("product not found")
}
return nil, fmt.Errorf("db error: %w", err)
}
return &p, nil
}
3. Strategi Cache Invalidation: Update vs Delete
Banyak developer pemula melakukan update cache saat entity diperbarui di database: DB Update -> Redis SET.
Pola ini rentan terhadap Race Condition:
Thread 1 meng-update DB (versi 1).
Thread 2 meng-update DB (versi 2).
Thread 2 menulis Redis (versi 2).
Thread 1 tertunda di network, lalu menulis Redis belakangan (versi 1).
Hasil: Redis menyimpan data usang (versi 1) padahal DB sudah versi 2.
Solusi Benar: Gunakan Cache Deletion / Eviction (Redis DEL): DB Update -> Redis DEL. Biarkan pembaca berikutnya yang melakukan reload data terbaru ke cache secara lazily via Singleflight.
func (s *Service) UpdatePrice(ctx context.Context, id string, newPrice float64) error {
query := `UPDATE products SET price = $1, updated_at = NOW() WHERE id = $2`
_, err := s.db.ExecContext(ctx, query, newPrice, id)
if err != nil {
return fmt.Errorf("update db error: %w", err)
}
// Hapus cache
cacheKey := fmt.Sprintf("product:%s", id)
_ = s.rdb.Del(ctx, cacheKey).Err()
return nil
}
4. Menangani Cache Penetration dengan Null-Value Caching
Cache Penetration terjadi jika pengguna mencari key yang memang tidak ada di database (misal ID acak dari bot). Setiap request akan miss di Redis dan langsung membebani Database.
Penyelesaian: Simpan null placeholder dengan TTL sangat singkat (misal 30 detik) di Redis saat database mengembalikan sql.ErrNoRows:
if errors.Is(err, sql.ErrNoRows) {
// Simpan placeholder NULL value
_ = s.rdb.Set(ctx, cacheKey, "NULL", 30*time.Second).Err()
return nil, errors.New("product not found")
}
5. Ringkasan Praktik Terbaik
Gunakan Singleflight: Selalu bungkus query database di belakang Singleflight untuk kunci yang panas.
Tambahkan Jitter pada TTL: Hindari jutaan key mati di detik yang sama dengan menambahkan randomized seconds (
TTL ± Jitter).Evict, Don't Update: Hapus key di Redis saat terjadi write, jangan timpa nilainya secara manual.
Resilient Fallback: Kegagalan koneksi Redis tidak boleh mematikan API utama; turunkan status secara elegan (graceful fallback ke database).
About the Author
huud
@huud
Systems architect and software engineer building high-performance distributed platforms.