Надо ли подключать первый уровень кэширования
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Надо ли подключать первый уровень кэширования
Краткий ответ
Да, нужно, но с условиями. Первый уровень кэширования (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) |
|---|---|---|
| Скорость | < 1ms | 5-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 для синхронизации между серверами.