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:

java
@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:

AnotasiPerilaku
@Cacheablecek cache dulu; isi bila kosong (miss)
@CachePutjalankan method selalu, perbarui cache dengan hasilnya
@CacheEvictjalankan method, hapus entri (atau allEntries = true)
@Cachinggabungkan beberapa anotasi cache pada satu method
@CacheConfigpengaturan 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:

xml
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
java
@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:

AspekCaffeine (lokal)Redis (terdistribusi)
Kecepatan aksesnanodetik (heap)milidetik (jaringan)
Berbagi antar-instancetidakya
Bertahan restarttidakya
Skala datadibatasi heapratusan 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:

java CacheTtl.java
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:

text
[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=3

Mari kita bedah peristiwa demi peristiwa:

  1. JamVirtual: satu field detik yang hanya bertambah lewat majuMenit(...); LocalTime.ofSecondOfDay mengubahnya menjadi tampilan jam rapi. Waktu bergerak atas perintah kita, sehingga urutan HIT/MISS deterministik dan dapat diuji ulang.
  2. Cache.ambil(kunci, pemuat): implementasi cache-aside yang dijalankan proxy @Cacheable. Entri segar selama jam.sekarang() < kedaluwarsaPada() — tiga cabang terlihat terpisah: HIT (kembalikan tanpa memuat), MISS-baru (belum ada di cache), dan MISS-kedaluwarsa (kedaluwarsa pada 08:05).
  3. 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.
  4. taruh(...) dan buang(...): padanan @CachePut dan @CacheEvict. taruh pada 08:06 menimpa entri tanpa menunggu kedaluwarsa — pembacaan 08:07 mengembalikan Katalog[v=manual] dari cache, bukan nilai pemuat. buang pada 08:09 memaksa pembacaan berikutnya menjadi MISS (pemuat #3).
  5. 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

CacheTtl.java

Unduh berkas
java
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());
    }
}