Как обеспечить согласованность кэша в распределённой системе?
Согласованность кэша в распределённой системе — это обеспечение того, чтобы все экземпляры приложения и микросервисы видели актуальные данные, несмотря на наличие локальных и распределённых кэшей.
Аналогия из жизни: представьте, что у каждого филиала компании есть свой прайс-лист (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.