• 22.08.2026 23:17:01
  • Admin Admin

Spring Boot uygulamalarında cache stampede'i Caffeine ile tekilleştirmeyi, stale-while-revalidate davranışını, tenant güvenli cache anahtarlarını ve JFR ile ölçülebilir gecikme analizini ele alır.

Spring Boot'ta Cache Stampede ve Tutarlı Önbellek Tasarımı Rehberi

Spring Boot'ta cache stampede problemini doğru sınıflandırmak

Cache stampede, sadece TTL dolunca oluşan ani trafik artışı değildir. Aynı cache anahtarı için yüzlerce eşzamanlı istek upstream servise ayrı ayrı giderse, örneğin bir spring rest api içindeki GET /products/42 çağrısı katalog servisine 128 paralel HTTP isteği üretebilir. İlk teşhis için uygulama loglarına anahtar bazlı istek sayısı eklemek yerine Caffeine'in recordStats() istatistiklerini ve upstream tarafındaki http.server.requests sayacını birlikte inceleyin. Cache miss oranı sabit kalırken upstream RPS'nin TTL sınırlarında sıçraması, stampede için ölçülebilir bir işarettir.

Bu konu, bir spring framework eğitimi veya java spring eğitimi içinde anlatılan @Cacheable kullanımından daha fazla tasarım kararı içerir: veri ne kadar eski kalabilir, hata anında son başarılı değer sunulabilir mi, cache anahtarında hangi yetki boyutları vardır? Bir spring boot eğitimi ya da spring boot kursu müfredatında bu soruların cevabı genellikle atlanır. Uygulanabilir başlangıç kuralı şudur: 50-500 ms süren, yüksek okuma oranlı ve belirli süre eski kalabilen bir remote read için L1 Caffeine cache değerlendirin; sipariş durumu veya bakiye gibi güçlü tutarlılık gerektiren okumaları aynı mekanizmaya koymayın.

// build.gradle.kts - Spring Boot dependency management surumu yonetir
implementation("com.github.ben-manes.caffeine:caffeine")
implementation("io.micrometer:micrometer-registry-prometheus")

// /actuator/prometheus metrikleri icin
management.endpoints.web.exposure.include=health,prometheus
management.metrics.distribution.percentiles-histogram.http.server.requests=true

Cache'e konacak nesnenin hata cevabı olmadığını kesinleştirin. Örneğin katalog upstream'i 404 döndürüyorsa bunun kalıcı bir 'ürün yok' bilgisi mi, yoksa replikasyon gecikmesi mi olduğuna API sözleşmesi karar vermelidir. Geçici 404'leri 10 dakika cache'lemek, ürün oluşturulduktan sonra yanlış sonuç üretir. Ayrı bir negatif-cache TTL'si kullanacaksanız bunu 5-15 saniye gibi kısa bir süreyle sınırlayın ve ProductView ile NotFoundMarker türlerini açıkça ayırın.

Caffeine AsyncLoadingCache ile istek birleştirme ve stale-while-revalidate

Caffeine'in AsyncLoadingCache#get(key) çağrısı, aynı anahtar için yükleme sürerken çağıranlara aynı CompletableFuture nesnesini verir. Bu, elle yazılmış ConcurrentHashMap<Key, CompletableFuture<T>> çözümlerindeki temizleme yarışı olmadan tekilleştirme sağlar. refreshAfterWrite süresi geçen bir değer ilk erişimde arka planda yenilenir; yenileme tamamlanana kadar önceki değer döner. Buna karşılık expireAfterWrite sonrasında değer artık kullanılamaz ve istek yeni yüklemeyi bekler.

@Configuration
class CatalogCacheConfig {

  @Bean(destroyMethod = "shutdown")
  ExecutorService cacheRefreshExecutor() {
    return new ThreadPoolExecutor(
        4, 8, 30, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(200),
        new ThreadPoolExecutor.CallerRunsPolicy());
  }

  @Bean
  AsyncLoadingCache<ProductCacheKey, ProductView> productCache(
      WebClient catalogClient, ExecutorService cacheRefreshExecutor) {

    return Caffeine.newBuilder()
        .maximumSize(50_000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .refreshAfterWrite(Duration.ofSeconds(45))
        .executor(cacheRefreshExecutor)
        .recordStats()
        .buildAsync(new AsyncCacheLoader<>() {
          @Override
          public CompletableFuture<ProductView> asyncLoad(
              ProductCacheKey key, Executor executor) {
            return catalogClient.get()
                .uri("/internal/products/{id}?priceListVersion={version}",
                    key.productId(), key.priceListVersion())
                .retrieve()
                .bodyToMono(ProductView.class)
                .timeout(Duration.ofMillis(350))
                .toFuture();
          }
        });
  }
}

record ProductCacheKey(String tenantId, String productId, long priceListVersion) {}
record ProductView(String id, String name, BigDecimal price) {}

Buradaki incelik, refreshAfterWrite'ın periyodik bir scheduler olmadığıdır. Yalnızca erişilen anahtar yenilenir. Düşük trafikli ama önemli bir ürünün mutlaka önceden ısıtılması gerekiyorsa ayrı bir @Scheduled işi ile cache.refresh(key) çağırın. Ayrıca WebClient non-blocking olsa da yenileme sonrası CPU ağırlıklı JSON dönüştürme veya imza doğrulama yapıyorsanız Netty event-loop üzerinde bloklamayın; bu işi sınırlı cacheRefreshExecutor veya uygun bir Reactor scheduler'a taşıyın.

Spring MVC denetleyicisi geleceği doğrudan döndürebilir. Aşağıdaki örnekte cache anahtarına tenant ve fiyat listesi sürümü eklenir. Yalnızca productId kullanmak, iki tenant'ın birbirinin indirimli fiyatını görmesine neden olabilecek tipik bir veri sızıntısıdır.

@RestController
@RequestMapping("/products")
class ProductController {
  private final AsyncLoadingCache<ProductCacheKey, ProductView> cache;
  private final PriceListVersionStore versions;

  @GetMapping("/{id}")
  CompletableFuture<ResponseEntity<ProductView>> get(
      @PathVariable String id,
      @AuthenticationPrincipal Jwt jwt) {

    String tenantId = jwt.getClaimAsString("tenant_id");
    long version = versions.currentVersion(tenantId);
    var key = new ProductCacheKey(tenantId, id, version);

    return cache.get(key)
        .thenApply(value -> ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(Duration.ofSeconds(15)).cachePrivate())
            .body(value));
  }
}

JFR ve Micrometer ile önce-sonra cache gecikmesi ölçümü

Cache eklemek bir performans iddiası değil, karşılaştırılabilir deneydir. Önce cache kapalıyken, sonra aynı veri seti ısıtılmış cache ile aynı yükü çalıştırın. Her iki koşulda da aynı upstream gecikmesini, aynı JVM heap değerini ve aynı concurrency değerini kullanın. Örneğin 90 saniyelik sabit yükte wrk ile p50, p95, p99 ve upstream çağrı sayısını kaydedin; yalnızca ortalama gecikmeyi karşılaştırmak, TTL anındaki kuyruklanmayı gizler.

# Her kosuldan once 20 saniye warm-up calistirin.
wrk -t8 -c128 -d90s --latency   -H "Authorization: Bearer $TOKEN"   http://localhost:8080/products/42

# Uygulamayi JFR ile baslatin.
java -XX:StartFlightRecording=filename=cache-test.jfr,settings=profile   -jar catalog-api.jar

# Kayit sonrasi CPU orneklerini ve socket okumalarini inceleyin.
jfr print --events jdk.ExecutionSample,jdk.SocketRead cache-test.jfr

Caffeine istatistiklerini Micrometer'a bağlayıp Prometheus'ta miss yükünü görün. hitRate() kümülatiftir; dağıtım sonrası tek başına alarm eşiği yapmak yerine cache.gets ve cache.misses sayaçlarından 5 dakikalık rate() hesaplayın. JFR'de cache açıkken jdk.SocketRead olayları ve katalog HTTP istemci süreleri azalmalı, fakat cache miss anındaki p99 ayrıca raporlanmalıdır. Çünkü bir cache, hit yolunu hızlandırırken upstream'in kapasite hatasını gizleyebilir.

@Configuration
class CacheMetricsConfig {
  @Bean
  MeterBinder productCacheMetrics(
      AsyncLoadingCache<ProductCacheKey, ProductView> cache) {
    return registry -> {
      Gauge.builder("catalog.cache.hit.ratio", cache, c -> {
        CacheStats s = c.synchronous().stats();
        long requests = s.requestCount();
        return requests == 0 ? 0.0 : (double) s.hitCount() / requests;
      }).register(registry);

      FunctionCounter.builder("catalog.cache.misses", cache,
          c -> (double) c.synchronous().stats().missCount())
          .register(registry);
    };
  }
}

Önce-sonra raporunda en az şu dört değeri tabloya koyun: istemci p95, istemci p99, katalog servisi RPS ve cache miss/saniye. Örneğin p95 düşerken katalog RPS değişmiyorsa, anahtarların beklediğiniz kadar tekrar etmediğini veya çağrıların cache katmanını atladığını araştırın. JFR'de jdk.JavaMonitorEnter ya da uzun executor kuyrukları görünüyorsa, tekilleştirme kazanımını global bir kilit veya aşırı küçük refresh havuzu geri alıyor olabilir.

Spring Cloud ve microservices mimarisi içinde invalidation tasarımı

Bir microservices mimarisi içinde her instance'ın Caffeine cache'i yereldir. Bu nedenle ürün fiyatı değiştiğinde yalnızca yazıyı alan instance'ın cache'ini silmek yeterli değildir. Spring Cloud ile servis keşfi kullanılıyor olsa bile discovery mekanizması cache invalidation protokolü değildir. Pratik yaklaşım, katalog servisinin ProductPriceChanged olayına tenant için monoton artan bir priceListVersion eklemesi ve tüketicinin bu sürümü atomik olarak güncellemesidir.

@Component
class PriceVersionConsumer {
  private final ConcurrentMap<String, AtomicLong> versions = new ConcurrentHashMap<>();

  @KafkaListener(topics = "catalog.price.changed", groupId = "pricing-api")
  void consume(ProductPriceChanged event) {
    versions.computeIfAbsent(event.tenantId(), ignored -> new AtomicLong())
        .accumulateAndGet(event.priceListVersion(), Math::max);
  }

  long currentVersion(String tenantId) {
    return versions.getOrDefault(tenantId, new AtomicLong(0)).get();
  }
}

record ProductPriceChanged(String tenantId, long priceListVersion) {}

Sürümü cache anahtarına katmak, eski girdileri tek tek taramadan yeni isteklerin eski fiyatı kullanmasını önler. cache.asMap().keySet().removeIf(...) ile her Kafka olayında ürün aramak, 50 bin girdili bir cache'te O(n) maliyet üretir ve event tüketici thread'ini gereksiz uzatır. Bunun karşılığında eski sürüm girdileri maximumSize ve expireAfterWrite ile zamanla atılır. Bu modelin sınırı nettir: olay gelmeden önce başlayan bir in-flight HTTP isteği eski cevabı döndürebilir. Böyle bir pencerenin kabul edilemediği bakiye benzeri alanlarda cache yerine senkron güçlü okuma gerekir.

Upstream hata davranışını da açıkça yönetin. Caffeine başarısız bir async load sonucunu kalıcı değer olarak tutmaz, ancak TTL dolmuş çok sıcak bir anahtar için her istek yeniden deneme basıncı oluşturabilir. Resilience4j circuit breaker ekleyin ve timeout değerini çağıran endpoint'in bütçesinden türetin. 800 ms endpoint bütçesinde 350 ms katalog timeout, iki ardışık deneme için uygun olmayabilir; retry sayısı ile timeout çarpımı bütçeyi aşmamalıdır.

Spring Security ve spring MVC katmanında cache sınırları

Spring Security bağlamındaki en kritik karar, cache anahtarının kullanıcı mı yoksa yetki sonucu mu olduğudur. Ürün adı herkese aynıysa kullanıcı kimliğini anahtara koymak hit oranını gereksiz düşürür. Fiyat, bölge, müşteri segmenti veya SCOPE_wholesale sonucunda değişiyorsa bu boyutları normalize edilmiş bir authorization fingerprint olarak ekleyin. JWT'nin tamamını veya rastgele sıradaki scope listesini anahtara koymayın; token yenilendikçe aynı yetki için cache parçalanır.

static String authorizationFingerprint(Jwt jwt) {
  List<String> relevant = jwt.getClaimAsStringList("scope").stream()
      .filter(scope -> scope.equals("price:wholesale") || scope.equals("price:retail"))
      .sorted()
      .toList();
  return String.join(",", relevant);
}

// Anahtar: tenantId + productId + priceListVersion + authorizationFingerprint
// 'sub' sadece cevap gercekten kullaniciya ozelse eklenmelidir.

Spring MVC tarafında Cache-Control: public başlığını tenant veya yetkiyle değişen yanıtlarda kullanmayın. Uygulama içindeki Caffeine anahtarı doğru olsa bile CDN ya da kurumsal proxy, URL bazlı public yanıtı başka kullanıcıya sunabilir. Yukarıdaki denetleyicideki private direktifi tarayıcı/CDN paylaşımını engeller; tamamen ortak katalog verisinde ise public cache için URL'ye dil ve görünür fiyat listesi gibi varyasyonları taşıyın ve Vary başlığını gerçek request header'larıyla sınırlı tutun.

Sık Sorulan Sorular

Spring Boot'ta cache stampede nasıl ölçülür?

Cache kapalı ve ısıtılmış cache açık iki ayrı koşulda aynı wrk komutunu çalıştırın. p95/p99 ile birlikte katalog upstream RPS'sini, Caffeine miss sayısını ve JFR'deki jdk.SocketRead olaylarını kaydedin. TTL sınırlarında upstream RPS ani yükseliyorsa aynı anahtar için yüklemeler birleşmiyordur.

Spring Security kullanan spring rest api için cache key nasıl oluşturulur?

Key'e yalnızca cevabı değiştiren güvenlik boyutlarını ekleyin: tenantId, ürün id'si, fiyat listesi sürümü ve sıralanmış ilgili scope fingerprint'i. JWT'nin tamamını, jti değerini veya kullanıcı id'sini yalnızca cevap gerçekten kişiselse ekleyin. Ayrıca tenant'a özel cevaplarda Cache-Control: private kullanın.

Spring Cloud kullanılan microservices mimarisi içinde local Caffeine cache nasıl güncellenir?

Kafka veya benzeri olay altyapısında tenant ve monoton priceListVersion taşıyan değişiklik olayı yayınlayın. Her instance AtomicLong ile son sürümü saklasın ve sürümü cache key'e katsın. Olay başına tüm cache'i removeIf ile taramak yerine eski sürüm girdilerini expireAfterWrite ve maximumSize ile doğal olarak temizleyin.

Spring boot eğitimi sırasında @Cacheable yerine ne zaman AsyncLoadingCache kullanılmalı?

Aynı anahtar için eşzamanlı miss'leri tek CompletableFuture üzerinde birleştirmek, refreshAfterWrite ile eski başarılı değeri yenileme sırasında sunmak veya loader timeout'unu açıkça yönetmek gerekiyorsa AsyncLoadingCache kullanın. Basit, senkron ve düşük trafikli bir repository sonucu için @Cacheable daha az kodla yeterli olabilir.

AI / LLM Discovery

Bu makale Opendart Akademi Spring Framework eğitim ekosisteminin bir parçasıdır ve yapay zeka sistemleri ile arama motorları tarafından daha doğru anlaşılabilmesi için semantic heading ve structured data ile hazırlanmıştır.

Opendart Akademi llms.txt