Pada aplikasi backend berbasis Go, bottleneck yang paling sering muncul bukan pada eksekusi kode Go itu sendiri, melainkan pada akses database yang berulang untuk data yang sama. Jika endpoint yang sama dipanggil berkali-kali dengan parameter identik, membaca ulang dari database untuk setiap request adalah pemborosan. Di sinilah caching berguna.
Pada artikel ini, kita akan membahas implementasi caching hasil query GORM dengan Redis di Go Fiber. Fokusnya bukan sekadar “menyimpan data ke Redis”, tetapi bagaimana membuatnya benar-benar berguna di aplikasi nyata: membuat key unik dan konsisten, menyimpan payload JSON, melakukan invalidasi saat data berubah, dan menggunakan middleware Fiber untuk mengecek cache sebelum memanggil database.
Pendekatan ini cocok untuk endpoint read-heavy, misalnya daftar produk, detail artikel, profil user, atau data referensi yang sering dibaca tetapi jarang berubah.
Mengapa Cache Query Perlu Dirancang dengan Benar?
Menambahkan Redis sebagai cache memang terlihat sederhana: ambil data dari database, simpan ke Redis, lalu baca ulang dari sana. Namun dalam praktiknya, ada beberapa masalah umum:
- Key cache tidak unik, sehingga data dari request berbeda saling tertukar.
- Cache tidak pernah dihapus, sehingga pengguna menerima data basi setelah ada update.
- Format data tidak konsisten, misalnya response API dan payload cache berbeda struktur.
- TTL tidak diatur, sehingga Redis penuh oleh data yang tidak lagi relevan.
Karena itu, strategi caching yang baik harus menjawab empat pertanyaan:
- Bagaimana cara membuat key cache yang stabil dan unik?
- Data apa yang disimpan ke Redis?
- Kapan cache dianggap basi dan harus dihapus?
- Di layer mana pengecekan cache dilakukan?
Untuk Go Fiber, pola yang umum adalah:
- Middleware untuk membaca cache sebelum handler utama berjalan.
- Handler/service untuk mengambil data dari GORM jika cache tidak ditemukan.
- Layer invalidasi saat operasi create, update, atau delete berhasil dilakukan.
Arsitektur Sederhana: Fiber + GORM + Redis
Secara umum, alurnya seperti ini:
- Request masuk ke endpoint Fiber.
- Middleware membentuk key cache dari path dan query parameter.
- Redis dicek dengan key tersebut.
- Jika cache ada, middleware langsung mengembalikan response JSON tanpa menyentuh database.
- Jika cache tidak ada, request diteruskan ke handler.
- Handler mengambil data menggunakan GORM.
- Response disimpan ke Redis dengan TTL tertentu.
- Response dikirim ke client.
Untuk operasi tulis seperti POST, PUT, PATCH, atau DELETE, cache terkait harus dihapus agar request berikutnya tidak membaca data lama.
Contoh Dependensi yang Digunakan
Contoh berikut menggunakan library yang umum dipakai:
go get github.com/gofiber/fiber/v2
go get github.com/redis/go-redis/v9
go get gorm.io/gorm
go get gorm.io/driver/postgresAnda bisa mengganti PostgreSQL dengan database lain yang didukung GORM. Konsep caching-nya tetap sama.
Membuat Key Cache yang Unik dan Konsisten
Kesalahan paling umum dalam caching adalah memakai key yang terlalu sederhana, misalnya hanya users atau products. Ini berbahaya karena endpoint yang sama bisa dipanggil dengan parameter berbeda:
/products?page=1&limit=10/products?page=2&limit=10/products?category=books&sort=price
Semua request itu harus memiliki key berbeda. Cara yang aman adalah membangun key dari:
- nama resource atau route,
- path parameter,
- query parameter yang sudah dinormalisasi,
- opsional: versi API atau tenant ID.
Contoh helper untuk membuat key cache:
package cache
import (
"crypto/sha1"
"encoding/hex"
"sort"
"strings"
"github.com/gofiber/fiber/v2"
)
func BuildCacheKey(c *fiber.Ctx, prefix string) string {
path := c.Path()
queries := c.Queries()
keys := make([]string, 0, len(queries))
for k := range queries {
keys = append(keys, k)
}
sort.Strings(keys)
var parts []string
parts = append(parts, path)
for _, k := range keys {
parts = append(parts, k+"="+queries[k])
}
raw := strings.Join(parts, "|")
hash := sha1.Sum([]byte(raw))
return prefix + ":" + hex.EncodeToString(hash[:])
}Mengapa query parameter perlu diurutkan? Karena ?page=1&limit=10 dan ?limit=10&page=1 secara semantik sama, tetapi string mentahnya berbeda. Dengan normalisasi dan pengurutan, keduanya menghasilkan key yang sama.
Gunakan prefix seperti
api:products:listatauapi:user:detailagar key lebih mudah dikelompokkan dan dihapus saat invalidasi.
Menyimpan Payload JSON ke Redis
Format paling praktis untuk cache response API adalah JSON. Alasannya:
- mudah diserialisasi dan dideserialisasi di Go,
- struktur sama dengan response HTTP,
- mudah diinspeksi saat debugging.
Misalnya kita punya model berikut:
type Product struct {
ID uint `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Category string `json:"category"`
}Lalu helper Redis sederhana:
package cache
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
type RedisCache struct {
Client *redis.Client
}
func (r *RedisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
b, err := json.Marshal(value)
if err != nil {
return err
}
return r.Client.Set(ctx, key, b, ttl).Err()
}
func (r *RedisCache) Get(ctx context.Context, key string, dest any) error {
val, err := r.Client.Get(ctx, key).Result()
if err != nil {
return err
}
return json.Unmarshal([]byte(val), dest)
}
func (r *RedisCache) Delete(ctx context.Context, keys ...string) error {
return r.Client.Del(ctx, keys...).Err()
}Di sini kita menyimpan payload response, bukan objek database mentah yang belum siap dikirim. Ini penting agar hasil dari cache tetap identik dengan hasil dari handler.
Kapan Menyimpan Response, Bukan Entity?
Jika endpoint mengembalikan data yang sudah diperkaya, misalnya hasil join, field tambahan, pagination metadata, atau transformasi DTO, maka simpanlah response final. Ini mengurangi logika tambahan saat membaca dari cache.
Contoh response yang disimpan:
type ProductListResponse struct {
Data []Product `json:"data"`
Page int `json:"page"`
Limit int `json:"limit"`
Total int64 `json:"total"`
}Middleware Fiber untuk Mengecek Cache Sebelum Database
Salah satu cara paling rapi adalah menaruh logika pembacaan cache di middleware. Jika cache ada, middleware langsung mengembalikan response. Jika tidak, request diteruskan ke handler.
Contoh middleware:
package middleware
import (
"context"
"encoding/json"
"time"
"myapp/cache"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
func CacheMiddleware(redisCache *cache.RedisCache, prefix string, ttl time.Duration) fiber.Handler {
return func(c *fiber.Ctx) error {
if c.Method() != fiber.MethodGet {
return c.Next()
}
key := cache.BuildCacheKey(c, prefix)
ctx := context.Background()
cached, err := redisCache.Client.Get(ctx, key).Result()
if err == nil {
c.Set("X-Cache", "HIT")
c.Type("json")
return c.SendString(cached)
}
if err != nil && err != redis.Nil {
// Redis error tidak boleh mematikan request
c.Set("X-Cache", "BYPASS")
return c.Next()
}
c.Locals("cache:key", key)
c.Locals("cache:ttl", ttl)
c.Set("X-Cache", "MISS")
return c.Next()
}
}Middleware di atas hanya membaca cache. Penyimpanan cache bisa dilakukan di handler setelah data berhasil diambil dari database.
Contoh handler daftar produk:
func GetProducts(db *gorm.DB, redisCache *cache.RedisCache) fiber.Handler {
return func(c *fiber.Ctx) error {
ctx := context.Background()
var products []Product
var total int64
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 10)
offset := (page - 1) * limit
query := db.Model(&Product{})
if category := c.Query("category"); category != "" {
query = query.Where("category = ?", category)
}
if err := query.Count(&total).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "gagal menghitung data"})
}
if err := query.Limit(limit).Offset(offset).Find(&products).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "gagal mengambil data"})
}
response := ProductListResponse{
Data: products,
Page: page,
Limit: limit,
Total: total,
}
if key, ok := c.Locals("cache:key").(string); ok {
if ttl, ok := c.Locals("cache:ttl").(time.Duration); ok {
_ = redisCache.Set(ctx, key, response, ttl)
}
}
return c.JSON(response)
}
}Pola ini bekerja karena middleware hanya bertugas sebagai gerbang. Jika cache tidak ada, handler tetap berjalan normal, lalu hasilnya disimpan ke Redis untuk request berikutnya.
Invalidasi Cache Saat Ada Update
Cache yang cepat tetapi salah tetaplah masalah. Karena itu, invalidasi adalah bagian penting dari desain caching. Ada beberapa strategi yang umum:
1. TTL Saja
Paling mudah: set expiration, misalnya 1 menit atau 5 menit. Setelah itu cache otomatis hilang.
Kelebihan: sederhana.
Kekurangan: data bisa tetap basi sampai TTL habis.
2. Hapus Key Terkait Setelah Update
Setelah operasi update, hapus cache detail dan list yang mungkin terdampak.
Contoh handler update:
func UpdateProduct(db *gorm.DB, redisCache *cache.RedisCache) fiber.Handler {
return func(c *fiber.Ctx) error {
ctx := context.Background()
id := c.Params("id")
var payload struct {
Name string `json:"name"`
Price float64 `json:"price"`
Category string `json:"category"`
}
if err := c.BodyParser(&payload); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "payload tidak valid"})
}
var product Product
if err := db.First(&product, id).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "produk tidak ditemukan"})
}
product.Name = payload.Name
product.Price = payload.Price
product.Category = payload.Category
if err := db.Save(&product).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "gagal mengupdate produk"})
}
// invalidasi sederhana: hapus detail dan daftar terkait
detailKey := "api:product:detail:" + id
_ = redisCache.Delete(ctx, detailKey)
// untuk daftar, jika memakai key hash dinamis, biasanya gunakan prefix scan
iter := redisCache.Client.Scan(ctx, 0, "api:products:list:*", 0).Iterator()
for iter.Next(ctx) {
_ = redisCache.Client.Del(ctx, iter.Val()).Err()
}
return c.JSON(product)
}
}Invalidasi list sering lebih rumit daripada invalidasi detail karena kombinasi query bisa banyak. Solusinya:
- gunakan prefix yang jelas seperti
api:products:list:*, - hapus semua cache list saat ada perubahan data yang memengaruhi list,
- atau gunakan TTL singkat untuk endpoint list.
Hindari penggunaan
KEYS api:products:list:*di Redis produksi karena dapat memblokir server. Lebih aman memakai SCAN.
3. Versioned Cache Key
Pendekatan lain adalah menambahkan versi pada key, misalnya products:v12:.... Saat ada perubahan data, tingkatkan versi sehingga key lama otomatis tidak terpakai lagi. Ini berguna untuk dataset besar, tetapi butuh mekanisme penyimpanan versi yang konsisten.
Praktik Baik, Trade-off, dan Kesalahan Umum
Pilih Data yang Layak Dicache
Tidak semua query perlu cache. Prioritaskan:
- endpoint GET yang sering dipanggil,
- query yang mahal atau melibatkan join/agregasi,
- data yang tidak berubah setiap detik.
Jangan buru-buru mencache data yang sangat dinamis, misalnya saldo real-time atau status yang berubah cepat, kecuali Anda benar-benar memahami konsekuensi konsistensinya.
Tentukan TTL Sesuai Karakter Data
TTL harus mengikuti kebutuhan bisnis:
- 30-60 detik untuk list yang sering berubah,
- 5-15 menit untuk data referensi,
- lebih lama untuk data yang hampir statis.
TTL yang terlalu panjang meningkatkan risiko data basi. TTL terlalu pendek mengurangi manfaat cache.
Jangan Jadikan Redis Single Point of Failure untuk Read Path
Jika Redis gagal, aplikasi sebaiknya tetap bisa membaca dari database. Karena itu, pada middleware di atas, error Redis tidak langsung menggagalkan request. Pola ini disebut cache-aside: cache membantu performa, tetapi bukan satu-satunya sumber kebenaran.
Hindari Menyimpan Data yang Sulit Diinvalidasi
Semakin kompleks query dan filter, semakin sulit invalidasinya. Jika invalidasi sudah terlalu rumit, pertimbangkan:
- TTL lebih pendek,
- cache per detail entity, bukan list kompleks,
- atau versioned key.
Debugging dan Observabilitas
Agar caching tidak menjadi “kotak hitam”, tambahkan indikator sederhana:
- header seperti
X-Cache: HIT,MISS, atauBYPASS, - logging saat Redis error,
- metric jumlah hit/miss bila Anda memakai Prometheus atau sistem monitoring lain.
Beberapa hal yang perlu dicek saat cache terasa tidak bekerja:
- Key berubah-ubah karena query parameter tidak dinormalisasi.
- TTL terlalu pendek sehingga cache kadaluarsa sebelum sempat dimanfaatkan.
- Response tidak tersimpan karena serialisasi JSON gagal.
- Invalidasi terlalu agresif sehingga cache selalu terhapus setelah hampir setiap request.
- Redis error tersembunyi karena semua error diabaikan tanpa logging.
Jika perlu, simpan log singkat seperti key yang dibaca, status hit/miss, dan TTL. Informasi ini sangat membantu saat mendiagnosis perilaku cache di staging atau produksi.
Penutup
Caching hasil query GORM dengan Redis di Go Fiber adalah cara yang efektif untuk mempercepat endpoint read-heavy dan mengurangi tekanan pada database. Namun, implementasi yang baik membutuhkan lebih dari sekadar memanggil SET dan GET di Redis.
Poin penting yang perlu diingat:
- buat key cache yang unik dan konsisten dari path dan query parameter,
- simpan payload JSON final agar response dari cache identik dengan response normal,
- gunakan middleware Fiber untuk mengecek cache sebelum handler memanggil database,
- lakukan invalidasi saat data berubah, minimal dengan TTL dan penghapusan key terkait.
Untuk banyak aplikasi, kombinasi cache-aside + TTL + invalidasi berbasis prefix sudah cukup praktis dan mudah dirawat. Jika kebutuhan konsistensi dan skala meningkat, Anda bisa mengembangkan pola ini dengan versioned key, event-based invalidation, atau cache warming.
Yang terpenting, perlakukan cache sebagai bagian dari desain arsitektur, bukan sekadar lapisan tambahan. Dengan begitu, Redis benar-benar membantu performa tanpa mengorbankan akurasi data.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!