Опирается на правила:
R-CACHE-PATTERN-1…R-CACHE-PATTERN-3иR-CACHE-PATTERN-X1…R-CACHE-PATTERN-X2из Caching Style Guide → раздел 6. Паттерны.
Важно знать
- Cache-aside (lazy load + write evict) — дефолтный паттерн UCP. Read через
@Cacheable, write —@CacheEvict.- Write-through через
@CachePut— данные сразу нужны после write, тот же flow продолжает работать с ними.- Refresh-ahead через
@Scheduled— для критичных hot keys (главная страница, top-100). Перезаливает cache до истечения TTL, no cache miss.- Write-behind (запись в кеш, async в БД) — запрещён для money/critical: crash до flush = потеря данных.
- Один cache = один паттерн. Mix cache-aside и write-through на одном кеше = непонятная invalidation logic.
Три паттерна — это три точки на спектре «свежесть vs производительность». Cache-aside — простой компромисс; write-through — больше работы, но кеш всегда актуален; refresh-ahead — максимальная защита от cache miss ценой фоновой нагрузки.
Cache-aside — дефолт
R-CACHE-PATTERN-1: read через @Cacheable, write — @CacheEvict.
GET request → @Cacheable
│
├── HIT → return cached
│
└── MISS → load from DB → put in cache → return
POST/PATCH/DELETE → @CacheEvict → DB write → следующий read загрузит свежее
Реализация:
@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();
}
}
@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);
}
}
Свойства:
- Простой код.
@Cacheable+@CacheEvictбез custom-логики. - Кеш отстаёт от БД на TTL — write evict-ит, но между write одного pod-а и read другого может быть гонка. Для большинства случаев — acceptable.
- Холодный старт затратный. После рестарта или массового evict-а все read'ы промахиваются.
- Простой бэкап при отказе кеша. Если Redis недоступен, всё ходит в БД, сервис работает (с худшим latency).
Подходит для подавляющего большинства сценариев. Если нет конкретной причины выбрать другой паттерн — cache-aside.
Write-through через @CachePut
R-CACHE-PATTERN-2: на write кеш обновляется значением, возвращённым методом.
@UseCase
@RequiredArgsConstructor
public class UpdateUserProfileWriteThroughHandler implements UseCaseHandler<UpdateUserProfileCommand, UserProfile> {
private final UserProfileRepository userProfileRepository;
@Override
@Transactional
@CachePut(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);
}
}
@CachePut всегда выполняет метод (в отличие от @Cacheable) и кладёт результат в кеш.
Когда применять:
- High read + write ratio одного значения, где есть continuation. После
updateтот же пользователь сразу делаетget→ write-through даётgetмгновенно из кеша. - Не нужно promoting «следующий read должен переоткрыть значение из БД» — write-сторона уверена в результате.
Когда не применять:
- Write меняет состояние через несколько сервисов (saga). Write-through на одном шаге = неконсистентность с остальными.
- Метод не возвращает финальное закешированное значение (только id, или void).
Refresh-ahead
R-CACHE-PATTERN-3: фоновое обновление до истечения TTL.
@Component
@RequiredArgsConstructor
public class TopProductsRefreshJob {
private final ProductRepository productRepository;
private final CacheManager cacheManager;
@Scheduled(fixedDelay = 30_000)
public void refresh() {
var top = productRepository.findTop100();
var cache = cacheManager.getCache("top-products");
if (cache != null) {
cache.put("global", top);
}
}
}
Свойства:
- No cache miss for hot keys. Cache всегда заполнен; пользователи никогда не платят latency «load from DB».
- Постоянная нагрузка на БД. Каждые 30 секунд независимо от read-нагрузки.
- Подходит для известных hot keys. Главная страница, top-100 продуктов, dashboard — заранее знаем, что будут читать.
- Не подходит для unbounded keys. Если ключей миллион (
user-profiles) — refresh-ahead невозможен.
Combo: refresh-ahead для hot + cache-aside для остального — нормальная стратегия для больших систем.
Что запрещено
Write-behind для money/critical
R-CACHE-PATTERN-X1: write в кеш, async flush в БД позже.
1. Write → put в Redis (быстро)
2. Background job → flush Redis → DB (eventually)
3. Crash сервиса между шагом 1 и 2 → данные в Redis потеряны
Для money это потеря денег. Для orders — OrderConfirmed событие пропало, downstream не обновился. Для analytics (где потеря 1% событий приемлема) — write-behind допустим, но в UCP-сервисах его не используем.
// КАТАСТРОФА для money
@CachePut(cacheNames = "user-balances", key = "#userId")
public Money updateBalanceInCacheOnly(Long userId, Money newBalance) {
// нет save в БД
return newBalance;
}
Money/orders/critical — всегда write-through или cache-aside.
Mix паттернов на одном cache
R-CACHE-PATTERN-X2: cache user-profiles с разными паттернами в разных handlers.
// ПЛОХО — один кеш, разные паттерны
@CachePut(cacheNames = "user-profiles", key = "#userId") // write-through
public UserProfile update(Long userId, ...) { ... }
@CacheEvict(cacheNames = "user-profiles", key = "#userId") // cache-aside
public void rename(Long userId, ...) { ... }
Проблема: невозможно понять «какое поведение ожидать после write на эту запись». В update — кеш с новым значением; в rename — кеш сброшен, следующий read загрузит. Тесты на консистентность ломаются.
Один cache = один паттерн. Если нужны разные — два cache-name.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Write-behind для money/critical | R-CACHE-PATTERN-X1 | write-through или cache-aside |
Mix @CachePut и @CacheEvict на одном cache | R-CACHE-PATTERN-X2 | один cache = один паттерн |
| Refresh-ahead для unbounded keys | R-CACHE-PATTERN-3 | cache-aside для всех |
| Cache-aside с критичной latency для hot keys | R-CACHE-PATTERN-3 | refresh-ahead для hot |
@CachePut без возврата финального значения | R-CACHE-PATTERN-2 | вернуть UserProfile, не void |
@Scheduled refresh без cache.put(...) | R-CACHE-PATTERN-3 | явный cache.put(key, value) |
Куда дальше
- Caching → раздел 6. Паттерны — нормативные формулировки.
- Где кешируем — какие данные подходят под какой паттерн.
- Invalidation —
@CacheEvictдля cache-aside. - Cache stampede — refresh-ahead решает stampede по дизайну.
- TTL — refresh-ahead делает TTL «backup-ом».
- CQRS → read-model — refresh-ahead для read-проекций.