Spring Boot In Memory Cache - Caffeine

Cache, en basit haliyle veriyi önbellekte saklayan ve tekrar ihtiyaç duyulduğunda, ek bir işlem gerektirmeden hızlıca bu önbellekten getiren bir mekanizmadır. Normal şartlarda bir veriye erişmek istediğimizde doğrudan veritabanına sorgu atarız. Ancak bu, özellikle yoğun erişimlerde, veritabanına sürekli sorgu atılmasına ve dolayısıyla performansın düşmesine neden olur.
Çok konuşma kodu ver diyenler için: github
Örneğin, uygulama ilk açıldığında çekilmesi gereken konfigürasyon verilerini sağlayan bir servisinizi düşünün. Bu servise çok sayıda kullanıcı aynı anda eriştiğinde veritabanına olan yük artar ve sistem performansı olumsuz etkilenir. Ancak bu tür, sık değişmeyen veriler için cache kullanmak, hem veritabanı yükünü azaltır hem de uygulama performansını ciddi ölçüde artırır.
Cache sistemleri kullanım senaryolarına göre farklı şekillerde uygulanabilir. En yaygın cache türleri şunlardır:
- In-Memory Cache
- Disk Cache
- Browser Cache
- Database Cache
- Distributed Cache
Spring Boot içerisinde cache işlemleri için farklı kütüphaneler kullanılabilir. En yaygın olanlar şunlardır:
- Guava
- Caffeine
- Redis
Bu yazıda, basit ve hafif bir çözüm olan Caffeine Cache’i ele alacağız.
Eğer herhangi bir ek kütüphane tanımlamazsanız, Spring Boot varsayılan olarak ConcurrentHashMap
kullanır. Ancak bu yapının TTL (Time To Live) gibi gelişmiş yapılandırma seçenekleri yoktur. Bu yüzden, TTL ve maksimum boyut gibi gelişmiş özellikler sunan Caffeine cache tercih edilmiştir.
İşe gerekli depencyleri eklemekle başlayalım:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
Cache i aktifleştirmek için @EnableCaching
anotasyonu kullanmalıyız:
@EnableCaching
@SpringBootApplication
public class SpringBootStarterTemplateApplication {
public static void main(String[] args) {
SpringApplication.run(SpringBootStarterTemplateApplication.class, args);
}
}
Cache'e ait logları daha iyi görebilmek adına application.properties
dosyamıza aşağıdaki configurasyonu ekleyelim:
logging.level.org.springframework.cache=TRACE
Cache işlemleri için, metodlarin uzerine ekleyecegimiz bazı anotasyonlar vardır. Bunlar:
@Cacheable: Metodun sonuçlarını önbelleğe alır, aynı parametrelerle tekrar çağrıldığında metodu çalıştırmadan önbellekten sonuç döndürür.
@CachePut: Metodu her zaman çalıştırır ve sonucu önbelleğe kaydeder/günceller.
- Not:
@Cacheable
'dan farkı metodu atlamamasıdır, her zaman çalışır.
@CacheEvict: Önbellekteki verileri siler.
- Not:
allEntries
parametresi tüm önbellek verilerini siler
Tüm Anotasyonlarda Ortak Parametreler
value/cacheNames
: Önbellek adlarıkey
: Önbellek anahtarı için kullanılacak SpEL ifadesikeyGenerator
: Özel anahtar üreteç sınıfıcacheManager
: Kullanılacak önbellek yöneticisicacheResolver
: Önbellek çözümleyicicondition
: Önbellekleme koşuluunless
: Önbellekleme yapılmayacak koşul
Aşağıda örnek kullanımını görebilirsiniz:
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
// Caches the result; skips DB call if data is in cache
// key also can be like concat of => key = "'entity-' + #id"
@Cacheable(value = "products", key = "#id")
public Product getProductById(Long id) {
log.info("Fetching from DB...");
return productRepository.findById(id).orElse(null);
}
// Always updates the DB and cache
@CachePut(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
log.info("Updating product and cache...");
return productRepository.save(product);
}
// Deletes the product from DB and evicts the cache entry
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
log.info("Deleting from DB and cache...");
productRepository.deleteById(id);
}
// clears entire cache
@CacheEvict(value = "products", allEntries = true)
public void clearAllCache() {
log.info("All cache entries cleared.");
}
}
Not: Eğer aynı servis içerisinde cache
kullanan bir methodu çağırmanız gerekirse, direkt olarak metodu çağırdığınızda cache'in çalışmadığını göreceksiniz. Bu sorunu çözmek için aşağıdaki yöntemi kullanabilirsiniz:
import org.springframework.context.ApplicationContext;
...
public class MyClass {
private final ApplicationContext applicationContext;
private MyClass self() {
return applicationContext.getBean(MyClass.class);
}
public void doSomethingWithCachedInSameClass(Long id) {
// Use self-invocation to ensure @Cacheable is applied
var existing = self().getById(id).orElseThrow(
() -> new EntityNotFoundException("Entity not found"));
log.info(existing.toString());
}
@Cacheable(key = "#id")
public Optional<MyEntity> getById(Long id) {
return myEntityRepo.findById(id);
}
Peki production ortaminda direkt bu halde mi kullanılıyor? Çoğunlukla hayır. Bunu daha yönetilebilir bir şekilde kullanmak gerek. Bu sepeble öncelikle bir CacheNames
sınıfı oluşturalım ve kullanacağımız cache isimlerini ekleyelim:
public class CacheNames {
public static final String PRODUCTS = "products";
public static final String CACHE_USER_PREFERENCES = "user_preferences";
public static final String CACHE_USER_SESSIONS = "user_sessions";
public static final String CACHE_TEMPORARY_TOKENS = "temporary_tokens";
public static List<String> getAllCacheNames() {
return Arrays.asList(PRODUCTS,
CACHE_USER_PREFERENCES,
CACHE_USER_SESSIONS,
CACHE_TEMPORARY_TOKENS);
}
}
Ardından bir cache managerı sınıfı oluşturalım:
cache.short.ttl=60
cache.medium.ttl=120
cache.default.max-size=1000
//@EnableCaching
@Configuration
public class CaffeineCacheConfig {
// Cache Manager Bean Names
public static final String CACHE_MANAGER_SHORT_LIVED = "shortLivedCacheManager";
public static final String CACHE_MANAGER_MEDIUM_LIVED = "mediumLivedCacheManager";
@Primary
@Bean(CACHE_MANAGER_SHORT_LIVED)
public CacheManager shortLivedCacheManager(
@Value("${cache.short.ttl:60}") int shortTtlSeconds,
@Value("${cache.default.max-size:1000}") int defaultMaxSize
) {
return buildCacheManager(shortTtlSeconds, defaultMaxSize);
}
@Bean(CACHE_MANAGER_MEDIUM_LIVED)
public CacheManager mediumLivedCacheManager(
@Value("${cache.medium.ttl:120}") int mediumTtlSeconds,
@Value("${cache.default.max-size:1000}") int defaultMaxSize
) {
return buildCacheManager(mediumTtlSeconds, defaultMaxSize);
}
private CacheManager buildCacheManager(int ttlSeconds, int maxSize) {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCacheNames(CacheNames.getAllCacheNames());
cacheManager.setCaffeine(
Caffeine.newBuilder()
.expireAfterWrite(ttlSeconds, TimeUnit.SECONDS)
.maximumSize(maxSize)
);
return cacheManager;
}
}
Burada iki farklı manager oluşturduk. Birisi uzun süren cache işlemeri diğeri ise kısa sürenler için. Ek olarak @Primary
anotasyonu ile de, cache manager verilmeme durumunda default olarak kısa süreli cache manager ın seçilmesini ayarladık.
Aşağıda, cache in kullanıldığı daha kapsamlı bir örneği bulabilirsiniz:
@Service
@Slf4j
public class CacheDemoService {
// Simulate database
private final Map<String, Object> mockDatabase = new HashMap<>();
/**
* Basic caching example using short-lived cache (default)
*/
@Cacheable(value = CacheNames.CACHE_PRODUCTS, key = "#id")
public String getProductData(String id) {
log.info("Cache MISS for product ID: {} - This would be a database call", id);
simulateSlowOperation();
return "Product data for " + id + " at " + System.currentTimeMillis();
}
/**
* Caching example with medium-lived cache manager
*/
@Cacheable(
value = CacheNames.CACHE_USER_PREFERENCES,
key = "#userId",
cacheManager = CaffeineCacheConfig.CACHE_MANAGER_MEDIUM_LIVED
)
public String getUserPreferences(String userId) {
log.info("Cache MISS for user preferences, userId: {} - This would be a database call", userId);
simulateSlowOperation();
return "User preferences for " + userId + " at " + System.currentTimeMillis();
}
/**
* Cache update example using CachePut
*/
@CachePut(value = CacheNames.CACHE_USER_SESSIONS, key = "#sessionId")
public String updateSession(String sessionId, String data) {
log.info("Updating session data for sessionId: {}", sessionId);
String sessionData = data + " (updated at " + System.currentTimeMillis() + ")";
mockDatabase.put(sessionId, sessionData);
return sessionData;
}
/**
* Retrieve session data with caching
*/
@Cacheable(value = CacheNames.CACHE_USER_SESSIONS, key = "#sessionId")
public String getSessionData(String sessionId) {
log.info("Cache MISS for session data, sessionId: {} - This would be a database call", sessionId);
simulateSlowOperation();
return mockDatabase.getOrDefault(sessionId, "No session data found").toString();
}
/**
* Cache eviction example - remove specific entry
*/
@CacheEvict(value = CacheNames.CACHE_PRODUCTS, key = "#id")
public void invalidateProductCache(String id) {
log.info("Invalidating cache for product ID: {}", id);
}
/**
* Multiple cache operations example
*/
@Caching(evict = {
@CacheEvict(value = CacheNames.CACHE_USER_PREFERENCES, key = "#userId"),
@CacheEvict(value = CacheNames.CACHE_USER_SESSIONS, key = "#sessionId")
})
public void logoutUser(String userId, String sessionId) {
log.info("User logout - clearing user-related caches for userId: {}, sessionId: {}", userId, sessionId);
// Perform actual logout operations
}
/**
* Clear all entries from a cache
*/
@CacheEvict(value = CacheNames.CACHE_TEMPORARY_TOKENS, allEntries = true)
public void clearAllTokens() {
log.info("Clearing all temporary token cache entries");
}
/**
* Clear all entries from multiple caches
*/
@Caching(evict = {
@CacheEvict(value = CacheNames.CACHE_PRODUCTS, allEntries = true),
@CacheEvict(value = CacheNames.CACHE_USER_PREFERENCES, allEntries = true),
@CacheEvict(value = CacheNames.CACHE_USER_SESSIONS, allEntries = true),
@CacheEvict(value = CacheNames.CACHE_TEMPORARY_TOKENS, allEntries = true)
})
public void clearAllCaches() {
log.info("Clearing ALL cache entries from all caches");
}
/**
* Conditional caching example
*/
@Cacheable(
value = CacheNames.CACHE_TEMPORARY_TOKENS,
key = "#token",
condition = "#token.length() > 10"
)
public String validateToken(String token) {
log.info("Cache MISS for token validation, token: {} - This would be a database call", token);
simulateSlowOperation();
return "Token " + token + " is " + (token.length() > 15 ? "valid" : "invalid");
}
/**
* Generate a random token for testing
*/
public String generateToken() {
String token = UUID.randomUUID().toString();
log.info("Generated new token: {}", token);
return token;
}
/**
* Helper method to simulate slow database operations
*/
private void simulateSlowOperation() {
try {
// Simulate database latency
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
https://www.spiceworks.com/tech/tech-101/articles/what-is-cache
https://docs.spring.io/spring-boot/reference/io/caching.html#io.caching.provider.simple