Kembali ke Artikel

Belajar Golang dari Nol #24: Caching dengan Redis biar API Instan

Solusi IT

Di Belajar Golang dari Nol #23 kita sudah bikin data aman lewat transaksi dan migrasi. API kamu sekarang benar. Tapi benar saja belum cukup. API juga harus cepat. Kali ini kita bahas caching dengan Redis, salah satu cara paling ampuh bikin API terasa instan tanpa upgrade server. Kalau kamu baru gabung di tengah seri, mampir dulu ke daftar lengkap seri supaya urutannya jelas.

Masalahnya: Database Dihajar Terus untuk Data yang Itu-Itu Saja

Bayangkan endpoint GET /produk di API toko online kamu. Halaman katalog memanggilnya setiap kali ada pengunjung. Seribu pengunjung per jam berarti seribu query ke database per jam. Padahal daftar produknya sendiri paling berubah beberapa kali sehari, saat admin menambah produk atau mengubah harga.

Jadi kamu membayar harga query yang sama ribuan kali untuk jawaban yang sama. Database sibuk, latensi naik, dan saat traffic melonjak, semua endpoint lain ikut lambat karena berebut koneksi.

Solusinya sederhana secara ide. Simpan hasil yang mahal di tempat yang cepat. Itulah cache. Query database itu mahal. Membaca dari memori itu cepat. Kalau jawabannya jarang berubah, kenapa harus dihitung ulang setiap kali.

Tingkat Pertama: Cache In-Memory di Proses Go

Sebelum ke Redis, kita bikin cache paling sederhana dulu: map di dalam proses Go sendiri. Ini latihan konsep yang bagus. Kamu sudah kenal map dan struct dari bagian 16, dan sudah kenal mutex dari bagian 19. Sekarang kita gabungkan, plus satu ide baru: TTL (time to live), yaitu masa berlaku sebuah data di cache.

package cache

import (
	"sync"
	"time"
)

type item struct {
	data      []byte
	expiredAt time.Time
}

type MemoryCache struct {
	mu    sync.RWMutex
	items map[string]item
}

func NewMemoryCache() *MemoryCache {
	return &MemoryCache{items: make(map[string]item)}
}

func (c *MemoryCache) Set(key string, data []byte, ttl time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.items[key] = item{data: data, expiredAt: time.Now().Add(ttl)}
}

func (c *MemoryCache) Get(key string) ([]byte, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	it, ok := c.items[key]
	if !ok || time.Now().After(it.expiredAt) {
		return nil, false
	}
	return it.data, true
}

Mutex di sini wajib. Handler HTTP jalan di banyak goroutine sekaligus, dan map di Go tidak aman diakses barengan tanpa kunci. RWMutex kita pakai supaya banyak pembaca boleh masuk bersamaan, tapi penulis harus antre sendirian.

Cache ini beneran jalan dan sangat cepat. Tapi ada dua batas yang serius. Pertama, isinya hilang setiap kali aplikasi restart. Deploy versi baru, cache kosong lagi, database dihajar lagi. Kedua, cache ini tidak bisa dibagi antar instance. Ingat masalah dua server di bagian 19: begitu aplikasi kamu jalan di dua instance di belakang load balancer, masing-masing punya map sendiri yang isinya beda. Server A sudah punya data, server B masih kosong. Lebih parah lagi saat invalidasi: server A menghapus cache-nya, server B masih menyajikan data basi.

Untuk itulah Redis ada. Redis adalah penyimpanan key-value di memori yang berdiri sebagai proses terpisah. Semua instance aplikasi kamu bicara ke Redis yang sama, jadi cache-nya satu sumber, bukan tersebar di tiap proses.

Pasang Redis di Lokal

Instalasinya singkat. Di Ubuntu atau Debian:

sudo apt install redis-server

Atau kalau kamu lebih suka Docker, satu baris ini cukup:

docker run -d --name redis-lokal -p 6379:6379 redis:7

Sekarang kenalan dengan redis-cli, terminal interaktif untuk ngobrol langsung dengan Redis. Coba empat perintah dasar ini:

$ redis-cli
127.0.0.1:6379> SET salam "halo dari redis"
OK
127.0.0.1:6379> GET salam
"halo dari redis"
127.0.0.1:6379> SET sesi:123 "data" EX 60
OK
127.0.0.1:6379> TTL sesi:123
(integer) 57
127.0.0.1:6379> DEL salam
(integer) 1

SET menyimpan, GET membaca, DEL menghapus. Opsi EX 60 memberi masa berlaku 60 detik, dan TTL menunjukkan sisa umurnya. Setelah lewat, key hilang sendiri. Konsep TTL yang tadi kita bikin manual di map, di Redis sudah bawaan.

Koneksi dari Go dengan go-redis

Klien resmi yang paling banyak dipakai adalah go-redis. Install dulu:

go get github.com/redis/go-redis/v9

Lalu buat fungsi koneksi. Alamat diambil dari environment variable, sama seperti kebiasaan kita untuk koneksi database. Ping dengan context untuk memastikan Redis benar-benar hidup sebelum aplikasi lanjut.

package main

import (
	"context"
	"fmt"
	"os"
	"time"

	"github.com/redis/go-redis/v9"
)

func NewRedis() (*redis.Client, error) {
	addr := os.Getenv("REDIS_ADDR")
	if addr == "" {
		addr = "localhost:6379"
	}
	rdb := redis.NewClient(&redis.Options{Addr: addr})

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	if err := rdb.Ping(ctx).Err(); err != nil {
		return nil, fmt.Errorf("gagal konek redis di %s: %w", addr, err)
	}
	return rdb, nil
}

Pola context dengan timeout ini sudah kamu kuasai dari bagian 17. Di sini gunanya jelas: kalau Redis mati atau alamatnya salah, kita tahu dalam 3 detik, bukan menggantung selamanya.

Pola Cache-Aside: Cek Cache Dulu, Database Belakangan

Ada beberapa pola caching, tapi satu yang paling umum dan paling layak kamu kuasai duluan adalah cache-aside. Alurnya begini:

Request masuk
     |
     v
Cek Redis: GET produk:daftar
     |
     +-- HIT?  --> balas langsung dari cache
     |            (database tidak disentuh sama sekali)
     |
     +-- MISS? --> query database
                       |
                       v
                  SET produk:daftar dengan TTL 5 menit
                       |
                       v
                  balas ke client

Kuncinya: aplikasi selalu bertanya ke cache dulu. Kalau jawabannya ada (hit), selesai, cepat. Kalau tidak ada (miss), aplikasi ambil dari database seperti biasa, lalu menitipkan hasilnya ke cache supaya request berikutnya hit. Database tetap jadi sumber kebenaran. Cache hanya salinan sementara.

Sekarang kita pasang di service produk yang strukturnya sudah kita rapikan sejak bagian 16. Redis menyimpan byte, sedangkan kita punya slice of struct, jadi jembatannya adalah json.Marshal dan json.Unmarshal.

package produk

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"time"

	"github.com/redis/go-redis/v9"
)

const kunciDaftarProduk = "produk:daftar"
const umurCache = 5 * time.Minute

type Service struct {
	repo  *Repository
	cache *redis.Client
}

func (s *Service) DaftarProduk(ctx context.Context) ([]Produk, error) {
	// 1. Cek cache dulu
	val, err := s.cache.Get(ctx, kunciDaftarProduk).Bytes()
	if err == nil {
		var daftar []Produk
		if err := json.Unmarshal(val, &daftar); err == nil {
			log.Println("cache HIT:", kunciDaftarProduk)
			return daftar, nil
		}
	} else if !errors.Is(err, redis.Nil) {
		// Redis bermasalah, jangan gagalkan request
		log.Println("WARNING: redis error, fallback ke database:", err)
	}

	// 2. Miss: ambil dari database
	log.Println("cache MISS:", kunciDaftarProduk)
	daftar, err := s.repo.Semua(ctx)
	if err != nil {
		return nil, err
	}

	// 3. Titip hasilnya ke cache dengan TTL
	if data, err := json.Marshal(daftar); err == nil {
		if err := s.cache.Set(ctx, kunciDaftarProduk, data, umurCache).Err(); err != nil {
			log.Println("WARNING: gagal simpan cache:", err)
		}
	}
	return daftar, nil
}

Perhatikan dua detail penting. Pertama, redis.Nil adalah error khusus yang artinya key tidak ditemukan. Itu bukan masalah, itu miss biasa. Error selain itu berarti Redis sendiri bermasalah. Kedua, saat Redis bermasalah kita hanya menulis log warning lalu lanjut ke database. Cache mati tidak boleh bikin aplikasi mati. Paling buruk API jadi selambat sebelum ada cache, dan itu masih hidup.

Invalidasi: Bagian yang Paling Sering Bikin Pusing

Ada lelucon lama di dunia programming: cuma ada dua hal sulit di ilmu komputer, invalidasi cache dan memberi nama. Leluconnya awet karena benar. Menyimpan ke cache itu gampang. Yang sulit adalah memastikan cache tidak menyajikan data basi setelah sumber aslinya berubah.

Kabar baiknya, untuk mayoritas kasus ada strategi sederhana yang cukup: hapus key saat datanya berubah. Setiap kali ada tulis ke tabel produk, panggil DEL untuk key terkait. Request berikutnya akan miss, ambil data segar dari database, dan cache terisi ulang dengan benar.

func (s *Service) TambahProduk(ctx context.Context, p Produk) error {
	if err := s.repo.Simpan(ctx, p); err != nil {
		return err
	}
	// data berubah, cache lama tidak valid lagi
	if err := s.cache.Del(ctx, kunciDaftarProduk).Err(); err != nil {
		log.Println("WARNING: gagal hapus cache:", err)
	}
	return nil
}

Lakukan hal yang sama di update dan delete. Lalu ada lapisan kedua: TTL sebagai jaring pengaman. Seandainya DEL gagal atau ada jalur update yang lupa menghapus cache, data basi paling lama hidup 5 menit sebelum kedaluwarsa sendiri. Kombinasi hapus-saat-berubah plus TTL pendek ini menutup hampir semua lubang tanpa arsitektur yang rumit.

Satu aturan keras: jangan cache data yang harus selalu akurat pada detik itu juga. Stok saat checkout adalah contohnya. Di bagian 23 kita susah payah pakai transaksi dan locking supaya dua pembeli tidak merebut barang terakhir yang sama. Kalau angka stok di jalur checkout dibaca dari cache yang bisa basi 5 menit, semua kerja keras itu percuma. Cache boleh dipakai untuk menampilkan stok di halaman katalog. Keputusan boleh atau tidaknya transaksi jalan harus selalu dari database.

Apa yang Layak Di-cache, Apa yang Tidak

Sebelum menambahkan cache di mana-mana, timbang dulu. Yang layak di-cache: data yang sering dibaca dan jarang berubah. Daftar produk, detail produk, daftar kategori, halaman artikel, hasil hitung yang mahal seperti laporan rekap harian. Rasio baca-tulisnya timpang jauh, jadi manfaatnya besar.

Yang perlu hati-hati: data per-user yang sensitif. Profil user, isi keranjang, apalagi saldo. Selain soal akurasi, ada risiko klasik: salah menyusun key, misalnya lupa menyertakan user ID, dan tiba-tiba user A melihat data user B. Kalau ragu, jangan cache dulu. Endpoint lambat itu masalah performa. Data nyasar ke orang lain itu insiden keamanan.

Redis Bukan Cuma untuk Cache

Sekilas saja, karena masing-masing layak jadi bahasan sendiri. Redis juga sering dipakai untuk antrian job yang durable. Ingat jebakan di bagian 19: job di channel Go hilang saat aplikasi restart. Antrian berbasis Redis membuat job selamat dari restart dan bisa digarap banyak worker di banyak instance. Redis juga umum dipakai untuk rate limiting, membatasi berapa request per menit dari satu IP, dan untuk menyimpan session login. Yang terakhir ini akan relevan sebentar lagi saat kita masuk ke autentikasi.

Latihan: Rangkai Semuanya

Sekarang giliran kamu. Rakit semuanya jadi satu di project API produk kamu.

Pertama, jalankan Redis lokal dan buat koneksi lewat NewRedis dengan alamat dari REDIS_ADDR. Kedua, pasang cache-aside di GET /produk persis seperti contoh di atas. Ketiga, pasang invalidasi DEL di handler POST dan PUT produk.

Keempat, ukur hasilnya. Pakai middleware logging dari bagian 18 yang mencatat durasi tiap request. Panggil GET /produk dua kali berturut-turut lalu bandingkan lognya. Panggilan pertama miss, misalnya 40 milidetik karena query database. Panggilan kedua hit, biasanya di bawah 2 milidetik. Melihat angkanya sendiri di log jauh lebih meyakinkan daripada membaca klaim orang.

Kelima, uji fallback. Matikan Redis dengan docker stop redis-lokal lalu panggil endpoint lagi. Aplikasi harus tetap menjawab dengan benar, langsung dari database, sambil menulis log warning bahwa Redis tidak tersedia. Kalau endpoint kamu ikut error saat Redis mati, berarti penanganan error di langkah cek cache masih perlu dibenahi.

Terakhir, uji invalidasi. Tambah produk baru lewat POST, lalu langsung GET. Produk baru harus muncul, bukan tertahan data basi 5 menit.

Penutup

Kamu sekarang tahu kenapa cache ada, sudah bikin versi in-memory sendiri untuk paham konsepnya, dan sudah pasang Redis dengan pola cache-aside lengkap dengan invalidasi dan fallback. API kamu bisa melayani traffic berkali lipat dengan database yang jauh lebih santai.

Di bagian 25 kita masuk ke materi yang ditunggu banyak orang: Autentikasi User: Register, Login, dan JWT. Di sana API kamu mulai punya konsep identitas, siapa yang boleh melakukan apa.

Kalau kamu butuh bantuan membangun API atau sistem aplikasi yang siap menghadapi traffic nyata, tim Arrazy Inovasi Teknologi biasa mengerjakannya, dari desain arsitektur sampai deployment.

BACA JUGA

Artikel Lainnya di Kategori Golang

Ingin Membaca Artikel Lainnya?

Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.

Lihat Semua Artikel