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 unlesscondition вычисляется ДО вызова метода (результат недоступен), 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» в реальном сервисе.

Sources

Related topics