Опирается на правила:
R-CACHE-WHERE-1…R-CACHE-WHERE-3иR-CACHE-WHERE-X1…R-CACHE-WHERE-X5из Caching Style Guide → раздел 1. Где кешируем.
Важно знать
- Кеш — оптимизация, не часть бизнес-контракта. Сервис обязан работать корректно и без кеша.
- Если «кеш обязателен для корректности» — это бизнес-данные, надо хранить в БД, не в кеше.
- Read-heavy + редко меняющееся — да: справочники (часы), user profile (15-30 мин), feature flags (60 сек), heavy aggregations.
- Money — допустимо, но TTL ≤ 30 секунд +
@CacheEvictна каждом write того же ресурса.- Cache-aside (lazy load + write evict) — дефолтный паттерн.
- Доменный агрегат целиком — никогда: нарушает границы агрегата; invalidation становится сложной. Кешируем read-проекции (
OrderSummary).- JWT/ABAC валидация не кешируется руками: JWK уже встроен (
AUTH-5), ABAC — каждый раз (изменение roles → security risk).
Кеш экономит latency и нагрузку на БД, но добавляет stale-data риск и invalidation-сложность. UCP формулирует правила так, чтобы выгода кеша всегда покрывала эту цену. Главный вопрос перед @Cacheable — «что произойдёт, если значение в кеше будет на 30 секунд устаревшим, и кто это заметит?»
Read-heavy + редко меняющееся — кешируем
R-CACHE-WHERE-1: типичные кандидаты по характеру данных.
| Что | TTL | Пример |
|---|---|---|
| Справочники | часы | страны, валюты, тарифы, типы документов |
| User profile / settings | 15-30 минут | UserProfile, UserSettings |
| Feature flags | 30-60 секунд | FeatureFlagSet |
| Heavy aggregations | 5-10 минут | DailyReport, TopProducts |
| JWK Set | 5 минут | OAuth публичные ключи (AUTH-5 — встроено) |
Критерий: ratio read/write > 100:1, данные не критичны к immediate consistency, бизнес-смысл допускает задержку TTL.
@UseCase
@RequiredArgsConstructor
public class GetUserProfileHandler implements UseCaseHandler<GetUserProfileQuery, UserProfile> {
private final UserProfileRepository userProfileRepository;
@Override
@Cacheable(cacheNames = "user-profiles", key = "#query.userId()")
@Transactional(readOnly = true)
public UserProfile handle(GetUserProfileQuery query) {
return userProfileRepository.findById(query.userId())
.orElseThrow(() -> new UserProfileNotFoundException(query.userId()));
}
}
Money — допустимо, но строго
R-CACHE-WHERE-2: баланс, лимиты, available credit можно кешировать только с явной invalidation strategy.
- TTL 5-30 секунд (не больше).
@CacheEvictна каждом write-методе того же ресурса.- Для критичных операций (списание, перевод) —
cache.evict(...)перед чтением, явный обход кеша.
@UseCase
@RequiredArgsConstructor
public class GetUserBalanceHandler implements UseCaseHandler<GetUserBalanceQuery, Money> {
private final UserBalanceRepository balanceRepository;
@Override
@Cacheable(cacheNames = "user-balances", key = "#query.userId()")
@Transactional(readOnly = true)
public Money handle(GetUserBalanceQuery query) {
return balanceRepository.findBalance(query.userId());
}
}
@UseCase
@RequiredArgsConstructor
public class DebitUserBalanceHandler implements UseCaseHandler<DebitUserBalanceCommand, Money> {
private final UserBalanceRepository balanceRepository;
private final CacheManager cacheManager;
@Override
@Transactional
public Money handle(DebitUserBalanceCommand command) {
cacheManager.getCache("user-balances").evict(command.userId());
var balance = balanceRepository.findBalance(command.userId());
var updated = balance.subtract(command.amount());
balanceRepository.save(updated);
cacheManager.getCache("user-balances").evict(command.userId());
return updated;
}
}
Double-evict (до и после) защищает от race: другой запрос мог положить старое значение между evict и save.
Cache-aside — дефолтный паттерн
R-CACHE-WHERE-3: read через @Cacheable, write — @CacheEvict или cache.evict().
Request → check cache
│
├── HIT → return cached
│
└── MISS → load from DB → put in cache → return
Write → DB update → evict cache → next read loads fresh
Простой, читаемый, предсказуемый. Подробнее — Паттерны.
Что запрещено
Кеш на write-методах
R-CACHE-WHERE-X1: @Cacheable на createOrder(), confirmPayment() — нонсенс.
// КАТАСТРОФА
@Cacheable(cacheNames = "orders", key = "#command.customerId()")
public Order create(CreateOrderCommand command) {
return orderRepository.save(...);
}
Сценарий: клиент шлёт CreateOrderCommand(customerId=42, items=[...]). Первый раз handler срабатывает, создаёт заказ. Второй раз — customerId тот же, Spring возвращает закешированный Order из первого вызова. Реально новый заказ не создаётся, но клиент думает, что создан.
@Cacheable имеет смысл только для read-методов: «для тех же параметров — тот же результат». Write-операции порождают side-effects, кеш их прячет.
Кеш доменного агрегата целиком
R-CACHE-WHERE-X2: @Cacheable на OrderRepository.findById(id) → Order aggregate с List<OrderItem> + Payment + Shipment.
Проблемы:
- Нарушает границы агрегата. Aggregate сам управляет invariants; внешний кеш видит детский dirty state.
- Sensitive data. Aggregate может содержать поля, которые нельзя кешировать (PII, payment details).
- Invalidation hell. Любой child-update (
OrderItem.markShipped,Payment.markRefunded) должен evict-ить parent aggregate. Сложно отследить. - Размер. Полный aggregate с join'ами — несколько KB, кеш заполняется быстро.
Корректно — read-проекции:
@Cacheable(cacheNames = "order-summaries", key = "#query.orderId()")
public OrderSummary handle(GetOrderSummaryQuery query) {
return orderSummaryRepository.findById(query.orderId());
}
public record OrderSummary(
Long orderId,
Long customerId,
String customerName,
OrderStatus status,
int itemCount,
BigDecimal totalAmount
) {}
OrderSummary — небольшой record, без sensitive-данных, без child-collections. Кешируется без проблем.
Money без TTL/invalidation
R-CACHE-WHERE-X3: «кешируем баланс на час». Сценарий: пользователь потратил, баланс в БД 0₽, в кеше 1000₽ ещё 50 минут. Открывает приложение → видит 1000₽ → пытается потратить → отказ → пишет в support.
Money — отдельный класс данных, для него правила строже:
- TTL ≤ 30 секунд.
@CacheEvictна каждой write-операции.- Для критичных flows —
cache.evict()перед чтением (защита от race).
Кеш бизнес-критичных данных без trade-off
R-CACHE-WHERE-X4: «кешируем, потому что быстрее» — без оценки stale-data риска.
Перед @Cacheable отвечаем:
- Что произойдёт при stale на TTL длительности? Кто пострадает?
- Можно ли это терпеть? (Список валют — да; счёт пользователя — нет.)
- Есть ли явная invalidation strategy?
Если три «да» — кешируем. Иначе — нет.
Кеш валидации/авторизации
R-CACHE-WHERE-X5: соблазн кешировать «у пользователя 42 есть роль ADMIN» на 10 минут — security risk.
Сценарий: admin лишил пользователя 42 роли в 10:00. До 10:10 пользователь продолжает выполнять admin-действия, потому что кеш на 10 минут.
JWT-валидация имеет встроенный JWK кеш (5 минут — AUTH-5). ABAC-проверки (@PreAuthorize("@access.canEditOrder(authentication, #orderId)")) делаются каждый раз, без кеша. Цена — пара ms на запрос, выгода — мгновенный revoke.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
@Cacheable на write-методе | R-CACHE-WHERE-X1 | только read, write — @CacheEvict |
| Кеш доменного агрегата целиком | R-CACHE-WHERE-X2 | read-проекции (OrderSummary) |
| Money-кеш без TTL/invalidation | R-CACHE-WHERE-X3 | TTL ≤ 30s + явный evict |
| Кеш бизнес-данных без trade-off оценки | R-CACHE-WHERE-X4 | три вопроса перед @Cacheable |
| Кеш JWT/ABAC-валидации руками | R-CACHE-WHERE-X5 | JWK встроен; ABAC — каждый раз |
| Cache-aside для write-heavy | R-CACHE-WHERE-3 | не кешируй вообще |
| Кеш > 1 минуты для money без double-evict | R-CACHE-WHERE-2 | TTL ≤ 30s + evict before/after write |
Куда дальше
- Caching → раздел 1. Где кешируем — нормативные формулировки.
- Конфигурация — RedisCacheManager, JSON serializer.
- TTL — типовые значения, политики истечения.
- Invalidation —
@CacheEvict,@EventListener, distributed. - Паттерны — cache-aside vs write-through vs refresh-ahead.
- CQRS → read-model — почему read-проекция, а не aggregate.
- Auth → AUTH-5 — встроенный JWK кеш.