Tampilan
Kenapa penting
Endpoint katalog yang memanggil database untuk setiap request akan baik-baik saja di 10 request per detik, lalu ambruk di 2.000. Banyak data di backend dibaca jauh lebih sering daripada diubah: detail produk, daftar kategori, kurs, konfigurasi ongkir. Caching menyimpan hasil yang mahal dihitung di tempat yang cepat diakses, sehingga latensi turun dan database tidak terbebani. Tapi cache juga sumber bug paling licin di production: harga lama tetap tampil, data pengguna A bocor ke pengguna B, atau database tumbang tepat saat cache kosong. Di bab ini kamu belajar memakai cache dengan sadar — kapan memakainya, cara memasangnya di Spring Boot dengan Redis, dan cara menjaga cache tetap jujur.
Konsep inti
Kapan data layak di-cache
Cache adalah salinan. Setiap salinan bisa basi, jadi pertanyaannya selalu: seberapa basi yang masih bisa diterima?
| Cocok di-cache | Hindari / hati-hati |
|---|---|
| Sering dibaca, jarang diubah (katalog, kurs) | Sumber kebenaran transaksi (saldo, stok saat bayar) |
| Mahal dihitung (agregasi, panggilan API luar) | Data yang harus selalu terbaru per detik |
| Boleh basi beberapa detik/menit | Data sangat personal tanpa key per-user |
| Hasil sama untuk input sama | Hasil acak atau bergantung waktu |
Ukur dulu sebelum menambah cache. Kalau query lambat karena index hilang, perbaiki index-nya — cache hanya menyembunyikan masalah.
Pola cache-aside
Pola paling umum adalah cache-aside (lazy loading): aplikasi mengecek cache dulu; bila tidak ada (miss), ambil dari sumber lalu simpan ke cache; bila ada (hit), langsung kembalikan. Saat data berubah, entri di cache dihapus (evict). Setiap entri juga diberi TTL (time to live) agar tidak hidup selamanya.
Agar mekanismenya terlihat jelas, berikut versi Java murni dengan ConcurrentHashMap:
java
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class CacheAsideDemo {
record Product(String sku, String name, long price) {}
record Entry(Product value, Instant expiresAt) {
boolean expired(Instant now) {
return now.isAfter(expiresAt);
}
}
// "Database" lambat: setiap panggilan butuh 200 ms
static final AtomicInteger dbHits = new AtomicInteger();
static Product loadFromDb(String sku) {
dbHits.incrementAndGet();
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new Product(sku, "Kopi Arabika 250g", 85_000);
}
static final Map<String, Entry> cache = new ConcurrentHashMap<>();
static final Duration TTL = Duration.ofMillis(500);
static Product findProduct(String sku) {
Instant now = Instant.now();
Entry entry = cache.get(sku);
if (entry != null && !entry.expired(now)) {
return entry.value(); // cache hit
}
Product product = loadFromDb(sku); // cache miss → ambil dari sumber
cache.put(sku, new Entry(product, now.plus(TTL)));
return product;
}
static void evict(String sku) {
cache.remove(sku);
}
public static void main(String[] args) throws InterruptedException {
long start = System.nanoTime();
for (int i = 0; i < 5; i++) {
findProduct("KOPI-250");
}
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("5 panggilan, db hits = " + dbHits.get() + ", waktu < 300 ms: " + (ms < 300));
Thread.sleep(600); // lewat TTL
findProduct("KOPI-250");
System.out.println("Setelah TTL habis, db hits = " + dbHits.get());
evict("KOPI-250"); // harga berubah → hapus entri
findProduct("KOPI-250");
System.out.println("Setelah evict, db hits = " + dbHits.get());
}
}text
5 panggilan, db hits = 1, waktu < 300 ms: true
Setelah TTL habis, db hits = 2
Setelah evict, db hits = 3Lima panggilan hanya sekali ke database. Setelah TTL lewat atau entri di-evict, panggilan berikutnya kembali ke sumber. Map ini belum punya batas ukuran — di production kamu memakai library yang membatasi jumlah entri dan membuang yang jarang dipakai.
Spring Cache: anotasi di atas abstraksi
Spring menyediakan cache abstraction: kamu menulis anotasi, Spring yang menjalankan logika cache-aside lewat proxy. Implementasi penyimpanannya (Caffeine, Redis) bisa diganti lewat konfigurasi tanpa mengubah kode service.
xml
<!-- Potongan pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>java
// Potongan
@Configuration
@EnableCaching
public class CacheConfig {}
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
@Cacheable(cacheNames = "products", key = "#id")
public ProductView getProduct(long id) {
return repository.findById(id)
.map(ProductView::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
@CachePut(cacheNames = "products", key = "#result.id()")
public ProductView updatePrice(long id, long newPrice) {
Product product = repository.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
product.setPrice(newPrice);
return ProductView.from(repository.save(product));
}
@CacheEvict(cacheNames = "products", key = "#id")
public void delete(long id) {
repository.deleteById(id);
}
@CacheEvict(cacheNames = "categories", allEntries = true)
public void reloadCategories() {
// dipanggil setelah impor kategori massal
}
}| Anotasi | Perilaku |
|---|---|
@Cacheable | Cek cache dulu; method hanya dijalankan saat miss, hasilnya disimpan |
@CachePut | Method selalu dijalankan, hasilnya menimpa entri cache |
@CacheEvict | Menghapus entri (key) atau seluruh isi cache (allEntries = true) |
@Caching | Menggabungkan beberapa anotasi di satu method |
Key ditulis dengan SpEL: #id merujuk parameter, #result merujuk nilai kembalian (hanya untuk @CachePut/@CacheEvict). Tanpa key, Spring membuat key dari semua parameter. Atribut unless = "#result == null" mencegah menyimpan hasil kosong, dan condition membatasi kapan cache dipakai.
Cache DTO, bukan entity
Simpan record DTO yang immutable (ProductView) di cache, bukan entity JPA. Entity membawa proxy lazy-loading dan bisa diubah tanpa sengaja oleh kode lain yang memegang referensi yang sama.
Caffeine lokal vs Redis terdistribusi
Caffeine adalah cache in-memory di heap JVM: sangat cepat (nanodetik), tanpa jaringan, tapi tiap instance punya salinan sendiri dan isinya hilang saat restart. Redis adalah server terpisah: semua instance berbagi data yang sama, isinya bertahan saat aplikasi di-deploy ulang, tapi tiap akses melewati jaringan (sub-milidetik) dan butuh serialisasi.
| Aspek | Caffeine | Redis |
|---|---|---|
| Lokasi | Heap tiap JVM | Server terpisah, dibagi semua instance |
| Latensi | Sangat rendah | Rendah, tapi ada jaringan + serialisasi |
| Konsistensi | Bisa beda antar-instance | Satu sumber untuk semua instance |
| Cocok untuk | Data referensi kecil, satu instance | Banyak instance, sesi, rate limit, data bersama |
Konfigurasi Caffeine cukup lewat properti (tambahkan dependency com.github.ben-manes.caffeine:caffeine):
yaml
spring:
cache:
type: caffeine
cache-names: categories,shipping-rates
caffeine:
spec: maximumSize=1000,expireAfterWrite=10mRedis dengan Spring Boot
Jalankan Redis lokal lewat Docker:
bash
docker run -d --name redis-dev -p 6379:6379 redis:8
docker exec -it redis-dev redis-cli ping # → PONGTambahkan starter, lalu atur koneksi dan TTL default:
xml
<!-- Potongan pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>yaml
spring:
data:
redis:
host: localhost
port: 6379
cache:
type: redis
redis:
time-to-live: 10m
key-prefix: "shop:"
cache-null-values: falseSecara default nilai disimpan dengan serialisasi Java (biner, rapuh saat class berubah, tidak terbaca di redis-cli). Lebih baik pakai JSON. Spring Boot 4 memakai Jackson 3, dan Spring Data Redis menyediakan JacksonJsonRedisSerializer untuk itu:
java
// Potongan
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new JacksonJsonRedisSerializer<>(ProductView.class)));
}
}Bean RedisCacheConfiguration ini menjadi konfigurasi default untuk cache yang dibuat Spring Boot. Karena serializer di atas terikat ke satu tipe, bila kamu punya beberapa cache dengan tipe berbeda, atur per cache lewat bean RedisCacheManagerBuilderCustomizer dan withCacheConfiguration("products", ...). Isi Redis kini bisa diperiksa langsung:
bash
docker exec -it redis-dev redis-cli --scan --pattern 'shop:*'
docker exec -it redis-dev redis-cli TTL 'shop:products::42'Invalidasi dan konsistensi
Ada pepatah: dua hal tersulit di ilmu komputer adalah invalidasi cache dan memberi nama. Aturan praktis:
- Evict saat menulis, dengan key yang sama persis dengan key
@Cacheable. Hapus lebih aman daripada memperbarui, karena entri berikutnya diisi dari sumber yang benar. - TTL selalu ada sebagai jaring pengaman bila ada jalur tulis yang lupa meng-evict (impor batch, perubahan manual di database).
- Evict setelah commit. Bila evict terjadi sebelum transaksi commit, request lain bisa membaca data lama dari database dan mengisinya kembali ke cache. Spring menunda operasi cache sampai commit bila cache manager dikonfigurasi transaction-aware; prinsipnya: tulis ke database dulu, baru buang cache.
- Terima bahwa cache-aside bersifat eventually consistent — ada jendela singkat data bisa basi. Jangan pakai cache untuk keputusan yang butuh angka pasti, misalnya validasi stok saat checkout.
Cache stampede
Saat entri populer kedaluwarsa, ratusan request bisa miss bersamaan dan semuanya menembak database — cache stampede. Program berikut membandingkan versi naif dengan computeIfAbsent yang memastikan hanya satu pemuatan per key:
java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class StampedeDemo {
static final AtomicInteger dbHits = new AtomicInteger();
static String loadFromDb(String key) {
dbHits.incrementAndGet();
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "data-" + key;
}
// Naif: cek lalu isi — semua request yang miss bersamaan menembak DB
static final Map<String, String> naiveCache = new ConcurrentHashMap<>();
static String naiveGet(String key) {
String value = naiveCache.get(key);
if (value == null) {
value = loadFromDb(key);
naiveCache.put(key, value);
}
return value;
}
// computeIfAbsent: hanya satu thread yang memuat, sisanya menunggu hasilnya
static final Map<String, String> safeCache = new ConcurrentHashMap<>();
static String safeGet(String key) {
return safeCache.computeIfAbsent(key, StampedeDemo::loadFromDb);
}
static void burst(java.util.function.Function<String, String> getter) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
executor.submit(() -> getter.apply("homepage-banner"));
}
} // close() menunggu semua task selesai
}
public static void main(String[] args) {
burst(StampedeDemo::naiveGet);
System.out.println("Naif : db hits = " + dbHits.get());
dbHits.set(0);
burst(StampedeDemo::safeGet);
System.out.println("computeIfAbsent: db hits = " + dbHits.get());
}
}text
Naif : db hits = 100
computeIfAbsent: db hits = 1Angka versi naif bisa sedikit berbeda tiap kali dijalankan, tapi selalu jauh di atas 1. Di Spring Cache, padanannya adalah @Cacheable(cacheNames = "banners", sync = true): hanya satu thread per key per instance yang menjalankan method. Untuk banyak instance, tambahkan TTL dengan sedikit variasi acak (jitter) agar key tidak kedaluwarsa serempak, atau perbarui entri populer di latar belakang sebelum kedaluwarsa.
Key per-user
Data yang bergantung pada siapa yang login wajib memasukkan identitas pengguna ke key. Kalau tidak, hasil milik pengguna pertama akan dilayani ke semua orang — kebocoran data, bukan sekadar bug tampilan.
java
// Potongan
@Cacheable(cacheNames = "order-summary", key = "#customerId + ':' + #page")
public OrderSummaryPage getSummary(long customerId, int page) {
return orderQueryService.summarize(customerId, page);
}Ambil customerId dari identitas terautentikasi di controller, bukan dari parameter yang dikirim klien.
Contoh di dunia backend
Layanan katalog e-commerce: detail produk di-cache di Redis 10 menit, daftar kategori di-cache lebih lama, dan perubahan harga langsung meng-evict entri.
java
// Potongan
public record ProductView(long id, String sku, String name, long price, int stock) {
static ProductView from(Product p) {
return new ProductView(p.getId(), p.getSku(), p.getName(), p.getPrice(), p.getStock());
}
}
@Service
public class CatalogService {
private final ProductRepository products;
private final CategoryRepository categories;
public CatalogService(ProductRepository products, CategoryRepository categories) {
this.products = products;
this.categories = categories;
}
@Cacheable(cacheNames = "products", key = "#id", sync = true)
public ProductView getProduct(long id) {
return products.findById(id)
.map(ProductView::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
@Transactional
@CacheEvict(cacheNames = "products", key = "#id")
public void changePrice(long id, long newPrice) {
if (newPrice <= 0) {
throw new IllegalArgumentException("Harga harus positif");
}
Product product = products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
product.setPrice(newPrice);
}
// Stok TIDAK dibaca dari cache saat checkout — selalu ke database dengan lock
@Transactional
public void reserveStock(long id, int qty) {
Product product = products.findByIdForUpdate(id).orElseThrow(() -> new ProductNotFoundException(id));
product.decreaseStock(qty);
}
}yaml
spring:
cache:
type: redis
cache-names: products,categories
redis:
time-to-live: 10m
key-prefix: "catalog:"Perhatikan pemisahannya: halaman detail boleh menampilkan stok yang basi beberapa menit, tapi reserveStock selalu membaca database. Cache mempercepat bacaan tampilan, bukan keputusan bisnis. Bila Redis mati, pertimbangkan CacheErrorHandler yang mencatat error dan membiarkan request jatuh ke database, agar gangguan cache tidak berubah menjadi gangguan seluruh layanan.
Pertanyaan interview
1. Apa itu pola cache-aside dan apa kelemahannya?
Aplikasi membaca dari cache dulu; saat miss, ia memuat dari database lalu mengisi cache, dan saat menulis ia meng-evict entri. Kelebihannya sederhana dan cache hanya berisi data yang benar-benar diminta. Kelemahannya: miss pertama selalu lambat, ada jendela data basi antara tulis dan evict, serta rentan stampede saat key populer kedaluwarsa.
2. Kapan memilih Caffeine dan kapan Redis?
Caffeine untuk data referensi kecil yang toleran beda sesaat antar-instance dan butuh latensi minimum, misalnya daftar kategori atau tabel ongkir. Redis ketika ada banyak instance yang harus melihat data sama, data harus bertahan saat deploy ulang, atau dipakai bersama untuk sesi dan rate limit. Keduanya bisa digabung: Caffeine sebagai L1 di depan Redis sebagai L2.
3. Bagaimana mencegah cache stampede?
Kunci pemuatan per key agar hanya satu request yang memuat (@Cacheable(sync = true) di satu instance, atau lock terdistribusi di Redis untuk banyak instance), beri jitter pada TTL agar key tidak kedaluwarsa serempak, dan untuk key yang sangat populer lakukan refresh di latar belakang sebelum kedaluwarsa.
Jebakan umum
- Lupa
@EnableCaching— anotasi@Cacheablediam-diam tidak berbuat apa-apa, dan tidak ada error. - Self-invocation — memanggil method ber-
@Cacheablelewatthismelewati proxy, jadi cache diabaikan. - Key tidak konsisten —
@Cacheabletanpakeypada method dua parameter(id, locale)membuat key gabungan, sedangkan@CacheEvict(key = "#id")hanya memakaiid; evict tidak pernah mengenai entri yang benar. Tuliskeysecara eksplisit di kedua sisi. - Cache tanpa TTL dan tanpa batas ukuran — memori Redis atau heap terus tumbuh sampai habis.
- Lupa identitas pengguna di key — data pribadi satu pengguna tersaji ke pengguna lain.
- Meng-cache entity JPA atau objek mutable — perubahan di satu tempat mencemari isi cache, dan serialisasi entity lazy sering gagal.
Ringkasan
- Cache hanya untuk data yang sering dibaca dan boleh basi sebentar; ukur dulu sebelum menambahkannya.
- Cache-aside: cek cache → miss → muat dari sumber → simpan dengan TTL; saat menulis, evict.
- Spring Cache:
@EnableCaching, lalu@Cacheable,@CachePut,@CacheEvictdengan key SpEL yang konsisten. - Caffeine cepat tapi lokal per instance; Redis dibagi semua instance — pakai serializer JSON dan TTL default.
- Evict setelah data sumber berubah, sediakan TTL sebagai jaring pengaman, dan cegah stampede dengan
sync = trueplus jitter. - Data per-user wajib punya identitas pengguna di key; keputusan bisnis (stok, saldo) tetap dibaca dari database.