Progress belajar
Modul 3 dari 3
0% 0/3 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
Belajar Redis dengan Go
Cepat di Tempat yang Benar
Redis adalah memory layer yang bisa membuat backend terasa instan, asal dipasang di tempat yang tepat dan tidak diperlakukan sebagai pengganti database.
Kenapa Redis (dan Kapan Berbahaya)
Akselerator opsional, bukan sumber kebenaran
Redis adalah penyimpanan key-value berbasis memori yang sangat cepat. Ia menolong untuk cache, session, rate limit, dan data sementara, tetapi ia bukan pengganti PostgreSQL untuk data bisnis yang harus permanen dan konsisten.
Banyak masalah performa backend berbentuk sama: satu query database yang sama dijalankan ribuan kali untuk data yang jarang berubah. Halaman detail produk, daftar kategori, dan profil publik dibaca jauh lebih sering daripada diubah. Di sinilah Redis bersinar. Ia menyimpan hasil di memori dan mengembalikannya dalam hitungan mikrodetik, sehingga database tidak menjadi titik panas tiap request.
Tetapi kecepatan itu datang dengan syarat. Redis menyimpan salinan data, dan salinan bisa basi. Begitu kamu menaruh data yang sensitif terhadap konsistensi (stok, status pesanan, status pembayaran) di Redis tanpa disiplin, kamu menukar bug yang jarang dengan bug yang sering, dan biasanya bug yang merugikan uang. Aturan mental yang dipegang sepanjang course ini: Redis adalah akselerator opsional, bukan sumber kebenaran.
Di React Query, cache hidup di memori satu browser dan hilang saat tab ditutup. Di Laravel, Cache::remember menyimpan hasil agar tidak menghitung ulang. Redis adalah versi shared dari ide yang sama: satu cache yang dipakai bersama oleh semua instance backend dan semua user, bukan per-tab atau per-proses.
flowchart LR
Client["Frontend"] -->|HTTP JSON| API["Go API"]
API -->|cek dulu| Redis[("Redis (memory)")]
Redis -.->|miss| DB[("PostgreSQL (sumber kebenaran)")]
API -->|tulis bisnis| DB
Redis -->|hit cepat| APIGambar 1. Redis duduk di depan PostgreSQL sebagai akselerator baca. PostgreSQL tetap satu-satunya sumber kebenaran untuk data bisnis.
Redis menolong
Cache hasil baca yang mahal, session dengan TTL alami, rate limit counter, ranking, dan event log ringan.
Redis berbahaya
Saat dipakai menyimpan stok, status order, atau saldo sebagai kebenaran. Salinan basi di sana berubah jadi kerugian nyata.
Redis bukan database utama
Data di memori bisa hilang saat restart bila tidak dikonfigurasi persisten. Jangan menaruh data yang tidak boleh hilang hanya di Redis.
Redis itu opsional
API harus tetap melayani request walau Redis mati. Kalau Redis jadi syarat hidup, kamu menambah titik kegagalan baru.
Refleks “cache semua GET biar cepat” adalah sumber bug paling umum di backend pemula. Sebelum cache apa pun, tanya: kalau data ini telat update beberapa detik atau menit, siapa yang rugi? Bila jawabannya pelanggan atau uang, jangan cache dulu.
Tiga section pertama membangun model pikir (kenapa, key/value/TTL, data types). Section 04 sampai 08 adalah inti caching. Section 09 sampai 11 memakai sifat atomic dan TTL Redis untuk rate limit, session, dan transaksi. Section 12 sampai 13 soal ketahanan dan stack lokal. Dua section terakhir merangkum dan menunjuk jalan ke materi scaling.
Mental Model: Key, Value, TTL
Berpikir memory-first sebelum menghafal command
Sebelum menghafal command, pegang model intinya: Redis adalah peta besar dari key ke value yang hidup di memori, dan setiap key bisa diberi waktu hidup (TTL) yang otomatis menghapusnya saat kedaluwarsa.
Tiga konsep ini lebih dulu dari sintaks command apa pun. Key adalah string unik yang menjadi alamat data. Value adalah isinya, yang bisa berupa string, hash, list, set, dan tipe lain. TTL adalah durasi sebelum key dihapus sendiri. Begitu kamu berpikir dalam tiga kata ini, sebagian besar keputusan caching jadi jelas.
Kebiasaan menaruh di Redis hanya data yang cepat berubah atau bisa dibuat ulang dari sumber lain. Bila data hilang dari Redis, sistem harus tetap benar, cukup sedikit lebih lambat.
Di JavaScript kamu mungkin menyimpan cache[productId] = data di sebuah object. Redis adalah object raksasa yang dipakai bersama lintas proses, dengan key berbentuk string terstruktur seperti product:123 dan kemampuan auto-expire yang tidak dimiliki object biasa.
Key yang baik bersifat deskriptif dan berpola namespace, dipisah titik dua. Pola entitas:id atau entitas:id:atribut membuat key mudah dibaca manusia dan mudah dikelola. Contohnya product:123 untuk detail produk, category:list untuk daftar kategori, dan session:abc123 untuk sesi login.
TTL adalah fitur yang membuat Redis ideal untuk data sementara. Kamu tidak perlu job pembersih yang menghapus data lama; Redis melakukannya sendiri. Inilah alasan session, rate limit window, dan cache berumur pendek terasa alami di Redis.
flowchart TD Set["SET product:123 (JSON) EX 300"] --> Live["Key hidup di memori"] Live -->|dalam 300 detik| Hit["GET product:123 -> value"] Live -->|lewat 300 detik| Expired["Key terhapus otomatis"] Expired --> Miss["GET product:123 -> nil (miss)"]
Gambar 2. Siklus hidup satu key dengan TTL 5 menit. Setelah kedaluwarsa, key hilang sendiri tanpa job pembersih.
Sebagai latihan model pikir, bayangkan menyimpan detail produk dengan TTL 5 menit. Selama 5 menit pertama, semua request membaca dari memori. Setelah itu, request pertama yang datang akan miss, mengambil ulang dari database, lalu mengisi cache lagi. Toleransi 5 menit ini adalah keputusan bisnis: harga dan deskripsi produk yang telat 5 menit hampir tidak pernah merugikan, dan inilah kandidat cache yang sehat.
Setiap key cache sebaiknya punya TTL eksplisit. Key tanpa TTL akan menumpuk di memori selamanya sampai dihapus manual atau di-evict. Kita bahas pemilihan durasi TTL dan kapan harus menghapus eksplisit di section TTL dan Invalidation.
Data Types Redis
Satu peta keputusan: pilih tipe sebelum menulis command
Redis bukan sekadar penyimpan string. Ia punya beberapa tipe data inti, dan memilih tipe yang tepat adalah keputusan desain yang lebih penting daripada hafal sintaks command.
Menurut dokumentasi Redis, tipe inti yang sering dipakai adalah String, Hash, List, Set, Sorted Set, dan Stream. Tiap tipe punya kasus pakai yang berbeda. Jangan memaksakan satu tipe untuk semua; pilih berdasar bentuk data dan operasi yang dibutuhkan.
| Tipe | Bentuk | Kasus pakai khas | Command kunci |
|---|---|---|---|
| String | Satu nilai (teks, angka, JSON) | Cache JSON produk, counter, flag | SET, GET, INCR |
| Hash | Map field ke value dalam satu key | Object ringan: ringkasan kartu produk | HSET, HGET, HGETALL |
| List | Urutan terurut, akses ujung | Antrian ringan, log terbaru | LPUSH, RPUSH, BRPOP |
| Set | Kumpulan anggota unik | Favorit unik, tag, membership | SADD, SISMEMBER, SMEMBERS |
| Sorted Set | Anggota dengan score terurut | Ranking, top viewed, leaderboard | ZADD, ZRANGE, ZREVRANGE |
| Stream | Log append-only dengan ID | Event log, event-driven worker | XADD, XREAD, XREADGROUP |
Pemetaan kasarnya: object atau Map di JS menjadi Hash, array menjadi List, Set menjadi Set, dan array yang diurutkan berdasar skor menjadi Sorted Set. Bedanya, operasi ini berjalan di server Redis dan dibagi semua proses, bukan di memori satu runtime.
String vs Hash untuk object
Untuk menyimpan satu produk, ada dua pilihan umum. String JSON menyimpan seluruh produk sebagai satu blob JSON. Hash menyimpan tiap field terpisah dalam satu key. Pilihannya bergantung pada apakah kamu sering memperbarui satu field saja.
- Simpan seluruh object sebagai satu blob:
SET product:123 (json). - Ambil sekali, decode di aplikasi. Sederhana dan cocok untuk cache baca utuh.
- Memperbarui satu field berarti baca, ubah, tulis ulang seluruh JSON.
- Simpan per field:
HSET product:123 name "..." price 129000. - Bisa ambil atau ubah satu field tanpa menyentuh sisanya:
HGET product:123 price. - Cocok untuk ringkasan kartu produk yang field-nya kadang diperbarui terpisah.
Untuk cache-aside produk yang dibaca utuh lalu dikirim ke frontend, String JSON biasanya lebih praktis karena satu kali GET dan satu kali decode. Hash menang ketika kamu butuh memperbarui atau membaca sebagian field tanpa memuat seluruh object.
List vs Stream untuk urutan event
List bisa dipakai sebagai antrian sederhana: LPUSH di satu ujung, BRPOP di ujung lain. Ini cukup untuk tugas ringan. Tetapi begitu kamu butuh banyak consumer yang membaca event yang sama, perlu acknowledgement, atau perlu memutar ulang event, List mulai kewalahan. Di titik itu, Stream adalah jawabannya.
Redis Streams didesain sebagai struktur data append-only log untuk event processing, dengan dukungan consumer group dan pending messages. Detail consumer group sengaja kita tunda ke section Topik Lanjutan agar fokus section ini tetap pada pemilihan tipe.
Set dan Sorted Set untuk fitur cepat
Set menyimpan anggota unik tanpa urutan, ideal untuk daftar favorit user (SADD favorites:user:42 product:123) karena SADD otomatis menolak duplikat. Sorted Set menambahkan score ke tiap anggota dan menjaganya tetap terurut, ideal untuk ranking seperti produk paling banyak dilihat (ZADD top:viewed 1 product:123, lalu ZREVRANGE untuk ambil teratas).
Pertanyaan kuncinya bukan “data ini bentuknya apa” melainkan “operasi apa yang sering saya jalankan”. Sering ambil satu field? Hash. Butuh keunikan? Set. Butuh urutan berdasar skor? Sorted Set. Butuh log event yang bisa dibaca banyak consumer? Stream.
Memakai go-redis/v9 di Go
Client resmi, context sebagai parameter pertama
Client Go resmi untuk Redis adalah github.com/redis/go-redis/v9. Versi terbaru saat course ini ditulis adalah v9.20.1 (rilis 11 Juni 2026), berlisensi BSD-2-Clause.
Pola di go-redis konsisten: setiap command menerima context.Context sebagai parameter pertama, lalu argumen command, dan mengembalikan sebuah objek hasil yang nilainya diambil lewat .Result() atau .Err(). Karena context jadi parameter pertama, kamu bisa memasang timeout dan pembatalan dengan rapi, persis seperti pada query pgx di modul PostgreSQL.
Di Node.js kamu menulis await client.get(key) dan key yang tidak ada mengembalikan null. Di Laravel, Redis::get($key) mengembalikan null juga. Di go-redis, key yang tidak ada bukan nilai kosong biasa; ia mengembalikan error khusus redis.Nil. Membedakan redis.Nil dari error sungguhan adalah inti dari menangani cache miss dengan benar.
Pertama, pasang dependency.
Terminalgo get github.com/redis/go-redis/v9@v9.20.1
Lalu buat client. redis.NewClient menerima *redis.Options, dan untuk lokal cukup mengisi Addr. Setelah itu, Ping memverifikasi koneksi.
internal/cache/redis.gopackage cache import ( "context" "time" "github.com/redis/go-redis/v9" ) // New membuat client Redis dan memverifikasi koneksi dengan Ping. func New(ctx context.Context, addr string) (*redis.Client, error) { client := redis.NewClient(&redis.Options{ Addr: addr, }) pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() if err := client.Ping(pingCtx).Err(); err != nil { return nil, err } return client, nil }
Sekarang operasi dasar Set dan Get. Perhatikan context.WithTimeout per operasi dan penanganan redis.Nil sebagai cache miss, bukan error.
internal/cache/redis.goimport ( "context" "errors" "time" "github.com/redis/go-redis/v9" ) // SetString menyimpan value string dengan TTL. func SetString(ctx context.Context, client *redis.Client, key, value string, ttl time.Duration) error { ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel() return client.Set(ctx, key, value, ttl).Err() } // GetString mengembalikan value, found=false bila key tidak ada (cache miss). func GetString(ctx context.Context, client *redis.Client, key string) (value string, found bool, err error) { ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel() value, err = client.Get(ctx, key).Result() if errors.Is(err, redis.Nil) { return "", false, nil // cache miss, bukan error } if err != nil { return "", false, err // error sungguhan (timeout, koneksi putus) } return value, true, nil }
Kesalahan klasik adalah memperlakukan redis.Nil sebagai kegagalan dan mengembalikan 500 ke client. Padahal redis.Nil cuma berarti “key tidak ada”, yaitu cache miss yang sepenuhnya normal. Periksa errors.Is(err, redis.Nil) lebih dulu, dan hanya error setelahnya yang dianggap kegagalan nyata.
flowchart TD
Get["client.Get(ctx, key).Result()"] --> Check{"err?"}
Check -->|err == nil| Hit["value valid (cache hit)"]
Check -->|errors.Is err redis.Nil| Miss["cache miss (normal)"]
Check -->|err lain| Fail["error nyata: timeout / koneksi"]Gambar 3. Tiga cabang hasil dari satu Get. Hanya cabang paling kanan yang benar-benar kegagalan.
Letakkan client Redis di internal/cache dan ekspos fungsi berdomain (mis. GetProductCache, SetProductCache) alih-alih membiarkan handler memanggil client.Get langsung. Ini menjaga key dan TTL terpusat, mudah diuji, dan mudah diganti.
Pola Cache-Aside
Cek Redis dulu, miss baru ke PostgreSQL, lalu isi cache
Cache-aside adalah pola caching paling umum dan paling aman untuk dipelajari pertama: aplikasi mengecek Redis dulu, kalau miss baru mengambil dari PostgreSQL, lalu mengisi Redis untuk request berikutnya.
Disebut “aside” karena cache berdiri di samping database, bukan di tengah jalur tulis. Aplikasi yang memegang kendali kapan membaca dan kapan mengisi cache. Pola ini cocok untuk data baca-berat yang jarang berubah, seperti detail produk.
sequenceDiagram
participant H as Handler
participant R as Redis
participant DB as PostgreSQL
H->>R: GET product:123
alt cache hit
R-->>H: JSON produk
else cache miss
R-->>H: nil
H->>DB: SELECT produk WHERE id=123
DB-->>H: row produk
H->>R: SET product:123 (JSON) EX 300
H-->>H: kembalikan produk
endGambar 4. Alur cache-aside. Database hanya tersentuh saat miss, sehingga ia tidak menjadi titik panas tiap request.
React Query menyimpan data agar komponen tidak fetch ulang terus-menerus, tetapi cache itu milik satu browser. Cache-aside di Redis adalah ide yang sama di sisi server: satu salinan dipakai semua user, sehingga query database benar-benar berkurang secara global, bukan per pengunjung.
Berikut penerapan pada GET /v1/products/:id tanpa mengubah kontrak API. Service mencoba cache lebih dulu, jatuh ke repository bila miss, lalu menyimpan hasilnya.
/v1/products/{id} Detail produk; dilayani dari cache bila tersedia, dari PostgreSQL bila miss internal/product/service.gopackage product import ( "context" "encoding/json" "errors" "time" "github.com/redis/go-redis/v9" ) const productTTL = 5 * time.Minute type Repository interface { FindByID(ctx context.Context, id int64) (Product, error) } type Service struct { repo Repository redis *redis.Client } func NewService(repo Repository, rdb *redis.Client) *Service { return &Service{repo: repo, redis: rdb} } // GetByID menerapkan cache-aside: cek Redis, miss baru ke PostgreSQL. func (s *Service) GetByID(ctx context.Context, id int64) (Product, error) { key := productKey(id) // 1. Cek cache. cached, err := s.redis.Get(ctx, key).Result() if err == nil { var p Product if jsonErr := json.Unmarshal([]byte(cached), &p); jsonErr == nil { return p, nil // cache hit } // JSON rusak: anggap miss, lanjut ke database. } else if !errors.Is(err, redis.Nil) { // Error Redis nyata: jangan gagalkan request, lanjut ke database. // (Resilience dibahas di section 12.) } // 2. Cache miss: ambil dari sumber kebenaran. p, err := s.repo.FindByID(ctx, id) if err != nil { return Product{}, err } // 3. Isi cache untuk request berikutnya (best-effort). if blob, marshalErr := json.Marshal(p); marshalErr == nil { _ = s.redis.Set(ctx, key, blob, productTTL).Err() } return p, nil } func productKey(id int64) string { return "product:" + itoa(id) }
Perhatikan langkah 3 memakai _ = untuk mengabaikan error Set. Bila Redis gagal menyimpan, request tetap mengembalikan data yang benar dari database. Caching yang menggagalkan request hanya karena gagal mengisi cache adalah desain yang salah; cache seharusnya menambah, bukan mengurangi, keandalan.
Frontend tidak tahu dan tidak peduli apakah respons datang dari Redis atau PostgreSQL. Bentuk JSON, status code, dan path tetap sama. Caching adalah optimasi internal, bukan perubahan kontrak.
Desain Cache Key
Key yang konsisten menentukan kemudahan invalidasi
Key yang dirancang dengan disiplin menentukan seberapa mudah kamu menghapus dan memperbarui cache nanti. Key yang berantakan membuat invalidasi jadi mimpi buruk.
Key yang baik punya struktur yang konsisten dan dapat ditebak. Pola yang umum dan kuat adalah menyusun komponen yang dipisah titik dua, dari yang paling umum ke paling spesifik.
| Komponen | Contoh | Tujuan |
|---|---|---|
| Environment prefix | prod, staging | Memisahkan cache antar lingkungan |
| Versi skema cache | v1 | Membuang seluruh cache lama saat bentuk berubah |
| Entitas | product, category | Mengelompokkan jenis data |
| ID atau hash query | 123, list, q hash | Mengidentifikasi item atau hasil spesifik |
Dengan pola itu, beberapa contoh key untuk online shop terlihat seperti ini.
Contoh cache keyprod:v1:product:123 detail produk id 123 prod:v1:category:list daftar kategori prod:v1:search:8f3a1c hasil pencarian (8f3a1c = hash query) prod:v1:session:abc123 sesi login
Kamu sudah terbiasa menamai route frontend secara konsisten, mis. /products/:id. Anggap cache key sebagai naming yang sama untuk data backend: terstruktur, dapat ditebak, dan stabil, sehingga siapa pun di tim tahu bentuk key tanpa menebak.
Untuk hasil yang bergantung pada banyak parameter (pencarian dengan filter dan paginasi), jangan menempelkan seluruh query mentah ke key karena panjang dan rawan karakter aneh. Buat hash pendek yang deterministik dari parameter yang sudah dinormalkan.
internal/product/cachekey.gopackage product import ( "crypto/sha1" "encoding/hex" "fmt" ) // searchKey membuat key stabil dari parameter pencarian yang sudah dinormalkan. func searchKey(env, q, category string, page int) string { raw := fmt.Sprintf("q=%s&cat=%s&page=%d", q, category, page) sum := sha1.Sum([]byte(raw)) short := hex.EncodeToString(sum[:])[:6] return fmt.Sprintf("%s:v1:search:%s", env, short) }
Menyisipkan v1 di key memberi cara murah membuang seluruh cache sekaligus. Saat kamu mengubah bentuk JSON yang disimpan, naikkan ke v2. Key lama v1 tidak akan pernah dibaca lagi dan akan kedaluwarsa sendiri lewat TTL, tanpa perlu menghapus satu per satu.
Bila key tersebar tanpa pola (mis. produk_123_cache di satu tempat dan cacheProduct123 di tempat lain), kamu tidak bisa menemukan dan menghapusnya saat data berubah. Konsistensi penamaan adalah fondasi invalidasi di section berikutnya.
TTL dan Invalidation
Kapan data cache dianggap basi dan bagaimana membuangnya
Pertanyaan tersulit dalam caching bukan cara menyimpan, melainkan kapan data cache dianggap basi dan bagaimana membuangnya. Ada dua alat utama: TTL dan penghapusan eksplisit saat data berubah.
TTL membuat cache kedaluwarsa sendiri setelah durasi tertentu. Ini cocok untuk data yang boleh sedikit telat. Penghapusan eksplisit (delete-on-write) membuang key cache segera setelah sumbernya berubah, sehingga request berikutnya pasti mengambil data segar. Keduanya sering dipakai bersama.
| Strategi | Cara kerja | Cocok untuk | Risiko |
|---|---|---|---|
| TTL pendek | Key hidup beberapa detik sampai menit | Data yang boleh telat sebentar | Hit rate lebih rendah, database sedikit lebih sibuk |
| TTL panjang | Key hidup jam sampai hari | Data yang sangat jarang berubah | Stale lama bila tidak ada invalidasi |
| Delete-on-write | Hapus key saat sumber diubah | Data yang harus segar setelah update | Harus konsisten dipanggil di setiap jalur tulis |
Di React Query, setelah mutation kamu memanggil queryClient.invalidateQueries agar data lama dibuang dan di-fetch ulang. Delete-on-write di Redis adalah versi server dari kebiasaan itu: setelah menulis ke PostgreSQL, hapus key cache yang terkait supaya pembaca berikutnya mendapat data segar.
Pola paling jelas untuk online shop adalah menghapus product:{id} setelah admin memperbarui produk. Update menulis ke PostgreSQL dulu (sumber kebenaran), baru menghapus cache. Urutan ini penting: tulis dulu, baru buang cache.
internal/product/service.go// Update menulis ke PostgreSQL lalu membuang cache produk terkait. func (s *Service) Update(ctx context.Context, p Product) error { // 1. Tulis ke sumber kebenaran dulu. if err := s.repo.Update(ctx, p); err != nil { return err } // 2. Buang cache (best-effort). Request berikutnya akan miss // lalu mengisi ulang dari data yang sudah segar. _ = s.redis.Del(ctx, productKey(p.ID)).Err() return nil }
sequenceDiagram participant Admin as Admin participant API as Go API participant DB as PostgreSQL participant R as Redis Admin->>API: PATCH /v1/admin/products/123 API->>DB: UPDATE produk SET ... WHERE id=123 DB-->>API: ok API->>R: DEL product:123 API-->>Admin: 200 OK Note over R: request baca berikutnya miss,<br/>isi ulang dari data segar
Gambar 5. Delete-on-write. Tulis ke PostgreSQL dulu, baru hapus cache, agar tidak ada jendela di mana cache terisi data lama setelah database sudah baru.
Bila kamu menghapus cache lebih dulu lalu menulis database, ada celah waktu di mana request lain bisa miss, membaca data lama dari database (karena update belum selesai), lalu mengisi cache dengan data basi yang justru baru saja kamu hapus. Selalu tulis sumber kebenaran dulu, baru buang cache.
Bila data tidak punya jalur tulis yang jelas atau update-nya jarang dan tidak kritikal (mis. daftar kategori), TTL pendek saja sudah cukup. Pakai delete-on-write hanya saat kamu butuh kesegaran segera setelah perubahan dan punya satu tempat jelas untuk memicu penghapusan.
Apa yang TIDAK Boleh Di-cache
Melawan refleks cache semua GET
Section ini adalah pertahanan. Tidak semua data layak di-cache, dan beberapa data justru berbahaya bila di-cache. Mengetahui apa yang tidak boleh di-cache sama pentingnya dengan tahu cara cache.
Aturan keputusannya sederhana: cache aman untuk data yang jarang berubah dan tidak merugikan bila telat. Cache berbahaya untuk data yang harus selalu akurat saat itu juga, terutama yang menyangkut uang, stok, dan status transaksi.
| Klasifikasi | Contoh data online shop | Alasan |
|---|---|---|
| Aman di-cache | Katalog produk, daftar kategori, detail produk, konten halaman statis | Jarang berubah, telat beberapa menit hampir tak merugikan |
| Hati-hati | Hasil pencarian, ranking produk | Boleh di-cache dengan TTL pendek, tetapi awasi kesegarannya |
| Dilarang di-cache (sebagai kebenaran) | Stok inventory, cart aktif, status order, status payment, data privat user | Salinan basi langsung berubah jadi bug yang merugikan uang atau kepercayaan |
Bila stok di-cache, pelanggan bisa melihat “tersedia” untuk produk yang sebenarnya sudah habis, lalu checkout gagal di langkah terakhir, atau lebih buruk, dua pelanggan membeli unit terakhir yang sama. Stok harus dibaca dan dikurangi dari PostgreSQL dalam transaksi, bukan dari salinan memori yang bisa basi.
Pelanggan yang baru membayar lalu melihat status “menunggu pembayaran” karena cache basi akan kehilangan kepercayaan. Status order dan payment menggerakkan keputusan dan emosi pelanggan; baca selalu dari sumber kebenaran.
flowchart TD
Q{"Data ini kalau telat<br/>beberapa menit,<br/>siapa yang rugi?"}
Q -->|tidak ada yang rugi| Cache["Aman di-cache dengan TTL"]
Q -->|UX sedikit terganggu| Careful["Hati-hati: TTL pendek + awasi"]
Q -->|pelanggan atau uang rugi| NoCache["JANGAN cache sebagai kebenaran"]Gambar 6. Pohon keputusan satu pertanyaan yang bisa langsung dipakai tim sebelum memutuskan cache.
Di tutorial caching umum, semua endpoint GET sering diperlakukan setara. Realita online shop berbeda: GET /v1/products aman di-cache, tetapi GET /v1/orders/{id} dan GET /v1/cart tidak, karena keduanya state hidup milik satu user yang harus akurat.
Selain soal kesegaran, data privat user (alamat, riwayat order) berisiko bocor bila key cache tidak mengikat ke user yang benar. Bila terpaksa cache data per-user, pastikan user ID jadi bagian key dan TTL pendek, dan jangan pernah cache data privat di key yang bisa dibaca lintas user.
Rate Limiting dengan INCR dan EXPIRE
Memanfaatkan operasi atomic untuk membatasi laju request
Redis sangat cocok untuk rate limiting karena INCR bersifat atomic: menaikkan counter aman walau banyak request datang bersamaan, sehingga hitungan tidak pernah salah karena race.
Pola paling sederhana adalah fixed window: untuk tiap kombinasi subjek dan jendela waktu, naikkan counter, dan bila ini kenaikan pertama, pasang TTL sepanjang jendela. Saat counter melewati batas, tolak request dengan status 429.
Di Laravel, throttle:60,1 memberi rate limit nyaris tanpa kamu memikirkan mekanismenya. Di Go kita membangunnya eksplisit dengan INCR dan EXPIRE di Redis. Lebih banyak baris, tetapi kamu paham persis bagaimana batas dihitung dan bisa menyesuaikannya per endpoint.
internal/ratelimit/fixedwindow.gopackage ratelimit import ( "context" "time" "github.com/redis/go-redis/v9" ) // Allow menaikkan counter untuk key di jendela tetap. // Mengembalikan true bila request masih di bawah limit. func Allow(ctx context.Context, rdb *redis.Client, key string, limit int64, window time.Duration) (bool, error) { count, err := rdb.Incr(ctx, key).Result() if err != nil { return false, err } // Saat counter baru dibuat (nilai 1), pasang TTL jendela. if count == 1 { if err := rdb.Expire(ctx, key, window).Err(); err != nil { return false, err } } return count <= limit, nil }
Karena INCR mengembalikan nilai baru secara atomic, dua request bersamaan tidak akan membaca nilai lama yang sama lalu menimpanya. Inilah yang membuat counter aman tanpa lock manual.
sequenceDiagram participant U as User participant API as Go API participant R as Redis U->>API: POST /v1/auth/login API->>R: INCR ratelimit:login:ip:1.2.3.4 R-->>API: count = 1 API->>R: EXPIRE key 60s (karena count==1) API-->>U: lanjut proses login Note over U,R: request ke-6 dalam 60 detik U->>API: POST /v1/auth/login API->>R: INCR key R-->>API: count = 6 (> limit 5) API-->>U: 429 Too Many Requests
Gambar 7. Fixed window limit 5 per 60 detik pada login per-IP. Request keenam ditolak sampai jendela reset.
Terapkan ini ke endpoint sensitif seperti login (per-IP, melawan brute force) dan checkout (per-user, melawan klik ganda atau abuse). Kunci key membedakan subjek: ratelimit:login:ip:{ip} dan ratelimit:checkout:user:{id}.
/v1/auth/login Login; dibatasi per-IP untuk meredam brute force /v1/checkout Checkout; dibatasi per-user untuk meredam abuse Fixed window sederhana tetapi punya efek tepi: dua lonjakan di akhir satu jendela dan awal jendela berikut bisa melebihi limit sesaat. Sliding window menghaluskan ini dengan memperhitungkan waktu lebih halus (mis. memakai Sorted Set berisi timestamp). Untuk perlindungan dasar, fixed window sudah cukup; sliding window dipakai saat kamu butuh batas yang lebih ketat.
Rate limit melindungi dari laju berlebih, bukan menjamin konsistensi data. Mencegah klik ganda di checkout dengan rate limit itu bagus, tetapi pencegahan pesanan ganda yang benar tetap butuh idempotency key dan transaksi di PostgreSQL.
Session dan Token Store
State autentikasi sementara dengan TTL alami
Redis ideal untuk session store dan token blacklist karena keduanya secara alami punya masa hidup. TTL Redis menghapus session kedaluwarsa dan token tercabut tanpa job pembersih.
Session adalah state autentikasi sementara: ID acak yang menunjuk ke data user yang sedang login, dengan TTL sebagai masa berlaku. Token blacklist menyimpan token yang sengaja dicabut (mis. setelah logout) sampai token itu kedaluwarsa sendiri. Keduanya bukan sumber kebenaran identitas; identitas tetap ada di PostgreSQL.
Laravel bisa menyimpan session di Redis lewat SESSION_DRIVER=redis, dan kamu jarang melihat mekanismenya. Di Go kita melakukannya eksplisit: simpan JSON session di session:{id} dengan TTL, baca saat request, perpanjang saat aktif, hapus saat logout.
internal/auth/session.gopackage auth import ( "context" "encoding/json" "errors" "time" "github.com/redis/go-redis/v9" ) const sessionTTL = 24 * time.Hour type Session struct { UserID int64 `json:"user_id"` Role string `json:"role"` } type Store struct { redis *redis.Client } func NewStore(rdb *redis.Client) *Store { return &Store{redis: rdb} } // Save menyimpan session dengan TTL. func (s *Store) Save(ctx context.Context, id string, sess Session) error { blob, err := json.Marshal(sess) if err != nil { return err } return s.redis.Set(ctx, "session:"+id, blob, sessionTTL).Err() } // Get membaca session; found=false bila tidak ada atau sudah kedaluwarsa. func (s *Store) Get(ctx context.Context, id string) (Session, bool, error) { blob, err := s.redis.Get(ctx, "session:"+id).Result() if errors.Is(err, redis.Nil) { return Session{}, false, nil } if err != nil { return Session{}, false, err } var sess Session if err := json.Unmarshal([]byte(blob), &sess); err != nil { return Session{}, false, err } return sess, true, nil } // Touch memperpanjang masa hidup session yang masih aktif. func (s *Store) Touch(ctx context.Context, id string) error { return s.redis.Expire(ctx, "session:"+id, sessionTTL).Err() } // Revoke menghapus session saat logout. func (s *Store) Revoke(ctx context.Context, id string) error { return s.redis.Del(ctx, "session:"+id).Err() }
stateDiagram-v2 [*] --> Aktif: login (Save, TTL 24 jam) Aktif --> Aktif: request (Touch, perpanjang TTL) Aktif --> Habis: TTL kedaluwarsa Aktif --> Dicabut: logout (Revoke / Del) Habis --> [*] Dicabut --> [*]
Gambar 8. Siklus hidup session. TTL menghapus session diam yang tidak aktif; logout mencabut segera.
Bila kamu memakai JWT stateless, logout tidak benar-benar membatalkan token sampai ia kedaluwarsa. Solusinya: simpan ID token tercabut di Redis (revoked:{jti}) dengan TTL sepanjang sisa umur token, lalu periksa setiap request. Setelah token kedaluwarsa, key blacklist-nya juga hilang sendiri, jadi memori tidak menumpuk.
Bila Redis tidak dikonfigurasi persisten, restart akan menghapus semua session dan semua user terpaksa login ulang. Itu merepotkan, tetapi tidak merusak data bisnis karena identitas tetap aman di PostgreSQL. Ini menegaskan: session adalah state sementara, bukan sumber kebenaran identitas.
Atomic Operation dan Transaction
Atomicity Redis lebih terbatas dari transaksi PostgreSQL
Banyak command Redis bersifat atomic per command, tetapi menggabungkan beberapa langkah jadi satu unit yang aman butuh desain. Atomicity Redis lebih terbatas dibanding transaksi PostgreSQL.
Ada tiga tingkat. Pertama, command tunggal seperti INCR sudah atomic, jadi counter dan flag aman tanpa usaha tambahan. Kedua, MULTI/EXEC (lewat TxPipelined di go-redis) mengirim beberapa command sebagai satu blok yang dijalankan berurutan tanpa disela command lain. Ketiga, optimistic transaction dengan WATCH (lewat client.Watch) untuk operasi baca-ubah-tulis yang harus gagal bila data berubah di tengah.
Transaksi PostgreSQL memberi ACID penuh: kamu bisa membaca, memutuskan, dan menulis dalam satu transaksi dengan rollback otomatis bila ada konflik. Redis lebih ramping. MULTI/EXEC tidak punya rollback bila satu command gagal di tengah, dan baca-ubah-tulis aman butuh pola WATCH yang gagal lalu di-retry, bukan locking otomatis.
Untuk command atomic tunggal seperti counter view produk, cukup INCR. Tidak perlu transaksi.
internal/product/views.go// IncrViews menaikkan penghitung tampilan produk secara atomic. func IncrViews(ctx context.Context, rdb *redis.Client, productID int64) (int64, error) { return rdb.Incr(ctx, viewsKey(productID)).Result() }
Untuk beberapa command yang harus berjalan sebagai satu blok, pakai TxPipelined. Contoh: menaikkan counter rate limit dan memasang TTL dalam satu kirim.
internal/ratelimit/pipeline.goimport "github.com/redis/go-redis/v9" // AllowPipelined menjalankan INCR + EXPIRE sebagai satu blok MULTI/EXEC. func AllowPipelined(ctx context.Context, rdb *redis.Client, key string, limit int64, window time.Duration) (bool, error) { var incr *redis.IntCmd _, err := rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error { incr = pipe.Incr(ctx, key) pipe.Expire(ctx, key, window) return nil }) if err != nil { return false, err } return incr.Val() <= limit, nil }
Untuk baca-ubah-tulis yang harus konsisten, pakai client.Watch. Menurut panduan transaksi Redis, bila key yang di-WATCH berubah sebelum Exec, transaksi gagal dengan redis.TxFailedErr dan perlu di-retry dalam loop.
internal/cache/optimistic.goimport ( "context" "errors" "github.com/redis/go-redis/v9" ) // IncrementBy melakukan baca-ubah-tulis aman dengan optimistic transaction. func IncrementBy(ctx context.Context, rdb *redis.Client, key string, delta int64) error { const maxRetries = 3 txf := func(tx *redis.Tx) error { current, err := tx.Get(ctx, key).Int64() if err != nil && !errors.Is(err, redis.Nil) { return err } next := current + delta // Exec hanya jalan bila key yang di-WATCH tidak berubah. _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, key, next, 0) return nil }) return err } for i := 0; i < maxRetries; i++ { err := rdb.Watch(ctx, txf, key) if err == nil { return nil // sukses } if errors.Is(err, redis.TxFailedErr) { continue // key berubah, coba lagi } return err // error nyata } return errors.New("optimistic transaction gagal setelah retry") }
flowchart TD
Watch["WATCH key"] --> Read["baca nilai sekarang"]
Read --> Compute["hitung nilai baru"]
Compute --> Exec["EXEC (TxPipelined)"]
Exec --> Check{"key berubah<br/>saat di-WATCH?"}
Check -->|tidak| Done["sukses"]
Check -->|ya: redis.TxFailedErr| Retry["retry dari WATCH"]
Retry --> WatchGambar 9. Pola optimistic transaction. Bila ada yang menyentuh key di tengah, EXEC gagal dan kita ulang dari WATCH.
Untuk logika atomic yang lebih rumit (beberapa baca dan tulis bersyarat dalam satu eksekusi tak terinterupsi), Redis mendukung Lua script yang dijalankan server secara atomic. Ini opsi kuat tetapi menambah kompleksitas; pakai hanya saat WATCH dan pipeline tidak cukup, dan jangan dipakai sebelum kebutuhannya jelas.
Untuk operasi business-critical seperti mengurangi stok saat checkout, gunakan transaksi PostgreSQL, bukan transaksi Redis. Atomic di Redis cocok untuk counter, rate limit, dan flag; konsistensi uang dan stok adalah urusan database relasional.
Error Handling, Resilience, dan Observability
Redis boleh gagal tanpa menjatuhkan API, dan harus dipantau
Redis adalah akselerator, jadi ia boleh gagal tanpa membuat API ikut gagal. Bila Redis mati, API harus tetap melayani request dari PostgreSQL, sedikit lebih lambat. Dan agar tahu cache benar-benar menolong, ia harus dipantau.
Inti resilience adalah membedakan cache error dari business error. Cache error (Redis timeout, koneksi putus) tidak boleh menggagalkan request; ia hanya berarti “lewati cache kali ini, ambil dari database”. Business error (produk tidak ada, validasi gagal) adalah hasil sah yang memang harus dikembalikan ke client.
Di Laravel, Cache::remember tetap memanggil closure database bila cache miss, jadi kegagalan cache terasa transparan. Di Go kita harus eksplisit: setiap pemanggilan Redis dibungkus timeout pendek, dan kegagalannya di-fallback ke database, bukan dipropagasi sebagai error 500.
Pola fallback: coba cache dengan timeout pendek, dan apa pun yang bukan cache hit (miss atau error) jatuh ke database. Inilah yang membuat Redis bukan single point of failure.
internal/product/service.go// GetByIDResilient memperlakukan kegagalan Redis sebagai miss, bukan error fatal. func (s *Service) GetByIDResilient(ctx context.Context, id int64) (Product, error) { key := productKey(id) // Timeout pendek khusus cache: kalau Redis lambat, jangan tahan request. cacheCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) cached, err := s.redis.Get(cacheCtx, key).Result() cancel() switch { case err == nil: var p Product if json.Unmarshal([]byte(cached), &p) == nil { s.metrics.RecordHit() return p, nil } case errors.Is(err, redis.Nil): s.metrics.RecordMiss() default: // Redis error (timeout, koneksi): catat, lalu lanjut ke database. s.metrics.RecordError() s.log.Warn("redis get gagal, fallback ke database", "key", key, "err", err) } // Sumber kebenaran selalu tersedia walau Redis bermasalah. p, dbErr := s.repo.FindByID(ctx, id) if dbErr != nil { return Product{}, dbErr } // Isi cache best-effort; abaikan error. if blob, mErr := json.Marshal(p); mErr == nil { setCtx, c := context.WithTimeout(ctx, 100*time.Millisecond) _ = s.redis.Set(setCtx, key, blob, productTTL).Err() c() } return p, nil }
flowchart TD
Req["GET /v1/products/123"] --> Try["coba Redis (timeout 100ms)"]
Try --> R{"hasil?"}
R -->|hit| Fast["balas cepat dari cache"]
R -->|miss| DB["ambil dari PostgreSQL"]
R -->|error / timeout| DB
DB --> Resp["balas dari database"]
DB --> Fill["isi cache best-effort"]Gambar 10. Dengan fallback, mematikan Redis hanya membuat lebih banyak request menyentuh PostgreSQL. API tetap hidup.
Cara terbaik membuktikan Redis bukan single point of failure adalah mematikannya saat development, lalu memastikan GET /v1/products/{id} tetap mengembalikan 200 dari PostgreSQL. Bila API ikut mati, berarti ada jalur yang memperlakukan cache error sebagai fatal; perbaiki itu.
Observability: tahu cache benar-benar efektif
Cache yang tidak dipantau adalah kotak ajaib. Metrik inti yang menentukan apakah cache efektif adalah hit rate (rasio hit terhadap total), latency pemanggilan Redis, pemakaian memori, eviction (key terbuang karena memori penuh), dan slowlog (command yang lambat).
| Metrik | Yang dijawab | Tindakan bila buruk |
|---|---|---|
| Hit rate | Seberapa sering cache menolong | Hit rate rendah berarti TTL terlalu pendek atau data tidak layak di-cache |
| Latency Redis call | Apakah Redis benar-benar cepat | Latency tinggi berarti masalah jaringan atau command berat |
| Memory usage | Seberapa penuh Redis | Mendekati batas berarti perlu TTL lebih ketat atau memori lebih besar |
| Eviction | Apakah key dibuang paksa | Eviction tinggi berarti memori kurang untuk beban cache |
| Slowlog | Command apa yang lambat | Hindari command yang memindai banyak key (mis. KEYS di produksi) |
Yang paling mudah dikontrol dari sisi aplikasi adalah hit rate dan latency. Catat hit, miss, dan error di service (seperti RecordHit/RecordMiss di kode di atas), lalu ukur durasi tiap pemanggilan Redis.
internal/cache/instrument.go// timed mengukur durasi satu operasi Redis untuk metrik latency. func timed(name string, log *slog.Logger, fn func() error) error { start := time.Now() err := fn() log.Info("redis op", "op", name, "ms", time.Since(start).Milliseconds(), "err", err) return err }
Jangan memakai KEYS * di produksi untuk mencari key; ia memblokir Redis selama memindai seluruh keyspace. Bila perlu iterasi, pakai SCAN dengan cursor. Slowlog akan menunjukkan command seperti ini, dan itu sinyal untuk segera mengubah pola akses.
Stack Lokal dan Peta Penggunaan Redis
Redis di Docker Compose plus peta di mana ia dipasang
Bagian ini menyatukan dua hal praktis: menambahkan Redis ke stack lokal lewat Docker Compose, dan memetakan di mana saja Redis dipakai (dan tidak dipakai) di online shop skincare.
Redis mudah dijalankan lokal bersama Go API dan PostgreSQL. Tambahkan satu service ringkas ke docker-compose.yml, dengan env REDIS_ADDR yang dibaca aplikasi.
docker-compose.ymlservices: api: build: . ports: - "8080:8080" environment: DATABASE_URL: postgres://app:secret@postgres:5432/skincare?sslmode=disable REDIS_ADDR: redis:6379 depends_on: - postgres - redis postgres: image: postgres:17 environment: POSTGRES_USER: app POSTGRES_PASSWORD: secret POSTGRES_DB: skincare ports: - "5432:5432" redis: image: redis:7 ports: - "6379:6379"
Laravel Sail memberi docker-compose.yml siap pakai dengan app, database, dan Redis. Di sini kita merakit yang setara untuk backend Go: satu service API, satu PostgreSQL sebagai sumber kebenaran, satu Redis sebagai akselerator. Aplikasi membaca alamat Redis dari REDIS_ADDR.
cmd/api/main.go// Baca alamat Redis dari environment, dengan default lokal yang masuk akal. addr := os.Getenv("REDIS_ADDR") if addr == "" { addr = "localhost:6379" } rdb, err := cache.New(ctx, addr) if err != nil { // Redis opsional: log peringatan, tetap jalan tanpa cache bila perlu. log.Printf("redis tidak tersedia, jalan tanpa cache: %v", err) }
- cmd/
- api/
- main.go baca REDIS_ADDR, buat client cache
- internal/
- cache/
- redis.go client + helper Get/Set
- instrument.go metrik latency cache
- product/
- service.go cache-aside detail produk
- ratelimit/
- fixedwindow.go INCR + EXPIRE per-IP / per-user
- auth/
- session.go session store + token blacklist
- docker-compose.yml service redis, postgres, api
Peta penggunaan Redis di online shop
Berikut peta selektif: bagian mana memakai Redis, dan bagian mana tetap murni di PostgreSQL. Ini ringkasan keputusan dari seluruh course.
| Bagian | Pakai Redis? | Caranya |
|---|---|---|
| Detail produk dan kategori | Ya | Cache-aside dengan TTL pendek, delete-on-write saat admin update |
| Session login | Ya | Session store session:{id} dengan TTL alami |
| Login dan checkout throttle | Ya | Rate limit fixed window dengan INCR + EXPIRE |
| Top viewed products | Ya | Sorted Set untuk ranking |
| Stok inventory | Tidak | Selalu dari PostgreSQL dalam transaksi |
| Status order dan payment | Tidak | Selalu dari PostgreSQL, harus segar |
| Cart aktif | Tidak (sebagai kebenaran) | State hidup milik user, simpan di PostgreSQL |
flowchart LR
subgraph Redis["Redis (akselerator + state sementara)"]
PC["product / category cache"]
SS["user session"]
RL["login / checkout throttle"]
TV["top viewed (sorted set)"]
end
subgraph PG["PostgreSQL (sumber kebenaran)"]
INV["inventory / stok"]
ORD["order + payment status"]
CART["cart aktif"]
end
API["Go API"] --> Redis
API --> PGGambar 11. Peta penggunaan. Redis memegang yang cepat dan sementara; PostgreSQL memegang yang harus benar dan permanen.
Dengan docker compose up, kamu mendapat API plus PostgreSQL plus Redis sekaligus, mencerminkan produksi. Ini mempermudah menguji cache-aside, rate limit, dan fallback (matikan service redis, lalu lihat API tetap hidup) tanpa memasang apa pun secara manual.
Topik Lanjutan & Langkah Berikutnya
Apa yang sengaja ditunda, lengkap dengan peringatannya
Course ini sengaja menunda beberapa topik agar fokus tetap pada fondasi caching dan state sementara. Bagian ini menyebutnya secara jujur, lengkap dengan peringatan dan arah lanjutan, supaya tidak ada yang hilang diam-diam.
Pub/Sub dan batasannya
Redis Pub/Sub memungkinkan satu proses mem-publish pesan ke channel dan proses lain men-subscribe untuk menerimanya, cocok untuk broadcast realtime ringan seperti memberi tahu antar proses bahwa sebuah cache perlu di-invalidasi. Tetapi menurut dokumentasi Pub/Sub Redis, Pub/Sub bersifat at-most-once: pesan dikirim sekali saja, tidak dipersistensi, dan bila subscriber sedang tidak bisa menerima (error atau disconnect), pesan itu hilang selamanya.
Karena pesan bisa hilang permanen, Pub/Sub tidak boleh dipakai untuk proses yang harus bisa retry seperti pemrosesan pembayaran atau order. Untuk jaminan at-least-once, pakai Redis Streams. Pub/Sub hanya untuk notifikasi ringan yang boleh hilang.
Distributed lock dengan SET NX PX
Lock terdistribusi memakai SET key value NX PX untuk memastikan hanya satu proses memegang lock pada satu waktu, dengan expiry (PX) dan token kepemilikan agar unlock aman. Ide ini menggoda, tetapi mudah salah.
Pada Redis single-instance, lock adalah single point of failure, dan karena replikasi Redis asinkron, failover bisa membuat dua proses sama-sama mengira memegang lock. Martin Kleppmann mengkritik Redlock sebagai tidak aman untuk correctness yang bergantung pada asumsi waktu (lihat tulisannya). Maka jangan pakai lock Redis untuk operasi business-critical seperti mengurangi stok checkout; itu urusan transaksi PostgreSQL. Lock Redis boleh untuk hal ringan seperti melindungi refresh cache mahal agar tidak dikerjakan banyak proses sekaligus.
Cache stampede dan singleflight
Cache stampede terjadi ketika banyak request miss bersamaan (mis. tepat saat key kedaluwarsa) lalu serentak menyerang database, mengubah cache miss jadi database spike. Solusi di Go adalah menekan panggilan duplikat dengan golang.org/x/sync/singleflight, yang menyediakan Group.Do: untuk key yang sama, fungsi dieksekusi sekali dan hasilnya dibagikan ke semua pemanggil konkuren.
internal/product/singleflight.goimport "golang.org/x/sync/singleflight" var group singleflight.Group // GetByIDDeduped memastikan hanya satu pengambilan database per key // walau banyak request miss bersamaan. func (s *Service) GetByIDDeduped(ctx context.Context, id int64) (Product, error) { key := productKey(id) v, err, _ := group.Do(key, func() (any, error) { return s.GetByIDResilient(ctx, id) }) if err != nil { return Product{}, err } return v.(Product), nil }
Selain singleflight, teknik pelengkap adalah memberi jitter (sedikit variasi acak) pada TTL agar banyak key tidak kedaluwarsa di detik yang sama, dan pola stale-while-revalidate yang menyajikan data lama sebentar sambil menyegarkan di latar.
Redis Streams dan consumer group
Stream adalah log append-only untuk event processing. Berbeda dari Pub/Sub, pesan di Stream dipersistensi dan mendukung at-least-once, sehingga cocok untuk event-driven backend dan worker. Consumer group memungkinkan banyak worker membagi beban membaca event yang sama, dengan pending messages dan acknowledgement agar tidak ada event yang hilang. Ini adalah pintu masuk ke materi scaling berikutnya.
Topik di atas membawa kamu ke caching strategy lanjutan (stampede, stale-while-revalidate), arsitektur worker (Streams + consumer group), dan event-driven backend. Semuanya adalah fondasi untuk backend high-traffic yang men-scale, di luar cakupan course pengantar ini.
Ringkasan & Poin Penting
Pakai Redis di tempat yang benar, bukan di mana-mana
Seluruh materi bermuara pada satu prinsip: pakai Redis di tempat yang benar, bukan di mana-mana. Redis adalah tool performa dan state sementara, bukan pengganti desain database yang benar.
Mari petakan ke empat flow nyata online shop. Pada detail produk, cache-aside dengan TTL dan delete-on-write membuat database tidak menjadi titik panas. Pada login, rate limit INCR+EXPIRE melindungi dari brute force, dan session disimpan di Redis dengan TTL alami. Pada checkout, Redis boleh membatasi laju, tetapi pengurangan stok dan pembuatan order tetap transaksi PostgreSQL. Pada admin update product, tulis ke PostgreSQL dulu lalu hapus product:{id} agar pembaca berikutnya mendapat data segar.
flowchart TD PD["Detail produk"] --> CA["cache-aside + TTL + delete-on-write"] LG["Login"] --> RT["rate limit + session store"] CO["Checkout"] --> TX["rate limit di Redis, stok & order di PostgreSQL"] AU["Admin update product"] --> INV["tulis DB dulu, lalu DEL cache"]
Gambar 12. Empat flow, satu prinsip: Redis mempercepat dan menyimpan sementara; PostgreSQL menjaga kebenaran.
Yang Wajib Menempel
- Redis adalah memory layer untuk cache, session, rate limit, dan data sementara, bukan sumber kebenaran data bisnis.
- Model intinya key + value + TTL; pilih tipe data (String, Hash, List, Set, Sorted Set, Stream) dari operasi yang dibutuhkan.
- Client resmi
github.com/redis/go-redis/v9(v9.20.1) memakaicontext.Contextsebagai parameter pertama;redis.Nilberarti cache miss, bukan error. - Cache-aside: cek Redis dulu, miss baru ke PostgreSQL, lalu isi cache best-effort tanpa menggagalkan request.
- Desain key konsisten (env, versi, entitas, id/hash) menentukan kemudahan invalidasi; versi key adalah tombol panic.
- TTL untuk data yang boleh telat; delete-on-write untuk kesegaran segera, selalu tulis database dulu baru hapus cache.
- Jangan cache stok, status order, status payment, cart aktif, dan data privat sebagai kebenaran.
INCR+EXPIREyang atomic untuk rate limit; session dan token blacklist memakai TTL alami Redis.- Atomicity Redis terbatas: command tunggal atomic,
TxPipelineduntuk MULTI/EXEC,Watchuntuk optimistic transaction yang di-retry saatredis.TxFailedErr. - Redis boleh gagal: timeout pendek + fallback database menjadikannya akselerator, bukan single point of failure; pantau hit rate, latency, memori, eviction, dan slowlog.
Langkah berikutnya menuju backend high-traffic adalah memperdalam caching strategy (stampede protection, stale-while-revalidate), membangun worker dan event-driven flow dengan Redis Streams, serta merancang observability yang matang. Tetapi prinsip yang kamu pegang di sini tidak berubah: tempatkan Redis di tempat yang benar, jaga PostgreSQL tetap menjadi sumber kebenaran, dan biarkan kecepatan datang dari desain yang disiplin, bukan dari cache yang dipasang di mana-mana.
Progress disimpan lokal di browser ini.