Кэширование in-memory vs redis

Пример задачи:

У нас есть сайт по учёту павлинов всего мира. Он неожиданно для всех стал безумно популярным и мы хотим масштабироваться.

Мы храним информацию о павлинах в PostgreSQL в таблице peacock:

Павлины в табличке peacock
Павлины в табличке peacock

* Новые павлины рождаются примерно 1 раз в секунду.
* Различные запросы по этой таблице выполняются около 100 раз в секунду.

Наш PostgreSQL спокойно выдерживает такую нагрузку.

Но тут мы решили добавить на главную страницу сайта диаграмму распределения павлинов по зонам в стране пользователя в реальном времени.

Очень важная диаграмма распределения павлинов на главной странице
Очень важная диаграмма распределения павлинов на главной странице

Главная страница открывается 2000 раз в секунду. Наш PostgreSQL уже почти не выдерживает выполнение такого запроса с такой частотой:

select zone_id, count(*)
from peacock
where zone_id in (1,2,3)
group by zone_id;
Нагрузка на бэкенд и БД
Нагрузка на бэкенд и БД

Поэтому мы решаем использовать кэширование

Кэширование in-memory

Попробуем сохранить результат запроса в память нашего веб-сервера.

Для этого будем использовать Guava CacheBuilder. Это очень гибкая реализация кэша в памяти от google.

Gauva Cache in-memory
Gauva Cache in-memory

Так будет выглядеть инициализация кэша:

Инициализация guava кэша
Инициализация guava кэша

А так мы будем доставать из него данные для диаграммы:

Прочитать из кэша количество павлинов в зонах 1, 2 и 3
Прочитать из кэша количество павлинов в зонах 1, 2 и 3

Что же тут происходит:

1. Кэш будет возвращать нам количество павлинов по zoneId [zoneId -> peackockCount]

2. Если подсунуть ему список zoneId, он вернет Map [zoneId -> peackockCount]

3. Мы ограничили максимальный размер зон до 1000. Если мы попытаемся добавить 1001 зону, кэш сам выкинет зону, которую дольше всего не запрашивали (смотри LRU-cache)

4. Мы научили кэш самостоятельно подгружать количество павлинов в зоне, если таких данных еще нет

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

Инвалидация guava кэша
Инвалидация guava кэша

Тогда при следующем запросе кэш не найдет данных по этой зоне и сам сходит в БД.

Таким образом, 2000 запросов/сек будут попадать в кэш, а БД будет спокойно заниматься своими делами. И только лишь раз в секунду одна из зон будет инвалидироваться, вызывая одно чтение из БД.

Все счастливы!

Почти...

Кэширование в Redis

Идет время. 2000 запросов с главной страницы плавно превращаются в 4000. Мы понимаем, что наш бэкенд перестает успевать справляться с нагрузкой, и решаем масштабироваться горизонтально - добавлением инстансов бэкендов.

Смасштабировались до трех бэкендов
Смасштабировались до трех бэкендов

Теперь запрос от клиента может попасть в один из 3 инстансов бэкенда.

Вопрос с нагрузкой решен, но наше кэширование сломалось!

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

Проблема с инвалидацией параллельных in-memory кэшей
Проблема с инвалидацией параллельных in-memory кэшей

Одним из выходов из этой ситуации будет создание общего кэша, который будет находится вне бэкендов:

Кэширование павлинов в Redis
Кэширование павлинов в Redis

Теперь все закэшированные данные лежат в одном месте. Поэтому инвалидация происходит корректно.

Минусы этого подхода по сравнению с in-memory:

- еще один сервер, который нужно поднять и поддерживать в рабочем состоянии (пока кэш лежит, базе будет очень туго)

- каждый запрос в кэш теперь = поход по сети (это намного дороже, чем достать из памяти)

Плюсы:

- эффективность кэша значительно больше в случае с несколькими инстансами бэкенда (чтобы сохранить в память 3-х бэкендов значение одного ключа, его нужно посчитать 3 раза)

- можно запихнуть намного больше данных (redis из коробки умеет шардироваться и реплицироваться = независимое горизонтальное масштабирование кэша)

- единое место для инвалидации (как в примере выше)

- мы делаем намного приятнее нашему GC (содержать кучу памяти для кэша в old gen не так бесплатно как кажется)

- redis еще и очень быстрый, потому что хранит все данные в памяти

- redis поддерживает очень много полезных операций с данными (например, incr в нашем примере мог бы помочь вообще избежать инвалидации)

Альтернативные технологии

In-memory: Caffeine
Distributed cache: Hazelcast, Apache Ignite

Выводы

1. Кэширование может значительно уменьшить время ответа на запросы и нагрузку на основной источник данных.

2. Нельзя однозначно выбрать, где держать кэш лучше. Если есть только один инстанс бэкенда и кэш маленький, то лучше брать in-memory. Если бэкендов много, и кэша много, и нужна явная инвалидация, то лучше брать Redis.