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

Надо ли подключать первый уровень кэширования

1.3 Junior🔥 151 комментариев
#ORM и Hibernate

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

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

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

Надо ли подключать первый уровень кэширования

Краткий ответ

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

Уровни кэширования в архитектуре

┌──────────────────────────────────────────────────────────────┐
│                    L0: CPU Cache                             │
│                  (управляется процессором)                   │
└──────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────┐
│  L1: Local Application Cache (In-Memory)                     │
│  (Guava Cache, Caffeine, ConcurrentHashMap)                 │
│  - Хранится в памяти одного сервера                          │
│  - Быстро (микросекунды)                                     │
│  - Не шарится между серверами                                │
└──────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────┐
│  L2: Distributed Cache (Network)                             │
│  (Redis, Memcached, Hazelcast)                              │
│  - Хранится на отдельном сервере                             │
│  - Медленнее (миллисекунды из-за сети)                       │
│  - Шарится между всеми серверами приложения                  │
└──────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────────────────────────────────────────────────────┐
│         L3: Database / External Service                      │
│  (PostgreSQL, MongoDB, REST API)                             │
│  - Самый медленный (десятки миллисекунд)                     │
│  - Single Source of Truth                                    │
└──────────────────────────────────────────────────────────────┘

Пример: L1 кэширование с Caffeine

@Configuration
public class CacheConfig {
    
    // L1: Локальное кэширование в памяти
    @Bean
    public Cache<Long, User> userCache() {
        return Caffeine.newBuilder()
            .maximumSize(10000)           // Максимум 10k записей
            .expireAfterWrite(10, TimeUnit.MINUTES)  // Истекает через 10 минут
            .recordStats()                // Сбор статистики
            .build();
    }
}

@Service
public class UserService {
    
    private final Cache<Long, User> userCache;
    private final UserRepository userRepository;
    
    public User getUserById(Long id) {
        // Шаг 1: Проверить L1 кэш (очень быстро)
        User cachedUser = userCache.getIfPresent(id);
        if (cachedUser != null) {
            return cachedUser;  // Hit! Возврат из памяти
        }
        
        // Шаг 2: Кэш miss, обратиться в БД
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
        
        // Шаг 3: Закэшировать в L1
        userCache.put(id, user);
        
        return user;
    }
}

Трёхуровневое кэширование (Best Practice)

L1 → L2 → L3 (Database)

@Service
public class UserServiceWithMultiLevelCache {
    
    // L1: Локальный кэш (Caffeine)
    private final Cache<Long, User> localCache;
    
    // L2: Распределённый кэш (Redis)
    @Autowired
    private RedisTemplate<String, User> redisTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    public User getUserById(Long id) {
        // L1: Проверка локального кэша
        User cachedUser = localCache.getIfPresent(id);
        if (cachedUser != null) {
            System.out.println("L1 Cache HIT");
            return cachedUser;
        }
        
        // L2: Проверка Redis (распределённого кэша)
        String redisKey = "user:" + id;
        User redisUser = redisTemplate.opsForValue().get(redisKey);
        if (redisUser != null) {
            System.out.println("L2 Cache HIT");
            // Пополнить L1 кэш
            localCache.put(id, redisUser);
            return redisUser;
        }
        
        // L3: Обратиться в БД
        System.out.println("L3 Database MISS");
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
        
        // Пополнить оба уровня кэша
        localCache.put(id, user);
        redisTemplate.opsForValue().set(redisKey, user, Duration.ofHours(1));
        
        return user;
    }
}

Когда использовать L1 кэш

✅ ДА, используйте L1 кэш когда:

1. Данные часто читаются, редко меняются

// Хорошо: справочники, конфигурации, данные которые меняются редко
@Service
public class CountryService {
    private final Cache<String, Country> countryCache;
    
    public Country getCountry(String code) {
        // Список стран меняется очень редко
        return countryCache.get(code, k -> loadCountry(code));
    }
}

2. Производительность критична (требуется минимальная задержка)

// Хорошо: для real-time операций
@Service
public class PricingService {
    private final Cache<String, BigDecimal> priceCache;
    
    public BigDecimal getProductPrice(String productId) {
        // Цена может быть в кэше максимум на несколько минут
        // Требуется минимальная задержка < 1ms
        return priceCache.get(productId, k -> fetchPrice(productId));
    }
}

3. Одна машина обслуживает миллионы запросов

// Хорошо: высокая нагрузка на один сервер
@Service
public class HighLoadService {
    
    private final Cache<Long, User> cache = Caffeine.newBuilder()
        .maximumSize(100000)
        .expireAfterAccess(5, TimeUnit.MINUTES)
        .build();
    
    public User getUser(Long id) {
        // При 10k req/sec L1 кэш экономит 9000+ обращений в БД
        return cache.get(id, k -> userRepository.findById(k).orElse(null));
    }
}

❌ НЕ используйте L1 кэш когда:

1. Данные часто меняются

// Плохо: аккаунт может быть заблокирован другим сервером
@Service
public class AccountService {
    private final Cache<Long, Account> accountCache;  // ❌ ПЛОХО
    
    public Account getAccount(Long id) {
        // Проблема: если другой сервер заблокировал аккаунт,
        // этот сервер не узнает об этом!
        return accountCache.get(id, k -> loadAccount(k));
    }
}

// Хорошо: использовать L2 (Redis) или не кэшировать
@Service
public class AccountService {
    
    @Autowired
    private RedisTemplate<String, Account> redis;
    
    public Account getAccount(Long id) {
        // Все серверы видят одно состояние
        return redis.opsForValue().get("account:" + id);
    }
}

2. Несколько инстансов приложения (Load Balancing)

Запрос 1: Server A -> L1 Cache (HIT) -> User cached
Запрос 2: Server B -> L1 Cache (MISS) -> Hit Database

Проблема: каждый сервер имеет свой L1 кэш!
// ❌ ПЛОХО в кластере
@Service
public class UserService {
    private final Cache<Long, User> localCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .build();
    
    public User getUser(Long id) {
        // Server A: L1 HIT (быстро)
        // Server B: L1 MISS (идёт в БД)
        // Server C: L1 MISS (идёт в БД)
        // Неэффективно!
    }
}

// ✅ ХОРОШО в кластере: комбинировать L1 и L2
@Service
public class UserService {
    private final Cache<Long, User> localCache;  // L1
    private final RedisTemplate<String, User> redis;  // L2
    
    public User getUser(Long id) {
        // Все серверы видят L2 кэш одновременно
        // L1 кэш — дополнительная оптимизация
    }
}

3. Критичны согласованность данных

// Плохо: операции с финансами
@Service
public class BalanceService {
    private final Cache<Long, BigDecimal> balanceCache;  // ❌ ОПАСНО
    
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // Может быть race condition между транзакциями
    }
}

// Хорошо: без L1 кэша, с блокировкой на БД
@Service
public class BalanceService {
    
    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // Используем SELECT FOR UPDATE в БД
        // Нет L1 кэша, всегда читаем актуальные данные
    }
}

Практический пример: Правильная реализация L1

@Configuration
public class CacheConfiguration {
    
    @Bean
    public Cache<Long, Product> productCache() {
        return Caffeine.newBuilder()
            .maximumSize(50000)                    // Максимум 50k продуктов
            .expireAfterWrite(30, TimeUnit.MINUTES)  // Теряет актуальность через 30 минут
            .refreshAfterWrite(10, TimeUnit.MINUTES) // Обновить через 10 минут
            .recordStats()                         // Собирать статистику
            .build();
    }
}

@Service
public class ProductService {
    
    private final Cache<Long, Product> productCache;
    private final ProductRepository productRepository;
    
    // Явная инвалидация кэша при изменении
    public Product createProduct(CreateProductRequest request) {
        Product product = new Product();
        product.setName(request.getName());
        product.setPrice(request.getPrice());
        
        Product saved = productRepository.save(product);
        
        // Кэшировать новый продукт
        productCache.put(saved.getId(), saved);
        
        return saved;
    }
    
    public Product updateProduct(Long id, UpdateProductRequest request) {
        Product product = productRepository.findById(id)
            .orElseThrow(() -> new NotFoundException(id));
        
        product.setName(request.getName());
        product.setPrice(request.getPrice());
        
        Product updated = productRepository.save(product);
        
        // Инвалидировать кэш
        productCache.invalidate(id);
        
        return updated;
    }
    
    public void deleteProduct(Long id) {
        productRepository.deleteById(id);
        
        // Инвалидировать кэш
        productCache.invalidate(id);
    }
    
    public Product getProduct(Long id) {
        return productCache.get(id, k -> 
            productRepository.findById(k)
                .orElseThrow(() -> new NotFoundException(k))
        );
    }
    
    // Мониторинг кэша
    @Scheduled(fixedRate = 60000)
    public void printCacheStats() {
        CacheStats stats = productCache.stats();
        System.out.println("Cache Hit Rate: " + stats.hitRate());
        System.out.println("Cache Evictions: " + stats.evictionCount());
    }
}

Таблица: L1 vs L2 Кэш

ХарактеристикаL1 (Local)L2 (Distributed)
Скорость< 1ms5-10ms
ВидимостьОдин серверВсе серверы
РезервированиеНетЕсть (Redis Cluster)
ИнвалидацияСложнаяПростая (Pub/Sub)
РазмерОграничен памятьюМожет быть больше
КонсистентностьТрудно гарантироватьЛегче синхронизировать
Используй дляСправочники, конфигиПользовательские данные

Рекомендация

Стратегия: L1 + L2 (Гибридный подход)

// Для production приложений
public User getUser(Long id) {
    // 1. L1: Локальный кэш (10 минут)
    User cachedUser = localCache.getIfPresent(id);
    if (cachedUser != null) return cachedUser;
    
    // 2. L2: Redis (1 час)
    User redisUser = redis.get("user:" + id);
    if (redisUser != null) {
        localCache.put(id, redisUser);
        return redisUser;
    }
    
    // 3. L3: Database
    User user = userRepository.findById(id).orElse(null);
    if (user != null) {
        localCache.put(id, user);
        redis.set("user:" + id, user, Duration.ofHours(1));
    }
    
    return user;
}

Заключение

Надо ли подключать L1 кэширование?

ДА, нужно если:

  • Данные часто читаются и редко меняются
  • Требуется минимальная задержка
  • Одна машина обслуживает много запросов
  • Вы понимаете проблемы инвалидации кэша

НЕ нужно если:

  • Данные часто меняются
  • Много инстансов приложения без синхронизации
  • Критична точность данных

Best Practice: Используйте L1 + L2 (гибридный подход) — L1 для локальной оптимизации, L2 для синхронизации между серверами.

Надо ли подключать первый уровень кэширования | PrepBro