Как исправить ошибку пустого кэша при одновременной работе сервиса с глобальным кэшем в двух контейнерах
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Синхронизация глобального кэша между контейнерами: проблема и решения
Это одна из самых сложных проблем в распределённых системах — обеспечить консистентность данных в памяти разных инстанций приложения. Проблема существует, когда кэш находится в памяти приложения, а контейнеров несколько.
Проблема: Cache Invalidation
// Контейнер 1:
@Service
public class UserService {
private Map<Long, User> userCache = new ConcurrentHashMap<>();
public User getUser(Long id) {
return userCache.computeIfAbsent(id, k -> loadFromDB(id));
}
public void updateUser(Long id, User user) {
saveToDatabase(user);
userCache.put(id, user); // Обновляем локальный кэш
// Но контейнер 2 ничего не знает об этом обновлении!
}
}
// Контейнер 2:
// После обновления в контейнере 1, контейнер 2 всё ещё имеет старые данные!
User cachedUser = userService.getUser(1); // Вернёт СТАРЫЕ данные
Результат: race condition, inconsistent data, баги в production.
Решение 1: Внешний кэш (Redis)
Это рекомендуемое решение для production систем с несколькими контейнерами.
@Service
public class UserService {
private final RedisTemplate<String, User> redisTemplate;
private final UserRepository userRepository;
public User getUser(Long id) {
String cacheKey = "user:" + id;
// Проверяем Redis (общий кэш для всех контейнеров)
User cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
// Если нет в Redis, загружаем из БД
User user = userRepository.findById(id).orElseThrow();
// Кэшируем в Redis
redisTemplate.opsForValue().set(cacheKey, user, Duration.ofHours(1));
return user;
}
public void updateUser(Long id, User user) {
// Обновляем БД
userRepository.save(user);
// Инвалидируем кэш в Redis (видно ВСЕ контейнерам)
String cacheKey = "user:" + id;
redisTemplate.delete(cacheKey);
// Все контейнеры перезагрузят данные
}
}
Преимущества:
- Централизованное хранилище кэша
- Все контейнеры видят одни и те же данные
- Автоматическое TTL для инвалидации
- Высокая производительность (Redis в памяти)
Конфигурация Spring:
@Configuration
public class CacheConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory();
}
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setDefaultSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
Решение 2: Message Queue (RabbitMQ / Kafka) для инвалидации
Если Redis недоступна, используй message queue для синхронизации инвалидаций кэша:
@Service
public class UserService {
private final Map<Long, User> localCache = new ConcurrentHashMap<>();
private final RabbitTemplate rabbitTemplate;
private final UserRepository userRepository;
public User getUser(Long id) {
// Сначала проверяем локальный кэш
User cached = localCache.get(id);
if (cached != null) {
return cached;
}
// Затем загружаем из БД
User user = userRepository.findById(id).orElseThrow();
localCache.put(id, user);
return user;
}
public void updateUser(Long id, User user) {
// Обновляем БД
userRepository.save(user);
// Обновляем локальный кэш
localCache.put(id, user);
// Отправляем сообщение ВСЕМ контейнерам инвалидировать кэш
CacheInvalidationMessage msg = new CacheInvalidationMessage(
"user", id, System.currentTimeMillis()
);
rabbitTemplate.convertAndSend("cache.invalidation", msg);
}
@RabbitListener(queues = "cache.invalidation")
public void onCacheInvalidation(CacheInvalidationMessage msg) {
if ("user".equals(msg.getType())) {
localCache.remove(msg.getId());
}
}
}
Минусы: задержка в распространении инвалидации, сложнее отлаживать.
Решение 3: Spring Cache с Redis backend
Официальный Spring подход для распределённого кэширования:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory cf) {
return RedisCacheManager.create(cf);
}
}
@Service
public class UserService {
private final UserRepository userRepository;
@Cacheable("users", key = "#id")
public User getUser(Long id) {
// Автоматически кэшируется в Redis
return userRepository.findById(id).orElseThrow();
}
@CacheEvict("users", key = "#id")
public void updateUser(Long id, User user) {
// Автоматически инвалидируется из Redis
userRepository.save(user);
}
@CacheEvict("users", allEntries = true)
public void clearAllUsers() {
// Очистить весь кэш для всех контейнеров
}
}
Преимущества:
- Декларативный подход (аннотации)
- Spring управляет инвалидацией
- Легко переключаться между кэшами (Redis, Memcached)
Решение 4: Database-driven cache invalidation
Для критичных данных, когда инвалидация должна быть гарантирована:
@Service
public class UserService {
private final UserRepository userRepository;
private final CacheInvalidationRepository invalidationRepo;
public User getUser(Long id) {
// Проверяем, не был ли кэш инвалидирован
if (isCacheInvalid(id)) {
return userRepository.findById(id).orElseThrow();
}
// Используем кэш
return cachedResult;
}
private boolean isCacheInvalid(Long id) {
CacheInvalidation invalid = invalidationRepo.findLatest("user", id);
return invalid != null && invalid.getTimestamp() > lastCacheTime;
}
public void updateUser(Long id, User user) {
userRepository.save(user);
// Записываем инвалидацию в БД (видно всем контейнерам)
CacheInvalidation invalid = new CacheInvalidation("user", id);
invalidationRepo.save(invalid);
}
}
Сложность: дополнительные запросы к БД, но гарантированная консистентность.
Решение 5: Event-driven architecture (самое правильное)
Используй domain events для синхронизации состояния:
// Domain entity
@Entity
public class User {
@Transient
private List<DomainEvent> events = new ArrayList<>();
public void updateProfile(String name, String email) {
this.name = name;
this.email = email;
// Генерируем event
events.add(new UserUpdatedEvent(this.id, name, email));
}
public List<DomainEvent> getDomainEvents() {
return events;
}
}
// Application service
@Service
public class UserApplicationService {
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
public void updateUser(Long id, UpdateUserCommand cmd) {
User user = userRepository.findById(id).orElseThrow();
user.updateProfile(cmd.getName(), cmd.getEmail());
// Сохраняем пользователя
userRepository.save(user);
// Публикуем events (слышит всё приложение)
user.getDomainEvents().forEach(eventPublisher::publishEvent);
}
}
// Event listener в ДРУГОМ контейнере
@Component
public class CacheInvalidationListener {
private final RedisTemplate<String, User> redisTemplate;
@EventListener
public void onUserUpdated(UserUpdatedEvent event) {
// Инвалидируем кэш в Redis
redisTemplate.delete("user:" + event.getUserId());
}
}
Сравнение решений
| Решение | Сложность | Задержка | Консистентность | Production |
|---|---|---|---|---|
| Redis | Низкая | < 1ms | Сильная | Да |
| RabbitMQ | Средняя | 100-500ms | Слабая | Частично |
| Spring Cache | Низкая | < 1ms | Сильная | Да |
| Database | Высокая | 50-100ms | Сильная | Edge cases |
| Event-driven | Высокая | < 1ms | Сильная | Да |
Рекомендация для разных сценариев
1. Начальный проект (1-2 контейнера)
// Просто используй @Cacheable с EhCache (локальный)
@Cacheable("users")
public User getUser(Long id) { ... }
2. Растущий проект (3-5 контейнеров)
// Мигрируй на Redis + @Cacheable
// @Cacheable автоматически будет использовать Redis
3. Критичный проект (10+ контейнеров, высокие требования)
// Используй event-driven architecture с Kafka
// Events гарантируют консистентность
Best practices
// 1. Всегда устанавливай TTL
@Cacheable("users", key = "#id")
public User getUser(Long id) {
return userRepository.findById(id).orElseThrow();
}
// cacheName: users, TTL: 1 час (в redisProperties)
// 2. Инвалидируй при обновлении
@CacheEvict("users", key = "#id")
public void updateUser(Long id, User user) {
userRepository.save(user);
}
// 3. Используй версионирование для распределённого кэша
@RedisHash("User", timeToLive = 3600)
public class User {
@Id
private Long id;
@Version
private Long version; // Помогает обнаружить конфликты
}
// 4. Логируй cache hits/misses
private static final Logger log = LoggerFactory.getLogger(...);
if (cachedValue != null) {
log.debug("Cache hit for key: {}", key);
} else {
log.debug("Cache miss for key: {}", key);
}
Вывод
Для production системы с несколькими контейнерами:
- Используй Redis (или Memcached) вместо локального кэша
- Используй Spring Cache (@Cacheable, @CacheEvict) для управления
- Устанавливай TTL для автоматической инвалидации
- Явно инвалидируй при обновлении данных
- Мониторь cache hit rate и latency
- Для критичных данных используй event-driven или database-driven подходы
Локальный в-памяти кэш приемлем только для одного контейнера или для данных, которые могут быть слегка несогласованными. Для всего остального — Redis и распределённое кэширование.