Опирается на правила: R-CACHE-INV-1R-CACHE-INV-4 и R-CACHE-INV-X1R-CACHE-INV-X3 из Caching Style Guide → раздел 5. Invalidation.

Важно знать

  • На каждом write-методе того же агрегата — @CacheEvict с явным key.
  • Несколько кешей@Caching композит с массивом evict.
  • Доменные события@EventListener + @CacheEvict пустой метод-marker, паттерн «invalidation as side-effect».
  • Distributed invalidation в Redis backend — встроено, отдельной обвязки не нужно.
  • @CacheEvict(allEntries = true) — только при админских операциях (rebuild). Иначе холодный старт = DB-спайк.
  • Только TTL для consistency — приемлемо для feature-flags, неприемлемо для money/orders.
  • Eventual consistency без декларации в OpenAPI — нарушение контракта.

Invalidation — единственная защита от stale-data. TTL даёт верхнюю границу задержки; invalidation делает задержку нулевой. Без invalidation кеш = «обещаем устаревшую правду на N минут».

@CacheEvict на write-методе

R-CACHE-INV-1: каждый write — invalidate соответствующий cache.

@UseCase
@RequiredArgsConstructor
public class UpdateUserProfileHandler implements UseCaseHandler<UpdateUserProfileCommand, UserProfile> {

    private final UserProfileRepository userProfileRepository;

    @Override
    @Transactional
    @CacheEvict(cacheNames = "user-profiles", key = "#command.userId()")
    public UserProfile handle(UpdateUserProfileCommand command) {
        var profile = userProfileRepository.findById(command.userId()).orElseThrow();
        profile.applyUpdate(command);
        return userProfileRepository.save(profile);
    }
}

SpEL правила те же, что в Ключи. Ключ инвалидации обязан совпадать с ключом кеширования; иначе invalidation промахивается.

Порядок выполнения: @CacheEvict срабатывает после успешного выполнения метода (beforeInvocation = false дефолт). Если method бросает exception — evict не происходит, cache остаётся с прежним (правильным) значением.

@Caching для нескольких кешей

R-CACHE-INV-2: одно изменение может затрагивать несколько проекций.

@Override
@Transactional
@Caching(evict = {
    @CacheEvict(cacheNames = "user-profiles", key = "#command.userId()"),
    @CacheEvict(cacheNames = "user-permissions", key = "#command.userId()"),
    @CacheEvict(cacheNames = "user-sessions", key = "#command.userId()")
})
public UserProfile handle(UpdateUserProfileCommand command) {
    // ...
}

@Caching — композит-аннотация Spring для случаев, когда @CacheEvict нужен несколько раз или вместе с @CachePut.

@EventListener + @CacheEvict

R-CACHE-INV-3: паттерн «invalidation as side-effect of domain event».

@Component
public class UserCacheInvalidationListener {

    @EventListener
    @CacheEvict(cacheNames = "user-profiles", key = "#event.userId()")
    public void onUserUpdated(UserUpdatedEvent event) {
    }

    @EventListener
    @Caching(evict = {
        @CacheEvict(cacheNames = "user-profiles", key = "#event.userId()"),
        @CacheEvict(cacheNames = "user-permissions", key = "#event.userId()")
    })
    public void onUserDeleted(UserDeletedEvent event) {
    }
}

Почему так:

  • Decouple. Handler не знает обо всех кешах, которые надо инвалидировать. Listener — единое место.
  • Множественные источники. Event может приходить от любого use case, который меняет User: UpdateUserHandler, BanUserHandler, MergeUsersHandler. Listener срабатывает всегда.
  • Распределённость. Если event приходит из Kafka (UserUpdatedEvent от другого сервиса) — listener инвалидирует кеш в этом сервисе.

Пустое тело метода — нормально. @CacheEvict срабатывает по факту вызова listener-а, бизнес-логики не нужно.

Distributed cache invalidation — встроено

R-CACHE-INV-4: RedisCacheManager сам по себе distributed.

pod-1: write → @CacheEvict → Redis DEL user-profiles::42
pod-2: next read → cache miss → load from DB → put в Redis

Redis — общий source of truth. Когда pod-1 evict-ит ключ, pod-2 это видит немедленно. Никакой Redis pub/sub для invalidation между pods не нужен.

Если используется локальный caching layer перед Redis (multi-tier cache: L1 = Caffeine на pod, L2 = Redis) — invalidation между L1 нужна явная через Redis pub/sub. Это редкий случай, в UCP-сервисах не дефолт.

Что запрещено

@CacheEvict(allEntries = true) без причины

R-CACHE-INV-X1: атомная бомба для кеша.

// ОПАСНО
@CacheEvict(cacheNames = "user-profiles", allEntries = true)
public void updateProfile(Long userId, ...) { ... }

Сценарий: 10000 пользователей в кеше, нагрузка 1000 read/s. Один write evict-ит всё → следующие 10000 уникальных reads все промахиваются → 10000 запросов в БД спайком → DB перегрузка → cascading.

Допустимо только при админских операциях:

  • Truncate / rebuild кеша.
  • После migration данных, когда структура изменилась.
  • В DEV-окружении для отладки.

В прод-handler-ах — никогда. Всегда key = "#командаId" для точечного evict.

Только TTL для consistency

R-CACHE-INV-X2: «подождёт минуту и обновится».

ДанныеТолько TTL — ок?
Feature flagsДа — задержка 60 сек терпима
CurrenciesДа — справочник меняется редко
User profileСпорно — пользователь видит свои старые данные после save
Order summariesНет — пользователь видит старый статус, путаница
User balanceНет — money, любая stale = инцидент

Для money/orders — @CacheEvict обязателен, TTL только backup.

Eventual consistency без декларации

R-CACHE-INV-X3: если endpoint может вернуть stale-data — это часть контракта, документируется в OpenAPI.

/users/{id}/profile:
  get:
    summary: Получить профиль пользователя
    description: |
      Возвращает профиль из кеша.

      **Eventual consistency**: после `PATCH /users/{id}/profile`
      возможна задержка до 15 минут (TTL кеша) до отражения
      изменений в этом endpoint. Для immediate consistency
      использовать `GET /users/{id}/profile?fresh=true`,
      обходящий кеш.
    responses:
      200:
        description: Профиль пользователя (может отставать на TTL)

Без декларации клиентский разработчик пишет тест PATCH + immediate GET и сообщает баг, который не баг — а архитектурный выбор.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
@CacheEvict(allEntries = true) в продеR-CACHE-INV-X1key = "..." точечно
Только TTL для money/ordersR-CACHE-INV-X2@CacheEvict на каждом write
Eventual consistency без OpenAPI декларацииR-CACHE-INV-X3description: 'Возможна задержка...'
@CacheEvict ключ не совпадает с @CacheableR-CACHE-INV-1те же SpEL правила
beforeInvocation = true дефолтомR-CACHE-INV-1дефолт false (после успеха)
Множественный @CacheEvict без @CachingR-CACHE-INV-2@Caching композит
Не инвалидирует кеш на event от KafkaR-CACHE-INV-3@EventListener для Kafka-events тоже
Redis pub/sub для инвалидации между podsR-CACHE-INV-4встроено в RedisCacheManager

Куда дальше

  • Caching → раздел 5. Invalidation — нормативные формулировки.
  • Где кешируем — что вообще кешировать.
  • Ключи — key = "..." SpEL.
  • TTL — TTL как backup для invalidation.
  • Паттерны — @CachePut write-through вместо evict.
  • Distributed → eventual consistency — декларация stale-data в OpenAPI.
  • DDD → domain events — откуда берётся UserUpdatedEvent.