← Назад к вопросам

​​​​​​​Как исправить ошибку пустого кэша при одновременной работе сервиса с глобальным кэшем в двух контейнерах

2.7 Senior🔥 91 комментариев
#Docker, Kubernetes и DevOps#Кэширование и NoSQL

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Синхронизация глобального кэша между контейнерами: проблема и решения

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

Проблема: 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 системы с несколькими контейнерами:

  1. Используй Redis (или Memcached) вместо локального кэша
  2. Используй Spring Cache (@Cacheable, @CacheEvict) для управления
  3. Устанавливай TTL для автоматической инвалидации
  4. Явно инвалидируй при обновлении данных
  5. Мониторь cache hit rate и latency
  6. Для критичных данных используй event-driven или database-driven подходы

Локальный в-памяти кэш приемлем только для одного контейнера или для данных, которые могут быть слегка несогласованными. Для всего остального — Redis и распределённое кэширование.

​​​​​​​Как исправить ошибку пустого кэша при одновременной работе сервиса с глобальным кэшем в двух контейнерах | PrepBro