Балансируем трафик

Балансируем трафик

t.me/thisnotes

Зачем балансировать трафик растекаться не буду. Все понимают важность масштабирования. А где масштабирование, там надо и учить систему работать в новой парадигме. 

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

Каждый день таким занимаюсь. 

Вообще написать свой базовый балансер, который трафик туда-сюда проксирует, не очень сложно. Какая-нибудь базовая кастомная версия спокойно выдаст вам пропускную способность в пару тысяч запросов на среднем ноутбуке (если запросы небольшие). Это если вы не пытаетесь в безопасность с шифрованием и разными сложными протоколами...

Жизнь чуть более прозаична, потому приходится обмазываться эвристиками. Но перед этим поймём, какие балансеры вообще бывают. 

Существует их три основных:

  • L3 работает на 3м уровне в модели OSI и занимается скорее маршрутизацией, а не распределением нагрузки
  • L4 (на 4м уровне) нужен чтобы отправлять все UDP пакеты и сообщения в рамках одного TCP подключения на один и тот же бэкенд
  • L7 (хаха попались да на 7 уровне) нужен для сложной кастомной логики типа балансировки по урлу/кукам/чему угодно ещё.

Алгоритмы балансировки

  • Рандомное распределение : )
  • Round Robin: раскидываем запросы по очереди по кругу (если у нас 3 сервера, то 1 2 3 1 2 3 1 2). 
    Почти наверняка вам этого будет достаточно в случае стандартного бэкенда, если у ваc гомогенный бэкенд (все сервера одинаковые). Нам вот достаточно.
  • Weighted Round Robin: взвешенная версия, для которой можно указать вероятность улетания запроса в конкретный сервер. 
    Может применяться если у вас каким-то чудом сервера разной мощности. Может вы там их в подвале собирали и один обделили железом. 
    Используется во всяких сетевых устройствах, когда есть несколько маршрутов и хочется утилизировать пропускную способность канала максимально. 
  • Least connections/Least response time: это когда мы отправляем запросы на сервера, с которыми у балансера либо меньше всего соединений, либо накоплена статистика про самые быстрые запросы. Важно, что обычно такие алгоритмы замеряют скорость ответа по условному /ping, что может не отражать реальную ситуацию на сервере, который может умирать по CPU, но справляться отвечать на лёгкие запросы. Или который может зависать на сложных запросах с походами в базу, но опять же легко обрабатывать пинг. 
    Если предыдущие два алгоритма были статическими, то эти -- динамические. То бишь могут менять своё распределение со временем.
  • IP hashing: хешируем ip source port и протокол и по хешу вычисляет сервер, на который надо отправить запрос. 
    Нужно это для каких-нибудь stateful серверов. Правда является уже не очень юзабельным, т.к. сейчас уже могут быть огромные подсети с одним и тем же IP, что будет сильно грузить конкретный сервер. Плохо он нагрузку распределяет. А ещё если один из серверов из строя выходит, весь маппинг юзера на сервер может сильно изменится. Не кайф. 
    Подобную задачу сегодня решают с помощью consistent hashing.
  • Consistent hashing: мапим всю числовую прямую на кольцо, вычисляем хеш каждого хоста и при запросе вычисляем хеш юзера. Простейшим бинпоиском находим ближайший хост. Вы великолепны!
    При желании (если у вас различных хешей мало) можно вычислить хост для каждого хеша заранее, чтобы не тратить время на логарифм операций. И доставать из памяти за константу готовый хост. 
    Однако это решение тоже не идеально. Представим, что у нас есть 4 хоста, которые обслуживают по четверти различных хешей каждый. Если упадёт один из хостов, то на его соседа прилетит в 2 раза больше нагрузки! Потому иногда в кольцо добавляют виртуальные ноды, которые мапят на физические. В итоге распределение получается более равномерным.
    Хотя пример с 4 серверами довольно вырожденный. В общем случае у вас изменится не более чем 1/n связей клиент-сервер, где n -- кол-во серверов.
  • Rendezvous hashing: заморочки с кольцами и виртуальными нодами бывают довольно неприятными, потому иногда прибегают к другому решению. Тут мы хешируем все пары от запроса и номеров нод. Например (пусть запросы == числа): 
hash(123, 1) = 24
hash(123, 2) = 19
hash(123, 3) = 3
hash(123, 4) = 45

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

Есть ещё всякие разные эвристики и способы балансироваться, чтобы не задушить хосты нагрузкой и обеспечить адекватный UX: получать инфу о загрузке хостов от них, а не опрашивать самому; приоритезация трафика по классам нагрузки (Voice over Video в Zoom, например); locality aware балансировка; 100% кто-то ML прикрутил для предикта нагрузки.

Когда всё ломается

Что будет, если хост выпал?

Обычно балансер шлёт простые запросы-хелсчеки, проверяющие состояние хостов. Дёргает тот самый /ping. Если хелсчек неудачный, то и запросы на хост слать больше не стоит. Если для активных запросов клиент получит timeout или что-нибудь такое, то он должен сам поретраить, ведь в общем случае у балансера не хватает контекста, чтобы понять, что ему делать. В зависимости от задачи можно настраивать авторетрай некоторых видов запросов на L7.

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

А что если сам балансер сломался? Ведь это такая же железка, как любая другая. Можно поставить ещё один балансер : )
Идея довольно простая: за одним и тем же IP стоит несколько серверов. Или можно использовать Anycast, который броадкастит запросы повсюду, и конкретный запрос будет обработан на произвольный балансер.

Но, несмотря на все плюсы, балансеры не идеальны:

  • их нужно настраивать.
    Любая нагрузка на поддержку инфраструктуры приводит к тому, что вы не пишите новые фичи, которые помогут заработать больше деняк.
  • балансер -- единая точка отказа.
    Если у вас несколько ДЦ, не переживёте, но всё равно не очень приятно потерять все хосты из ДЦ.
    Если вы вдруг почему-то боитесь, что упрётесь в пропускную способность балансера, то волноваться не стоит. Скорее проблемы будут с сетью.
  • дополнительный хоп по сети.
    Который иногда может вносить задержку в единицы-десятки мс, что для некоторых приложений и сфер может быть критично.

Ну и конечно есть альтернатива -- балансировка на клиенте! У неё

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

Такое часто применяется в сложный системах. Например в разных распределённых БД. Не призываю вас делать балансировку на мобилке юзера.

Но понятно, что подобный подход приводит к более сложному коду, да и клиенту доверять может быть сложно (потому обычно применяется в защищённой среде).

Не переусердствуем

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

Т.к. пост основан на рассказе моего близкого друга с одного из наших семинаров, тут я позволю себе процитировать его слово в слово:

Постоянно на разных докладах и в коммьюнити обсуждают проблемы бигтехов, как работают разные сложные системы. Мы привыкли, что у нас сотни тысяч RPS, большие нагрузки, нужно много-много-много девяток. Но на самом деле, если открыть дверь и посмотреть в мир, где люди решают реальные проблемы, развивают стартапы и зарабатывают миллиарды долларов, можно понять, что код у них выглядит скорее всего по-другому. Да и мир по-другому у них устроен.

Посмотрим на кейс Notion.

В 2020-м у них было что-то около 1к юзеров. А в 2024-м уже 100М. При этом в 2021-м они имели единственную postgres базу.

В какой-то момент они перестали в одну инсталляцию вмещаться, потому пришлось шардироваться. Why complicate your life? Чуваки взяли один большой postgres и заменили на 32 postgres'а поменьше, шардируя все данные по user_id. Единственная проблема -- переход со старого сетапа на новый. Но в целом подход довольно простой и позволяет не помирать.

Переключение происходило в 4 этапа:

  1. Double-write.
    При записи пишем и в старый сетап, и в новый.
  2. Backfill.
    Фоновый процесс переноса данных из старой бд в новые.
  3. Verification.
    Проверка данных в двух сетапах на консистентность.
  4. Switch-over.
    Непосредственно переключение со старой базы на новую.

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

Это касается не только балансировки нагрузки, но и всего остального. Отшардируйте втупую ваш postgres. Сделайте простой тупой эксперимент, чтобы проверить гипотезу, а не тратьте дохера времени на сложную фичу без профита. Напишите код попроще, пусть он и не так красиво выглядит. Зато осознать легче.

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


Ссылочки про Notion:

И ещё одна по теме:


Report Page