Redis как кэш
Перейти к содержимому

Redis как кэш

  • автор:

Redis на практических примерах

Redis — достаточно популярный инструмент, который из коробки поддерживает большое количество различных типов данных и методов работы с ними. Во многих проектах он используется в качестве кэшируещего слоя, но его возможности намного шире. Мы в ManyChat очень любим Redis и активно используем его в нашем продукте для решения огромного количества задач. Про некоторые интересные кейсы использования этой in-memory key-value базы данных я расскажу на примерах. Надеюсь, вам они будут полезны, и вы сможете применить что-то в своих проектах.

Рассмотрим следующие кейсы:

  • Кэширование данных (да, банально и скучно, но это классный инструмент для кэширования и обойти стороной этот кейс, кажется будет не правильно)
  • Работа с очередями на базе redis
  • Организация блокировок (mutex)
  • Делаем систему rate-limit
  • Pubsub — делаем рассылки сообщений на клиенты

Кэширование данных

Давайте начнем с самого простого, один из самых популярных кейсов использования Redis — кэширование данных. Будет полезно для тех, кто не работал с Redis. Для тех, кто уже давно пользуется этим инструментом — можно смело переходить к следующему кейсу. Для того, чтобы снизить нагрузку на БД, иметь возможность запрашивать часто используемые данные максимально быстро, используется кэш. Redis — это in-memory хранилище, то есть данные хранятся в оперативной памяти. Ещё это key-value хранилище, где доступ к данным по их ключу имеет сложность O(1) — поэтому данные мы получаем очень быстро.

Получение данных из хранилища выглядит следующим образом:

public function getValueFromCache(string $key) < return $this->getRedis()->rawCommand('GET', $key); > 

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

public function setValueToCache(string $key, $value) < $this->getRedis()->rawCommand('SET', $key, $value); > 

Таким образом, мы запишем данные в Redis и сможем их считать по тому же самому ключу в любой нужный нам момент. Но если мы будем все время писать в Redis, данные в нем будут занимать все больше и больше места в оперативной памяти. Нам нужно удалять нерелевантные данные, контролировать это вручную достаточно проблематично, поэтому пускай redis занимается этим самостоятельно. Добавим к нашему ключу TTL (время жизни ключа):

public function setValueToCache(string $key, $value, int $ttl = 3600) < $this->getRedis()->rawCommand('SET', $key, $value, 'EX', $ttl); >

По истечении времени ttl (в секундах) данные по этому ключу будут автоматически удалены.

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

public function dropValueFromCache(string $key) < $this->getRedis()->rawCommand('DEL', $key); >

Также редис позволяет получить массив значений по списку ключей:

public function getValuesFromCache(array $keys) < return $this->getRedis()->rawCommand('MGET', . $keys); >

И соответственно массовое удаление данных по массиву ключей:

public function dropValuesFromCache(array $keys) < $this->getRedis()->rawCommand('MDEL', . $keys); >

Очереди

Используя имеющиеся в Redis структуры данных, мы можем запросто реализовать стандартные очереди FIFO или LIFO. Для этого используем структуру List и методы по работе с ней. Работа с очередями состоит из двух основных действий: отправить задачу в очередь, и взять задачу из очереди. Отправлять задачи в очередь мы можем из любой части системы. Получением задачи из очереди и ее обработкой обычно занимается выделенный процесс, который называется консьюмером (consumer).

Итак, для того, чтобы отправить нашу задачу в очередь, нам достаточно использовать следующий метод:

public function pushToQueue(string $queueName, $payload) < $this->getRedis()->rawCommand('RPUSH', $queueName, serialize($payload)); >

Тем самым мы добавим в конец листа с названием $queueName некий $payload, который может представлять из себя JSON для инициализации нужной нам бизнес логики (например данные по денежной транзакции, данные для инициализации отправки письма пользователю, etc.). Если же в нашем хранилище не существует листа с именем $queueName, он будет автоматически создан, и туда попадет первый элемент $payload.

Со стороны консьюмера нам необходимо обеспечить получение задач из очереди, это реализуется простой командой чтения из листа. Для реализации FIFO очереди мы используем чтение с обратной записи стороны (в нашем случае мы писали через RPUSH), то есть читать будем через LPOP:

public function popFromQueue(string $queueName) < return $this->getRedis()->rawCommand('LPOP', $queueName); >

Для реализации LIFO очереди, нам нужно будет читать лист с той же стороны, с которой мы в него пишем, то есть через RPOP.

Тем самым мы вычитываем по одному сообщению из очереди. В случае если листа не существует (он пустой), то мы получим NULL. Каркас консьюмера мог бы выглядеть так:

class Consumer < private string $queueName; public function __construct(string $queueName) < $this->queueName = $queueName; > public function run() < while (true) < //Вычитываем в бесконечном цикле нашу очередь $payload = $this->popFromQueue(); if ($payload === null) < //Если мы получили NULL, значит очередь пустая, сделаем небольшую паузу в ожидании новых сообщений sleep(1); continue; >//Если очередь не пустая и мы получили $payload, то запускаем обработку этого $payload $this->process($payload); > > private function popFromQueue() < return $this->getRedis()->rawCommand('LPOP', $this->queueName); > >

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

public function getQueueLength(string $queueName) < return $this->getRedis()->rawCommand('LLEN', $queueName); >

Мы рассмотрели базовую реализацию простых очередей, но Redis позволяет строить более сложные очереди. Например, мы хотим знать о времени последней активности наших пользователей на сайте. Нам не важно знать это с точностью вплоть до секунды, приемлемая погрешность — 3 минуты. Мы можем обновлять поле last_visit пользователя при каждом запросе на наш бэкенд от этого пользователя. Но если этих пользователей большое количество в онлайне — 10,000 или 100,000? А если у нас еще и SPA, которое отправляет много асинхронных запросов? Если на каждый такой запрос обновлять поле в бд, мы получим большое количество тупых запросов к нашей БД. Эту задачу можно решать разными способами, один из вариантов — это сделать некую отложенную очередь, в рамках которой мы будем схлопывать одинаковые задачи в одну в определенном промежутке времени. Здесь на помощь нам придет такая структура, как Sorted SET. Это взвешенное множество, каждый элемент которого имеет свой вес (score). А что если в качестве score мы будем использовать timestamp добавления элемента в этот sorted set? Тогда мы сможем организовать очередь, в которой можно будет откладывать некоторые события на определенное время. Для этого используем следующую функцию:

public function pushToDelayedQueue(string $queueName, $payload, int $delay = 180) < $this->getRedis()->rawCommand('ZADD', $queueName, 'NX', time() + $delay, serialize($payload)) >

В такой схеме идентификатор пользователя, зашедшего на сайт, попадет в очередь $queueName и будет висеть там в течение 180 секунд. Все другие запросы в рамках этого времени будут также отправляться в эту очередь, но они не будут туда добавлены, так как идентификатор этого пользователя уже существует в этой очереди и продублирован он не будет (за это отвечает параметр ‘NX’). Так мы отсекаем всю лишнюю нагрузку и каждый пользователь будет генерить не более одного запроса в 3 минуты на обновление поля last_visit.

Теперь возникает вопрос о том, как читать эту очередь. Если методы LPOP и RPOP для листа читают значение и удаляют его из листа атомарно (это значит, что одно и тоже значение не может быть взято несколькими консьюмерами), то sorted set такого метода из коробки не имеет. Мы можем сделать чтение и удаление элемента только двумя последовательными командами. Но мы можем выполнить эти команды атомарно, используя простой LUA скрипт!

public function popFromDelayedQueue(string $queueName) < $command = 'eval " local val = redis.call(\'ZRANGEBYSCORE\', KEYS[1], 0, ARGV[1], \'LIMIT\', 0, 1)[1] if val then redis.call(\'ZREM\', KEYS[1], val) end return val" '; return $this->getRedis()->rawCommand($command, 1, $queueName, time()); >

В этом LUA скрипте мы пытаемся получить первое значение с весом в диапазоне от 0 до текущего timestamp в переменную val с помощью команды ZRANGEBYSCORE, если нам удалось получить это значение, то удаляем его из sorted set командой ZREM и возвращаем само значение val. Все эти операции выполняются атомарно. Таким образом мы можем вычитывать нашу очередь в консьюмере, аналогично с примером очереди построенной на структуре LIST.

Я рассказал про несколько базовых паттернов очередей, реализованных в нашей системе. На текущий момент у нас в продакшене существуют более сложные механизмы построения очередей — линейных, составных, шардированных. При этом Redis позволяет все это делать при помощи смекалки и готовых круто работающих структур из коробки, без сложного программирования.

Блокировки (Mutex)

Mutex (блокировка) — это механизм синхронизации доступа к shared ресурсу нескольких процессов, тем самым гарантируя, что только один процесс будет взаимодействовать с этим ресурсом в единицу времени. Этот механизм часто применяется в биллинге и других системах, где важно соблюдать потоковую безопасность (thread safety).

Для реализации mutex на базе Redis прекрасно подойдет стандартный метод SET с дополнительными параметрами:

public function lock(string $key, string $hash, int $ttl = 10): bool < return (bool)$this->getRedis()->rawCommand('SET', $key, $hash, 'NX', 'EX', $ttl); >

где параметрами для установки mutex являются:

  • $key — ключ идентифицирующий mutex;
  • $hash — генерируем некую подпись, которая идентифицирует того, кто поставил mutex. Мы же не хотим, чтобы кто-то в другом месте случайно снял блокировку и вся наша логика рассыпалась.
  • $ttl — время в секундах, которое мы отводим на блокировку (на тот случай, если что-то пойдет не так, например процесс, поставивший блокировку, по какой-то причине умер и не снял ее, чтобы это блокировка не висела бесконечно).

Чаще всего, когда мы пишем код, который пытается работать с shared ресурсом, который заблокирован, мы хотим дождаться его разблокировки и продолжить работу с этим ресурсом. Для этого можем реализовать простой метод для ожидания освободившегося ресурса:

public function tryLock(string $key, string $hash, int $timeout, int $ttl = 10): bool < $startTime = microtime(true); while (!this->lock($key, $hash, $ttl)) < if ((microtime(true) - $startTime) >$timeout) < return false; // не удалось взять shared ресурс под блокировку за указанный $timeout >usleep(500 * 1000) //ждем 500 миллисекунд до следующей попытки поставить блокировку > return true; //блокировка успешно поставлена >

Мы разобрались как ставить блокировку, теперь нам нужно научиться ее снимать. Для того, чтобы гарантировать снятие блокировки тем процессом, который ее установил, нам понадобится перед удалением значения из хранилища Redis, сверить хранимый хэш по этому ключу. Для того, чтобы сделать это атомарно, воспользуемся LUA скриптом:

public function releaseLock(string $key, string $hash): bool < $command = 'eval " if redis.call("GET",KEYS[1])==ARGV[1] then return redis.call("DEL",KEYS[1]) else return 0 end" '; return (bool) $this->getRedis()->rawCommand($command, 1, $key, $hash); >

Здесь мы пытаемся найти с помощью команды GET значение по ключу $key, если оно равно значению $hash, то удаляем его при помощи команды DEL, которая вернет нам количество удаленных ключей, если же значения по ключу $key не существует, или оно не равно значению $hash, то мы возвращаем 0, что значит блокировку снять не удалось. Базовый пример использования mutex:

class Billing < public function charge(int $userId, int $amount) < $mutexName = sprintf('billing_%d', $userId); $hash = sha1(sprintf('billing_%d_%d'), $userId, mt_rand()); //генерим некий хэш запущенного потока if (!$this->tryLock($mutexName, $hash, 10)) < //пытаемся поставить блокировку в течение 10 секунд throw new Exception('Не получилось поставить lock, shared ресурс занят'); >//lock получен, процессим бизнес-логику $this->doSomeLogick(); //освобождаем shared ресурс, снимаем блокировку $this->releaseLock($mutexName, $hash); > >

Rate limiter

Достаточно частая задача, когда мы хотим ограничить количество запросов к нашему апи. Например на один API endpoint от одного аккаунта мы хотим принимать не более 100 запросов в минуту. Эта задача легко решается с помощью нашего любимого Redis:

public function isLimitReached(string $method, int $userId, int $limit): bool < $currentTime = time(); $timeWindow = $currentTime - ($currentTime % 60); //Так как наш rate limit имеет ограничение 100 запросов в минуту, //то округляем текущий timestamp до начала минуты — это будет частью нашего ключа, //по которому мы будем считать количество запросов $key = sprintf('api_%s_%d_%d', $method, $userId, $timeWindow); //генерируем ключ для счетчика, соответственно каждую минуту он будет меняться исходя из $timeWindow $count = $this->getRedis()->rawCommand('INCR', $key); //метод INCR увеличивает значение по указанному ключу, и возвращает новое значение. //Если ключа не существует, он будут инициализирован со значением 0 и после этого увеличен $this->getRedis()->rawCommand('EXPIRE', $key, 60); // Обновляем TTL нашему ключу, выставляя его в минуту, для того, чтобы не накапливать не актуальные данные if ($count > $limit) < //limit достигнут return true; >return false; > 

Таким простым методом мы можем лимитировать количество запросов к нашему API, базовый каркас нашего контроллера мог бы выглядеть следующим образом:

class FooController < public function actionBar() < if ($this->isLimitReached(__METHOD__, $this->getUserId(), 100)) < throw new Exception('API method max limit reached'); >$this->doSomeLogick(); > >

Pub/sub

Pub/sub — интересный механизм, который позволяет, с одной стороны, подписаться на канал и получать сообщения из него, с другой стороны — отправлять в этот канал сообщение, которое будет получено всеми подписчиками. Наверное у многих, кто работал с вебсокетами, возникла аналогия с этим механизмом, они действительно очень похожи. Механизм pub/sub не гарантирует доставки сообщений, он не гарантирует консистентности, поэтому не стоит его использовать в системах, для которых важны эти критерии. Однако рассмотрим этот механизм на практическом примере. Предположим, что у нас есть большое количество демонизированных команд, которыми мы хотим централизованно управлять. При инициализации нашей команды мы подписываемся на канал, через который будем получать сообщения с инструкциями. С другой стороны у нас есть управляющий скрипт, который отправляет сообщения с инструкциям в указанный канал. К сожалению, стандартный PHP работает в одном блокирующем потоке; для того, чтобы реализовать задуманное, используем ReactPHP и реализованный под него клиент Redis.

Подписка на канал:

class FooDaemon < private $throttleParam = 10; public function run() < $loop = React\EventLoop\Factory::create(); //инициализируем event-loop ReactPHP $redisClient = $this->getRedis($loop); //инициализируем клиента Redis для ReactPHP $redisClient->subscribe(__CLASS__); // подписываемся на нужный нам канал в Redis, в нашем примере название канала соответствует названию класса $redisClient->on('message', static function($channel, $payload) < //слушаем события message, при возникновении такого события, получаем channel и payload switch (true) < // Здесь может быть любая логика обработки сообщений, в качестве примера пускай будет так: case \is_int($payload): //Если к нам пришло число – обновим параметр $throttleParam на полученное значение $this->throttleParam = $payload; break; case $payload === 'exit': //Если к нам пришла команда 'exit' – завершим выполнение скрипта exit; default: //Если пришло что-то другое, то просто залогируем это $this->log($payload); break; > >); $loop->addPeriodicTimer(0, function() < $this->doSomeLogick(); // Здесь в бесконечном цикле может выполняться какая-то логика, например чтение задач из очереди и их процессинг >); $loop->run(); //Запускаем наш event-loop > >

Отправка сообщения в канал — более простое действие, мы можем сделать это абсолютно из любого места системы одной командой:

public function publishMessage($channel, $message) < $this->getRedis()->publish($channel, $message); >

В результате такой отправки сообщения в канал, все клиенты, которые подписаны на данный канал, получат это сообщение.

Итог

Мы рассмотрели 5 примеров использования Redis на практике, надеюсь что каждый найдет для себя что-то интересное. В нашем стэке технологий Redis занимает важное место, мы любим этот инструмент за его скорость и гибкость. Мы используем Redis в продакшене уже много лет, и он зарекомендовал себя как очень крутой и надежный инструмент, который лежит в основе многих частей нашего продукта. Наш небольшой кластер Redis серверов обрабатывает около 1 миллиона запросов в секунду. А как вы используете Redis в своем проекте? Делитесь опытом в комментариях!

  • Блог компании ManyChat
  • PHP
  • Программирование
  • Анализ и проектирование систем
  • NoSQL

Как работает Redis. Особенности кэширования

Почему Redis хорош для хранения сессионных данных: разбираемся в устройстве, преимуществах и имплементируем систему.

Изображение записи

Redis — одна из самых популярных NoSQL баз данных. Рассказываем о функциональности и практиках использования.

Особенности Redis

Почему Redis стал таким популярным и продолжает активно развиваться?

  • Redis очень быстрый. Он обеспечивает моментальный доступ к данным, поскольку хранит их в оперативной памяти (in-memory).
  • Redis легко масштабируется горизонтально (до 1000 нод на практике).
  • Кластеры Redis поддерживают master-slave репликацию и могут быть отказоустойчивыми. При потере связи с мастер-нодой, кластер автоматически повышает одну из ее реплик до мастера, а вышедшую из строя ноду пересоздает.
  • Большое комьюнити и обилие документации.
  • Redis проще в обслуживании, чем аналоги вроде Memcached или Tarantool + почти не уступает им в функциональности, а где-то даже опережает.

Redis отлично подходит для кэширования и хранения сессионной информации. Например, хранения маршрутов или пользовательских контактов во время онлайн-покупок. Redis подходит для работы с различными счетчиками и метриками. В нем также можно создавать различные стратегии очистки ключей.

В отличие от других популярных in-memory БД, например, Memcached, Redis умеет работать с хранением структурированных данных.

Redis поддерживает пять типов данных и пару надстроек. Каждый тип данных охватывает различные операции.

  • String — любые значения до 512 Мб.
  • Hash — хэш-таблицы для хранения объектов.
  • List — список строковых значений.
  • Set — не повторяющийся список строковых значений.
  • Sorted set — отсортированный set.
  • Bitmap — набор битовых операций поверх string.
  • Hyper loglog — вероятностная структура данных для подсчета количества уникальных элементов в множестве (cardinality, мощность множества) без сохранения самих данных.

Кроме этого, Redis может периодически записывать данные на диск. Полную защиту от потери данных решение не гарантирует, но все равно эта опция добавляет надежности. Подробнее о том, что такое Redis и кому подойдет эта БД, читайте в этом материале.

Redis относится к однопоточным БД. Команды не обрабатываются параллельно, а только в определенной последовательности, поэтому с его помощью часто выстраивают систему очередей.

В таком случае Redis становится промежуточным звеном между веб-сервером (producer) и программой (consumer). Например, используя тип данных list, можно разгрузить сервер и записывать данные в очередь, из которой их позже будет забирать consumer. Благодаря командам LPUSH и RPUSH в Redis также реализована функциональность, что новые значения можно записывать в начало или конец очереди.

О том, как работают очереди в Redis мы расскажем в одном из следующих материалов.

Как работает кэширование в Redis

Чаще всего Redis используют как вспомогательную базу данных (или кэш-сервер), которая работает в паре с объектно-реляционными системами управления БД, например, PostgreSQL. Парное использование БД решает проблему быстрой загрузки мелких часто обновляемых объектов и защищает от потерь важных данных.

Кэш – это как посредник между хранилищем и данными, которые запрашивает клиент. Если настроить/использовать кэш правильно, это снижает нагрузку на сервера БД.

Запрос пользователя сначала уходит в Redis и проверяет наличие какого-либо значения по ключу. Если оно есть, то пользователю будет дан ответ из Redis, а не напрямую из основной базы данных. Если значения нет, то БД также сообщит Redis запомнить связку ключ-значение для этого запроса.

Эффект хорошо виден в высоконагруженных системах, когда группы данных запрашиваются действительно часто. При нагрузке в 100 пользователей/час, особой пользы от кэширования не будет.

Помимо того, что Redis является кэш-сервером, он также является сервером структур данных.

То есть Redis может построить список, хэш-карту, ранжированное множество и предоставить эти возможности простым способом, что значительно облегчает разработку.

Имплементация Redis

Для работы с Python 3 сначала нужно установить Redis-библиотеку, а далее подключаться через Import.

Примера того, как должно выглядеть соединение:

Import redis Import sqlite3 def get_my_friends(): connection = sqlite3.connect(database="database.db") cursor = connection.cursor() redis_client = redis.Redis() # в промежуточной строчке мы спрашиваем у Redis наличие ключа. cache_value = redis_client.get("user") # здесь прописывается уникальный ключ для базы данных, например, id пользователя. if cache_value is not None: return json.loads(cache_value) cursor.execute("Select id, name FROM users;") result = cursor.fetchall() redis_client.set("user", json.dumps(result)) cursor.close() redis_client.close() return result 

В боевых проектах эту конструкцию нужно как минимум обернуть в try except.

Заключение

Использование Redis в проектах помогает разгрузить основную базу данных и управлять ресурсами сервера более эффективно. Имплементация Redis помогает высоконагруженным системам предоставлять высокий уровень сервиса всем клиентам.

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

Redis Cache

By default, Quarkus Cache uses Caffeine as backend. It’s possible to use Redis instead.

This technology is considered preview.

In preview, backward compatibility and presence in the ecosystem is not guaranteed. Specific improvements might require changing configuration or APIs, and plans to become stable are under way. Feedback is welcome on our mailing list or as issues in our GitHub issue tracker.

For a full list of possible statuses, check our FAQ entry.

Redis as cache backend

When using Redis as the backend for Quarkus cache, each cached item will be stored in Redis:

  • The backend uses the Redis client (if not configured otherwise), so make sure it’s configured (or use the redis dev service)
  • the Redis key is built as follows: cache:$cache-name:$cache-key , where cache-key is the key the application uses.
  • the value is encoded to JSON if needed

Use the Redis backend

First, you need to add the quarkus-redis-cache extension to your project:

 io.quarkus quarkus-redis-cache 

build.gradle

implementation("io.quarkus:quarkus-redis-cache")

Then, use the @CacheResult and others cache annotations as explained in the Quarkus Cache guide:

@GET @Path("///") @CacheResult(cacheName = "expensiveResourceCache") public ExpensiveResponse getExpensiveResponse(@PathParam("keyElement1") @CacheKey String keyElement1, @PathParam("keyElement2") @CacheKey String keyElement2, @PathParam("keyElement3") @CacheKey String keyElement3, @QueryParam("foo") String foo) < invocations.incrementAndGet(); ExpensiveResponse response = new ExpensiveResponse(); response.setResult(keyElement1 + " " + keyElement2 + " " + keyElement3 + " too!"); return response; >@POST @CacheInvalidateAll(cacheName = "expensiveResourceCache") public void invalidateAll()

Configure the Redis backend

The Redis backend uses the Redis client. See the Redis reference to configure the access to Redis.

In dev mode, you can use the Redis Dev Service.

If you want to use another Redis for your cache, configure the client-name as follows:

quarkus.cache.redis.client-name=my-redis-for-cache

When writing to Redis or reading from Redis, Quarkus needs to know the type. Indeed, the objects need to be serialized and deserialized. For that purpose, you may need to configure type (class names) of the key and value you want to cache. At build time, Quarkus tries to deduce the types from the application code, but that decision can be overridden using:

# Default configuration quarkus.cache.redis.key-type=java.lang.String quarkus.cache.redis.value-type=org.acme.Person # Configuration for `expensiveResourceCache` quarkus.cache.redis.expensiveResourceCache.key-type=java.lang.String quarkus.cache.redis.expensiveResourceCache.value-type=org.acme.Supes

You can also configure the time to live of the cached entries:

# Default configuration quarkus.cache.redis.expire-after-write=10s # Configuration for `expensiveResourceCache` quarkus.cache.redis.expensiveResourceCache.expire-after-write=1h

If the expire-after-write is not configured, the entry won’t be evicted. You would need to invalidate the values using the @CacheInvalidateAll or @CacheInvalidate annotations.

The following table lists the supported properties:

Configuration property fixed at build time — All other configuration properties are overridable at runtime

Кэш — Основы Redis

На сегодняшний день время отклика на запрос является критичной метрикой для любого проекта. Пользователи не очень любят, когда web-страница формируется долго (несколько секунд или дольше). Поэтому разработчики стремятся уменьшить время ответа на запрос клиента, для чего прибегают к кэшированию.

Представим, что разрабатывается торговая платформа и есть личный кабинет с аналитикой сделок пользователя. Нужно выводить количество сделок, прибыль, средний оборот и тд. Большинство метрик являются агрегационными, то есть их нужно высчитывать. Если использовать только РСУБД, то среднее время ответа будет сотни миллисекунд, а при большой нагрузке — секунды. Можно подключить подходящие аналитические базы данных, но чаще всего это слишком дорого в средних проектах.

Самая распространенная практика решения сегодня — это кэш. Сложные вычисления кэшируются, то есть записываются в оперативную память. Обращение к оперативной памяти займет сотни наносекунд, примерно в миллион раз быстрее, чем вычислить данные из РСУБД. Новая схема взаимодействия будет включать кэширующий сервер. Алгоритм следующий:

  1. клиент делает запрос
  2. бекенд проверяет кэш. Если значение есть в кэше, то оно просто возвращается клиенту
  3. если значения в кэше нет, то сервер вычисляет все из базы, записывает числа в кэш и отдает клиенту. Теперь все последующие запросы будут возвращать результаты вычислений из кэша

Подводные камни

Как у любого решения в разработке, кэширование — не серебряная пуля и имеет свои недостатки. Несколько моментов, которые стоит держать в уме при использовании кэша:

  • приложение должно уметь работать без кэша. Кэширующий сервер упал или недоступен? Пользователь все равно должен получать все данные просто с небольшой задержкой. Завязывать какую-то логику продукта на кэш (например, хранить данные аналитики только в кэше) — нельзя, потому что это ненадежное хранилище.
  • с введением нового звена в архитектуру всегда следует усложнение разработки и поддержки продукта. Проверка и запись значений в кэше — это дополнительная логика в коде, которая требует дополнительного времени на тестирование и исправление потенциальных ошибок.
  • сколько времени должно храниться значение в кэше? Что делать, если значение невалидно, так как пользователь открыл новую сделку или получил прибыль? Эти вопросы будут возникать постоянно, и в каждом проекте ответы будут уникальными.

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 студентов

Наши выпускники работают в компаниях:

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *