Кэш — Основы Redis
На сегодняшний день время отклика на запрос является критичной метрикой для любого проекта. Пользователи не очень любят, когда web-страница формируется долго (несколько секунд или дольше). Поэтому разработчики стремятся уменьшить время ответа на запрос клиента, для чего прибегают к кэшированию.
Представим, что разрабатывается торговая платформа и есть личный кабинет с аналитикой сделок пользователя. Нужно выводить количество сделок, прибыль, средний оборот и тд. Большинство метрик являются агрегационными, то есть их нужно высчитывать. Если использовать только РСУБД, то среднее время ответа будет сотни миллисекунд, а при большой нагрузке — секунды. Можно подключить подходящие аналитические базы данных, но чаще всего это слишком дорого в средних проектах.
Самая распространенная практика решения сегодня — это кэш. Сложные вычисления кэшируются, то есть записываются в оперативную память. Обращение к оперативной памяти займет сотни наносекунд, примерно в миллион раз быстрее, чем вычислить данные из РСУБД. Новая схема взаимодействия будет включать кэширующий сервер. Алгоритм следующий:
- клиент делает запрос
- бекенд проверяет кэш. Если значение есть в кэше, то оно просто возвращается клиенту
- если значения в кэше нет, то сервер вычисляет все из базы, записывает числа в кэш и отдает клиенту. Теперь все последующие запросы будут возвращать результаты вычислений из кэша
Подводные камни
Как у любого решения в разработке, кэширование — не серебряная пуля и имеет свои недостатки. Несколько моментов, которые стоит держать в уме при использовании кэша:
- приложение должно уметь работать без кэша. Кэширующий сервер упал или недоступен? Пользователь все равно должен получать все данные просто с небольшой задержкой. Завязывать какую-то логику продукта на кэш (например, хранить данные аналитики только в кэше) — нельзя, потому что это ненадежное хранилище.
- с введением нового звена в архитектуру всегда следует усложнение разработки и поддержки продукта. Проверка и запись значений в кэше — это дополнительная логика в коде, которая требует дополнительного времени на тестирование и исправление потенциальных ошибок.
- сколько времени должно храниться значение в кэше? Что делать, если значение невалидно, так как пользователь открыл новую сделку или получил прибыль? Эти вопросы будут возникать постоянно, и в каждом проекте ответы будут уникальными.
Redis как кэш
Redis чаще всего используют именно как кэширующий сервер. Он имеет богатый встроенный функционал для этих целей. Рассмотрим основные команды, с помощью которых кэшируют значения в Redis.
Кэширование
Представим, что нужно записать количество сделок пользователя в кэш. Запишем, что у пользователя с ID 33 имеется 5 сделок. Используем обычную команду set :
set user:33:deals_count 5 OK
Данные записаны. Однако они будут храниться вечно до следующей перезагрузки сервера или пока не будут удалены вручную. Одно из основных свойств кэша — это то, что он хранится короткий промежуток времени. Данных может быть много, а ресурсы серверов не бесконечны. К счастью в Redis можно указать время жизни ключа (время экспирации), по истечении которого ключ будет удален. Для этого достаточно добавить постфикс ex количество_секунд к команде set :
set user:33:deals_count 5 ex 120 OK
Проверить, через сколько ключ удалится можно командой ttl :
(integer) 115
В данном случае ключ user:33:deals_count исчезнет через 115 секунд.
Если попытаться получить значение спустя это время, то вернется пустой ответ:
Время жизни можно задавать не только в секундах, но и в миллисекундах с помощью префикса px количество_миллисекунд :
set user:33:deals_count 5 px 10000
Проверка времени жизни осуществляется командой pttl :
(integer) 7484
Вывод выше показывает, что ключ user:33:deals_count исчезнет через 7484 миллисекунд (~7.4 секунд).
Для установки времени жизни для уже существующего ключа используется команда expire
set user:33:deals_count 5 ex 100 ttl user:33:deals_count (integer) 99 expire user:33:deals_count 300 ttl user:33:deals_count (integer) 298
Инвалидация кэша
Любой кэш может стать неактуальным раньше времени жизни. В нашем примере со сделками это может произойти при возникновении новой сделки у пользователя. В этом случае нужно удалить ключ в кэше, чтобы при следующем запросе произошел пересчет значения. Может возникнуть вопрос: а почему бы просто не добавить значение в имеющийся ключ? В данном примере это было бы наилучшим решением, но действовать необходимо не «в лоб». При увеличении значения двумя командами get + set может получиться неконсистентное состояние (подробнее эта проблема будет рассмотрена в следующем уроке). Также обновление значения не всегда возможно, например, в случае если значение — это среднее арифметическое.
Удаление ключа происходит командой del :
(integer) 1
Вернувшееся значение 1 означает, что ключ существовал и был успешно удален. Если ключа не было, то вернулся бы 0.
Иногда требуется удалить тысячи ключей за раз. Например, у поставщика цен на акции произошел сбой и все сделки за период сбоя оказались недействительными. В этом случае нельзя использовать N вызовов команды del , потому что это будет выполняться очень долго и заберет все ресурсы Redis. Для множественного удаления существует команда unlink , которую можно безопасно использовать в продакшен-среде:
unlink user:1:deals_count user:2:deals_count user:3:deals_count (integer) 1
Все ключи для удаления указываются через пробел. Стоит учитывать, что ключи удаляются асинхронно, то есть могут существовать после unlink короткий промежуток времени.
Резюме
Открыть доступ
Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно
- 130 курсов, 2000+ часов теории
- 1000 практических заданий в браузере
- 360 000 студентов
Наши выпускники работают в компаниях:
К вопросу об инвалидации кеша
Инвалидация кеша, возможно, одна из самых запутанных вещей в программировании. Тонкость вопроса состоит в компромиссе между полнотой, избыточностью и сложностью этой процедуры. Так о чём же эта статья? Хотелось бы не привязываясь к какой-либо платформе, языку или фреймворку, подумать о том как следует реализовывать систему инвалидации. Ну а чтобы не писать обо всём и ни о чём, сконцентрируемся на кешировании результатов SQL-запросов построенных с помощью ORM, которые в наше время встречаются нередко.
Полнота и избыточность
Начнём всё же с общих соображений не специфичных ни для SQL-запросов, ни для ORM. Упомянутые полноту и избыточность я определяю следующим образом. Полнота инвалидации — это её характеристика, определяющая насколько часто и в каких случаях может/будет возникать ситуация когда в кеше будут содержаться грязные данные и как долго они там будут оставаться. Избыточностью, в свою очередь, назовём то как часто кеш будет инвалидироваться без необходимости.
Рассмотрим для примера распространённый способ инвалидации по времени. С одной стороны, он практически гарантирует, что сразу после изменения данных кеш грязен. С другой стороны, время которое кеш остаётся грязным, мы можем легко ограничить уменьшив время жизни (что в свою очередь сократит процент попаданий). Т.е. при сокращении времени жизни кеша полнота инвалидации улучшается, а избыточность ухудшается. В итоге, чтобы достигнуть идеальной полноты инвалидации (никаких грязных данных) мы должны выставить таймаут в 0, или, другими словами, отключить кеш. Во многих случаях временное устаревание данных в кеше допустимо. Например, как правило, не так уж и страшно если новость в блоке последних новостей появится там на несколько минут позже или общее количество пользователей вашей социальной сети будет указано с ошибкой в пару-тройку тысяч.
Инвалидация по событию
Способ с инвалидацией по времени хорош своей простотой, однако, не всегда применим. Что ж, можно сбрасывать кеш при изменении данных. Одной из проблем при таком подходе является то, что при добавлении нового запроса, который мы кешируем приходиться добавлять код для его инвалидации в при изменении данных. Если мы используем ORM, то данные изменяются (в хорошем случае) в одном месте — при сохранении модели. Наличие одного центрального кода изменения данных облегчает задачу, однако, при большом количестве разнообразных запросов приходиться всё время дописывать туда всё новые и новые строки сброса различных кусочков кеша. Таким образом, мы получаем на свою голову избыточную связность кода. Пора её ослабить.
Воспользуемся событиями — ORM при сохранении/удалении модели будет события генерировать, а мы при кешировании чего-либо будем тут же и вешать обработчик на соответствующее событие, удаляющий это что-либо из кеша. Всё отлично, однако, написание большого количества похожих обработчиков утомляет, плюс логика приложения зарастает логикой кеширования/инвалидации как свинья жиром.
Автоматическая инвалидация ORM-запросов
Вспомним, что у нас есть ORM, а для него каждый запрос представляет не просто текст, а определённую структуру — модели, дерево условий и прочее. Так что, по идее, ORM может и кешировать и вешать инвалидационные обработчики прямо при кешировании по мере надобности. Чертовски привлекательное решение для ленивых ребят, вроде меня.
Небольшой пример. Допустим мы выполняем запрос:
select * from post where category_id =2 and published
и кешируем его. Очевидно, нам нужно сбросить запрос если при добавлении/обновлении/удалении поста для его старой или новой версии выполняется условие category_id=2 and published=true . Через некоторое время для каждой модели образуются списки инвалидаторов, каждый из которых хранит список запросов, которые должен сбрасывать:
post:
category_id =2 and published = true :
select * from post where category_id =2 and published
select count ( * ) from post where category_id =2 and published
select * from post where category_id =2 and published limit 20
category_id =3 and published = true :
select * from post where category_id =3 and published limit 20 offset 20
category_id =3 and published = false :
select count ( * ) from post where category_id =3 and not published
foo:
a =1 or b =10 :
or_sql
a in ( 2 , 3 ) and b =10 :
in_sql
a >1 and b =10 :
gt_sql
и т.д.
В реальности в инвалидаторах удобнее хранить списки ключей кеша, а не тексты запросов, тексты здесь для наглядности.
Посмотрим, что будет происходить при добавлении объекта. Мы должны пройти по всему списку инвалидаторов и стереть ключи кеша для условий, выполняющихся для добавленного объекта. Но инвалидаторов может быть много, и храниться они должны там же где сам кеш, т.е. скорее всего не в памяти процесса и загружать их все каждый раз не хотелось бы, да и последовательная проверка всех условий больно долга.
Очевидно, нужно как-то группировать и отсеивать инвалидаторы без их полной проверки. Заметим, что картина когда условия различаются только значениями. Например, инвалидаторы в модели post все имеют вид category_id=? and published=. Сгруппируем инвалидаторы из примера по схемам:
post:
category_id =? and published =? :
2 , true :
select * from post where category_id =2 and published
select count ( * ) from post where category_id =2 and published
select * from post where category_id =2 and published limit 20
3 , true :
select * from post where category_id =3 and published limit 20 offset 20
3 , false :
select count ( * ) from post where category_id =3 and not published
foo:
a =? or b =? :
1 , 10 :
or_sql
a in ? and b =? :
( 2 , 3 ), 10 :
in_sql
a > ? and b =? :
1 , 10 :
gt_sql
Обратим внимание на условие category_id=? and published=?, зная значения полей добавляемого поста, мы можем однозначно заполнить метки «?». Если объект:
то единственный подходящий инвалидатор из семейства будет category_id=2 and published=true и, следовательно нужно стереть соответствующие ему 3 ключа кеша. Т.е. не требуется последовательная проверка условий мы сразу получаем нужный инвалидатор по схеме и данным объекта.
Однако, что делать с более сложными условиями? В отдельных случаях кое-что можно сделать: or разложить на два инвалидатора, in развернуть в or. В остальных случаях либо придётся всё усложнить, либо сделать инвалидацию избыточной, отбросив такие условия. Приведём то, какими будут инвалидаторы для foo после таких преобразований:
foo:
a = ? :
1 : or_sql
b = ? :
10 : or_sql, gt_sql
a = ? and b = ? :
2 , 10 : in_sql
3 , 10 : in_sql
Таким образом, нам нужно для каждой модели только хранить схемы (просто списки полей), по которым при надобности мы строим инвалидаторы и запрашиваем списки ключей, которые следует стереть.
Приведу пример процедуры инвалидации для foo. Пусть мы запросили из базы объект
сменили значение a на 2 и записали обратно. При обновлении процедуру инвалидации следует прогонять и для старого, и для нового состояния объекта. Итак, инвалидаторы для старого состояния: a =1 , b =10 , a =1 and b =10 , соответствующие ключи or_sql и gt_sql (последний инвалидатор отсутсвует, можно считать пустым). Для нового состояния получаем инвалидаторы a =2 , b =10 , a =2 and b =10 , что добавляет ключ in_sql. В итоге стираются все 3 запроса.
Реализация
Я старался по-возможности абстрагироваться от языка и платформы, однако, рабочая и работающая в довольно нагруженном проекте система тоже существует. Подробнее о ней и о хитростях реализации вообще в следующей статье.
Инвалидация кеша
Инвалидация кеша — это процесс удаления всех кешированных объектов, связанных с изменениями в состоянии вашей модели. Наиболее распространённым типом инвалидации является прямое удаление объектов. Но если состояние первоначального источника распространилось на несколько кешированных объектов, то содержать их в синхронизрованном состоянии может быть сложно.
Компонент Symfony Cache предоставляет два механизма, чтобы помочь решить эту проблему:
- Инвалидация, основанная на тегах для управления зависимостями данных;
- Инвалидация, основанная на сроке действия для зависимостей, связанных со временем.
Использование тегов кеша
Чтобы получить преимущества инвалидации, основанной на тегах, вам нужно присоединить к каждому кешированному объекту правильные теги. Каждый тег — это простой идентификатор строки, который вы можете использовать в любое время, чтобы запустить удаление всех объектов, связанных с этим тегом.
Чтобы присоединить теги к кешированным объектам, вам нужно использовать метод tag(), который реализуется объектами кеша, возвращёнными адаптерами кеша:
1 2 3 4 5 6
$item = $cache->getItem('cache_key'); // . // добавьте один или несколько тегов $item->tag('tag_1'); $item->tag(array('tag_2', 'tag_3')); $cache->save($item);
Если $cache реализует TagAwareAdapterInterface, то вы можете нивалидировать кешированные объекты, вызвав invalidateTags():
1 2 3 4 5 6 7 8
// инвалидировать все объекты, связанные с `tag_1` или `tag_3` $cache->invalidateTags(array('tag_1', 'tag_3')); // если вы знаете ключ кеша, то вы также можете удалить объект напрямую $cache->deleteItem('cache_key'); // если вы не помните ключ объекта, то выможете использовать метод getKey() $cache->deleteItem($item->getKey());
Использование инвалидации тегов очень полезно, когда отслеживать ключи кеша становится сложно.
Адаптеры, знающие о тегах
Для того, чтобы хранить теги, вам нужно обернуть адаптер кеша классом TagAwareAdapter или реализовать TagAwareAdapterInterface и его единственнй метод invalidateTags().
Класс TagAwareAdapter реализует мгновенную инвалидацию (временная сложность — O(N) , где N — количество инвалидированных тегов). Ему требуется один или два адаптера кеша: первый, обязательный, используется для хранения кешированных объектов; второй, необязательный, используется для хранения тегав и их номера версии инвалидации (концептуально схоже с их последней датой инвалидации). Когда используется только один адаптер, объекты и теги все хранятся в одном месте. Используя 2 адаптера, вы можете, к примеру, хранить некоторые большие кешированные объекты в файловой системе или в DB, и держать теги в DB Redis, чтобы синхронизировать все ваши фронты и проводить очень быстрые проверки инвалидации:
1 2 3 4 5 6 7 8 9 10
use Symfony\Component\Cache\Adapter\TagAwareAdapter; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\RedisAdapter; $cache = new TagAwareAdapter( // адаптер для кешированных объектов new FilesystemAdapter(), // адаптер для тегов new RedisAdapter('redis://localhost') );
Начиная с Symfony 3.4, TagAwareAdapter реализует PruneableInterface, что позволяет вручную отсекать просроченные записи кеша, путём вызова его метода prune() (предполагая, что сам обёрнутый адаптер реализует PruneableInterface).
Использование срока действия кеша
Если ваши данные валидны только в течение ограниченного количества времени, вы можете указать из время жизни или дату истечения срока действия с интерфейсом PSR-6, как объясняется в статье Объекты кеша.
Symfony is a trademark of Symfony SAS. Переклад — Playtini. UA RU RU EN
Инвалидация кеша
Как только URL кеширован шлюзовым кешем, кеш больше не будет просить у приложения это содержимое. Это позволяет кешу предоставлять быстрые ответы и сокращает нагрузку на ваше приложение. Однако, вы рискуете отправкой устаревшего содержимого. Выходом из этой дилеммы будет использование длинных жизненных циклов кеша, с активным уведомлением шлюзового кеша при изменении содержимого. Обратные прокси обычно предоставляют канал для получения таких уведомлений, чаще всего через специальные HTTP-запросы.
Несмотря на то, что инвалидация кеша мощная, лучше её избегать при возможности. Если вы не сможете что-то инвалидировать, устаревшие кеши будут обслуживаться потенциально долгое время. Вместо этого, используйте короткие жизненные циклы кеша или используйте модель инвалидации и просто настраивайте ваши контроллеры так, чтобы они выполняли действенные проверки валидации, как объясняется в .
Более того, так как инвалидация — тема особенная для каждого типа обратного прокси, использование этого концепта привяжет вас к конкретному обратному прокси или необхдимости использования дополнительных усилий для поддержки разных прокси.
Но, иногда, вам нужна эта дополнительная производиельность, которую вы можете получить при ясной инвалидации. Для инвалидации, ваше приложение должно обранружить, когда содержимое изменяется и сообщать кешу об удалении URL, содержащих эти данные, из кеша.
Если вы хотите использовать инвалидацию кеша, посмотрите на FOSHttpCacheBundle. Этот пакет предоставляет сервисы, чтобы помочь вам с разнличными концептами инвалидации кеша, а также документирует конфигурацию для некольких распространённых кеширующих прокси.
Если одно содержимое соответствует одному URL, то хорошо работает модель PURGE . Вы отправляете запрос к кешу прокси с HTTP-методом PURGE (использование слова «PURGE» — это соглашение, технически это может быть любой строкой) вместо GET , и заставляеть прокси-кеш определить это и удалить данные из кеша, вместо того, чтобы обращаться к приложению за ответом.
Вот как вы можете сконфигурировать обратный прокси Symfony (см. HTTP-кеширование), чтобы он поддерживал HTTP-метод PURGE :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
// src/CacheKernel.php namespace App; use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; // . class CacheKernel extends HttpCache < protected function invalidate(Request $request, bool $catch = false): Response < if ('PURGE' !== $request->getMethod()) < return parent::invalidate($request, $catch); > if ('127.0.0.1' !== $request->getClientIp()) < return new Response( 'Invalid HTTP method', Response::HTTP_BAD_REQUEST ); > $response = new Response(); if ($this->getStore()->purge($request->getUri())) < $response->setStatusCode(Response::HTTP_OK, 'Purged'); > else < $response->setStatusCode(Response::HTTP_NOT_FOUND, 'Not found'); > return $response; > >
Вы должны как-то защитить HTTP-метод PURGE , чтобы избежать очистки ваших кешированных данных случайными пользователями.
Очистка заставляет кеш сбросить ресурс во всех его вариантах (в соответствии с заголовком Vary , см. Варьирование ответа для HTTP-кеша). Альтернативой очистке может быть обновление содержимого. Обновление означает, что кеширующий прокси должен удалить весь локальный кеш и получить содержимое снова. Таким образом, новое содержимое уже доступно в кеше. Недостаток обновления заключается в том, что варианты не инвалидируются.
Во многих приложениях, одна и та же часть содержимого используется на различных страницах с разными URL. Для таких случаев существуют более гибкие концепты:
- Запрещение инвалидирует ответы, совпадающие с ргулярными выражениями в URL или других критериях;
- Тегирование кеша позвляет вам добавлять тег для каждого содержимого, использованного в ответе, чтобы вы могли инвалидировать все URL, имеющие определённое содержимое.
Symfony is a trademark of Symfony SAS. Переклад — Playtini. UA RU RU EN