Каковы цели и компромиссы использования локального кэша в микросервисной архитектуре?

Ответ

Локальный (in-memory) кэш хранит данные в оперативной памяти экземпляра сервиса. Его основная цель — резкое снижение задержки (latency) и нагрузки на нижележащие источники данных (базы данных, другие сервисы) для часто запрашиваемой, относительно статичной информации.

Цели и преимущества:

  • Минимальная задержка: Доступ к данным из памяти на порядки быстрее, чем сетевой вызов или запрос к БД.
  • Снижение нагрузки: Уменьшает количество запросов к БД или внешним API, защищая их от перегрузки.
  • Устойчивость к сбоям: При недоступности основного источника данных сервис может какое-то время работать с устаревшими (кэшированными) данными.
  • Простота: Легко реализуется с помощью библиотек (Caffeine, Ehcache) или фреймворков (Spring Cache).

Пример с Spring Cache и Caffeine:

@Service
public class CatalogService {
    @Cacheable(value = "products", key = "#id")
    public Product getProduct(Long id) {
        // Дорогой вызов к БД выполняется только при промахе кэша
        return productRepository.findById(id).orElseThrow();
    }
}

// Конфигурация Caffeine для TTL и размера
@Configuration
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .maximumSize(1000));
        return manager;
    }
}

Компромиссы и проблемы:

  • Несогласованность данных (Consistency): Каждый экземпляр сервиса имеет свою копию кэша. Изменение данных в одном экземпляре не обновляет кэш в других. Это проблема для данных, требующих сильной согласованности.
  • Использование памяти: Кэш потребляет RAM, что ограничивает его объем и требует стратегий вытеснения (LRU, LFU).
  • Сложность инвалидации: Актуальность данных зависит от TTL (время жизни) или явной инвалидации при обновлении.

Когда использовать: Для данных, которые часто читаются, редко меняются и допускают eventual consistency (справочники, конфигурация, статичный контент). Для синхронизации между инстансами или часто изменяющихся данных часто используют распределенный кэш (Redis, Hazelcast) поверх или вместо локального.

Ответ 18+ 🔞

Ну слушай, вот есть у тебя сервис, а в нём данные. И каждый раз, когда кто-то эти данные просит, ты лезешь в базу, которая где-то там, за хуйней сетевой, и тащишь их. А потом ещё раз лезешь, потому что другой запрос пришёл. И так до тех пор, пока база не скажет: «Да иди ты нахуй, я устала!» — и ляжет.

Так вот, чтобы этого не происходило, умные люди придумали локальный кэш. Суть проста, как три рубля: ты берёшь самые популярные, часто запрашиваемые данные — и просто запихиваешь их в оперативную память своего сервиса. Всё. Больше никуда ходить не надо. Запросили продукт с ID 123? Бдыщ! Он уже тут, в памяти, готовый. Задержка — микросекунды, а не миллисекунды. База отдыхает, все довольны. Просто ёбушки-воробушки!

Зачем это, собственно, надо:

  • Скорость, блядь! Достать что-то из памяти своего же процесса — это на порядки быстрее, чем тащиться через сеть до базы или другого сервиса. Прям волшебство, хуле.
  • Разгрузить основную тягловую силу. База данных — она как та лошадь: может много, но если её постоянно погонять, сдохнет. Кэш принимает на себя львиную долю простых читающих запросов, и база не захлёбывается.
  • Немного живучести. Если база вдруг легла, сервис какое-то время может протянуть на старых, закэшированных данных. Не на всех, конечно, но хоть что-то будет работать, а не просто «ошибка 500».
  • Проще некуда. Взял какую-нибудь библиотечку вроде Caffeine, накропал пару аннотаций — и всё, кэш готов. Не надо городить свою велосипедную телегу с мапами и таймерами.

Вот, смотри, как это выглядит в коде (Spring + Caffeine):

@Service
public class CatalogService {
    // Говорим: «Слушай, Spring, перед тем как лезть в метод, проверь кэш с именем "products"»
    @Cacheable(value = "products", key = "#id")
    public Product getProduct(Long id) {
        // А вот эта дорогая операция — запрос к БД — выполнится ТОЛЬКО если в кэше пусто.
        return productRepository.findById(id).orElseThrow();
    }
}

// А тут мы настраиваем, как этот кэш будет себя вести
@Configuration
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            // Данные протухнут через 10 минут после записи
            .expireAfterWrite(10, TimeUnit.MINUTES)
            // И больше 1000 записей держать не будем — память не резиновая
            .maximumSize(1000));
        return manager;
    }
}

Но не всё так гладко, конечно. Есть и подводные, блядь, камни:

  • Консистентность — её нет. Это главная проблема. У каждого экземпляра твоего сервиса — своя собственная копия кэша в памяти. Обновил данные в одном месте — в других-то кэшах осталась старая версия. Для данных, которые должны быть строго актуальными везде и сразу — это пиздец, а не решение.
  • Жрёт память. Оперативка — ресурс конечный. Нельзя туда запихнуть всю базу. Приходится выбирать: что кэшировать, а что нет, и выкидывать старое по каким-то правилам (LRU, LFU).
  • Инвалидация — головная боль. Когда данные всё-таки поменялись, надо как-то сказать кэшу: «Эй, дружок, это уже неактуально, выкинь». Либо ждать, пока само протухнет (TTL), либо городить механизмы ручного сброса.

Так когда же это юзать? Идеальный случай — данные, которые очень часто читаются, но почти никогда не меняются. Ну, всякие справочники, конфигурация, статичный контент. Там, где можно смириться с тем, что обновление придёт не мгновенно (eventual consistency). Если же данные меняются часто и должны быть синхронизированы между всеми экземплярами — тут уже нужен распределённый кэш (типа Redis), чтобы у всех была одна правда. Но это уже совсем другая история, с другими граблями.