Aplikasi kita kini melayani data terpaginasi dan menerima berkas — tetapi amati pola trafiknya: katalog yang sama dibaca ribuan kali per menit, tiap pembacaan memukul basis data dengan query identik. Cache menyimpan hasil kerja berat di lapisan memori sehingga pemanggilan berikutnya tidak mengulang pekerjaan itu. Spring mengemasnya menjadi deklaratif: satu anotasi @Cacheable pada method, proxy AOP mengurus sisanya. Pada Chapter ini kita bedah cache-aside, keluarga anotasi cache, dan CacheManager, lalu membuktikan perilaku TTL (waktu kedaluwarsa) dengan jam virtual di Java murni.
Cache-Aside dan Anotasi Deklaratif #
Pola yang dipakai Spring bernama cache-aside: pemanggil menengok cache lebih dulu; bila kunci ada, nilai dikembalikan tanpa menyentuh method, bila tidak ada, method berjalan dan hasilnya disimpan. Seluruh alur terjadi di proxy pembungkus bean — method kita tidak tahu sedang di-cache:
@Service
public class KatalogService {
private final ProdukRepository repo;
public KatalogService(ProdukRepository repo) {
this.repo = repo;
}
@Cacheable(cacheNames = "katalog", key = "#kategori")
public List<Produk> daftar(String kategori) {
return repo.cariByKategoriJoinStok(kategori); // query mahal
}
}Saat method daftar("kopi") dipanggil, proxy menghitung kunci dari ekspresi SpEL #kategori, memeriksa cache bernama katalog, dan hanya menjalankan query bila kunci belum ada; tanpa atribut key, seluruh parameter menjadi kunci gabungan. Keluarga anotasinya lengkap:
| Anotasi | Perilaku |
|---|---|
@Cacheable | cek cache dulu; isi bila kosong (miss) |
@CachePut | jalankan method selalu, perbarui cache dengan hasilnya |
@CacheEvict | jalankan method, hapus entri (atau allEntries = true) |
@Caching | gabungkan beberapa anotasi cache pada satu method |
@CacheConfig | pengaturan bersama di level kelas (nama cache bawaan) |
Perbedaan @Cacheable dan @CachePut sering salah dipahami: yang pertama melompati method saat hit, yang kedua selalu menjalankan method dan hanya menyimpan hasil barunya. @CachePut tepat untuk operasi tulis yang sekaligus menyegarkan data baca; memasangnya pada method read-only justru menghapus manfaat cache.
CacheManager dan Penyedia Cache #
Anotasi hanyalah bahasa; penyimpanan fisik ditentukan CacheManager. Tanpa pustaka tambahan, Spring Boot menyediakan ConcurrentMapCacheManager — ConcurrentHashMap per nama cache, tanpa batas ukuran, tanpa kedaluwarsa. Cocok untuk pengembangan, berbahaya di produksi karena entri hidup selamanya. Praktik umum produksi menambahkan Caffeine:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>@Bean
public CaffeineCacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager("katalog", "rekomendasi");
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(5)) // TTL
.maximumSize(10_000)); // pagar memori
return manager;
}Caffeine adalah cache lokal di dalam satu JVM: cepat, hemat, tetapi tidak dibagi antar-instance dan hilang saat restart. Redis menempatkan cache di proses terpisah sehingga seluruh instance berbagi isi yang sama — dengan biaya satu lompatan jaringan per akses. Pilihan mengikuti bentuk deployment aplikasi:
| Aspek | Caffeine (lokal) | Redis (terdistribusi) |
|---|---|---|
| Kecepatan akses | nanodetik (heap) | milidetik (jaringan) |
| Berbagi antar-instance | tidak | ya |
| Bertahan restart | tidak | ya |
| Skala data | dibatasi heap | ratusan GB |
Membuktikan TTL dengan Jam Virtual #
Kedaluwarsa sulit didemokan — menunggu lima menit nyata tidak praktis. Solusinya: kita kendalikan waktu sendiri. Program berikut membangun jam virtual yang hanya bergerak atas perintah kita, lalu cache dengan TTL 5 menit di atasnya; delapan peristiwa mengalir dari pukul 08:00 sampai 08:09:
import java.time.LocalTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Supplier;
public class CacheTtl {
// ==== Jam virtual: waktu bergerak karena kita memintanya, bukan dinding ====
static class JamVirtual {
private long detik = 8 * 3600; // mulai 08:00:00
void majuMenit(long menit) {
detik += menit * 60;
}
long sekarang() {
return detik;
}
String tampil() {
return LocalTime.ofSecondOfDay(detik).toString();
}
}
record Entri(String nilai, long kedaluwarsaPada) { }
record Hasil(boolean hit, String nilai, String catatan) { }
// ==== Cache TTL ala Caffeine expireAfterWrite ====
static class Cache {
private final Map<String, Entri> isi = new LinkedHashMap<>();
private final JamVirtual jam;
private final long ttlDetik;
private int hit = 0;
private int miss = 0;
Cache(JamVirtual jam, long ttlMenit) {
this.jam = jam;
this.ttlDetik = ttlMenit * 60;
}
// Pola cache-aside: padanan method bertanda @Cacheable.
Hasil ambil(String kunci, Supplier<String> pemuat) {
Entri e = isi.get(kunci);
if (e != null && jam.sekarang() < e.kedaluwarsaPada()) {
hit++;
return new Hasil(true, e.nilai(), "masih segar");
}
miss++;
String alasan = e == null ? "belum ada di cache"
: "kedaluwarsa pada " + LocalTime.ofSecondOfDay(e.kedaluwarsaPada());
String nilai = pemuat.get();
isi.put(kunci, new Entri(nilai, jam.sekarang() + ttlDetik));
return new Hasil(false, nilai, alasan);
}
// Padanan @CachePut: perbarui cache bersamaan pemanggilan method.
void taruh(String kunci, String nilai) {
isi.put(kunci, new Entri(nilai, jam.sekarang() + ttlDetik));
}
// Padanan @CacheEvict: buang entri agar dimuat ulang berikutnya.
void buang(String kunci) {
isi.remove(kunci);
}
int hit() { return hit; }
int miss() { return miss; }
}
// "Basis data" mahal: kita menghitung setiap pemanggilannya.
static int bacaBasisData = 0;
static String muatKatalog() {
bacaBasisData++;
return "Katalog[v=" + bacaBasisData + "]";
}
public static void main(String[] args) {
JamVirtual jam = new JamVirtual();
Cache cache = new Cache(jam, 5); // TTL 5 menit
ambil(cache, jam);
jam.majuMenit(1);
ambil(cache, jam); // HIT: umur 1 menit
jam.majuMenit(3); // sekarang 08:04
ambil(cache, jam); // HIT: umur 4 menit
jam.majuMenit(2); // sekarang 08:06
ambil(cache, jam); // MISS: umur 6 menit > TTL
System.out.println("[" + jam.tampil() + "] taruh katalog -> @CachePut memperbarui entri");
cache.taruh("katalog", "Katalog[v=manual]");
jam.majuMenit(1); // 08:07
ambil(cache, jam); // HIT dengan nilai baru
jam.majuMenit(2); // 08:09
System.out.println("[" + jam.tampil() + "] buang katalog -> @CacheEvict menghapus entri");
cache.buang("katalog");
ambil(cache, jam); // MISS: dimuat ulang
System.out.println();
System.out.println("statistik: hit=" + cache.hit() + " miss=" + cache.miss()
+ " bacaBasisData=" + bacaBasisData);
}
static void ambil(Cache cache, JamVirtual jam) {
Hasil h = cache.ambil("katalog", CacheTtl::muatKatalog);
System.out.printf("[%s] ambil katalog -> %s (%s) %s%n",
jam.tampil(), h.hit() ? "HIT " : "MISS", h.catatan(), h.nilai());
}
}Output yang Dihasilkan:
[08:00] ambil katalog -> MISS (belum ada di cache) Katalog[v=1]
[08:01] ambil katalog -> HIT (masih segar) Katalog[v=1]
[08:04] ambil katalog -> HIT (masih segar) Katalog[v=1]
[08:06] ambil katalog -> MISS (kedaluwarsa pada 08:05) Katalog[v=2]
[08:06] taruh katalog -> @CachePut memperbarui entri
[08:07] ambil katalog -> HIT (masih segar) Katalog[v=manual]
[08:09] buang katalog -> @CacheEvict menghapus entri
[08:09] ambil katalog -> MISS (belum ada di cache) Katalog[v=3]
statistik: hit=3 miss=3 bacaBasisData=3Mari kita bedah peristiwa demi peristiwa:
JamVirtual: satu fielddetikyang hanya bertambah lewatmajuMenit(...);LocalTime.ofSecondOfDaymengubahnya menjadi tampilan jam rapi. Waktu bergerak atas perintah kita, sehingga urutan HIT/MISS deterministik dan dapat diuji ulang.Cache.ambil(kunci, pemuat): implementasi cache-aside yang dijalankan proxy@Cacheable. Entri segar selamajam.sekarang() < kedaluwarsaPada()— tiga cabang terlihat terpisah: HIT (kembalikan tanpa memuat), MISS-baru (belum ada di cache), dan MISS-kedaluwarsa (kedaluwarsa pada 08:05).- Urutan 08:00 → 08:04: pembacaan pertama adalah MISS yang mengisi cache (pemuat #1), pembacaan menit ke-1 dan ke-4 adalah HIT — umur 4 menit masih di bawah TTL 5. Pembacaan pukul 08:06 menemukan entri berumur 6 menit: MISS karena
kedaluwarsa pada 08:05, lalu pemuat #2 berjalan dan TTL dihitung ulang. taruh(...)danbuang(...): padanan@CachePutdan@CacheEvict.taruhpada 08:06 menimpa entri tanpa menunggu kedaluwarsa — pembacaan 08:07 mengembalikanKatalog[v=manual]dari cache, bukan nilai pemuat.buangpada 08:09 memaksa pembacaan berikutnya menjadi MISS (pemuat #3).- Baris statistik:
hit=3 miss=3 bacaBasisData=3— basis data hanya dibaca tiga kali untuk delapan peristiwa. Makin mahal pemuat dan makin tinggi rasio hit, makin besar nilai cache.
Ada satu batasan mendasar dari anotasi cache dan method call umumnya: pemanggil menunggu hingga method selesai. Untuk pekerjaan yang memang tidak perlu ditunggu — kirim surel, buat laporan, panggil layanan pihak ketiga — Spring menyediakan @Scheduled dan @Async, dua topik dua Chapter berikutnya.
Referensi #
- Spring Framework Reference: Cache Abstraction
- Spring Boot Reference: Caching
- Spring Guides: Caching Data
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
CacheTtl.java
Unduh berkasimport java.time.LocalTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Supplier;
public class CacheTtl {
// ==== Jam virtual: waktu bergerak karena kita memintanya, bukan dinding ====
static class JamVirtual {
private long detik = 8 * 3600; // mulai 08:00:00
void majuMenit(long menit) {
detik += menit * 60;
}
long sekarang() {
return detik;
}
String tampil() {
return LocalTime.ofSecondOfDay(detik).toString();
}
}
record Entri(String nilai, long kedaluwarsaPada) { }
record Hasil(boolean hit, String nilai, String catatan) { }
// ==== Cache TTL ala Caffeine expireAfterWrite ====
static class Cache {
private final Map<String, Entri> isi = new LinkedHashMap<>();
private final JamVirtual jam;
private final long ttlDetik;
private int hit = 0;
private int miss = 0;
Cache(JamVirtual jam, long ttlMenit) {
this.jam = jam;
this.ttlDetik = ttlMenit * 60;
}
// Pola cache-aside: padanan method bertanda @Cacheable.
Hasil ambil(String kunci, Supplier<String> pemuat) {
Entri e = isi.get(kunci);
if (e != null && jam.sekarang() < e.kedaluwarsaPada()) {
hit++;
return new Hasil(true, e.nilai(), "masih segar");
}
miss++;
String alasan = e == null ? "belum ada di cache"
: "kedaluwarsa pada " + LocalTime.ofSecondOfDay(e.kedaluwarsaPada());
String nilai = pemuat.get();
isi.put(kunci, new Entri(nilai, jam.sekarang() + ttlDetik));
return new Hasil(false, nilai, alasan);
}
// Padanan @CachePut: perbarui cache bersamaan pemanggilan method.
void taruh(String kunci, String nilai) {
isi.put(kunci, new Entri(nilai, jam.sekarang() + ttlDetik));
}
// Padanan @CacheEvict: buang entri agar dimuat ulang berikutnya.
void buang(String kunci) {
isi.remove(kunci);
}
int hit() { return hit; }
int miss() { return miss; }
}
// "Basis data" mahal: kita menghitung setiap pemanggilannya.
static int bacaBasisData = 0;
static String muatKatalog() {
bacaBasisData++;
return "Katalog[v=" + bacaBasisData + "]";
}
public static void main(String[] args) {
JamVirtual jam = new JamVirtual();
Cache cache = new Cache(jam, 5); // TTL 5 menit
ambil(cache, jam);
jam.majuMenit(1);
ambil(cache, jam); // HIT: umur 1 menit
jam.majuMenit(3); // sekarang 08:04
ambil(cache, jam); // HIT: umur 4 menit
jam.majuMenit(2); // sekarang 08:06
ambil(cache, jam); // MISS: umur 6 menit > TTL
System.out.println("[" + jam.tampil() + "] taruh katalog -> @CachePut memperbarui entri");
cache.taruh("katalog", "Katalog[v=manual]");
jam.majuMenit(1); // 08:07
ambil(cache, jam); // HIT dengan nilai baru
jam.majuMenit(2); // 08:09
System.out.println("[" + jam.tampil() + "] buang katalog -> @CacheEvict menghapus entri");
cache.buang("katalog");
ambil(cache, jam); // MISS: dimuat ulang
System.out.println();
System.out.println("statistik: hit=" + cache.hit() + " miss=" + cache.miss()
+ " bacaBasisData=" + bacaBasisData);
}
static void ambil(Cache cache, JamVirtual jam) {
Hasil h = cache.ambil("katalog", CacheTtl::muatKatalog);
System.out.printf("[%s] ambil katalog -> %s (%s) %s%n",
jam.tampil(), h.hit() ? "HIT " : "MISS", h.catatan(), h.nilai());
}
}