Опирается на правила: R-CACHE-PATTERN-1R-CACHE-PATTERN-3 и R-CACHE-PATTERN-X1R-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/criticalR-CACHE-PATTERN-X1write-through или cache-aside
Mix @CachePut и @CacheEvict на одном cacheR-CACHE-PATTERN-X2один cache = один паттерн
Refresh-ahead для unbounded keysR-CACHE-PATTERN-3cache-aside для всех
Cache-aside с критичной latency для hot keysR-CACHE-PATTERN-3refresh-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-проекций.