Gymterview
senior

Как обеспечить согласованность кэша в распределённой системе?

Согласованность кэша в распределённой системе — это обеспечение того, чтобы все экземпляры приложения и микросервисы видели актуальные данные, несмотря на наличие локальных и распределённых кэшей.

Аналогия из жизни: представьте, что у каждого филиала компании есть свой прайс-лист (L1-кэш). Когда центральный офис (БД) меняет цены, нужен механизм оповещения всех филиалов — иначе клиенты в разных городах увидят разные цены.

Проблемы

  • Экземпляр A обновил БД и инвалидировал свой L1-кэш, но экземпляр B отдаёт старые данные из своего L1
  • Сервис A обновил пользователя, но сервис B кэширует старую версию
  • Race condition: два запроса одновременно обновляют кэш

Стратегии обеспечения согласованности

1. Только Redis (без L1)

Самый простой вариант — все читают/пишут в один Redis. Согласованность гарантирована.

Минус: каждое чтение — сетевой вызов (~1 мс).

2. Redis Pub/Sub для инвалидации L1

Пример кода
// При обновлении — publish в канал
public void updateUser(User user) {
    userRepository.save(user);
    cache.invalidate(user.getId());                    // свой L1
    redis.delete("user:" + user.getId());              // L2
    redis.convertAndSend("cache:invalidate", "user:" + user.getId()); // все L1
}

// Все экземпляры подписаны на канал
@Component
public class CacheInvalidationSubscriber implements MessageListener {
    private final Cache<Long, User> localCache;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String key = new String(message.getBody()); // "user:42"
        Long id = Long.parseLong(key.split(":")[1]);
        localCache.invalidate(id);
    }
}

3. Event-driven через Kafka

Пример кода
// Сервис A → Kafka → Сервисы B, C, D
@TransactionalEventListener(phase = AFTER_COMMIT)
public void onUserUpdated(UserUpdatedEvent event) {
    kafkaTemplate.send("user-cache-invalidation", event.getUserId().toString());
}

// Каждый сервис-потребитель
@KafkaListener(topics = "user-cache-invalidation")
public void handleInvalidation(String userId) {
    cacheManager.getCache("users").evict(Long.parseLong(userId));
}

4. Короткий TTL для L1

Самый простой компромисс: L1 TTL = 30 секунд. Данные обновятся максимум через 30 секунд без дополнительной инфраструктуры.

Сравнение стратегий

Стратегия Задержка обновления Сложность Инфраструктура
Только Redis (без L1) 0 Низкая Redis
Redis Pub/Sub Миллисекунды Средняя Redis
Kafka events Секунды Высокая Kafka
Короткий TTL До TTL секунд Низкая Ничего

Ключевые принципы

  • Строгая согласованность (strong consistency) невозможна с кэшем — кэш по определению eventual consistent
  • Приемлемая задержка — ключевой вопрос: 30 секунд устаревших данных допустимо для каталога, но не для баланса
  • Для критичных данных (баланс, инвентарь) — не кэшировать или использовать только Redis с Write-Through

Частые ошибки

  • Кэшировать мутабельные критичные данные — баланс счёта, количество товара не должны кэшироваться в L1
  • Redis Pub/Sub без обработки пропусков — если экземпляр был недоступен, он пропустит сообщение; TTL как fallback обязателен
  • Полагаться только на TTL — для важных данных TTL 10 минут = до 10 минут stale data

Как используется в 2026

  • Redis Pub/Sub для L1-инвалидации — простой и эффективный подход
  • Kafka для межсервисной инвалидации — стандарт в микросервисах
  • Короткий L1 TTL (30с-2мин) — pragmatic подход, когда Pub/Sub избыточен

На собеседовании: интервьюер проверяет опыт работы с распределёнными системами. Ключевой ответ: строгая согласованность с кэшем невозможна, поэтому нужно выбрать приемлемый уровень eventual consistency. Частая ошибка — предложить только TTL и не упомянуть Pub/Sub или Kafka.