Опирается на правила:
R-CACHE-INV-1…R-CACHE-INV-4иR-CACHE-INV-X1…R-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-X1 | key = "..." точечно |
| Только TTL для money/orders | R-CACHE-INV-X2 | @CacheEvict на каждом write |
| Eventual consistency без OpenAPI декларации | R-CACHE-INV-X3 | description: 'Возможна задержка...' |
@CacheEvict ключ не совпадает с @Cacheable | R-CACHE-INV-1 | те же SpEL правила |
beforeInvocation = true дефолтом | R-CACHE-INV-1 | дефолт false (после успеха) |
Множественный @CacheEvict без @Caching | R-CACHE-INV-2 | @Caching композит |
| Не инвалидирует кеш на event от Kafka | R-CACHE-INV-3 | @EventListener для Kafka-events тоже |
| Redis pub/sub для инвалидации между pods | R-CACHE-INV-4 | встроено в RedisCacheManager |
Куда дальше
- Caching → раздел 5. Invalidation — нормативные формулировки.
- Где кешируем — что вообще кешировать.
- Ключи —
key = "..."SpEL. - TTL — TTL как backup для invalidation.
- Паттерны —
@CachePutwrite-through вместо evict. - Distributed → eventual consistency — декларация stale-data в OpenAPI.
- DDD → domain events — откуда берётся
UserUpdatedEvent.