Пример задачи:
У нас есть сайт по учёту павлинов всего мира. Он неожиданно для всех стал безумно популярным и мы хотим масштабироваться.
Мы храним информацию о павлинах в PostgreSQL в таблице 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.
Так будет выглядеть инициализация кэша:
А так мы будем доставать из него данные для диаграммы:
Что же тут происходит:
1. Кэш будет возвращать нам количество павлинов по zoneId [zoneId -> peackockCount]
2. Если подсунуть ему список zoneId, он вернет Map [zoneId -> peackockCount]
3. Мы ограничили максимальный размер зон до 1000. Если мы попытаемся добавить 1001 зону, кэш сам выкинет зону, которую дольше всего не запрашивали (смотри LRU-cache)
4. Мы научили кэш самостоятельно подгружать количество павлинов в зоне, если таких данных еще нет
Чтобы это работало правильно осталось только инвалидировать зону в кэше, когда рождается новый павлиненок или павлин переезжает в другую зону:
Тогда при следующем запросе кэш не найдет данных по этой зоне и сам сходит в БД.
Таким образом, 2000 запросов/сек будут попадать в кэш, а БД будет спокойно заниматься своими делами. И только лишь раз в секунду одна из зон будет инвалидироваться, вызывая одно чтение из БД.
Все счастливы!
Почти...
Кэширование в Redis
Идет время. 2000 запросов с главной страницы плавно превращаются в 4000. Мы понимаем, что наш бэкенд перестает успевать справляться с нагрузкой, и решаем масштабироваться горизонтально - добавлением инстансов бэкендов.
Теперь запрос от клиента может попасть в один из 3 инстансов бэкенда.
Вопрос с нагрузкой решен, но наше кэширование сломалось!
При добавлении нового павлина инвалидация будет происходить только в том бэкенде, который обрабатывал запрос добавления. В остальных же бэкендах число павлинов останется неактуальным:
Одним из выходов из этой ситуации будет создание общего кэша, который будет находится вне бэкендов:
Теперь все закэшированные данные лежат в одном месте. Поэтому инвалидация происходит корректно.
Минусы этого подхода по сравнению с in-memory:
- еще один сервер, который нужно поднять и поддерживать в рабочем состоянии (пока кэш лежит, базе будет очень туго)
- каждый запрос в кэш теперь = поход по сети (это намного дороже, чем достать из памяти)
Плюсы:
- эффективность кэша значительно больше в случае с несколькими инстансами бэкенда (чтобы сохранить в память 3-х бэкендов значение одного ключа, его нужно посчитать 3 раза)
- можно запихнуть намного больше данных (redis из коробки умеет шардироваться и реплицироваться = независимое горизонтальное масштабирование кэша)
- единое место для инвалидации (как в примере выше)
- мы делаем намного приятнее нашему GC (содержать кучу памяти для кэша в old gen не так бесплатно как кажется)
- redis еще и очень быстрый, потому что хранит все данные в памяти
- redis поддерживает очень много полезных операций с данными (например, incr в нашем примере мог бы помочь вообще избежать инвалидации)
Альтернативные технологии
In-memory: Caffeine
Distributed cache: Hazelcast, Apache Ignite
Выводы
1. Кэширование может значительно уменьшить время ответа на запросы и нагрузку на основной источник данных.
2. Нельзя однозначно выбрать, где держать кэш лучше. Если есть только один инстанс бэкенда и кэш маленький, то лучше брать in-memory. Если бэкендов много, и кэша много, и нужна явная инвалидация, то лучше брать Redis.