Redis Python based cluster. Часть 2: зачем нужен Dynamo и что делать, когда Redis больше одного

от автора

Рано или поздно сервисы растут, а с большим 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 будет готов за две недели. Вы показываете продукт, вам дают команду и деньги. Но если вы планируете дальше расти, то лучше переписать на более быстрые решения.

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


ссылка на оригинал статьи https://habr.com/ru/articles/568618/


Комментарии

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

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