Эта статья — переработанная версия доклада Отказоустойчивая работа с Redis с прошедшего 17 октября 2020 митапа PHP-разработчиков Йошкар-Олы.

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

Немного о Redis

Redis — это NoSQL СУБД с открытым исходным кодом, работающая с данными вида «ключ — значение». Если вы захотите узнать о преимуществах Redis, вы наверняка найдёте примерно такой список:

  1. Redis хранит все данные в оперативной памяти, что повышает производительность

  2. В Redis нет SQL и схемы хранилища, что опять же повышает производительность (нет интерпретатора SQL) и даёт гибкость

  3. Нет никаких ACID транзакций, изменения просто сохраняются на диск в фоне, что тоже повышает производительность

Иначе говоря, Redis может быть очень быстрым.

Но давайте взглянем на это с точки зрения надёжности и отказоустойчивости:

  1. Redis хранит все данные в оперативной памяти, и при аварийном завершении процесса или выключении машины данные будут потеряны

  2. Redis не ориентирован на ACID транзакции, и на практике значит:

    • даже при включённом сохранении на диск данные могут быть потеряны частично либо полностью

    • в данных может быть нарушена целостность с точки зрения приложения

    • фоновое сохранение данных на диск может нанести ущерб производительности и отказоустойчивости

Значит ли это, что Redis плох? Вовсе нет. Наша статья о том, что при внедрении Redis в проект нужно рассматривать разные варианты и принимать взвешенные решения, а не верить слепо, что всё уже предусмотрено умными дядьками и никаких проблем на production не возникнет.

Теперь поговорим про это подробнее.

Сценарии отказа Redis

Допустим, мы используем классический метод развёртывания веб-приложений, и у нас есть две машины — на одной развёрнуто PHP приложение, на другой — Redis.

Обычно в PHP приложении на каждый HTTP-запрос создаётся новый контекст PHP-интерпретатора, со своей памятью и ресурсами. Чтобы обратиться к Redis (например, для чтения/записи данных сессии), приложению придётся установить соединение с Redis, а потом отправить одну или несколько команд — например, SETEX key, value, ttl. С точки зрения Redis, есть 4 этапа обработки первой полученной команды:

  1. Приём соединения

  2. Приём команды

  3. Ожидание в очереди

  4. Обработка команды

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

Во-первых, Redis может упасть:

В этом случае приложение попытается установить соединение, но не сможет (сразу либо после произвольного таймаута, установленного для библиотеки Predis). Будет создано исключение, к которому код может быть не готов.

Во-вторых, может нарушиться сетевая связность:

В этом случае бесполезное ожидание и отказ по таймауту неизбежны, что может привести к веерным отказам: сначала бесполезные таймауты под нагрузкой приведут к повышению числа активных процессов php-fpm и числа соединений с СУБД, а потом закончится либо одно, либо другое, клиенты начнут получать 500-е и 504-е коды ошибок — сервис перестанет их обслуживать.

В-третьих, под высокой нагрузкой Redis может не успеть обработать полученную команду:

С точки зрения приложения произойдёт отказ по таймауту, с рисками веерного отказа. Что ещё хуже, веерные отказы могут произойти и без явных отказов Redis: приложение просто будет ждать дольше обычного и исчерпаются соединения с СУБД либо процессы php-fpm.

Наконец, у Redis может кончиться память, и тогда всё зависит от настроек maxmemory и maxmemory-policy. По умолчанию maxmemory-policy имеет значение noeviction, и это означает, что при нехватке памяти Redis перейдёт в режим readonly:

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

Сценарии отказа Redis в Kubernetes

Давайте сгустим краски ещё больше: добавим Kubernetes и засунем в него Redis.

Kubernetes — это система управления кластером. Он запускает N приложений в контейнерах на меньшем числе машин, распределяя нагрузку такими сложными путями, что для сопровождения проекта в Kubernetes вам потребуется отдельная команда (если у вас всё кросс-функциональное, тогда её размажет ровным слоем по остальным командам).

Допустим, у нас есть кластер из 4 worker'ов, управляемых Kubernetes. Допустим, Kubernetes раскидал экземпляры PHP-приложения и Redis так, как показано на картинке:

После этого worker #3 вывели на обслуживание, в результате мы теряем единственный экземпляр Redis и 2 из 4 экземпляров приложения и получаем кратковременный отказ. Если Redis прислал свои данные не на сетевой диск, то получаем ещё и потерю всех его данных. Если же он писал на сетевой диск, то делал это медленно, поскрипывая винчестерами на весь дата-центр и мешая дежурным спать.

Как бы развернуть Redis, чтобы потеря одной worker-машины не создавала проблем?

У Redis предусмотрено два способа организации отказоустойчивости: Redis Sentinel и Redis Cluster. Прочитав документацию и собрав факты, вы увидите следующее:

  1. Обе реализации являются скорее кирпичиками для построения отказоустойчивости с автоматическим failover

  2. Redis Cluster имеет некоторую автоматизацию, но эта разница нивелируется в Kubernetes за счёт Redis Operator (например, spotahome/redis-operator), который основан на Redis Sentinel

  3. Redis Cluster даёт шардирование, Redis Sentinel его не даст

Если вы хотите производительность выше той космической, что можно выжать из одного инстанса, подумайте о Redis Cluster вне Kubernetes. Если вы хотите засунуть Redis в Kuberntes, используйте Redis + Sentinel + Redis Operator.

Отказоустойчивость Sentinel и Cluster имеет свою цену:

  1. Оба варианта меняют протокол взаимодействия с Redis, и клиент должен поддерживать новый вариант

  2. Как минимум 3 экземпляра Redis Sentinel нужно, чтобы достигнуть кворума при выборке master

  3. Сам Redis Sentinel нового мастера не назначит, его надо попросить, что и делает Redis Operator

Взаимодействие PHP-приложения с Redis Sentinel показано на схеме:

Сначала приложение обращается к Redis Sentinel и узнаёт, какой из экземпляров Redis является мастером. Затем приложение обращается к нужному экземпляру Redis.

Допустим, в нашем кластере распределено 3 redis и 3 redis-sentinel, причём redis master и один из sentinel оказались на одной машине #3:

После этого worker #3 вывели на обслуживание, в результате мы теряем redis master и один из redis-sentinel. Пока redis-sentinel не перезапустится на другой машине, а redis operator не попросит всех троих выбрать нового master, redis останется недоступен (по крайней мере на запись).

Чтобы PHP-приложение (или, например, NGINX/Lua) не обращалось к redis-sentinel на каждый запрос, можно ввести ещё одного игрока: redis-proxy (например, ifwe/twemproxy). Этот proxy является stateless, он может быть запущен внутри контейнера PHP-приложения либо как sidecar-контейнер, чтобы минимизировать задержки сети.

Взаимодействие PHP-приложения с Redis Proxy показано на схеме:

Возможен ещё один вариант отказа из статьи Одна история с оператором Redis в K8s и мини-обзор утилит для анализа данных этой БД

  1. Worker, на котором находится redis master, выводится на обслуживание

  2. Запускается новый redis

  3. Посовещавшись, тройка redis sentinel выбирает новый redis мастером

  4. Новый и абсолютно пустой redis в роли мастера просит реплики удалить все данные

Баг, приводящий к такому сценарию, исправили в spotahome/redis-operator, но кто даст гарантию, что это был последний баг?

Избежать проблемы можно путём добавления сетевого диска — например, NFS. Тогда NFS станет ещё одной точкой отказа.

Так что же делать?

Если вы на этапе проектирования, подумайте:

  1. Так ли вам нужен Redis в этом классе/модуле/сервисе? избегайте хранения в Redis важных данные или данных, требующих контроля целостности

  2. Так ли вам нужен Redis в Kubernetes, или подойдёт Redis на отдельной машине?

В целом стоит насторожиться, когда кто-то предлагает засунуть хранилища данных в Kubernetes. Если это предлагает коллега, расскажите ему анекдот про мужика, сено и скафандр.

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

  1. В проекте уже используют Redis

    • возражение: важно понимать, для чего именно использован Redis и на какие компромиссы при этом согласились

  2. Данные имеют time to life

    • возражение: это не мешает хранить их в SQL СУБД

  3. Redis быстрее SQL СУБД

    • возражение: перегруженный redis работает медленее, кроме того, взаимодействие с двумя хранилищами вместо одного добавляет сетевые задержки и точки отказа

Достойная причина использовать Redis — высокие нагрузки и данные, которые не так страшно потерять.

Как улучшить отказоустойчивость

Сложная система порождает новые сценарии катастроф. Что ещё хуже, вы не можете заранее предсказать все риски. Но можно практиковать избыточность, нагрузочное тестирование и тестирование тех сценариев отказа, которые вы можете предсказать.

Избыточность может проявляться по-разному:

  1. В дополнительных сущностях: redis-sentinel, redis-operator, redis-proxy

  2. В распределении redis по разным машинам или даже разным дата-центрам

  3. В дополнительной отказоустойчивости со стороны приложения, а не инфраструктуры

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

Делаем кэширование необязательным

Допустим, наше приложение на PHP использует Redis как кэш. В таком случае мы должны:

  1. При записи сохранять данные и в Redis, и в основную БД

  2. При чтении читать сначала из Redis, а при ошибке — читать из БД

  3. Сохранить небольшой timeout для клиента Redis — например, 100 или 300 миллисекунд

Рассмотрим схему классов для хранения данных сессии:

  1. Интерфейс KeyValueCacheInterface объявляет обобщённый API для работы с кэшем, за которым скрыт Redis

  2. Интерфейс SessionStorageInterface объявляет хранилище данных сессии

    • реализация DatabaseSessionStorage хранит сессии в таблице в БД

    • реализация KeyValueCacheSessionStorage хранит сессии в кэше

  3. SessionService использует оба варианта хранилища, но работает с ними по-разному

При чтении данных в SessionService сначала пытаемся читать из Redis, в случае ошибки — читаем из БД:

private function doRead(callable $job)
{
   try
   {
       return $job($this->cacheStorage);
   }
   catch (KeyValueCacheNotAvailableException $e)
   {
       $this->loggerService->error(
           'failed to read from session cache',
           ['exception' => $e]
       );
       return $job($this->storage);
   }
}

Для обновления данных записываем и Redis, и в БД:

private function doUpdate(callable $job): void
{
   try
   {
       $job($this->cacheStorage);
   }
   catch (KeyValueCacheNotAvailableException $e)
   {
       $this->loggerService->error(
           'failed to write into session cache',
           ['exception' => $e]
       );
   }
   $job($this->storage);
}	

Такое решение имеет свои ограничения:

  1. Возрастёт нагрузка на БД — в сравнении с хранением данных только в Redis

  2. После отказа и восстановления кэша в нём появляются неактуальные данные, записанные в БД в период отказа

Усовершенствование в хранении сессий

На примере всё тех же сессий посмотрим, как можно усовершенствовать механизм отказоустойчивости.

Допустим, у нас есть сессии, которые хранятся в трёх хранилищах: session id хранится в Cookie, а данные сессии хранятся одновременно и в Redis, и в MySQL:

Сессию надо периодически обновлять, чтобы при активном использовании TTL (time to life) не истёк и сессия не закончилась. Чтобы уменьшить частоту обновления сессий, мы можем добавить в данные сессии дату последнего обновления, и обновлять сессию в обработчике запроса только в том случае, если с последнего обновления прошло больше определённого числа секунд (например, 60 секунд).

Кстати, именно так ведут себя современные PHP фреймворки — например, Symfony.

Обновлённое расположение данных будет выглядеть так:

Чтобы решить проблему инвалидации кэша при его недоступности, мы можем добавить в Cookie ещё одно значение: целочисленный generation number данных сессии, который увеличивается на единицу при каждом изменении данных сессии, и предпочитать чтение данных из БД в случае, если generation number в Redis не совпадает с тем, что пришёл из Cookie:

Сессии в Signed Cookies

Идею хранения данных в Cookie можно развить, но для этого надо сделать Cookie надёжным хранилищем, в котором нельзя подменить данные. Этого можно достигнуть с помощью Signed Cookies — например, можно в качестве значения хранить JWT (JSON Web Token). В этом случае:

  1. Секретный ключ для подписи JWT хранится на сервере, и пользователь не может подменить содержимое Cookie, не нарушив целостность подписи JWT.

  2. Секретные данные в Signed Cookie хранить по-прежнему нельзя, т.к. полезная нагрузка в JWT хранится в открытом виде

С таким подходом основные данные сессии можно переместить в Cookie небольшого размера (не более 1-2 КБ), и до истечения JWT вообще не обращаться к Redis и MySQL. Короткое время жизни такой Cookie обеспечит баланс между временем инвалидации данных и снижением нагрузки на сервера.

Состояние сессии, влияющее только на просмотр, можно хранить в открытом виде в Cookie, а можно точно так же поместить в JWT.

После этих изменений схема расположения данных выглядит, как показано ниже. Можно убрать из схемы Redis, поскольку Signed Cookie снизит число обращений к данным сессии на сервере.

Подытожим

Отказоустойчивости в работе с Redis или с любым другим вспомогательным хранилищем можно достигнуть не только за счёт схемы развёртывания и инфраструктуры, но и за счёт пересмотра архитектуры или доработок приложения, обеспечивающих устойчивость к отказам.

Чтобы это сработало, надо:

  1. Учесть разные сценарии отказа Redis, в том числе связанные с превышением таймаутов, превышением лимита памяти или сетевых соединений

  2. Пересмотреть архитектуру и реализацию системы с точки зрения взаимодействия с Redis

  3. Решить, куда вносить доработки: в схему развёртывания, в реализацию взаимодействия с Redis или даже в архитектуру хранения данных

    • Для большей устойчивости к отказам и нагрузкам потребуется применить все три метода

Кроме того, стоит решить, будет ли использование Redis, memcached или иного быстрого хранилища уместным:

  1. Возможно, требования по нагрузке и отказоустойчивости позволяют не усложнять и хранить всё, включая сессии, в основной БД

  2. Возможно, идея использовать Redis пришла из-за того, что данные временные; в этом случае данные можно так же хранить в БД, не забывая:

    • Добавить в таблицу и во все связанные SQL-запросы колонку expiration date

    • Реализовать механизм очистки старых данных фоновыми задачами, запускаемыми через cron или CronJob (в Kubernetes), например, каждую ночь

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