Рано или поздно сервисы растут, а с большим RPS приходит Highload.

Что делать, когда ресурсов для вертикального масштабирования Redis уже нет, а данных меньше не становится? Как решить эту задачу без downtime и стоит ли её решать с помощью redis-cluster?

На воркшопе Redis Python based cluster Савва Демиденко и Илья Сильченков пробежались по теории алгоритмов консенсуса и попробовали в реальном времени показать, как можно решить проблему с данными, воспользовавшись sharding’ом, который уже входит в redis-cluster.


Воркшоп растянулся на два часа. Внутри этого поста — сокращённая расшифровка самых важных мыслей.

В предыдущем посте Савва Демиденко и Илья Сильченков обсудили теорию, поговорили, как и для чего используется Redis, выделили особенности распределённых систем, а также теоремы CAP и PACELC. Теперь узнаем, зачем нужен Dynamo, что делать, когда Redis больше одного, а также ответим на вопросы зрителей.

Когда Redis несколько


Итак, продолжим смотреть в код. Когда есть ключ, мы сохраняем его в Redis. Затем сталкиваемся с проблемой, когда используется несколько Redis, и поэтому нужно выбирать, в какой ходить.
Посмотрим diff с веткой, где это всё уже реализовано. При запуске сервиса REDIS_PORT с REDIS_HOST мы заменяем на REDIS_DSNS.



Теперь наш сервис знает о двух Redis. Можно попробовать взять и три: если всё будет работать с тремя Redis, то будет работать и с большим количеством. А если с двумя — ещё не факт.

Самое интересное, с чем мы столкнёмся — это изменение в redis_client.py. В нашем случае это выделено в отдельную структуру умышленно: мы изначально хотели написать попроще и лишь потом переписать код так, чтобы он легко менялся в рамках нашей задачи. Здесь нужно поменять один класс, чтобы всё остальное заработало: только redis_client.py.

В RedisRepository мы используем redis_client.py. Repository — это бизнес-логика работы с нашими сущностями, а redis_client — это конкретная реализация того, как она будет взаимодействовать с Redis. Мы просто меняем один Redis на несколько и предоставляем тот же самый API.

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

У нас появился отдельный класс на случай, если завтра мы захотим отказаться от Redis и переписать всё на Memcached. В этом случае мы не ходим по всему коду и не собираем эти вызовы. Мы переименуем один класс и импорт, возможно, даже оставим те же самые методы.



Слева старый код, справа — новый. Раньше был один Redis, теперь несколько. Заметны обвязки вокруг асинхронных фреймворков, но они нам неинтересны. Куда важнее то, как мы выбираем, к какой ноде обращаться.

Решим эту задачу


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

Первая же мысль — взять остаток от деления числовых данных. Это решение сверхпростое и приходит в голову в течение пяти минут. Тут так и сделано: вот этот кусочек кода на строчке 39.



Берём от URL хэш и получаем последовательность в 32 символа. Это наш ключ. Кастим ключ к encode() к UTF, а его превращаем в байты, от байтов берём остаток от деления на число серверов Redis. Затем мы смотрим, в какой Redis попадаем.

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

А ещё мы упомянули даунтайм. Избежать его значит при выкатывании нового сервиса не иметь времени простоя. Это ещё называют классом высокой доступности по «девяткам»: мой сервис работает N девяток (99,99… %). Например, шесть девяток — это 30 секунд простоя в год. Чем меньше время недоступности, тем лучше. В больших компаниях заседают целые комитеты, которые разбирают инциденты долгого простоя, чтобы они не повторялись в будущем. В маленьких компаниях на даунтайм могут смотреть сквозь пальцы, но в больших посчитают потери финансов.

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

Итак, мы сохраняем старую логику и выбираем нужную ноду по остатку от деления. Если данных нет, мы идём в дефолтную. Ответы будут идти чуть дольше, потому что вместо одного хопа нужно сделать два. Постепенно данные из одной ноды нужно вставлять в новую. Лишней работы мы не сде��аем, потому что какие-то данные со временем могут протухнуть, и их придётся мигрировать.



Время жизни данных — неделя. Данные переливаются. Когда всё закончится, этот код можно убирать.

Еще в коде есть corner case: при DDoS-атаке мы постоянно нагружаем оба Redis. Но если идёт DDoS, то проблему нужно решать не на уровне сервиса, а перед ним. Это уже nginx, специальные железки или чьи-то услуги.

Отлично. Кажется, задача решена.

Зачем нужен Dynamo


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

Бывает так, что сеть, такая же, как та подсетка, которую выделяет Docker, уже занята каким-то сервисом. Чтобы избежать настройки конфигурации и смены настроек по умолчанию, лучше использовать network. Так получится получить подсетки, которые точно не будут задействованы.
Помните пример про «чёрную пятницу» и Amazon? Покупателей много, а случается такая ситуация один раз в год. Возникает похожая на наш рассматриваемый вопрос ситуация: в инфраструктуре нужно сначала добавить много нод, а потом убрать их, и при этом ничего не должно поменяться.

В 2007 году компания Amazon написала про продукт DynamoDB, который по��ог решить эту проблему. Через несколько лет Netflix выпустила открытую реализацию Dynamo поверх Redis.



Алгоритм следующий. Эта штука берёт хэш, закольцовывает его и на нём выбирает токены — то есть то, как данные будут распределяться по нодам. Любой сегмент этого круга можно вставлять в отдельную ноду. Это значит, что мы не берём остаток от деления, а всегда двигаемся по какому-то направлению. Токены расположены на всём круге.

Обратим внимание на диаграмму слева, где от min_key по max_key расположены A, B и C. Условно представим, что это отрезок от 0 до 100, где A — это диапазон от 0 до 33, B — от 34 до 66, C — от 67 до 100.

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

А ещё гораздо проще внедрять ноды. Допустим, между С и A добавили новую ноду. D залезло в A, но если бы оно залезло ещё и в C, то мы бы поняли, как оно там появилось. При добавлении ноды все нужные данные заберём с A, потому что мы вторгаемся в часть её рамок. После добавления новой ноды мы со старым ключом будем попадать уже в D.

Так мы получили алгоритм, который показал Amazon, а Netflix реализовал и выложил в свой GitHub в готовом для использования виде.

Вернёмся к нашему первому решению с остатком от деления. Какие есть плюсы этого алгоритма относительно тривиального решения с остатком от деления?

Первый плюс — это веса нод.

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

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

Вопросы зрителей


А где в коде логика того, как мы выбираем Redis, в который кладём данные? Как потом определяем, из какого надо читать?

Это как раз файл redis_client.py. Вернёмся к нему и посмотрим ещё раз. Сначала рассмотрим старый вариант, затем — новый.



В старом варианте всё просто: всё передаётся в настройках, создаётся пул подключения к Redis.
Записываем в клиент, сохраняем self, затем работаем. Причём мы работаем не с redis, а с redis_pool — нужен именно пул коннекшенов. Если вы работаете с каким-то популярным фреймворком, то у вас он уже может быть «из коробки».

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

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

Выбором времени жизни соединений заведуют DBA, которые знают, какое время жизни коннекшенов базы данных переваривают плохо. Поэтому они подрезают время жизни соединения. А если такое нужно для больших и длинных операций, соединения можно переоткрывать. Для этого могут понадобиться плагины. Например, Django «из коробки» это не умеет.

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



До этого здесь просто работал Redis: мы отправляли в него get и set и получали по ключу.

А теперь поинтересней — наше тривиальное решение с остатком от деления. Теперь в конфигах мы записываем список УРЛов: localhost:8808, localhost:8807 и так далее — всего N штук. В какой записывать, определяем по индексу.



Теперь в redis_pool много Redis-пулов, а не соединений.



Здесь мы создаём массив ссылок и проверяем, что это ссылка с помощью yarl.

А что, с aiohttp уже не круто?

Мы выбрали фреймворк FastAP, потому что у него хороший комплект библиотек. Фреймворк — это то, что забирает на себя много работы. aiohttp, Twisted, Tornado — всё это работает примерно одинаково: под капотом у них event loop, который обрабатывает таски Python.

Сложно угадать, какой взять. Может, aiohttp расцветёт новыми красками через год, а FastAPI умрёт или наоборот, станет суперпопулярным. Мы не можем завязываться на этом — вместо этого нужно смотреть на инфраструктуру и документацию.

Самая большая проблема асинхронных фреймворков — в драйверах. Можно даже и не заметить, как асинхронный фреймворк превратится в синхронный при выборе одной неправильной библиотеки. Например, синхронный драйвер обращения к PostgreSQL полностью сведёт на нет асинхронность — с таким же успехом можно было писать на Flask.

Что почитать?

Ещё просят «литературу по микросервисам». Автор сайта microservices.io — кстати, он тесно связан с Коболом — ездит по компаниям и рассказывает, как их правильно готовить.



Есть ли нюансы, связанные с удалением из кластера Redis при использовании концепции Dynamo?

Да, есть.

Вспомним графики с окружностями. Здесь не обойтись без репликаций: записываем на первую, на вторую и так далее. И лишь после этого можно безболезненно выкинуть ноду. Там специальным образом настраивается репликация: мы ходим по кругу и дописываем в следующую ноду.

Кстати, здесь присутствует и консенсус на чтение. Можно прочитать не только из одной ноды, но и зайти в следующие, чтобы проверить, на месте ли данные.

В каких случаях микросервисы — это оверхед и не нужно? Как понять и в какой момент, что нужно юзать микросервисы?

Задачу мож��о решать и монолитом, и микросервисами.

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

MVP — берите монолит. Instagram до сих пор прекрасно живёт на Django и не помирает, заливает рынок деньгами и масштабируется горизонтально, хотя микросервисы масштабировать проще и дешевле.

Если сервисом будет пользоваться мало людей и запросы в базу не очень большие, то синхронный фреймворк выиграет?

Асинхронный код сложно писать, это правда.

Если нужна скорость разработки, берите синхронную Django. Она всё сделает за вас, хотя и будет медленней и с оверхедом. Ваш MVP будет готов за две недели. Вы показываете продукт, вам дают команду и деньги. Но если вы планируете дальше расти, то лучше переписать на более быстрые решения.

Список литературы и исходный код