Strategi Caching untuk Backend
Artikel sebelumnya membahas Redis sebagai cache in-memory — di mana menyimpan data sementara agar API menjadi cepat. Artikel ini memperdalam topik tersebut: caching bukan sekadar "pakai Redis", melainkan rangkaian keputusan — data apa yang layak di-cache, di lapisan mana, pakai pola tulis-baca yang mana, dan bagaimana membersihkannya tanpa membuat pengguna melihat data basi. Setelah membaca ini Anda diharapkan bisa mendesain strategi caching yang matang, bukan sekadar menempel @Cacheable.
Kenapa Caching?
Alasan utamanya sederhana: membaca dari memori jauh lebih cepat daripada membaca dari disk atau jaringan. Saat aplikasi meminta data ke database, data itu harus dibaca dari disk (piringan magnetik atau SSD), melewati sistem operasi, driver database, parser SQL, hingga di-serialize menjadi objek Java. Sebagian besar langkah itu hilang ketika data sudah ada di RAM server.
Berikut perkiraan orde latensi yang sering dikutip dari pengalaman industri — nilai pastinya tergantung perangkat keras, tapi urutannya hampir selalu sama:
| Sumber baca | Latensi perkiraan | Relatif terhadap RAM |
|---|---|---|
RAM (in-memory, mis. Redis) | 0,1 – 1 ms | 1× |
| SSD (disk solid-state) | 0,1 – 1 ms | ~1–10× |
| HDD (disk mekanik) | 5 – 15 ms | ~50–100× |
| Database query tanpa index | 10 – 100 ms | ~100–1000× |
| Database query + index yang tepat | 1 – 10 ms | ~10× |
| Network call antar service | 1 – 50 ms | ~10–500× |
Angka ini bukan harga mati, melainkan arah yang benar: setiap lapisan yang bisa Anda lewati — disk, query, jaringan — adalah waktu yang Anda hemat. Cache yang tepat sasaran bisa menurunkan latensi dari puluhan milidetik menjadi sub-milidetik.
Tujuan caching secara umum ada tiga, sebagaimana dirangkum AWS best practices for caching:
- Kurangi beban database — query yang mahal hanya dijalankan sekali, sisanya dilayani dari cache.
- Turunkan latensi — respons yang lebih cepat membuat pengguna merasa aplikasi responsif.
- Hemat biaya — infrastruktur database yang kecil bisa melayani trafik besar bila sebagian besar bacaannya tidak pernah menyentuh database.
Hierarki Lapisan Cache
Cache tidak harus (dan sebaiknya tidak) berada di satu tempat saja. Ada empat lapisan utama, dari yang paling dekat dengan pengguna (luar) hingga paling dalam:
Pengguna (browser / mobile)
|
v
+----------------------+ 1. CDN (Cloudinary, CloudFront) -> aset statis: gambar, CSS, JS
+----------------------+ 2. HTTP / Browser cache -> respons halaman & API
|
v
+----------------------+ 3. Application-level cache -> Redis / in-memory (hot data)
+----------------------+ 4. Database-level cache -> buffer pool, query cache
|
v
Database (PostgreSQL)
| Lapisan | Media | Apa yang di-cache | Contoh alat |
|---|---|---|---|
| CDN | Server edge global | Aset statis, gambar, video | Cloudinary, CloudFront, Cloudflare |
| Browser/HTTP | Browser pengguna | Respons halaman, ETag, Cache-Control | Cache bawaan HTTP |
| Application-level | RAM di service | Hasil query, objek bisnis, session | Redis, Caffeine, Hazelcast |
| Database-level | RAM di dalam database | Block/halaman tabel yang sering diakses | Buffer pool PostgreSQL/MySQL |
CDN (Content Delivery Network)
Lapisan paling luar. CDN menyalin aset statis ke banyak server yang tersebar geografis, sehingga pengguna di Jakarta mengunduh gambar dari server terdekat, bukan dari server origin di Singapura. Untuk proyek ini, Cloudinary adalah contoh nyata: gambar produk diunggah ke Cloudinary dan disajikan lewat URL CDN — backend tidak pernah membaca file gambar dari disk, dan beban transfer gambar tidak pernah membebani API.
Kuncinya: aset statis yang bisa diidentifikasi permanen (URL stabil) sangat ideal untuk CDN. URL yang berubah isinya pada URL yang sama adalah sumber bug klasik (cache CDN mengembalikan versi lama) — karena itu biasanya ditambah versi, misalnya /img/logo-v2.png.
Browser / HTTP Cache
Lapisan kedua dikendalikan oleh protokol HTTP lewat header Cache-Control dan validator ETag. Detail lengkapnya dijelaskan di MDN HTTP Caching, tapi intinya:
Cache-Control: max-age=3600— browser boleh memakai salinan respons selama 1 jam tanpa bertanya ke server.Cache-Control: no-store— respons tidak boleh di-cache sama sekali (misal data akun).ETag— validator: browser menyimpan respons, lalu mengirimIf-None-Match; server menjawab304 Not Modifiedtanpa body bila isinya masih sama. Hemat bandwidth, tidak hemat query database.
GET /api/categories -> 200 + ETag: "v1.7"
GET /api/categories -> If-None-Match: "v1.7"
-> 304 Not Modified (tanpa body)
Application-level Cache
Lapisan yang paling sering dikendalikan langsung oleh developer: cache di RAM aplikasi (mis. Caffeine di Java) atau store eksternal seperti Redis. Inilah tempat pola penempatan data (bab berikutnya) diterapkan. Data yang di-cache di sini biasanya hasil query database yang mahal, respons API antar service, atau perhitungan yang berat.
Database-level Cache
Bahkan database sendiri sudah punya cache internal. PostgreSQL punya buffer pool (halaman tabel yang tersimpan di RAM) dan query cache (hasil query yang diingat untuk sementara, contoh klasik di MySQL). Dengan kata lain, query yang sama dijalankan berulang kali bisa jadi cepat bukan karena SQL-nya, melainkan karena blok datanya sudah panas di memori database.
Prinsip penting: cache di lapisan yang lebih dalam adalah "gratis" dan transparan; cache di lapisan aplikasi memberi Anda kontrol penuh tapi menuntut Anda mengelola sendiri konsistensinya. Lapisan tidak saling menggantikan — semuanya bekerja berlapis: CDN menghemat jaringan, HTTP cache menghemat server, application cache menghemat database.
Pola Penempatan Data
Ini bagian inti artikel. "Pola penempatan data" (caching patterns) menjawab satu pertanyaan: siapa yang bertanggung jawab mengisi dan menyinkronkan cache dengan database? Ada empat pola klasik yang perlu dipahami.
Cache-aside (Lazy Loading)
Pola yang sudah Anda kenal dari artikel Redis: aplikasi yang memegang kendali. Aplikasi cek cache → kena (HIT) → langsung balas; tidak kena (MISS) → baca database → tulis hasilnya ke cache → balas. Referensi resminya ada di Redis glossary: cache-aside pattern.
1. GET product:42 ----------------> Redis
2a. HIT <-------------------------- Redis -> balas langsung
2b. MISS (tidak ada) Redis
\-> 3. SELECT * FROM products WHERE id = 42 -> PostgreSQL
\-> 4. SET product:42 ... EX 600 -> Redis
5. balas ke pemanggil
Kelebihan: hanya data yang benar-benar dibaca yang masuk cache (hemat memori), implementasi lurus, dan aplikasi bebas memutuskan kebijakan sendiri.
Kekurangan: setiap miss tetap membebani database, dan data yang basi tidak teratasi otomatis — butuh TTL atau invalidasi manual. Cache-aside cocok untuk beban baca banyak, tulis sedikit.
Read-through
Hampir sama dengan cache-aside, tapi tanggung jawab dipegang cache, bukan aplikasi. Saat aplikasi meminta get(key), cache yang mengerti cara membaca database (dengan memakai loader/CacheLoader). Aplikasi tidak perlu tahu dari mana data berasal — ia hanya bilang "beri saya product:42".
Bedanya dari cache-aside hanya pada siapa yang menulis kode pembacaan: di cache-aside aplikasi yang mengatur alurnya, di read-through aplikasi cukup memanggil cache. Cocok bila Anda memakai library cache yang menyediakan loader, mis. Caffeine dengan CacheLoader atau Spring Cache dengan cache resolver.
CacheLoader<Long, Product> loader = new CacheLoader<>() {
@Override
public Product load(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
};
LoadingCache<Long, Product> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.build(loader);
Product p = cache.get(42L); // aplikasi tidak tahu ada database di belakangnya
Write-through
Pola tulis-sinkron: setiap tulis ditulis ke cache DAN database sekaligus dalam satu transaksi logika. Data di cache selalu sinkron dengan database, sehingga pembaca tidak pernah mendapat data basi — selama alur tulisnya lewat jalur ini.
Kekurangannya jelas: setiap operasi tulis kini membayar dua operasi (cache + DB), sehingga tulis menjadi lebih lambat. Write-through cocok untuk data yang ditulis sesekali tapi harus selalu konsisten.
@Transactional
public Product saveProduct(Product product) {
Product saved = productRepository.save(product); // 1. tulis ke DB
cache.put("product:" + saved.getId(), saved); // 2. tulis ke cache
return saved;
}
Write-back (Write-behind)
Kebalikan filosofi write-through: tulis masuk ke cache dulu, dan disinkronkan ke database belakangan secara asinkron (misalnya di-flush tiap 5 detik atau saat antrian penuh). Ini pola tercepat untuk beban tulis yang sangat tinggi — tulis hanya membayar satu operasi ke RAM.
Risiko besarnya: kehilangan data. Kalau server crash sebelum flush selesai, data yang baru saja "ditulis" bisa hilang karena belum pernah sampai ke database. Write-back cocok untuk data yang boleh hilang sedikit, seperti hit counter, log, atau data statistik — bukan untuk pesanan, saldo, atau data keuangan.
public void recordView(String productId) {
// tulis ke cache dulu, flush ke DB nanti oleh scheduler
viewCounter.inc("views:" + productId);
}
Tabel Perbandingan
| Pola | Arah baca | Arah tulis | Latensi baca | Latensi tulis | Risiko data basi | Risiko kehilangan data |
|---|---|---|---|---|---|---|
Cache-aside | App → cache → DB (miss) | App → cache & DB manual | Rendah (HIT) / tinggi (MISS) | Normal | Perlu TTL/invalidasi | Tidak (DB selalu ditulis) |
Read-through | Cache → DB (miss) | App → DB, cache tak tahu | Sama seperti cache-aside | Normal | Perlu TTL/invalidasi | Tidak |
Write-through | Cache (selalu HIT) | Cache + DB sinkron | Sangat rendah | Lebih lambat (2x tulis) | Minimal | Tidak |
Write-back | Cache (selalu HIT) | Cache dulu, DB nanti | Sangat rendah | Sangat cepat | Bisa basi antar-flush | Ya, bila crash sebelum flush |
Ringkasnya:
- Baca berat, data jarang berubah →
cache-asideatauread-through. - Tulis sedikit tapi wajib konsisten →
write-through. - Tulis sangat banyak, kehilangan data kecil diterima →
write-back.
TTL & Expiry
TTL (Time-To-Live) adalah "pengaman terakhir" dari cache: setelah masa hidupnya habis, data dihapus otomatis tanpa harus ada kode invalidasi. TTL paling masuk akal untuk data yang berubah jarang, seperti daftar kategori, konfigurasi, atau metadata produk.
Pemilihan nilai TTL adalah keseimbangan dua risiko: terlalu pendek → cache jarang berguna (banyak miss); terlalu panjang → makin lama pengguna melihat data basi. Sebagai titik awal:
- Konfigurasi yang praktis tidak berubah: 1 jam – 24 jam.
- Data katalog yang berubah sesekali: 5 – 30 menit.
- Data yang berubah sering: 30 detik – 5 menit, atau jangan di-cache sama sekali.
Satu jebakan penting: TTL seragam untuk semua key membuat "batch expiry" — puluhan key yang dibuat bersamaan akan kedaluwarsa bersamaan pula, dan database mendadak kena ledakan query. Solusinya adalah jitter: tambahkan nilai acak kecil pada TTL tiap key supaya kedaluwarsanya tersebar.
// jitter: TTL 10 menit ± 0–60 detik agar tidak expire serentak
long baseTtl = Duration.ofMinutes(10).toSeconds();
long jitter = ThreadLocalRandom.current().nextLong(0, 60);
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(baseTtl + jitter));
Cache Invalidation
Bab ini sering disebut "bagian tersulit dari ilmu komputer" (dua hal tersulit dalam CS: naming dan cache invalidation). Masalahnya satu: cache stale — cache berisi data lama sementara database sudah berubah, dan pengguna terus mendapat data basi sampai entri itu kebetulan kedaluwarsa.
Ada tiga strategi menangani data basi:
1. TTL (kedaluwarsa otomatis)
Mudah dan aman, tapi nilai data basi ditentukan sepenuhnya oleh lamanya TTL. Data basi selama TTL itu "diterima" sebagai harga yang wajar.
2. Eviction (pengusiran saat memori penuh)
Bukan penyebab sinkronisasi, melainkan strategi memilih key mana yang dibuang saat RAM penuh:
| Strategi | Cara kerja | Kapan cocok |
|---|---|---|
LRU (Least Recently Used) | Buang key yang paling lama tidak diakses | Pola akses cukup merata, default paling umum |
LFU (Least Frequently Used) | Buang key yang paling jarang diakses | Ada key "panas" yang jelas dominan |
FIFO (First In First Out) | Buang key yang paling dulu masuk | Butuh prediktabilitas sederhana |
Random | Buang key acak | Implementasi termurah, jarang dipakai |
Di Redis, pilihan ini dikonfigurasi lewat maxmemory-policy (misal allkeys-lru). Ingat: eviction tidak menyelesaikan data basi — ia hanya mengatur memori.
3. Explicit invalidation (invalidasi eksplisit)
Cara paling presisi: hapus atau update key di cache tepat saat data di database berubah. Di Spring, ini dilakukan dengan @CacheEvict. Contohnya untuk update dan delete, mengacu pada Spring Framework cache abstraction:
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
return repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
@CacheEvict(value = "products", key = "#id")
public Product updateProduct(Long id, ProductUpdateDto dto) {
Product product = repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
product.setName(dto.name());
product.setPrice(dto.price());
return repository.save(product);
}
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
repository.deleteById(id);
}
}
@CacheEvict pada method updateProduct dan deleteProduct memastikan entri cache untuk id itu dihapus begitu data berubah, jadi pembaca berikutnya otomatis mendapat versi baru.
Kenapa invalidasi itu sulit
Tantangannya bukan menulis anotasi, melainkan siapa yang tahu bahwa data berubah. Penulis data dan pembaca cache bisa berada di service yang berbeda — misalnya service order-service mengubah stok produk, sementara product-service yang mengelola cache produk. @CacheEvict hanya bekerja kalau perubahan lewat method yang diberi anotasi; kalau ada jalur tulis lain (CRON, batch, admin langsung ke DB), invalidasi tidak terjadi dan cache basi.
Aturan praktis: validasi ulang asumsi "data tidak berubah" secara berkala. Cache yang dipakai lama tanpa pernah ditinjau biasanya menyimpan data basi tanpa disadari — dan tidak ada yang mengetahuinya sampai pengguna mengeluh.
Cache Stampede (Thundering Herd)
Dibayangkan: key product:42 kedaluwarsa pada jam 12:00:00 tepat, dan pada detik yang sama 500 pengguna memuat halaman produk itu. Semuanya miss bersamaan, semuanya menembus ke database → database menerima 500 query identik sekaligus. Inilah cache stampede (disebut juga thundering herd): ledakan beban tepat saat cache kosong. Dampaknya bisa server melambat atau timeout, bahkan crash di saat trafik paling ramai.
Tiga solusi yang umum, disarankan juga di AWS caching best practices:
1. Lock / single-flight
Hanya satu request yang boleh mengisi cache; request lain menunggu hasilnya. Ini bisa memakai distributed lock di Redis:
public Product getProductWithLock(Long id) {
String cacheKey = "product:" + id;
Product cached = (Product) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
String lockKey = "lock:product:" + id;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
try {
// re-check setelah dapat lock (mungkin sudah diisi request lain)
cached = (Product) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
Product product = repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(10));
return product;
} finally {
redisTemplate.delete(lockKey);
}
}
// tidak dapat lock: tunggu sebentar, lalu baca cache lagi
Thread.sleep(50);
return (Product) redisTemplate.opsForValue().get(cacheKey);
}
2. Early recomputation
Jangan menunggu key kedaluwarsa; perpanjang TTL atau muat ulang sebelum habis. Misalnya TTL 10 menit, tapi saat sisa umur kurang dari 2 menit dan ada request, refresh datanya. Dengan begitu key praktis tidak pernah kosong, dan ledakan miss tidak terjadi.
3. Stale-while-revalidate
Saat key sudah kedaluwarsa, layani request dengan data lama (stale) terlebih dahulu, sementara satu request di latar belakang mengambil data baru. Pengguna tidak pernah menunggu, database tidak pernah kelebihan beban — imbalannya pengguna sesekali melihat data yang usang beberapa detik. Pola ini bisa dikombinasikan dengan write-back dan disarankan oleh stale-while-revalidate spec MDN.
Menghitung Efektivitas Cache
Cache tidak berguna kalau tidak diukur. Metrik utamanya adalah hit ratio:
hit ratio = jumlah HIT / (jumlah HIT + jumlah MISS) * 100%
Contoh: dalam satu jam tercatat 90.000 HIT dan 10.000 MISS.
hit ratio = 90.000 / 100.000 * 100% = 90%
Artinya 90% request tidak menyentuh database — database hanya menerima 10.000 query dari 100.000 request. Target yang wajar:
| Jenis data | Hit ratio yang wajar |
|---|---|
| Data statis/katalog (jarang berubah) | 95% ke atas |
| Data yang berubah sesekali | 80 – 95% |
| Data dinamis | di bawah 80% → pertimbangkan ulang layak di-cache |
Kalau hit ratio Anda di bawah 70% untuk data yang terlihat statis, curigai key yang tidak stabil — misalnya key menyertakan timestamp sehingga hampir setiap request menghasilkan key baru (semua miss). Cache miss rate yang tinggi bukan soal kecepatan Redis, melainkan desain key.
Berapa TTL dan ukuran cache yang masuk akal? Dua acuan praktis:
- Ukuran cache harus menampung set working data — himpunan key yang benar-benar diakses dalam satu periode TTL. Cache yang lebih besar dari itu hanya menampung data dingin. Pantau metrik eviction rate: kalau terus tinggi, cache terlalu kecil untuk working set Anda.
- TTL sebaiknya = siklus pembaruan data × faktor 2–3, lalu pantau miss rate setelah perubahan. Tidak ada rumus universal — ukur, ubah, ukur lagi.
Kapan JANGAN Caching
Caching bukan solusi universal. Ada beberapa kondisi di mana cache lebih banyak mudaratnya:
- Data yang sangat dinamis atau sensitif — saldo akun, session keamanan, data keuangan. Data basi beberapa detik untuk saldo bisa memicu keputusan keliru (misal batas transaksi). Lebih baik langsung ke database.
- Data per-user dengan TTL sama — cache pollution: setiap pengguna punya key sendiri (misal
cart:<userId>), tapi isinya unik sehingga hampir tidak pernah shareable. Kalau jumlah pengguna besar dan tiap key nyaris tak pernah dibaca ulang, cache hanya memakanRAMtanpa manfaat. - Data yang invalidasinya lebih mahal daripada membaca langsung — kalau setiap tulis harus membersihkan puluhan key yang terkait (misal query agregat per kategori), biaya invalidasi bisa lebih besar daripada sekadar melewati cache.
Prinsip: cache menghemat biaya bila rasio baca terhadap tulis besar (mis. 100:1) dan data bisa dibagi antar request. Kalau rasionya mendekati 1:1 dan tiap key unik per pengguna, cache hampir pasti merugikan.
Jebakan Umum
Pengalaman production memunculkan beberapa jebakan yang berulang:
- Cache duplikasi antar service —
product-servicedanorder-servicemasing-masing meng-cache data produk dengan TTL dan key berbeda. Hasilnya dua versi kebenaran yang bisa tidak sinkron. Pusatkan kebijakan cache di satu tempat. - Serialization besar — menyimpan objek raksasa sebagai
JSONlalu deserialize setiap request punya biaya CPU nyata. Cache yang terlalu besar justru memperlambat request — ukur ukuran entri, jangan hanya jumlahnya. - Key yang tidak stabil — key berisi timestamp, UUID, atau ID request membuat cache selalu miss. Key harus stabil dan reusable.
- Secret di cache — jangan simpan password, access token, atau data pribadi di cache tanpa enkripsi dan kontrol akses. Cache (terutama Redis tanpa konfigurasi keamanan) adalah permukaan serangan.
- Cache basi untuk data keuangan — harga, stok, dan saldo yang di-cache terlalu lama menciptakan keputusan bisnis keliru. Untuk domain ini, jangan pernah mengandalkan TTL sendirian; gunakan invalidasi eksplisit atau jangan di-cache sama sekali.
Studi Kasus
Mari terapkan semua prinsip di atas pada satu proyek toko online yang umum:
| Data | Strategi | Alasan |
|---|---|---|
| Katalog produk (nama, deskripsi, spesifikasi) | Cache lama, TTL panjang (10–60 menit), cache-aside | Berubah jarang, dibaca sangat sering → hit ratio tinggi |
| Gambar produk | CDN (Cloudinary) + URL permanen dengan versi | Aset statis, hemat bandwidth, browser cache lewat max-age |
| Stok real-time | Tidak di-cache | Berubah setiap transaksi, data basi = pesanan gagal / over-sell |
| Profil user (nama, alamat, preferensi) | Short TTL (1–5 menit), key per-user user:<id> | Data semi-dinamis; TTL pendek membatasi usia data basi |
| Keranjang belanja | write-through atau langsung ke DB | Harus konsisten seketika, frekuensi tulis rendah |
| Hit counter / statistik kunjungan | write-back (hitung di cache, flush berkala) | Kehilangan beberapa counter saat crash bisa diterima |
Perhatikan polanya: data yang dibaca banyak dan jarang berubah diberi cache besar dengan TTL panjang; data yang berubah setiap detik dan bernilai akurat (stok) tidak di-cache; data semi-dinamis per pengguna diberi TTL pendek dan key yang stabil.
Ringkasan
Caching adalah kompromi terkelola antara kecepatan dan konsistensi: Anda menukar sedikit kemungkinan data basi dengan latensi yang jauh lebih rendah dan database yang lebih ringan. Mulai dari pertanyaan apakah perlu di-cache (rasio baca:tulis besar, data shareable), lalu pilih lapisan (CDN → HTTP → aplikasi → database), lalu pola penempatan (cache-aside untuk baca berat, write-through untuk konsistensi, write-back untuk tulis sangat tinggi), lalu amankan dengan TTL + jitter dan invalidasi eksplisit bagi penulis data. Waspadai cache stampede dengan single-flight atau stale-while-revalidate, dan ukur hit ratio untuk membuktikan cache Anda benar-benar bekerja — bukan sekadar memenuhi RAM.
Lanjut membaca
Referensi resmi:
- Redis: Cache-aside Pattern
- AWS Caching Best Practices
- MDN: HTTP Caching
- Spring Framework: Cache Abstraction
Materi lanjutan:
- Redis: Caching & Token Blacklist — dasar Redis, cache-aside, dan
@Cacheable/@CacheEvict. - PostgreSQL: SQL Query Optimization — mempercepat sisi database, sebelum memutuskan apa yang perlu di-cache.