Cache aplikasi dengan Redis
Cache mengubah kueri 50 ms jadi pencarian 0,5 ms. Yang menentukan apakah ia membantu atau merugikan adalah dua hal: kunci yang benar, dan strategi invalidasi yang bisa dijelaskan.
Intisari
- Pola default: cache-aside โ cek cache, kalau kosong ambil dari database lalu isi.
- Kunci cache untuk data personal wajib memuat ID pengguna atau tenant. Tanpa itu, data satu orang tersaji ke orang lain.
- Selalu pasang TTL. Cache tanpa kedaluwarsa akan menyimpan data basi selamanya setelah satu invalidasi terlewat.
singleflight(Fase 2) mencegah cache stampede saat kunci populer kedaluwarsa.- Redis mati tidak boleh berarti aplikasi mati โ gagal ke database, jangan gagal ke pengguna.
Cache-aside
func (l *Layanan) Ambil(ctx context.Context, id int64) (Produk, error) {
kunci := fmt.Sprintf("produk:v2:%d", id)
// 1. Coba cache
if b, err := l.rdb.Get(ctx, kunci).Bytes(); err == nil {
var p Produk
if json.Unmarshal(b, &p) == nil {
l.metrik.Inc("cache.hit")
return p, nil
}
} else if !errors.Is(err, redis.Nil) {
// Redis bermasalah: CATAT, lalu LANJUT ke database.
l.log.WarnContext(ctx, "cache tidak tersedia", "err", err)
}
l.metrik.Inc("cache.miss")
// 2. singleflight: seribu permintaan bersamaan untuk id yang sama
// menghasilkan SATU kueri database.
v, err, _ := l.grup.Do(kunci, func() (any, error) {
return l.simpan.Ambil(ctx, id)
})
if err != nil {
return Produk{}, err
}
p := v.(Produk)
// 3. Isi cache โ kegagalan di sini tidak boleh menggagalkan permintaan.
if b, err := json.Marshal(p); err == nil {
if err := l.rdb.Set(ctx, kunci, b, ttlAcak(5*time.Minute)).Err(); err != nil {
l.log.WarnContext(ctx, "gagal mengisi cache", "err", err)
}
}
return p, nil
}
// TTL diberi variasi ยฑ20% supaya ribuan kunci yang diisi bersamaan
// tidak kedaluwarsa bersamaan juga.
func ttlAcak(d time.Duration) time.Duration {
return d + time.Duration(rand.Int64N(int64(d/5))) - d/10
}
Tiga keputusan penting di potongan itu: kegagalan Redis tidak menggagalkan permintaan;
singleflight menutup celah stampede; dan TTL diberi jitter supaya tidak ada gelombang
kedaluwarsa serentak. Ketiganya adalah pelajaran operasional, bukan detail implementasi.
Merancang kunci
| Data | Kunci |
|---|---|
| Produk publik | produk:v2:{id} |
| Daftar dengan filter | produk:daftar:v2:{hashFilter} |
| Keranjang pengguna | keranjang:{penggunaID} |
| Dasbor personal | dasbor:{penggunaID}:{tanggal} |
| Multi-tenant | {tenantID}:produk:{id} |
Dua aturan yang mencegah kebocoran data:
- Data personal โ kunci harus memuat ID pemiliknya. Kunci
"dasbor"berarti dasbor pengguna pertama disajikan ke semua orang (Fase 6). - Multi-tenant โ ID tenant jadi awalan. Satu kunci yang lupa memuatnya cukup untuk membocorkan data antar pelanggan (Fase 11).
Dan beri versi pada kunci (:v2:). Saat bentuk data berubah, naikkan
versinya โ jauh lebih aman daripada menghapus jutaan kunci, dan tidak ada risiko kode baru membaca
format lama.
Invalidasi
// 1. TTL โ paling sederhana, dan sering sudah cukup.
// Cocok untuk data yang boleh basi beberapa menit.
// 2. Hapus saat tulis โ cocok untuk data yang harus segera benar.
func (l *Layanan) Ubah(ctx context.Context, p Produk) error {
if err := l.simpan.Simpan(ctx, p); err != nil {
return err
}
// HAPUS, jangan perbarui: memperbarui menciptakan balapan antara
// dua penulis yang bisa meninggalkan nilai lama di cache.
_ = l.rdb.Del(ctx, fmt.Sprintf("produk:v2:%d", p.ID)).Err()
return nil
}
// 3. Tag lewat set โ untuk daftar yang terpengaruh banyak perubahan.
func (l *Layanan) invalidasiKategori(ctx context.Context, katID int64) error {
tag := fmt.Sprintf("tag:kategori:%d", katID)
kunci, err := l.rdb.SMembers(ctx, tag).Result()
if err != nil {
return err
}
if len(kunci) > 0 {
_ = l.rdb.Del(ctx, kunci...).Err()
}
return l.rdb.Del(ctx, tag).Err()
}
Jangan pernah KEYS produk:* di produksi. Ia memindai seluruh basis data Redis dan
memblokir server selama pemindaian โ pada instans dengan jutaan kunci, itu berarti seluruh aplikasimu
berhenti. Kalau memang harus memindai, pakai SCAN dengan kursor.
Apa yang layak di-cache
| Data | TTL | Catatan |
|---|---|---|
| Katalog produk | 5โ60 menit | Sasaran utama; jarang berubah, sering dibaca |
| Konfigurasi, daftar referensi | 1 jam+ | Hampir tidak pernah berubah |
| Sesi | Selama sesi | Bukan cache โ ini penyimpanan utama (Fase 6) |
| Hasil agregasi mahal | 1โ15 menit | Pertimbangkan materialized view |
| Hasil rate limit | Sesuai jendela | Redis memang tempatnya |
| Saldo, stok, harga saat checkout | Jangan | Basi = kerugian uang |
| Data yang berubah tiap permintaan | Jangan | Hit rate mendekati nol |
Yang harus dipantau
// Rasio hit adalah metrik nomor satu.
// Di bawah 80% untuk data katalog berarti ada yang salah:
// TTL terlalu pendek, kunci terlalu spesifik, atau invalidasi terlalu agresif.
metrik.Inc("cache.hit") // di jalur hit
metrik.Inc("cache.miss") // di jalur miss
| Metrik | Perhatikan kalau |
|---|---|
| Rasio hit | < 80% untuk data yang jarang berubah |
evicted_keys Redis | > 0 โ memori kurang; kunci dibuang sebelum kedaluwarsa |
| Latensi p99 Redis | > 5 ms โ jaringan atau instans terlalu kecil |
| Kesalahan koneksi | Harus nol; aplikasi harus tetap jalan kalau tidak |
| Ukuran nilai | Nilai megabita menghabiskan bandwidth per hit |
Cache dalam proses versus Redis
| Dalam proses (map + mutex) | Redis | |
|---|---|---|
| Latensi | ~100 ns | ~0,5 ms |
| Dibagi antar task | Tidak | Ya |
| Bertahan setelah restart | Tidak | Ya |
| Invalidasi lintas task | Sulit (perlu pub/sub) | Bawaan |
| Cocok untuk | Konfigurasi, daftar referensi | Data aplikasi |
Pola berlapis yang umum: cache dalam proses berumur sangat pendek (5โ10 detik) di depan Redis, untuk data yang dibaca berkali-kali dalam satu permintaan. Tapi ingat konsekuensinya โ selama beberapa detik itu, tiap task bisa punya jawaban yang berbeda.
Latihan: pasang cache-aside untuk satu endpoint, catat rasio hit-nya, dan ukur p95 sebelum dan sesudah. Lalu matikan Redis dan pastikan aplikasimu tetap melayani (lebih lambat) alih-alih membalas 500. Terakhir, hapus ID pengguna dari kunci cache endpoint personal dan lihat sendiri data siapa yang muncul.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.