Cache stampede—atau dikenal sebagai thundering herd problem—terjadi ketika kunci cache dengan beban pembacaan tinggi (hot key) kedaluwarsa secara tiba-tiba. Ratusan hingga ribuan thread konkuren yang mengalami cache miss pada milidetik yang sama akan mengeksekusi query fallback ke database secara paralel. Lonjakan kueri serentak ini dapat menguras connection pool HikariCP dalam hitungan detik, memicu SQLTransientConnectionException: Connection is not available, request timed out, serta mendegradasi performa sistem secara drastis.

Anatomi Cache Stampede: Mengapa Connection Pool Menjadi Korban

Pola cache-aside standar mengasumsikan bahwa proses membaca database dan menulis kembali ke cache berlangsung cepat. Namun, pada lalu lintas tinggi:

  1. Kunci cache kedaluwarsa sesuai waktu Time to Live (TTL).
  2. Sebanyak 500 permintaan masuk secara serentak dalam jendela 50 milidetik untuk kunci yang sama.
  3. Karena cache kosong, seluruh 500 thread mengeksekusi method service yang memanggil database.
  4. Pool HikariCP yang secara default berkapasitas 10 koneksi terisi penuh. 490 permintaan lainnya masuk ke status tunggu (pending/blocked).
  5. Database mengalami lonjakan CPU, durasi eksekusi query meningkat, connection timeout tercapai, dan request mulai gagal dengan status HTTP 500.

Solusi Single-Node: Tuning Parameter sync pada @Cacheable

Spring Framework menyediakan solusi bawaan untuk single-instance deployment menggunakan parameter sync = true pada anotasi @Cacheable.

@Service
public class ProductCatalogService {

    private final ProductRepository productRepository;

    public ProductCatalogService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(value = "products", key = "#id", sync = true)
    public ProductResponse getProductById(Long id) {
        return productRepository.findById(id)
                .map(ProductResponse::fromEntity)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
    }
}

Cara Kerja sync = true

Ketika sync = true diaktifkan, Spring mendelegasikan pemanggilan cache ke method org.springframework.cache.Cache#get(Object key, Callable<T> valueLoader) alih-alih alur pemanggilan standar. Pada implementasi in-memory atau Redis default Spring Cache, operasi ini melakukan sinkronisasi lokal pada level JVM (menggunakan mutex per kunci atau monitor internal).

  • Hanya satu thread di dalam JVM tersebut yang diizinkan mengeksekusi method database loader.
  • Thread lain yang meminta kunci yang sama akan diblokir dan menunggu hingga data selesai ditulis ke cache oleh thread pertama.
  • Setelah kunci terisi, thread yang menunggu langsung membaca data dari cache tanpa menyentuh database.
Batasan Kritis: Mekanisme sync = true hanya beroperasi di tingkat local memory (JVM-level lock). Jika aplikasi dideploy secara terdistribusi di Kubernetes dengan 20 replika pod, masing-masing pod tetap akan meloloskan 1 query ke database secara bersamaan (total 20 query serentak). Meskipun mereduksi ratusan request, ini masih memicu spike jika cluster memiliki banyak node.

Solusi Multi-Node: Distributed Lock dengan Redis Mutex

Untuk cluster multi-node, koordinasi locking harus dilakukan secara global menggunakan distributed lock di Redis sebelum mengakses database. Pola yang wajib digunakan di sini adalah Double-Checked Locking untuk mencegah race condition.

@Service
public class ResilientProductService {

    private static final Logger log = LoggerFactory.getLogger(ResilientProductService.class);
    private static final String CACHE_PREFIX = "cache:product:";
    private static final String LOCK_PREFIX = "lock:product:";
    private static final Duration LOCK_WAIT_TIME = Duration.ofMillis(200);
    private static final Duration LOCK_LEASE_TIME = Duration.ofSeconds(5);

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;
    private final ProductRepository productRepository;

    public ResilientProductService(StringRedisTemplate redisTemplate, 
                                  ObjectMapper objectMapper, 
                                  ProductRepository productRepository) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
        this.productRepository = productRepository;
    }

    public ProductResponse getProductById(Long id) {
        String cacheKey = CACHE_PREFIX + id;
        String lockKey = LOCK_PREFIX + id;

        // Pengecekan pertama: Baca dari cache
        String cachedData = redisTemplate.opsForValue().get(cacheKey);
        if (cachedData != null) {
            return deserialize(cachedData);
        }

        // Cache miss: Upayakan akuisisi Distributed Lock
        String lockToken = UUID.randomUUID().toString();
        Boolean acquired = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, lockToken, LOCK_LEASE_TIME);

        if (Boolean.TRUE.equals(acquired)) {
            try {
                // Pengecekan kedua (Double-check): Pastikan data belum diisi thread lain
                String recheckData = redisTemplate.opsForValue().get(cacheKey);
                if (recheckData != null) {
                    return deserialize(recheckData);
                }

                // Query ke database hanya dilakukan oleh satu node
                Product product = productRepository.findById(id)
                        .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
                ProductResponse response = ProductResponse.fromEntity(product);

                // Tulis kembali ke cache dengan default TTL (misal 1 jam)
                redisTemplate.opsForValue().set(cacheKey, serialize(response), Duration.ofHours(1));
                return response;
            } finally {
                // Lepas lock hanya jika token cocok (cegah pelepasan lock thread lain akibat timeout)
                releaseLock(lockKey, lockToken);
            }
        } else {
            // Gagal mengakuisisi lock: Tunggu sejenak dan coba ambil kembali dari cache (Backoff & Retry)
            try {
                Thread.sleep(LOCK_WAIT_TIME.toMillis());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("Thread interrupted while waiting for cache", e);
            }
            return getProductById(id);
        }
    }

    private void releaseLock(String lockKey, String lockToken) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), 
                              Collections.singletonList(lockKey), 
                              lockToken);
    }

    private ProductResponse deserialize(String json) {
        try {
            return objectMapper.readValue(json, ProductResponse.class);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Deserialization error", e);
        }
    }

    private String serialize(ProductResponse object) {
        try {
            return objectMapper.writeValueAsString(object);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Serialization error", e);
        }
    }
}

Mengapa Double-Checked Locking Mutlak Diperlukan?

Ketika Pod A memegang lock dan sedang menjalankan query, Pod B gagal mendapatkan lock dan masuk ke mode tunggu. Setelah Pod A selesai memperbarui cache dan melepas lock, Pod B yang mengulang proses atau menunggu giliran tidak boleh langsung memanggil database. Pengecekan kedua (double-check) memastikan Pod B membaca data yang baru saja ditulis oleh Pod A.

Mencegah Mass Expiration dengan TTL Jitter

Masalah lain terjadi ketika ribuan kunci di-cache secara bersamaan (misalnya via batch job atau deployment awal) dengan nilai TTL yang statis (misal: tepat 30 menit). Seluruh kunci tersebut akan kedaluwarsa pada detik yang persis sama, memicu stampede massal.

Solusinya adalah menambahkan deviasi acak (TTL Jitter). Kita dapat mengonfigurasi RedisCacheManager kustom untuk menyisipkan jitter acak pada setiap write operation.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration defaultConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .disableCachingNullValues()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfiguration)
                .withCacheConfiguration("products", customJitterConfiguration(Duration.ofMinutes(60), 10))
                .build();
    }

    private RedisCacheConfiguration customJitterConfiguration(Duration baseTtl, int jitterPercent) {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(calculateJitterSeconds(baseTtl.getSeconds(), jitterPercent)));
    }

    private long calculateJitterSeconds(long baseSeconds, int jitterPercent) {
        long maxJitter = (baseSeconds * jitterPercent) / 100;
        long randomJitter = ThreadLocalRandom.current().nextLong(-maxJitter, maxJitter + 1);
        return baseSeconds + randomJitter;
    }
}

Dengan variasi ±10% pada TTL 60 menit, kunci akan kedaluwarsa tersebar secara acak antara menit ke-54 hingga ke-66, meratakan kurva beban database secara signifikan.

Metrik Observabilitas: HikariCP dan Cache Hit Ratio

Untuk memvalidasi efektivitas solusi, pantau metrik performa melalui Micrometer dan Prometheus:

1. HikariCP Connection Metrics

Pastikan metrik koneksi aktif dan pending tidak mengalami lonjakan mendadak saat TTL berakhir:

  • hikaricp.connections.active: Menunjukkan jumlah koneksi database yang sedang aktif digunakan. Jika terjadi cache stampede, grafik ini akan membentur batas maksimal pool (plateau).
  • hikaricp.connections.pending: Jumlah thread yang antre meminta koneksi database. Nilai ideal harus mendekati 0. Angka di atas 0 menandakan degradasi query atau starving pool.

2. Cache Hit vs Miss Ratio

Dihitung menggunakan formula:

sum(rate(cache_gets_total{result="hit"}[5m])) / 
(sum(rate(cache_gets_total{result="hit"}[5m])) + sum(rate(cache_gets_total{result="miss"}[5m])))

Penurunan hit ratio yang tajam disertai kenaikan korelasi pada hikaricp.connections.active merupakan indikator utama terjadinya stampede.

3. HTTP Latency Distribution (p99)

Pantau metrik http.server.requests.seconds pada percentile p99. Penggunaan distributed lock yang tepat akan menjaga latency p99 tetap stabil di bawah batas degradasi, alih-alih melonjak hingga menyentuh HTTP gateway timeout (504).

Panduan Pemilihan Strategi

Kondisi LingkunganSolusi RekomendasiTrade-off
Single Pod / Standalone@Cacheable(sync = true)Locking overhead minimal, namun tidak efektif untuk multi-pod cluster.
Cluster Multi-NodeRedis Distributed Mutex + Double-checkKonsistensi tinggi, proteksi total pada database; latensi sedikit naik untuk thread yang menunggu.
Batch/Data Kolektif BesarTTL Jitter + Async RefreshMencegah kedaluwarsa serentak tanpa perlu mekanisme lock yang kompleks di tiap kunci.