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

Какая основная проблема у Cache?

2.2 Middle🔥 231 комментариев
#JVM и управление памятью#Кэширование и NoSQL#Многопоточность

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

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

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

Основная проблема Cache: Cache Invalidation

Есть известное выражение в Computer Science: «There are only two hard things in Computer Science: cache invalidation and naming things.» (Фил Карлтон, Том Лорд-Алми)

Основная проблема кеша — это не его реализация, а то, как поддерживать консистентность данных между кешем и источником данных (обычно БД).

Проблема 1: Cache Invalidation (инвалидация кеша)

Когда данные в БД изменяются, кеш может содержать устаревшую информацию:

// Сценарий проблемы
@Cacheable("users")  // Кешируем результат
public User getUserById(Long id) {
    return userRepository.findById(id).orElseThrow();
}

@Transactional
public void updateUser(Long id, String newName) {
    User user = userRepository.findById(id).orElseThrow();
    user.setName(newName);
    userRepository.save(user);
    // ПРОБЛЕМА: Кеш не обновился!
    // Следующий вызов getUserById(id) вернёт старое имя
}

// Пример:
User user = getUserById(1L);  // Загружается из БД, кешируется
System.out.println(user.getName());  // "John"

updateUser(1L, "Jane");

User cachedUser = getUserById(1L);  // Возвращается из кеша
System.out.println(cachedUser.getName());  // "John" ❌ Устаревшее значение!

Решение: Явная инвалидация кеша

@Service
public class UserService {
    
    @Cacheable("users")
    public User getUserById(Long id) {
        return userRepository.findById(id).orElseThrow();
    }
    
    @Transactional
    @CacheEvict(value = "users", key = "#id")  // Удаляем конкретный элемент из кеша
    public void updateUser(Long id, String newName) {
        User user = userRepository.findById(id).orElseThrow();
        user.setName(newName);
        userRepository.save(user);
    }
    
    @CacheEvict(value = "users", allEntries = true)  // Очищаем весь кеш
    public void deleteAllUsers() {
        userRepository.deleteAll();
    }
}

Проблема 2: Stale Data (устаревшие данные)

И даже с инвалидацией, между обновлением БД и очисткой кеша может быть race condition:

Тайм-лайн:
1. Поток 1: updateUser() начал транзакцию
2. Поток 2: getUserById() вернул данные ИЗ КЕША (старые)
3. Поток 1: updateUser() завершил транзакцию и инвалидировал кеш
4. Поток 2: приложение работает со старыми данными

Результат: Race condition, данные неконсистентны

Решение: Time-to-Live (TTL)

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager("users");
        
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)           // Максимум 1000 элементов
            .expireAfterWrite(5, TimeUnit.MINUTES)  // TTL = 5 минут
            .recordStats()               // Для мониторинга
        );
        
        return cacheManager;
    }
}

// Теперь данные автоматически удалятся из кеша через 5 минут
User user = getUserById(1L);  // Загружается из БД
// ... пользователь ждёт 5+ минут ...
User user2 = getUserById(1L);  // Снова загружается из БД (истёк TTL)

Проблема 3: Cache Coherence в распределённых системах

Если у вас несколько инстансов приложения, кеш не синхронизируется между ними:

Инстанс 1          Инстанс 2
┌────────────┐     ┌────────────┐
│ Кеш        │     │ Кеш        │
│ users: {1} │     │ users: {1} │
└────────────┘     └────────────┘

Инстанс 1 обновляет user 1 → Инвалидирует свой кеш
Инстанс 1: Кеш = {}         Инстанс 2: Кеш = {1} ❌ Неконсистентно!

Если запрос попадёт на Инстанс 2, он вернёт устаревшие данные

Решение: Redis как распределённый кеш

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(RedisSerializationContext
                .SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
        
        return RedisCacheManager.create(connectionFactory);
    }
}

// application.yml
spring:
  redis:
    host: localhost
    port: 6379
  cache:
    type: redis

Теперь все инстансы разделяют один Redis кеш:

Инстанс 1          REDIS          Инстанс 2
│                   │              │
├─ GET user:1 ────→ │ ← ─ ─ ─ ─ ─ ─┤
│                   │              │
├─ SET user:1 ────→ │              │
│                   │              │
│                 ┌─┴─┐             │
│                 │1.5│ (TTL)       │
│                 └─┬─┘             │
│                   │              │
│                   │ ← ─ ─ GET ─ ←┤

Проблема 4: Cache Stampede (Thundering Herd)

Когда популярный элемент истекает из кеша, все запросы одновременно пытаются переложить его:

// В момент T истекает TTL элемента "hot-data"

// Запрос 1: Вычисляет hot-data (дорого)
// Запрос 2: Вычисляет hot-data (дорого)
// Запрос 3: Вычисляет hot-data (дорого)
// ...
// Запрос 1000: Вычисляет hot-data (дорого)

// Результат: Пик нагрузки на БД/приложение

Решение 1: Refresh перед TTL

@Service
public class UserService {
    
    private final UserRepository userRepository;
    private final CacheManager cacheManager;
    
    @Cacheable(value = "users", unless = "#result == null")
    public User getUserById(Long id) {
        return userRepository.findById(id).orElse(null);
    }
    
    @Scheduled(fixedRate = 240000)  // Каждые 4 минуты (TTL = 5)
    public void refreshHotUsers() {
        // Обновляем популярные данные до истечения TTL
        List<Long> hotUserIds = Arrays.asList(1L, 2L, 3L);
        hotUserIds.forEach(id -> {
            // Это вызовет cacheable и обновит кеш
            getUserById(id);
        });
    }
}

Решение 2: Lock-based approach

@Service
public class UserService {
    
    private final UserRepository userRepository;
    private final CacheManager cacheManager;
    private final ReentrantLock lock = new ReentrantLock();
    
    public User getUserById(Long id) {
        Cache cache = cacheManager.getCache("users");
        Cache.ValueWrapper cached = cache.get(id);
        
        if (cached != null) {
            return (User) cached.get();
        }
        
        // Только один поток вычисляет, остальные ждут
        if (lock.tryLock()) {
            try {
                // Проверяем ещё раз (double-check locking)
                cached = cache.get(id);
                if (cached != null) {
                    return (User) cached.get();
                }
                
                User user = userRepository.findById(id).orElseThrow();
                cache.put(id, user);
                return user;
            } finally {
                lock.unlock();
            }
        } else {
            // Ждём пока другой поток вычислит
            lock.lock();
            try {
                cached = cache.get(id);
                return (User) cached.get();
            } finally {
                lock.unlock();
            }
        }
    }
}

Проблема 5: Wrong Key Strategy

Неправильно выбранный ключ кеша может привести к коллизиям:

// ❌ Плохо: не учитываются параметры запроса
@Cacheable("users")
public User getUserById(Long id) {
    return userRepository.findById(id).orElseThrow();
}

// ✅ Хорошо: явно указываем ключ
@Cacheable(value = "users", key = "#id")
public User getUserById(Long id) {
    return userRepository.findById(id).orElseThrow();
}

// ✅ Ещё лучше: для сложных случаев
@Cacheable(value = "users", key = "T(java.lang.String).format('user:%d:%s', #id, #locale)")
public User getUserById(Long id, String locale) {
    return userRepository.findById(id).orElseThrow();
}

// ✅ Или создать кастомный KeyGenerator
@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public KeyGenerator keyGenerator() {
        return (target, method, params) -> {
            if (params.length == 0) {
                return method.getName();
            }
            return target.getClass().getSimpleName() + 
                   ":" + method.getName() + 
                   ":" + Arrays.stream(params)
                       .map(Object::toString)
                       .collect(Collectors.joining(":"));
        };
    }
}

Проблема 6: Memory Overhead

Кеш может занять слишком много памяти:

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.registerCustomCache("users", 
            Caffeine.newBuilder()
                .maximumSize(1000)           // Не более 1000 пользователей
                .maximumWeight(10_000_000)   // Не более 10MB
                .weigher((id, user) -> {
                    // Примерный размер объекта
                    return user.toString().length() + 100;
                })
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .build()
        );
        return cacheManager;
    }
}

Сводка основных проблем и решений

ПроблемаРешение
Статалые данныеTTL + CacheEvict
Несинхронизированный кешRedis (распределённый)
Cache stampedeRefresh перед TTL или Lock
Неправильные ключиЯвно указать key или KeyGenerator
Переполнение памятиmaximumSize, maximumWeight, TTL
Race conditionsПравильная обработка транзакций

Мой практический совет

@Service
public class UserService {
    
    @Cacheable(
        value = "users",
        key = "#id",
        unless = "#result == null"
    )
    @Transactional(readOnly = true)
    public User getUserById(Long id) {
        return userRepository.findById(id).orElse(null);
    }
    
    @Transactional
    @CacheEvict(value = "users", key = "#id")
    public User updateUser(Long id, String name) {
        User user = userRepository.findById(id).orElseThrow();
        user.setName(name);
        return userRepository.save(user);
    }
    
    @CacheEvict(value = "users", allEntries = true)
    public void clearCache() {
        // Явная инвалидация при необходимости
    }
}

Ключевое правило: Cache invalidation — это не проблема технологии, это проблема дизайна. Решайте её на уровне архитектуры, не на уровне написания кода.

Какая основная проблема у Cache? | PrepBro