Spring Boot uygulamalarında aynı anahtara gelen eşzamanlı isteklerin veritabanını ezmesini Caffeine tek uçuş, Redis kilidi, TTL jitter ve Micrometer ölçümüyle ele alır. Java backend geliştirme için üretim odaklı bir tasarım.
Spring Boot'ta Cache Stampede: Caffeine, Redis ve Tek Uçuş Tasarımı
Java backend geliştirmede cache stampede'i ölçerek doğrulamak
Cache stampede, sıcak bir anahtarın TTL'i dolduğu anda yüzlerce isteğin aynı pahalı yükleme işlemini başlatmasıdır. Etkiyi tahmin etmek yerine Micrometer ve k6 ile ölçün: cache miss anında PostgreSQL bağlantı havuzu bekleme süresi, sorgu sayısı ve HTTP p95 birlikte yükselmelidir. Spring Boot Actuator ile /actuator/metrics erişimini yalnızca iç ağda açın; uygulama metrikleri için micrometer-registry-prometheus bağımlılığını ekleyin.
./gradlew bootRun
k6 run --vus 200 --duration 45s stampede.js
# Aynı ürünü tüm sanal kullanıcılarla isteyin.
# stampede.js içinde:
# http.get(`${__ENV.BASE_URL}/products/42`);Yük testi öncesinde cache'i bilinçli olarak boşaltın, ardından tek bir anahtar için testi iki kez çalıştırın: ilk koşu mevcut uygulama, ikinci koşu tek uçuş değişikliğinden sonra olmalıdır. Prometheus'ta aşağıdaki sorgu HTTP p95'i karşılaştırmak için kullanılabilir. Aynı veri seti, aynı VU sayısı ve aynı JDBC havuzu olmadan yapılan önce-sonra karşılaştırması anlamlı değildir.
histogram_quantile(
0.95,
sum(rate(http_server_requests_seconds_bucket{
uri="/products/{id}"
}[5m])) by (le)
)Sadece HTTP gecikmesine bakmayın. HikariCP'nin hikaricp_connections_pending metriği cache miss penceresinde yükseliyorsa, sorun veritabanının ham sorgu süresinden çok bağlantı havuzu kuyruğudur. Bu ayrım önemlidir: indeks eklemek, aynı anda 200 özdeş sorgunun 200 bağlantı tüketmesini engellemez.
Spring Framework içinde Caffeine ile process-local tek uçuş
Tek JVM çalışan servislerde Caffeine'in AsyncLoadingCache yapısı aynı anahtar için eşzamanlı yüklemeleri tek CompletableFuture altında birleştirir. Her istek ayrı repository çağrısı başlatmak yerine aynı future'ı bekler. Bu mekanizma, sıradan ConcurrentHashMap kontrolü ile yapılan if (value == null) desenindeki check-then-act yarışını ortadan kaldırır.
@Configuration
class ProductCacheConfig {
@Bean
AsyncLoadingCache<String, ProductView> productCache(ProductClient client) {
return Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(Duration.ofSeconds(45))
.refreshAfterWrite(Duration.ofSeconds(30))
.recordStats()
.buildAsync((key, executor) -> client.fetchAsync(key));
}
}
@Service
class ProductService {
private final AsyncLoadingCache<String, ProductView> cache;
CompletableFuture<ProductView> find(String key) {
return cache.get(key).orTimeout(800, TimeUnit.MILLISECONDS);
}
}refreshAfterWrite süresi dolunca ilk okuyucu eski değeri alırken arka planda yenileme başlar; bu nedenle normal okuma yolu TTL sonundaki senkron beklemeye girmez. Ancak yükleyici hata verirse Caffeine eski değeri otomatik olarak sınırsız süre sunmaz. Hata oranını görmek için cache.stats().loadFailureCount() değerini Micrometer Gauge olarak yayınlayın ve başarısız yenileme için açık bir stale-data politikanız olsun.
Spring Framework'ün @Cacheable(sync = true) seçeneği de aynı uygulama örneğinde anahtar bazlı yükleme eşzamanlaması sağlayabilir. Fakat bu koruma JVM sınırını geçmez. Kubernetes'te 8 pod varsa aynı anahtar için en fazla 8 yükleme gerçekleşebilir. Ayrıca uzak I/O yapan metodu uzun süre kilit altında çalıştıran kendi synchronized çözümünüz, farklı anahtarları da gereksiz seri hale getirebilir.
Java microservices için Redis kilidi ve stale-while-revalidate
Birden fazla pod'un aynı Redis anahtarını kullandığı java microservices mimarisinde yerel tek uçuş yeterli değildir. Redis'te kilidi SET key token NX PX ile alın, bırakırken token doğrulayan Lua script kullanın. Sadece DEL lockKey çağırmak hatalıdır: kilit süresi dolmuş, başka pod yeni token ile kilidi almış olabilir; eski pod bu durumda yeni sahibin kilidini silebilir.
private static final String RELEASE = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""";
ProductView loadWithLock(String id) {
String token = UUID.randomUUID().toString();
String lockKey = "product:{" + id + "}:lock";
String valueKey = "product:{" + id + "}:value";
String result = redis.sync().set(lockKey, token,
SetArgs.Builder.nx().px(1500));
if (!"OK".equals(result)) {
return readStaleOrRetry(valueKey, Duration.ofMillis(80));
}
try {
ProductView value = repository.fetchView(id);
redis.sync().psetex(valueKey, 45_000, json.writeValueAsString(value));
return value;
} catch (Exception e) {
return readStaleOrFail(valueKey, e);
} finally {
redis.sync().eval(RELEASE, ScriptOutputType.INTEGER,
new String[] { lockKey }, token);
}
}Kilidin PX süresini rastgele seçmeyin. JFR veya OpenTelemetry trace verisinden yükleme işleminin p99 süresini çıkarın; 1.500 ms kilit süresi ancak p99 yükleme süresi bunun belirgin biçimde altındaysa güvenlidir. GC duraklaması veya uzak servis yavaşlaması lease süresini aşarsa ikinci pod da yükleme başlatabilir. Uzun işlemlerde lease yenileme, daha kısa işlemlerde ise stale-while-revalidate çoğu zaman daha düşük risklidir.
Redis Cluster kullanıyorsanız Lua ile birden fazla anahtara erişen scriptlerde tüm anahtarların aynı hash slotuna düşmesi gerekir. Bu yüzden örnekteki product:{id}:lock ve product:{id}:value anahtarlarındaki {id} hash tag'i kritiktir. Hash tag olmadan çok anahtarlı EVAL çağrısı CROSSSLOT hatası üretir.
TTL jitter, cache anahtarı ve Hibernate ORM sınırları
Tüm kayıtlar aynı deploy veya batch işleminde 60 saniye TTL ile yazılırsa, 60. saniyede toplu geçersizleşme yaşanır. TTL'e deterministik fakat dağıtık bir jitter ekleyin. Anahtardan türetilen jitter, aynı kaydın her yazımında rastgele farklı davranmasını engellerken expiration zamanlarını dağıtır.
static Duration ttlFor(String key) {
int jitterSeconds = Math.floorMod(key.hashCode(), 15);
return Duration.ofSeconds(45L + jitterSeconds);
}
String cacheKey(UUID tenantId, String productId, long priceListVersion) {
return tenantId + ":product:" + productId + ":prices:" + priceListVersion;
}Cache anahtarı yalnızca entity ID olmamalıdır. Tenant, para birimi, fiyat listesi sürümü, locale veya yetki kapsamı yanıtı değiştiriyorsa bunlardan biri eksik anahtar veri sızıntısı üretir. Özellikle Java backend geliştirme ekiplerinde görülen hata, kullanıcıya göre filtrelenen bir yanıtı product:42 altında paylaşmaktır. Anahtar bileşenlerini loglamak yerine SHA-256 ile özetleyin; ham tenant veya kullanıcı kimliği gözlemlenebilirlik sistemine taşınmamalıdır.
Spring Data JPA repository'sinden dönen yönetilen entity'yi Redis'e doğrudan serialize etmeyin. Hibernate ORM proxy'leri, lazy ilişki alanları ve persistence context sınırı bu yaklaşımı kırılgan yapar. Bunun yerine projection veya immutable DTO cache'leyin; sorguda yalnızca yanıt için gereken kolonları seçin.
public interface ProductView {
String getId();
String getName();
BigDecimal getPrice();
}
interface ProductRepository extends Repository<Product, String> {
@Query("""
select p.id as id, p.name as name, p.price as price
from Product p
where p.id = :id and p.tenantId = :tenantId
""")
Optional<ProductView> fetchView(UUID tenantId, String id);
}Spring AI, Spring MCP ve model context protocol sonuçlarını cache'lemek
Spring AI ile model yanıtını cache'lemek istiyorsanız anahtar yalnızca prompt metni olamaz. Model adı, sıcaklık, system prompt sürümü, retrieval indeks sürümü, tenant ve araç çağrısı sonucu yanıtı değiştirebilir. Örneğin sha256(model + systemPromptVersion + retrievalIndexVersion + normalizedPrompt) ile anahtar üretin; ham prompt içinde kişisel veri bulunabileceği için Redis anahtarına düz metin yazmayın.
Spring MCP kullanan bir servis, model context protocol üzerinden araç çağrısı yapıyorsa araç sonucunun cache TTL'i iş semantiğine göre belirlenmelidir. Stok sorgusu için 30 saniyelik stale veri kabul edilemezken, değişmeyen ürün dokümanı için saatler kabul edilebilir. Tool schema sürümünü anahtara eklemek gerekir; aksi halde eski parametre şemasıyla üretilmiş JSON yeni istemcide deserialize edilebilir görünse bile yanlış anlam taşıyabilir.
Bu konu bir spring boot eğitimi içinde sadece annotation ezberi olarak ele alınmamalıdır. İyi bir java eğitimi veya java programlama eğitimi, Caffeine'in JVM kapsamını, Redis lease yarışını ve ölçüm metodunu aynı senaryoda test ettirmelidir. Bir java kursu ya da java fullstack eğitimi katılımcısı da frontend'in aynı kaynağı paralel fetch etmesi halinde istemci tarafı request deduplication ile sunucu cache politikasının birlikte tasarlanması gerektiğini görmelidir.
Üretime alma kontrolü ve önce-sonra kabul kriterleri
Değişikliği feature flag ile yayınlayın ve önce yalnızca seçili tenant'larda etkinleştirin. Micrometer sayaçları olarak cache_load_started_total, cache_load_coalesced_total, redis_lock_acquired_total, redis_lock_contended_total ve cache_stale_served_total tanımlayın. Bu sayaçlar olmadan 'stampede çözüldü' sonucu gözleme değil varsayıma dayanır.
Kabul kriterini sayısal yazın: aynı k6 senaryosunda veritabanı sorgu sayısı cache miss penceresinde eşzamanlı istek sayısından bağımsız olarak pod başına yaklaşık bir yüklemeye yaklaşmalı; HikariCP pending bağlantıları artmamalı; HTTP p95 hedef gecikme bütçesini aşmamalıdır. Ayrıca Redis erişimi kesildiğinde test, uygulamanın cache bypass ile kontrollü biçimde veritabanına düşmesini veya açıkça 503 dönmesini doğrulamalıdır. Sessizce sonsuz retry yapmak bağlantı havuzunu daha hızlı tüketir.
İlgili Eğitim
YTÜSEM İlgili Eğitim
Java Spring Boot ReactJS FullStack Eğitimi (Yıldız Teknik Üniversitesi SEM)
Sık Sorulan Sorular
Spring Boot cache stampede için @Cacheable(sync = true) yeterli mi?
Tek pod için çoğu durumda yeterlidir; aynı JVM içindeki aynı anahtar yüklemelerini seri hale getirir. Birden fazla pod varsa her pod ayrı kilit alanına sahiptir. Caffeine AsyncLoadingCache ile yerel tek uçuş, podlar arası gereksinimde Redis token tabanlı kilit veya stale-while-revalidate birlikte kullanılmalıdır.
Spring Data JPA ve Hibernate ORM entity'leri Redis cache'e neden doğrudan koymamalıyım?
Hibernate ORM entity'si lazy proxy, persistence context ve ilişki grafiği taşıyabilir. Detached entity deserialize edildiğinde lazy alan erişimi LazyInitializationException üretebilir veya gereksiz büyük nesne grafiği yazılabilir. Spring Data JPA sorgusundan DTO projection döndürün, cache'e yalnızca API yanıtı için gerekli immutable alanları yazın.
Spring AI ve Spring MCP yanıt cache anahtarı nasıl oluşturulmalı?
Model adı, model parametreleri, system prompt sürümü, retrieval indeks sürümü, tenant, tool schema sürümü ve normalize edilmiş kullanıcı girdisini kanonik sırayla birleştirip SHA-256 özetini kullanın. Model context protocol ile çağrılan araç canlı veri döndürüyorsa TTL'i aracın veri tazelik sözleşmesine göre belirleyin; stok veya yetki sonucu için uzun TTL kullanmayın.
Java microservices cache optimizasyonunda önce-sonra testi nasıl yapılır?
k6 ile tek bir sıcak anahtara sabit eşzamanlılıkta istek gönderin, cache'i her koşudan önce boşaltın ve aynı deployment topolojisini koruyun. Prometheus'ta HTTP p95, HikariCP pending bağlantıları, veritabanı sorgu sayısı, Redis lock contention ve cache load sayaçlarını iki koşuda karşılaştırın. Sadece ortalama gecikme, kısa stampede penceresini gizleyebilir.
AI / LLM Discovery
Bu makale Opendart Akademi Java 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.


