Web Artisan
Beranda

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.

Roadmap 9 · Advanced Scaling

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.

Bahasa: Go 1.26~75 menit baca
01

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.

🧭Prinsip modul ini

Jangan optimasi sebelum profil - ini berlaku double di Go.

cache

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.

Laravel Cache facade
  • Cache::remember('key', 300, fn () => DB::...) menyembunyikan banyak detail di balik facade.
  • Sebenarnya Cache::remember adalah cache-aside, dan Cache::lock (atomic lock di Redis) yang bisa mencegah stampede.
  • Nyaman untuk produktivitas, tetapi invalidation sering tersebar di banyak tempat.
Go manual cache
  • Kita membuat interface cache, key convention, TTL, dan invalidation secara eksplisit.
  • Stampede ditahan dengan singleflight (peran setara Cache::lock / Redis SETNX).
  • Lebih banyak kode, tetapi lebih jelas saat debugging latency, stale data, dan Redis outage.
🌉Jembatan: Cache::remember bukan sihir

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:

GET /v1/products/{id} Baca product detail, kandidat cache 5 menit
GET /v1/categories Baca daftar kategori, kandidat cache 1 jam
PUT /v1/admin/products/{id} Update produk, wajib invalidasi cache product detail
02

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
  end

Gambar 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.

🌉Jembatan: dari TanStack Query ke Redis

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.

TanStack Query (klien)
  • staleTime menentukan berapa lama data dianggap segar.
  • invalidateQueries() menandai cache basi agar di-refetch.
  • refetchOnWindowFocus memuat ulang saat tab kembali fokus.
Redis cache-aside (server)
  • TTL Redis berperan seperti staleTime, tetapi expiry-nya keras (key hilang).
  • DEL key setara invalidateQueries(), 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.
03

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.

Terminal
go 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).

🌉Jembatan: dari node-redis/ioredis ke go-redis

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:

Struktur caching di modular monolith
  • 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.go
package 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.go
package 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 }
💡Idiom Go

Service menerima interface cache.Store, tetapi constructor mengembalikan struct konkret *Service. Ini menjaga dependency mudah dites tanpa membuat API internal terlalu abstrak.

04

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.go
package 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.go
package 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) }
⚠️Jebakan: cache error jangan selalu jadi HTTP 500

Jika PostgreSQL berhasil mengembalikan product tetapi Redis gagal menyimpan cache, response ke user tetap harus sukses karena Redis bukan source of truth.

📌Satu Service, jangan disalin

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.

05

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.go
package 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 }
🌉Jembatan: dari cache helper ke domain rule

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.

06

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.go
package 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.go
package 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 }
⚠️Jebakan: delete sebelum update

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.

🌉Jembatan: cache tags Laravel

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.

07

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.go
package 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 }
⚠️Jebakan: hasil singleflight dipakai bersama

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.

💡Kenapa jitter harus acak

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.

📌Batas singleflight

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.

08

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:

Berapa mahal query-nya

Query murah yang jarang dipanggil tidak perlu cache hanya karena bisa di-cache.

Berapa sering data berubah

Data yang berubah tiap detik biasanya bukan kandidat cache-aside sederhana.

Apa risiko stale

Stale pada deskripsi produk mungkin diterima, stale pada stok bisa membuat overselling.

Bagaimana invalidation dilakukan

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”.

🧴Analogi skincare

Cache itu seperti tester produk di etalase. Bagus untuk melihat deskripsi dan contoh kemasan, tetapi jangan pakai tester untuk menghitung stok gudang.

09

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.go
package 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 }
⚠️Jebakan: negative TTL kepanjangan

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.

🌉Jembatan: null di node-redis

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.

10

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.go
func (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.go
func (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 }
📌Production sizing dan eviction

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.

🌉Jembatan: dari Promise.all ke MGet

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.

11

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.

⚠️Jebakan: cache order status

Jika webhook payment sudah mengubah order menjadi paid tetapi cache masih menampilkan pending, pelanggan bisa membayar ulang atau menghubungi support karena informasi salah.

🌉Jembatan: session driver=redis di Laravel

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.

12

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.yml
services: 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.

Terminal
docker 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.go
package 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.go
package 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:

Request pertama

Harus miss, query PostgreSQL, lalu mengisi Redis dengan key product:42.

Request kedua

Harus hit, tidak memanggil repository untuk product yang sama selama TTL aktif.

Id yang tidak ada

Hit product:999999999 dua kali, request kedua harus berhenti di penanda negatif tanpa memukul PostgreSQL.

Update produk

Setelah PUT /v1/admin/products/42, key product:42 harus hilang.

Redis mati

Matikan Redis, endpoint product tetap membaca dari PostgreSQL dan error Redis tercatat sebagai log atau metric.

💡Langkah lanjut observability

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.

13

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:42 dengan TTL 5 menit, category list memakai key categories dengan 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.
  • singleflight menahan 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.
  • MGET membaca banyak key katalog dalam satu round-trip; set maxmemory-policy allkeys-lru di 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.