โ† Semua pembelajaran / Go Nol โ†’ Enterprise
Fase 9 ยท Performa & Observability

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.

Sumber asli pkg.go.dev Resmi Rangkuman ~8 menit baca

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

DataKunci
Produk publikproduk:v2:{id}
Daftar dengan filterproduk:daftar:v2:{hashFilter}
Keranjang penggunakeranjang:{penggunaID}
Dasbor personaldasbor:{penggunaID}:{tanggal}
Multi-tenant{tenantID}:produk:{id}

Dua aturan yang mencegah kebocoran data:

  1. Data personal โ†’ kunci harus memuat ID pemiliknya. Kunci "dasbor" berarti dasbor pengguna pertama disajikan ke semua orang (Fase 6).
  2. 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

DataTTLCatatan
Katalog produk5โ€“60 menitSasaran utama; jarang berubah, sering dibaca
Konfigurasi, daftar referensi1 jam+Hampir tidak pernah berubah
SesiSelama sesiBukan cache โ€” ini penyimpanan utama (Fase 6)
Hasil agregasi mahal1โ€“15 menitPertimbangkan materialized view
Hasil rate limitSesuai jendelaRedis memang tempatnya
Saldo, stok, harga saat checkoutJanganBasi = kerugian uang
Data yang berubah tiap permintaanJanganHit 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
MetrikPerhatikan 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 koneksiHarus nol; aplikasi harus tetap jalan kalau tidak
Ukuran nilaiNilai megabita menghabiskan bandwidth per hit

Cache dalam proses versus Redis

Dalam proses (map + mutex)Redis
Latensi~100 ns~0,5 ms
Dibagi antar taskTidakYa
Bertahan setelah restartTidakYa
Invalidasi lintas taskSulit (perlu pub/sub)Bawaan
Cocok untukKonfigurasi, daftar referensiData 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.