Spring BootMiddleCoding
Как реализовать кэширование в Spring Boot с помощью @Cacheable, @CacheEvict и @CachePut?
Spring Cache Abstraction использует @Cacheable для чтения из кэша, @CachePut для принудительного обновления и @CacheEvict для инвалидации. Нужно добавить @EnableCaching и настроить CacheManager (Caffeine, Redis и т.д.).
Кэширование в Spring Boot: @Cacheable, @CacheEvict, @CachePut
Spring Cache Abstraction предоставляет декларативный кэш через аннотации, независимо от конкретного провайдера. Провайдер подключается через CacheManager — в продакшне обычно Caffeine (in-memory) или Redis (распределённый).
Подключение и активация
<!-- Caffeine (in-memory) -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- Redis (distributed) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@SpringBootApplication
@EnableCaching // обязательно — без этого аннотации игнорируются
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Настройка CacheManager
# Caffeine через application.yml
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=1000,expireAfterWrite=10m
# Redis
spring:
cache:
type: redis
redis:
time-to-live: 600000 # 10 минут в мс
cache-null-values: false
Для Caffeine с разными настройками под разные кэши используйте Java-конфигурацию:
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCacheNames(List.of("users", "products", "categories"));
manager.registerCustomCache("users",
Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats() // для мониторинга через Micrometer
.build());
manager.registerCustomCache("products",
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofHours(1))
.build());
return manager;
}
}
@Cacheable — чтение с кэшированием результата
@Service
public class ProductService {
// Простое кэширование по одному ключу
@Cacheable(cacheNames = "products", key = "#id")
public Product findById(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
// Составной ключ
@Cacheable(cacheNames = "products", key = "#category + ':' + #page")
public Page<Product> findByCategory(String category, int page, int size) {
return productRepository.findByCategory(category, PageRequest.of(page, size));
}
// Условное кэширование — только для активных продуктов
@Cacheable(
cacheNames = "products",
key = "#id",
condition = "#id > 0",
unless = "#result.status == T(com.example.ProductStatus).DRAFT"
)
public Product findActiveById(Long id) {
return productRepository.findById(id).orElseThrow();
}
}
@CachePut — принудительное обновление кэша
@CachePut всегда выполняет метод и записывает результат в кэш. Используется при обновлении данных, чтобы кэш не устарел:
@CachePut(cacheNames = "products", key = "#result.id")
public Product updateProduct(Long id, UpdateProductRequest req) {
Product product = productRepository.findById(id).orElseThrow();
product.setName(req.getName());
product.setPrice(req.getPrice());
return productRepository.save(product);
// результат автоматически запишется в кэш
}
@CacheEvict — инвалидация кэша
// Удалить конкретную запись
@CacheEvict(cacheNames = "products", key = "#id")
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
// Очистить весь кэш (например, при импорте данных)
@CacheEvict(cacheNames = "products", allEntries = true)
public void importProducts(List<Product> products) {
productRepository.saveAll(products);
}
// Очистить ДО выполнения метода (beforeInvocation = true)
// Полезно когда метод может выбросить исключение
@CacheEvict(cacheNames = "users", key = "#userId", beforeInvocation = true)
public void deactivateUser(Long userId) {
userRepository.deactivateById(userId);
}
// Комбинирование нескольких операций через @Caching
@Caching(
evict = {
@CacheEvict(cacheNames = "products", key = "#id"),
@CacheEvict(cacheNames = "categories", allEntries = true)
}
)
public void deleteProductAndInvalidateCategories(Long id) {
productRepository.deleteById(id);
}
SpEL-выражения для ключей
// Доступ к полям аргумента
@Cacheable(cacheNames = "users", key = "#request.userId")
public User findUser(UserRequest request) { ... }
// Использование имени метода в ключе
@Cacheable(cacheNames = "reports", key = "#root.methodName + ':' + #year")
public Report generateReport(int year) { ... }
// Кастомный KeyGenerator
@Cacheable(cacheNames = "search", keyGenerator = "searchKeyGenerator")
public List<Product> search(SearchCriteria criteria) { ... }
Подводные камни
- Self-invocation не работает — вызов
this.findById(id)внутри того же бина обходит Spring AOP proxy; кэш не сработает. Инжектируйте бин сам в себя или выносите метод в отдельный бин. - @EnableCaching забыт — без этой аннотации на конфигурационном классе все кэш-аннотации молча игнорируются; метод выполняется каждый раз.
- Сериализация в Redis — по умолчанию Spring Data Redis использует
JdkSerializationRedisSerializer, который требуетSerializable. При смене версии класса кэш содержит несовместимые данные. ИспользуйтеGenericJackson2JsonRedisSerializer. - condition vs unless —
conditionвычисляется ДО вызова метода (результат недоступен),unless— ПОСЛЕ (результат в#result). Использование#resultвconditionвсегда даст null. - null в кэше — по умолчанию
spring.cache.redis.cache-null-values=true. Если метод возвращает null (не найдено), null кэшируется и отдаётся при следующих запросах. Отключайте или обрабатывайте явно. - TTL по умолчанию бесконечен — без явного
expireAfterWrite(Caffeine) илиtime-to-live(Redis) кэш никогда не инвалидируется. Это приводит к устаревшим данным после рестарта только одного из сервисов. - Cache stampede при высоком трафике — если кэш протухает, все одновременные запросы идут в БД. Используйте Caffeine
refreshAfterWriteили распределённую блокировку для предотвращения stampede. - @Transactional + @CacheEvict порядок — при
beforeInvocation = false(по умолчанию) инвалидация происходит после commit транзакции. Если транзакция откатится, кэш всё равно будет удалён — это корректно, но нужно понимать порядок.
Common mistakes
- Путать термин «spring boot caching» с соседним механизмом Spring Boot.
- Не называть границу lifecycle, transaction, thread или request для «spring boot caching».
- Игнорировать production-эффекты «spring boot caching»: latency, SQL shape, memory, security или observability.
What the interviewer is testing
- Попросить объяснить механизм «spring boot caching» на минимальном примере.
- Проверить, видит ли кандидат failure mode и диагностику для «spring boot caching».
- Уточнить, какие настройки или API меняют «spring boot caching» в реальном сервисе.