Progress belajar
Modul 69 dari 73
0% 0/73 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
Caching Strategy dengan Redis
Bottleneck Dibantu, Konsistensi Dijaga
Cache yang baik bukan cache sebanyak mungkin, tetapi cache yang mempercepat read path tanpa membuat data bisnis jadi menipu.
Kenapa Caching Harus Selektif
Tambah cache di tempat yang tepat, bukan semua query di-cache
Di React kamu mungkin mengenal cache lewat TanStack Query, SWR, atau CDN, sedangkan di Laravel ada Cache facade yang membuat operasi cache terasa sangat singkat.
Di backend Go, caching biasanya dibuat lebih eksplisit. Kita memilih key, TTL, invalidation, dan fallback ketika Redis bermasalah. Itu sedikit lebih manual dibanding Laravel, tetapi hasilnya lebih mudah diaudit saat sistem skincare mulai ramai dan performa tidak boleh ditebak.
Jangan optimasi sebelum profil - ini berlaku double di Go.
Cache adalah salinan data yang disimpan di media lebih cepat dari sumber utama, biasanya dengan batas waktu atau aturan invalidation agar staleness tetap terkendali.
Caching cocok untuk data yang sering dibaca, relatif jarang berubah, dan toleran terhadap stale singkat. Pada online shop skincare, contoh terbaiknya adalah detail produk dan daftar kategori. Contoh terburuknya adalah stok, isi cart, dan status order.
Cache::remember('key', 300, fn () => DB::...)menyembunyikan banyak detail di balik facade.- Sebenarnya
Cache::rememberadalah cache-aside, danCache::lock(atomic lock di Redis) yang bisa mencegah stampede. - Nyaman untuk produktivitas, tetapi invalidation sering tersebar di banyak tempat.
- Kita membuat interface cache, key convention, TTL, dan invalidation secara eksplisit.
- Stampede ditahan dengan
singleflight(peran setaraCache::lock/ RedisSETNX). - Lebih banyak kode, tetapi lebih jelas saat debugging latency, stale data, dan Redis outage.
Pembaca Laravel sering mengira Cache::remember ajaib. Sebenarnya isinya cache-aside biasa plus opsi locking bawaan, persis yang kita rakit manual di Go dengan singleflight + Redis. Bedanya hanya: di Go semua langkahnya terlihat.
Endpoint yang akan kita pakai sebagai konteks:
/v1/products/{id} Baca product detail, kandidat cache 5 menit /v1/categories Baca daftar kategori, kandidat cache 1 jam /v1/admin/products/{id} Update produk, wajib invalidasi cache product detail Cache-Aside Pattern
Aplikasi cek cache dulu, database tetap sumber kebenaran
Cache-aside adalah pattern paling masuk akal untuk proyek ini karena aplikasi Go tetap mengontrol kapan membaca Redis, kapan fallback ke PostgreSQL, dan kapan menghapus cache.
Menurut dokumentasi Redis, cache-aside dipakai untuk repeated reads dengan staleness yang dibatasi TTL. AWS menyebut pola yang sama sebagai lazy loading (pendekatan reaktif: cache diisi setelah request membutuhkannya), lawannya write-through (proaktif: cache diisi saat data ditulis). Dokumentasi ElastiCache (Redis OSS / Valkey) memakai istilah ini. Intinya sederhana: cek Redis, kalau hit langsung return, kalau miss ambil dari PostgreSQL, lalu simpan hasilnya ke Redis.
sequenceDiagram
participant FE as Frontend React
participant API as Go API
participant Redis as Redis Cache
participant DB as PostgreSQL
FE->>API: GET /v1/products/42
API->>Redis: GET product:42
alt cache hit
Redis-->>API: JSON product
API-->>FE: 200 OK
else cache miss
Redis-->>API: nil
API->>DB: SELECT product by id
DB-->>API: product row
API->>Redis: SET product:42 TTL 5 menit
API-->>FE: 200 OK
endGambar 1. Alur cache-aside untuk product detail, Redis mempercepat request berikutnya tetapi PostgreSQL tetap sumber data utama.
Hit
Redis punya data, API tidak perlu query PostgreSQL.
Miss
Redis kosong atau expired, API query PostgreSQL lalu mengisi Redis.
Invalidate
Data utama berubah, API menghapus key terkait agar request berikutnya mengambil data segar.
TanStack Query menyimpan cache di browser per user, sedangkan Redis menyimpan cache di server lintas instance API, sehingga semua task ECS bisa berbagi cache yang sama.
Padanan konsepnya cukup rapi, tetapi ada satu yang tidak ada padanannya di server. Itu bagus untuk diingat agar tidak salah menyamakan cache klien dengan cache server.
staleTimemenentukan berapa lama data dianggap segar.invalidateQueries()menandai cache basi agar di-refetch.refetchOnWindowFocusmemuat ulang saat tab kembali fokus.
- TTL Redis berperan seperti
staleTime, tetapi expiry-nya keras (key hilang). DEL keysetarainvalidateQueries(), menghapus entri agar miss berikutnya mengisi ulang.- Tidak ada padanan
refetchOnWindowFocus, karena server tidak tahu kapan user kembali. Inilah beda mendasar cache klien dan cache server.
Redis Client di Go
Pakai github.com/redis/go-redis/v9 dengan wrapper kecil
Library yang kita pakai adalah github.com/redis/go-redis/v9, klien Go resmi yang di-maintain oleh Redis, dan tetap dipanggil dengan context.Context seperti operasi I/O Go lain.
Dokumentasi go-redis memasang paket dengan go get github.com/redis/go-redis/v9. Pakai go-redis v9.20.x atau yang lebih baru agar API yang kita pakai di modul ini cocok. Perlu dicatat, v9 mensyaratkan minimal Go 1.24 dan hanya menjamin dua versi Go terakhir, jadi Go 1.26 di repo ini aman. Untuk Go 1.26, modul tetap dideklarasikan di go.mod, dan dependency eksternal masuk lewat Go Modules.
Terminalgo get github.com/redis/go-redis/v9@v9.20.0 go get golang.org/x/sync/singleflight@v0.21.0
Redis mana yang kita pakai
Sebelum menulis kode, tentukan dulu engine target di production. Modul ini mengasumsikan Redis 8 (image redis:8-alpine, seri 8.8.x). Satu catatan lisensi yang penting: sejak Maret 2024 Redis pindah dari BSD ke lisensi ganda (RSALv2/SSPLv1), sehingga banyak tim memilih drop-in pengganti Valkey (BSD, di bawah Linux Foundation) lewat image valkey/valkey:8-alpine. Untuk kode kita ini tidak ada bedanya, karena go-redis berbicara lewat protokol RESP yang sama, jadi go-redis tetap kompatibel dengan Redis 8 maupun Valkey 8. Di AWS, padanan terkelolanya adalah ElastiCache (Redis OSS / Valkey).
Buat pembaca Node, go-redis kira-kira berperan seperti ioredis atau node-redis, tetapi context.Context menggantikan timeout/AbortController, dan sentinel redis.Nil saat key tidak ada setara dengan reply null di node-redis.
Struktur yang akan kita tambahkan:
- internal/
- cache/
- redis_client.go koneksi go-redis
- store.go wrapper JSON cache dengan ErrMiss
- product/
- model.go
- repository.go
- service.go satu Service: cache-aside, singleflight, invalidation
- cmd/
- api/
- main.go wiring Redis client + logger ke service
Pertama, buat koneksi Redis sebagai dependency aplikasi. Jangan membuat client baru per request, karena client sudah mengelola koneksi di balik layar.
internal/cache/redis_client.gopackage cache import ( "context" "fmt" "time" redis "github.com/redis/go-redis/v9" ) type RedisConfig struct { Addr string Password string DB int } func NewRedisClient(cfg RedisConfig) *redis.Client { return redis.NewClient(&redis.Options{ Addr: cfg.Addr, Password: cfg.Password, DB: cfg.DB, }) } func PingRedis(ctx context.Context, client *redis.Client) error { ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() if err := client.Ping(ctx).Err(); err != nil { return fmt.Errorf("ping redis: %w", err) } return nil }
Lalu bungkus Redis dengan interface kecil agar service layer tidak penuh detail serialization.
internal/cache/store.gopackage cache import ( "context" "encoding/json" "errors" "fmt" "time" redis "github.com/redis/go-redis/v9" ) var ErrMiss = errors.New("cache miss") type Store interface { GetJSON(ctx context.Context, key string, dst any) error SetJSON(ctx context.Context, key string, value any, ttl time.Duration) error GetManyJSON(ctx context.Context, keys []string) (map[string][]byte, error) Delete(ctx context.Context, keys ...string) error } type RedisStore struct { client *redis.Client } func NewRedisStore(client *redis.Client) *RedisStore { return &RedisStore{client: client} } func (s *RedisStore) GetJSON(ctx context.Context, key string, dst any) error { raw, err := s.client.Get(ctx, key).Bytes() if errors.Is(err, redis.Nil) { return ErrMiss } if err != nil { return fmt.Errorf("get redis key %q: %w", key, err) } if len(raw) == 0 { // Value kosong jarang terjadi, tetapi anggap saja miss agar caller jatuh ke database. return ErrMiss } if err := json.Unmarshal(raw, dst); err != nil { return fmt.Errorf("decode redis key %q: %w", key, err) } return nil } func (s *RedisStore) SetJSON(ctx context.Context, key string, value any, ttl time.Duration) error { raw, err := json.Marshal(value) if err != nil { return fmt.Errorf("encode redis key %q: %w", key, err) } if err := s.client.Set(ctx, key, raw, ttl).Err(); err != nil { return fmt.Errorf("set redis key %q: %w", key, err) } return nil } func (s *RedisStore) Delete(ctx context.Context, keys ...string) error { if len(keys) == 0 { return nil } if err := s.client.Del(ctx, keys...).Err(); err != nil { return fmt.Errorf("delete redis keys: %w", err) } return nil }
Service menerima interface cache.Store, tetapi constructor mengembalikan struct konkret *Service. Ini menjaga dependency mudah dites tanpa membuat API internal terlalu abstrak.
Cache Product Detail
Key product:{id}, TTL 5 menit
Product detail adalah kandidat cache yang bagus karena banyak user membaca produk yang sama, sementara perubahan nama, harga, deskripsi, dan gambar tidak terjadi setiap detik.
Key convention yang dipakai adalah format product:42 untuk produk ID 42. Di modul ini, format umumnya adalah product:{id}, dengan TTL 5 menit.
internal/product/model.gopackage product import "time" type Product struct { ID int64 `json:"id"` Name string `json:"name"` Slug string `json:"slug"` CategoryID int64 `json:"category_id"` PriceCents int64 `json:"price_cents"` Description string `json:"description"` ImageURL string `json:"image_url"` UpdatedAt time.Time `json:"updated_at"` } type Category struct { ID int64 `json:"id"` Name string `json:"name"` Slug string `json:"slug"` }
Kita pakai satu Service sebagai rumah tunggal untuk product detail, category list, invalidation, dan nanti singleflight. Service ini menerima *slog.Logger (paket log/slog standar sejak Go 1.21) sehingga error Redis benar-benar dicatat, bukan ditelan diam-diam. Logger di-pass sebagai dependency, bukan global, agar mudah dites dan konsisten dengan request id dari middleware.
internal/product/service.gopackage product import ( "context" "errors" "fmt" "log/slog" "time" "github.com/kamu/skincare-backend/internal/cache" "golang.org/x/sync/singleflight" ) const productDetailTTL = 5 * time.Minute type Repository interface { GetByID(ctx context.Context, id int64) (Product, error) GetByIDs(ctx context.Context, ids []int64) ([]Product, error) ListCategories(ctx context.Context) ([]Category, error) Update(ctx context.Context, p Product) error RenameCategory(ctx context.Context, categoryID int64, name string) error } type Service struct { repo Repository cache cache.Store log *slog.Logger group singleflight.Group } func NewService(repo Repository, cacheStore cache.Store, log *slog.Logger) *Service { return &Service{ repo: repo, cache: cacheStore, log: log, } } func (s *Service) GetProduct(ctx context.Context, id int64) (Product, error) { key := productCacheKey(id) var cached Product if err := s.cache.GetJSON(ctx, key, &cached); err == nil { return cached, nil } else if !errors.Is(err, cache.ErrMiss) { // Cache harus mempercepat sistem, bukan membuat endpoint gagal saat Redis sedang bermasalah. s.log.WarnContext(ctx, "cache read failed, falling back to db", "key", key, "err", err) } p, err := s.repo.GetByID(ctx, id) if err != nil { return Product{}, fmt.Errorf("get product from repository: %w", err) } if err := s.cache.SetJSON(ctx, key, p, productDetailTTL); err != nil { // Cache write failure tidak boleh menggagalkan read dari database. s.log.WarnContext(ctx, "cache write failed", "key", key, "err", err) } return p, nil } func productCacheKey(id int64) string { return fmt.Sprintf("product:%d", id) }
Jika PostgreSQL berhasil mengembalikan product tetapi Redis gagal menyimpan cache, response ke user tetap harus sukses karena Redis bukan source of truth.
Service menyimpan singleflight.Group sebagai field nilai (zero value-nya langsung siap pakai). Group tidak boleh disalin setelah dipakai, jadi konstruktor sengaja mengembalikan *Service dan kita selalu mengoper pointer, bukan menyalin Service setelah dibuat.
Cache Category List
Key categories, TTL 1 jam
Daftar kategori skincare jauh lebih statis daripada product detail, sehingga TTL bisa lebih panjang.
Kategori seperti cleanser, toner, serum, sunscreen, dan moisturizer biasanya jarang berubah. Dengan cache key categories dan TTL 1 jam, halaman katalog tidak perlu terus membaca tabel kategori.
internal/product/category_service.gopackage product import ( "context" "errors" "fmt" "time" "github.com/kamu/skincare-backend/internal/cache" ) const categoryListKey = "categories" const categoryListTTL = time.Hour func (s *Service) ListCategories(ctx context.Context) ([]Category, error) { var cached []Category if err := s.cache.GetJSON(ctx, categoryListKey, &cached); err == nil { return cached, nil } else if !errors.Is(err, cache.ErrMiss) { s.log.WarnContext(ctx, "cache read failed, falling back to db", "key", categoryListKey, "err", err) } categories, err := s.repo.ListCategories(ctx) if err != nil { return nil, fmt.Errorf("list categories from repository: %w", err) } if err := s.cache.SetJSON(ctx, categoryListKey, categories, categoryListTTL); err != nil { s.log.WarnContext(ctx, "cache write failed", "key", categoryListKey, "err", err) } return categories, nil }
Di Laravel kamu bisa memakai Cache::remember, tetapi di Go kita menaruh cache di service agar domain tahu data mana yang aman stale dan data mana yang harus selalu live.
Cache Invalidation Saat Update
Update database dulu, lalu hapus cache
TTL membatasi umur cache, tetapi invalidation membuat perubahan penting terlihat lebih cepat.
Untuk update produk, urutan aman adalah menulis ke PostgreSQL lebih dulu, lalu menghapus cache product detail. Jika delete cache gagal, data stale bisa bertahan sampai TTL habis. Karena itu, error invalidation perlu dicatat sebagai log atau metric, walaupun endpoint update bisa tetap berhasil sesuai kebijakan bisnis.
sequenceDiagram participant Admin as Admin Backoffice participant API as Go API participant DB as PostgreSQL participant Redis as Redis Cache Admin->>API: PUT /v1/admin/products/42 API->>DB: UPDATE products SET ... WHERE id = 42 DB-->>API: updated API->>Redis: DEL product:42 Redis-->>API: deleted API-->>Admin: 200 OK
Gambar 2. Invalidation dilakukan setelah write sukses ke PostgreSQL agar cache tidak menyimpan versi lama terlalu lama.
internal/product/update_service.gopackage product import ( "context" "fmt" ) func (s *Service) UpdateProduct(ctx context.Context, p Product) error { if err := s.repo.Update(ctx, p); err != nil { return fmt.Errorf("update product: %w", err) } if err := s.cache.Delete(ctx, productCacheKey(p.ID)); err != nil { // Pilihan production: return error jika admin butuh konsistensi ketat, atau log dan lanjut jika TTL pendek. return fmt.Errorf("invalidate product cache: %w", err) } return nil }
Jika update produk juga mengubah kategori, misalnya admin mengganti nama kategori atau memindahkan produk ke kategori baru yang memengaruhi daftar navigasi, hapus juga key categories.
internal/product/category_update_service.gopackage product import ( "context" "fmt" ) func (s *Service) RenameCategory(ctx context.Context, categoryID int64, name string) error { if err := s.repo.RenameCategory(ctx, categoryID, name); err != nil { return fmt.Errorf("rename category: %w", err) } if err := s.cache.Delete(ctx, categoryListKey); err != nil { return fmt.Errorf("invalidate category cache: %w", err) } return nil }
Jika cache dihapus sebelum database commit sukses, request lain bisa mengisi cache dengan data lama dari PostgreSQL, lalu stale bertahan sampai TTL selesai.
Kenapa urutan write-DB-dulu-baru-DEL itu penting bisa dilihat sebagai dua request yang berlomba. Jika DEL dikerjakan sebelum commit, ada celah waktu saat reader mengisi ulang cache dari data lama.
sequenceDiagram participant W as Writer (admin update) participant DB as PostgreSQL participant R as Redis participant Rdr as Reader (request lain) Note over W,Rdr: URUTAN SALAH: DEL dulu, commit belakangan W->>R: DEL product:42 Rdr->>R: GET product:42 (miss) Rdr->>DB: SELECT (masih versi lama) Rdr->>R: SET product:42 (data lama) W->>DB: COMMIT versi baru Note over R: cache kini stale sampai TTL habis
Gambar 3. Race “delete sebelum commit”: reader sempat mengisi ulang cache dengan data lama sebelum writer commit, sehingga stale bertahan sampai TTL. Menulis DB lalu DEL setelah commit menutup celah ini.
Invalidation yang lebih sulit: banyak key sekaligus
Sejauh ini invalidation kita masih satu key per perubahan (product:42, categories). Masalah muncul ketika satu perubahan memengaruhi banyak key. Contoh nyata di katalog skincare: admin mengganti nama kategori “Serum”, dan kita meng-cache daftar produk per kategori dengan key seperti category:7:products. Satu rename idealnya membuat semua key produk di kategori itu basi, bukan hanya satu.
Godaan pertama adalah menyapu key dengan pola, misalnya hapus semua product:*. Hindari KEYS product:* di production: perintah itu memblokir Redis selama memindai seluruh keyspace. Bahkan SCAN yang lebih aman pun tetap berarti memindai banyak key dan memberi beban; tidak ideal sebagai jalur invalidation panas.
Dua pola yang lebih aman:
Set-of-keys
Saat menulis category:7:products, daftarkan key itu ke sebuah Set Redis cat:7:keys. Saat kategori 7 berubah, baca anggota Set, DEL semuanya sekaligus, lalu hapus Set-nya. Invalidation terarah, tanpa memindai keyspace.
Key versioning
Selipkan nomor versi ke dalam key, mis. category:7:v3:products. Saat kategori 7 berubah, cukup INCR cat:7:ver. Key versi lama tidak dihapus, ia tinggal kedaluwarsa sendiri lewat TTL, sementara request baru langsung membentuk key versi baru.
Di Laravel kamu bisa Cache::tags(['products'])->flush() untuk membuang semua entri bertag sekaligus. Go tidak punya cache tags bawaan, jadi invalidation multi-key harus kita rancang sendiri, dan pola Set-of-keys di atas pada dasarnya adalah cara manual membangun “tag” itu.
Stampede Protection dengan singleflight
Cegah banyak goroutine memukul database untuk key yang sama
Cache stampede terjadi ketika key populer expired, lalu banyak request bersamaan sama-sama miss dan semuanya query database.
Paket golang.org/x/sync/singleflight (modul golang.org/x/sync v0.21.x) menyediakan Group.Do, yang memastikan hanya satu eksekusi berjalan untuk key tertentu, sementara pemanggil duplikat menunggu hasil yang sama. Ini proteksi lokal di satu proses Go. Jika API berjalan di banyak task ECS, tiap task tetap punya group sendiri, sehingga Redis TTL jitter dan observability tetap dibutuhkan.
sequenceDiagram
participant R1 as Request 1
participant R2 as Request 2
participant R3 as Request 3
participant SF as singleflight.Group
participant DB as PostgreSQL
Note over R1,DB: TTL product:42 baru saja expired, tiga request miss bersamaan
R1->>SF: Do("product:42")
R2->>SF: Do("product:42")
R3->>SF: Do("product:42")
SF->>DB: SELECT (hanya sekali)
DB-->>SF: product row
SF-->>R1: product (hasil eksekusi)
SF-->>R2: product (hasil yang sama, shared)
SF-->>R3: product (hasil yang sama, shared)Gambar 4. Tanpa singleflight, ketiga request memukul PostgreSQL. Dengan singleflight, hanya satu yang query, dua sisanya menunggu lalu memakai hasil yang sama.
Kita tidak membuat tipe service kedua. singleflight menjadi evolusi dari Service yang sama: cukup tambahkan method GetProductSingleflight (atau ganti GetProduct begitu kamu yakin), memakai field group yang sudah ada di Service.
internal/product/service_singleflight.gopackage product import ( "context" "errors" "fmt" "math/rand/v2" "time" "github.com/kamu/skincare-backend/internal/cache" ) func (s *Service) GetProductSingleflight(ctx context.Context, id int64) (Product, error) { key := productCacheKey(id) var cached Product if err := s.cache.GetJSON(ctx, key, &cached); err == nil { return cached, nil } else if !errors.Is(err, cache.ErrMiss) { s.log.WarnContext(ctx, "cache read failed, falling back to db", "key", key, "err", err) } value, err, _ := s.group.Do(key, func() (any, error) { p, err := s.repo.GetByID(ctx, id) if err != nil { return Product{}, fmt.Errorf("get product from repository: %w", err) } ttl := productTTLWithJitter(productDetailTTL) if err := s.cache.SetJSON(ctx, key, p, ttl); err != nil { s.log.WarnContext(ctx, "cache write failed", "key", key, "err", err) } return p, nil }) if err != nil { return Product{}, err } p, ok := value.(Product) if !ok { return Product{}, fmt.Errorf("unexpected singleflight value for key %s", key) } return p, nil } func productTTLWithJitter(base time.Duration) time.Duration { // Jitter acak nyata: sebar expiry hingga +60 detik agar key tidak expired serempak. // rand.N tersedia di math/rand/v2 (Go 1.22+) dan aman dipakai konkuren. return base + time.Duration(rand.N(60))*time.Second }
Nilai yang dikembalikan Group.Do dipakai bersama (shared) oleh semua pemanggil duplikat. Jangan memutasi struct hasil tanpa menyalin lebih dulu. Di modul ini aman karena Product di-pass by value, jadi tiap pemanggil memegang salinannya sendiri.
Jika TTL persis 5 menit untuk semua product populer sejak warmup, banyak key expired di detik yang sama dan membuat spike ke PostgreSQL. Yang memecah herd adalah randomisasi (base + rand), bukan offset konstan: menambah +15 detik tetap ke semua key tidak menyebar expiry sama sekali, semuanya tetap kedaluwarsa serempak.
singleflight hanya mengurangi duplikasi dalam satu proses Go. Untuk koordinasi lintas instance, tetap andalkan TTL yang sehat, Redis yang stabil, dan kapasitas PostgreSQL yang realistis.
Diagram berikut menegaskan batas itu: tiap task ECS punya singleflight.Group lokal sendiri, tetapi semuanya berbagi satu ElastiCache. Stampede ditahan per task, bukan lintas task.
flowchart TD
subgraph Task A
A1["Request"] --> AG["singleflight.Group (lokal)"]
end
subgraph Task B
B1["Request"] --> BG["singleflight.Group (lokal)"]
end
AG --> R[("ElastiCache: Redis/Valkey")]
BG --> R
R -.miss.-> DB[("PostgreSQL")]Gambar 5. Tiga task berbagi satu Redis/ElastiCache, tetapi tiap task punya singleflight lokal. Maka pada miss serempak, paling buruk ada satu query DB per task, bukan per request.
Memilih TTL yang Tepat
Data statis boleh lama, data dinamis harus pendek atau tidak di-cache
TTL bukan angka magis. TTL adalah kontrak berapa lama sistem boleh menampilkan versi lama.
Product detail
TTL 5 menit masuk akal untuk nama, deskripsi, gambar, dan harga jika admin update tidak terlalu sering.
Category list
TTL 1 jam masuk akal karena kategori skincare biasanya sangat jarang berubah.
Campaign banner
TTL pendek atau invalidation eksplisit karena jadwal promo bisa sensitif terhadap waktu.
Gunakan pertanyaan ini saat memilih TTL:
Query murah yang jarang dipanggil tidak perlu cache hanya karena bisa di-cache.
Data yang berubah tiap detik biasanya bukan kandidat cache-aside sederhana.
Stale pada deskripsi produk mungkin diterima, stale pada stok bisa membuat overselling.
Semakin sulit invalidation, semakin pendek TTL atau semakin kuat alasan untuk tidak cache.
Empat pertanyaan di atas bisa dirangkai jadi satu alur keputusan “cache atau jangan”. Pemetaannya: katalog (product detail, category list) jatuh ke jalur cache, sedangkan inventory, cart, dan order status jatuh ke jalur jangan cache.
flowchart TD
Q1{"Sering dibaca?"} -->|tidak| NO["Jangan cache"]
Q1 -->|ya| Q2{"Jarang berubah?"}
Q2 -->|tidak| Q3{"Toleran stale singkat?"}
Q2 -->|ya| Q4{"Mudah di-invalidate?"}
Q3 -->|tidak| NO
Q3 -->|ya| Q4
Q4 -->|tidak| SHORT["Cache dengan TTL pendek"]
Q4 -->|ya| YES["Cache dengan TTL wajar"]Gambar 6. Alur keputusan cache. Product detail dan category list menempuh jalur “Cache”, sedangkan stok, cart, dan order status berakhir di “Jangan cache”.
Cache itu seperti tester produk di etalase. Bagus untuk melihat deskripsi dan contoh kemasan, tetapi jangan pakai tester untuk menghitung stok gudang.
Negative Caching dan Cache Penetration
Lindungi database dari id yang tidak ada dan request sampah
Cache-aside klasik hanya menyimpan data yang ada. Id yang tidak ada selalu miss, lalu memukul PostgreSQL berulang kali, dan itu celah yang nyata untuk katalog read-heavy.
Bayangkan bot atau link rusak terus meminta GET /v1/products/999999999 untuk produk yang tidak ada. Setiap request miss di Redis, jatuh ke PostgreSQL, dapat “not found”, dan tidak ada yang tersimpan. Lain kali polanya berulang. Inilah yang disebut cache penetration: request yang tidak pernah bisa di-cache menembus lapisan cache dan terus menekan database.
Negative caching
Solusinya adalah menyimpan fakta “tidak ada” itu sendiri dengan TTL pendek (sengaja lebih pendek dari TTL data normal, agar produk yang baru dibuat tidak tertahan terlalu lama). Kita pakai sentinel kecil sebagai penanda negatif.
internal/product/service_negative.gopackage product import ( "context" "errors" "fmt" "time" "github.com/kamu/skincare-backend/internal/cache" ) const productMissTTL = 30 * time.Second // ErrNotFound dikembalikan repository saat row tidak ada. var ErrNotFound = errors.New("product not found") func (s *Service) GetProductNegative(ctx context.Context, id int64) (Product, error) { key := productCacheKey(id) missKey := key + ":miss" // Cek penanda negatif lebih dulu: kalau pernah "not found", jangan repotkan database. var tombstone bool if err := s.cache.GetJSON(ctx, missKey, &tombstone); err == nil { return Product{}, ErrNotFound } else if !errors.Is(err, cache.ErrMiss) { s.log.WarnContext(ctx, "negative cache read failed", "key", missKey, "err", err) } var cached Product if err := s.cache.GetJSON(ctx, key, &cached); err == nil { return cached, nil } else if !errors.Is(err, cache.ErrMiss) { s.log.WarnContext(ctx, "cache read failed, falling back to db", "key", key, "err", err) } p, err := s.repo.GetByID(ctx, id) if errors.Is(err, ErrNotFound) { // Simpan penanda negatif dengan TTL pendek. if setErr := s.cache.SetJSON(ctx, missKey, true, productMissTTL); setErr != nil { s.log.WarnContext(ctx, "negative cache write failed", "key", missKey, "err", setErr) } return Product{}, ErrNotFound } if err != nil { return Product{}, fmt.Errorf("get product from repository: %w", err) } if err := s.cache.SetJSON(ctx, key, p, productDetailTTL); err != nil { s.log.WarnContext(ctx, "cache write failed", "key", key, "err", err) } return p, nil }
Jika produk baru dibuat dengan id yang sebelumnya pernah ditandai negatif, ia akan terlihat “tidak ada” sampai penanda kedaluwarsa. Karena itu TTL negatif sengaja pendek, dan saat membuat produk baru, hapus juga key :miss-nya.
Batas negative caching dan early expiration
Negative caching menutup id sampah yang berulang, tetapi tidak menutup id acak yang selalu berbeda (penetration murni). Untuk itu, di sistem besar orang memakai Bloom filter atau membatasi rentang id yang valid di lapisan validasi. Untuk modul ini, validasi id dan rate limit di gateway sudah cukup; cukup tahu trade-off-nya.
Ada juga teknik probabilistic early expiration (kadang disebut “early recompute”): satu request beruntung me-refresh cache sedikit sebelum TTL benar-benar habis, sehingga tidak ada momen “semua miss serempak”. Itu pelengkap singleflight, bukan pengganti. Untuk skincare-backend, kombinasi TTL berjitter + singleflight sudah memadai, jadi early expiration cukup dicatat sebagai opsi lanjutan.
Di node-redis, key yang tidak ada mengembalikan reply null, mirip redis.Nil di Go. Negative caching berarti kita sengaja menyimpan nilai untuk “tidak ada”, jadi null berikutnya berubah menjadi “ada penanda tidak-ada”, bukan miss kosong.
Serialisasi dan Batch Read
JSON, ukuran payload, dan MGet untuk daftar produk
Sampai sini kita meng-cache satu produk per key. Untuk daftar katalog, membaca puluhan key satu per satu boros round-trip ke Redis.
Pilihan serialisasi
Kita memakai encoding/json karena mudah dibaca, mudah didebug (redis-cli GET product:42 langsung terbaca manusia), dan cukup cepat untuk payload katalog. Jika nanti payload besar atau hit rate sangat tinggi dan profiling menunjuk serialisasi sebagai bottleneck, alternatif biner seperti MessagePack atau protobuf menghemat ukuran dan CPU. Untuk skincare-backend, JSON adalah default yang sehat; jangan ganti tanpa angka dari profiling.
MGet untuk daftar produk
Saat menampilkan daftar produk (mis. hasil pencarian atau halaman kategori), kita punya banyak id sekaligus. Daripada GET satu per satu, pakai MGET agar Redis mengembalikan semua nilai dalam satu round-trip, lalu sisanya kita ambil dari PostgreSQL hanya untuk id yang miss.
internal/cache/store.gofunc (s *RedisStore) GetManyJSON(ctx context.Context, keys []string) (map[string][]byte, error) { if len(keys) == 0 { return map[string][]byte{}, nil } vals, err := s.client.MGet(ctx, keys...).Result() if err != nil { return nil, fmt.Errorf("mget redis keys: %w", err) } hits := make(map[string][]byte, len(keys)) for i, v := range vals { // MGET mengembalikan nil untuk key yang tidak ada; lewati saja sebagai miss. if v == nil { continue } if s, ok := v.(string); ok { hits[keys[i]] = []byte(s) } } return hits, nil }
internal/product/service_batch.gofunc (s *Service) GetProducts(ctx context.Context, ids []int64) ([]Product, error) { keys := make([]string, len(ids)) for i, id := range ids { keys[i] = productCacheKey(id) } hits, err := s.cache.GetManyJSON(ctx, keys) if err != nil { s.log.WarnContext(ctx, "batch cache read failed, falling back to db", "err", err) hits = map[string][]byte{} // perlakukan seluruhnya sebagai miss } result := make([]Product, 0, len(ids)) var missing []int64 for _, id := range ids { raw, ok := hits[productCacheKey(id)] if !ok { missing = append(missing, id) continue } var p Product if err := json.Unmarshal(raw, &p); err != nil { missing = append(missing, id) continue } result = append(result, p) } if len(missing) > 0 { fromDB, err := s.repo.GetByIDs(ctx, missing) if err != nil { return nil, fmt.Errorf("get missing products: %w", err) } // Isi ulang cache untuk id yang tadi miss (set per key, atau pipeline). result = append(result, fromDB...) } return result, nil }
Di ElastiCache (Redis OSS / Valkey), set maxmemory-policy ke allkeys-lru agar key yang jarang dipakai otomatis tergusur saat memori penuh, cocok untuk cache murni seperti katalog. Hitung kapasitas dari ukuran payload rata-rata dikali jumlah produk aktif dikali faktor jitter, lalu beri ruang lega. Untuk cache (bukan source of truth), kehilangan key bukan bencana, ia hanya menjadi miss berikutnya.
Di frontend kamu sering menggabungkan banyak fetch dengan Promise.all. MGET adalah versi server satu round-trip: satu perjalanan jaringan untuk banyak key, bukan banyak round-trip kecil yang menumpuk latensi.
Data yang Tidak Boleh Di-cache
Inventory, cart, dan order status punya risiko bisnis tinggi
Tidak semua read path harus dipercepat dengan Redis cache-aside.
Inventory
Stok harus konsisten dengan transaksi checkout. Cache stale bisa menyebabkan overselling.
Cart
Cart sangat personal dan sering berubah. Simpan di database atau Redis sebagai state eksplisit, bukan cache read-only yang mudah stale.
Order status
Status order terkait pembayaran, pengiriman, refund, dan customer support. Stale bisa menyesatkan pelanggan.
Perhatikan bedanya Redis sebagai cache dan Redis sebagai storage state. Modul ini membahas Redis cache-aside untuk read optimization. Kalau nanti Redis dipakai untuk session, rate limit, lock, atau queue, aturan konsistensinya berbeda dan harus didesain sebagai fitur terpisah.
Jika webhook payment sudah mengubah order menjadi paid tetapi cache masih menampilkan pending, pelanggan bisa membayar ulang atau menghubungi support karena informasi salah.
Di Laravel, SESSION_DRIVER=redis menaruh cart atau state user di Redis, tetapi di situ Redis adalah source of truth, bukan cache. Bedakan tegas: cache boleh hilang tanpa konsekuensi (jadi miss), sedangkan state cart yang hilang berarti kehilangan data nyata. Aturan TTL dan invalidation modul ini hanya untuk peran cache.
Hands-on Ringan
Jalankan Redis lokal, pasang wrapper, lalu ukur hit dan miss
Latihan ini menambahkan Redis lokal dan menguji cache-aside untuk product detail tanpa mengubah semua query repository.
Tambahkan Redis ke local development stack. Jika modul Docker Compose sebelumnya sudah punya file lengkap, cukup tambahkan service Redis seperti ini.
docker-compose.redis.ymlservices: redis: image: redis:8-alpine ports: - "6379:6379" command: ["redis-server", "--appendonly", "no"]
Kalau tim memilih jalur lisensi BSD, tukar saja image-nya ke valkey/valkey:8-alpine. Karena protokol RESP sama, kode go-redis dan port 6379 tidak berubah sama sekali.
Jalankan Redis dan API, lalu hit endpoint product yang sama dua kali.
Terminaldocker compose -f docker-compose.redis.yml up -d redis export REDIS_ADDR=localhost:6379 go run ./cmd/api curl -s http://localhost:8080/v1/products/42 curl -s http://localhost:8080/v1/products/42
Tambahkan wiring Redis di main.go.
cmd/api/main.gopackage main import ( "context" "log/slog" "os" "github.com/kamu/skincare-backend/internal/cache" "github.com/kamu/skincare-backend/internal/product" ) func main() { ctx := context.Background() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) redisClient := cache.NewRedisClient(cache.RedisConfig{ Addr: env("REDIS_ADDR", "localhost:6379"), Password: os.Getenv("REDIS_PASSWORD"), DB: 0, }) defer redisClient.Close() // Cache sengaja dianggap dependency opsional: jika Redis mati, API tetap jalan // dan membaca langsung dari PostgreSQL (graceful degradation), bukan gagal start. if err := cache.PingRedis(ctx, redisClient); err != nil { logger.Warn("redis unavailable, API will still start", "err", err) } cacheStore := cache.NewRedisStore(redisClient) productRepo := product.NewPostgresRepository(nil) productService := product.NewService(productRepo, cacheStore, logger) _ = productService // Router chi dan HTTP server disambungkan seperti modul Roadmap 2 dan Roadmap 4. } func env(key string, fallback string) string { value := os.Getenv(key) if value == "" { return fallback } return value }
Contoh repository constructor di atas memakai nil hanya sebagai placeholder agar fokus tetap di caching. Di repo asli, masukkan *pgxpool.Pool dari wiring aplikasi.
internal/product/repository.gopackage product import ( "context" "github.com/jackc/pgx/v5/pgxpool" ) type PostgresRepository struct { pool *pgxpool.Pool } func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository { return &PostgresRepository{pool: pool} } func (r *PostgresRepository) GetByID(ctx context.Context, id int64) (Product, error) { // Implementasi SQL asli mengikuti modul PostgreSQL dan pgx. // Kembalikan ErrNotFound saat row tidak ada agar negative caching bekerja. panic("implement me") } func (r *PostgresRepository) GetByIDs(ctx context.Context, ids []int64) ([]Product, error) { // Implementasi SQL asli: SELECT ... WHERE id = ANY($1) untuk batch read. panic("implement me") } func (r *PostgresRepository) ListCategories(ctx context.Context) ([]Category, error) { // Implementasi SQL asli mengikuti modul PostgreSQL dan pgx. panic("implement me") } func (r *PostgresRepository) Update(ctx context.Context, p Product) error { // Implementasi SQL asli mengikuti modul PostgreSQL dan pgx. panic("implement me") } func (r *PostgresRepository) RenameCategory(ctx context.Context, categoryID int64, name string) error { // Implementasi SQL asli mengikuti modul PostgreSQL dan pgx. panic("implement me") }
Checklist observasi sederhana:
Harus miss, query PostgreSQL, lalu mengisi Redis dengan key product:42.
Harus hit, tidak memanggil repository untuk product yang sama selama TTL aktif.
Hit product:999999999 dua kali, request kedua harus berhenti di penanda negatif tanpa memukul PostgreSQL.
Setelah PUT /v1/admin/products/42, key product:42 harus hilang.
Matikan Redis, endpoint product tetap membaca dari PostgreSQL dan error Redis tercatat sebagai log atau metric.
Tambahkan metric cache_hit_total, cache_miss_total, dan cache_error_total. Untuk tracing siap pakai, instrumentasi client dengan github.com/redis/go-redis/extra/redisotel/v9 agar tiap perintah Redis muncul sebagai span OpenTelemetry, menyambung ke Roadmap 8 Observability.
Ringkasan & Poin Penting
Caching yang sehat mempercepat read path tanpa mengubah PostgreSQL sebagai sumber kebenaran.
Yang Wajib Menempel
- Cache-aside berarti API cek Redis dulu, fallback ke PostgreSQL saat miss, lalu menyimpan hasil ke Redis dengan TTL.
- Product detail memakai key
product:42dengan TTL 5 menit, category list memakai keycategoriesdengan TTL 1 jam. - Invalidation dilakukan setelah update database sukses, bukan sebelum update; untuk banyak key pakai Set-of-keys atau key versioning, hindari
KEYS. - TTL diberi jitter acak (
math/rand/v2), bukan offset konstan, agar key tidak expired serempak. singleflightmenahan request duplikat dalam satu proses Go; hasilnya shared, jangan dimutasi tanpa salinan.- Negative caching menyimpan “not found” dengan TTL pendek agar id sampah tidak terus memukul PostgreSQL.
MGETmembaca banyak key katalog dalam satu round-trip; setmaxmemory-policy allkeys-lrudi ElastiCache.- Inventory, cart, dan order status tidak boleh diperlakukan sebagai cache read-only karena risiko bisnisnya tinggi.
Di proyek online shop skincare, modul ini menambah lapisan performa untuk katalog. Setelah R9.C1 profiling menemukan bottleneck read path, R9.C2 memberi strategi untuk mengurangi beban PostgreSQL secara terukur. Langkah berikutnya di Roadmap 9 bisa masuk ke search, event-driven processing, dan scaling yang lebih sadar data.
Sumber resmi yang relevan untuk pendalaman: Go 1.26 release notes, go-redis guide, Redis cache-aside, ElastiCache caching strategies, dan singleflight package. Whitepaper AWS database caching strategies kini berstatus arsip (historical reference), pakai halaman ElastiCache di atas sebagai rujukan utama.
Progress disimpan lokal di browser ini.