Strategi Caching untuk Backend

17 min readAdvanced
CachingPerformanceRedisArsitektur

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 bacaLatensi perkiraanRelatif terhadap RAM
RAM (in-memory, mis. Redis)0,1 – 1 ms
SSD (disk solid-state)0,1 – 1 ms~1–10×
HDD (disk mekanik)5 – 15 ms~50–100×
Database query tanpa index10 – 100 ms~100–1000×
Database query + index yang tepat1 – 10 ms~10×
Network call antar service1 – 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:

  1. Kurangi beban database — query yang mahal hanya dijalankan sekali, sisanya dilayani dari cache.
  2. Turunkan latensi — respons yang lebih cepat membuat pengguna merasa aplikasi responsif.
  3. 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)
LapisanMediaApa yang di-cacheContoh alat
CDNServer edge globalAset statis, gambar, videoCloudinary, CloudFront, Cloudflare
Browser/HTTPBrowser penggunaRespons halaman, ETag, Cache-ControlCache bawaan HTTP
Application-levelRAM di serviceHasil query, objek bisnis, sessionRedis, Caffeine, Hazelcast
Database-levelRAM di dalam databaseBlock/halaman tabel yang sering diaksesBuffer 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).
  • ETagvalidator: browser menyimpan respons, lalu mengirim If-None-Match; server menjawab 304 Not Modified tanpa 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

PolaArah bacaArah tulisLatensi bacaLatensi tulisRisiko data basiRisiko kehilangan data
Cache-asideApp → cache → DB (miss)App → cache & DB manualRendah (HIT) / tinggi (MISS)NormalPerlu TTL/invalidasiTidak (DB selalu ditulis)
Read-throughCache → DB (miss)App → DB, cache tak tahuSama seperti cache-asideNormalPerlu TTL/invalidasiTidak
Write-throughCache (selalu HIT)Cache + DB sinkronSangat rendahLebih lambat (2x tulis)MinimalTidak
Write-backCache (selalu HIT)Cache dulu, DB nantiSangat rendahSangat cepatBisa basi antar-flushYa, bila crash sebelum flush

Ringkasnya:

  • Baca berat, data jarang berubahcache-aside atau read-through.
  • Tulis sedikit tapi wajib konsistenwrite-through.
  • Tulis sangat banyak, kehilangan data kecil diterimawrite-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:

StrategiCara kerjaKapan cocok
LRU (Least Recently Used)Buang key yang paling lama tidak diaksesPola akses cukup merata, default paling umum
LFU (Least Frequently Used)Buang key yang paling jarang diaksesAda key "panas" yang jelas dominan
FIFO (First In First Out)Buang key yang paling dulu masukButuh prediktabilitas sederhana
RandomBuang key acakImplementasi 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 dataHit ratio yang wajar
Data statis/katalog (jarang berubah)95% ke atas
Data yang berubah sesekali80 – 95%
Data dinamisdi 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 memakan RAM tanpa 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 serviceproduct-service dan order-service masing-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 JSON lalu 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:

DataStrategiAlasan
Katalog produk (nama, deskripsi, spesifikasi)Cache lama, TTL panjang (10–60 menit), cache-asideBerubah jarang, dibaca sangat sering → hit ratio tinggi
Gambar produkCDN (Cloudinary) + URL permanen dengan versiAset statis, hemat bandwidth, browser cache lewat max-age
Stok real-timeTidak di-cacheBerubah 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 belanjawrite-through atau langsung ke DBHarus konsisten seketika, frekuensi tulis rendah
Hit counter / statistik kunjunganwrite-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:

Materi lanjutan:

← Back to technical articles