Почему round-robin — плохой способ раздавать нагрузку, хотя он честный
Round-robin — первый алгоритм балансировки, который встречается почти в любом учебнике и в дефолтном конфиге nginx. Он звучит справедливо: раздать запросы по очереди, каждому бэкенду поровну. Проблема в том, что «поровну по числу запросов» и «поровну по нагрузке» — это два разных обещания, и round-robin выполняет только первое. Разберём, почему честность по счётчику не спасает от перекоса, и какие алгоритмы закрывают эту дыру.
Содержание
Что на самом деле делает round-robin
Идея алгоритма предельно простая: балансировщик держит список бэкендов и раздаёт им запросы по кругу — первый запрос на сервер A, второй на B, третий на C, четвёртый снова на A, и так далее. Никакой статистики, никакого учёта прошлого — только позиция в списке. В upstream-блоке nginx это поведение по умолчанию, отдельно включать ничего не нужно:
upstream backend {
server 10.0.0.1:8080;
server 10.0.0.2:8080;
server 10.0.0.3:8080;
}
Три сервера — при 300 запросах каждый получит примерно по 100, если не считать погрешность на старте и в моменты, когда сервер выпадает из ротации по ошибке соединения. Это и есть вся суть алгоритма: равное число обращений на каждый бэкенд за equal интервал времени.
У round-robin есть вариант — random (случайный выбор бэкенда на каждый запрос). Статистически на большом числе запросов он даёт то же самое распределение, что и round-robin по кругу, просто без строгой последовательности. Для целей этой статьи оба ведут себя одинаково: оба считают запросы, а не работу.
Плюс алгоритма реален и его не стоит обесценивать: он не требует памяти о состоянии, не хранит счётчики соединений, не делает дополнительных проверок при выборе бэкенда — выбор происходит за одну операцию над индексом. На практике это значит минимальные накладные расходы балансировщика и предсказуемое поведение, которое легко объяснить и отладить. Для многих сценариев — статические файлы, короткие однотипные API-запросы на одинаковых серверах — этого действительно достаточно, и менять алгоритм смысла нет.
Честно поровну — это не то же самое, что оптимально
Здесь и находится главная ловушка round-robin: слово «честно» относится к арифметике распределения, а не к результату. Балансировщик честно раздаёт запросы по кругу, но он ничего не знает о том:
- сколько времени и ресурсов займёт обработка каждого конкретного запроса;
- насколько бэкенд, которому сейчас достанется запрос, уже занят предыдущими запросами;
- одинаково ли вообще мощны серверы в пуле.
Round-robin оптимален только в одном частном случае: если все запросы примерно одинаковы по стоимости обработки, а все бэкенды в любой момент времени примерно одинаково загружены и одинаково мощны. На практике хотя бы одно из этих условий обычно нарушено, и тогда «поровну по числу» превращается в «неровно по факту».
Разберём оба источника перекоса по отдельности — это разные проблемы с разными решениями.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверЗапросы разной тяжести: одна очередь, разная цена билета
Реальный трафик редко состоит из одинаковых запросов. На одном и том же бэкенде рядом уживаются:
- лёгкий GET к статическому JSON или к закешированной странице — миллисекунды;
- запрос, который бьёт в базу с JOIN на нескольких таблицах — десятки или сотни миллисекунд;
- генерация отчёта, экспорт CSV, обработка загруженного файла или картинки — секунды;
- долгий стрим или long-polling соединение, которое держит воркер занятым, пока клиент не отключится.
Round-robin отправит все четыре типа запросов на бэкенды по кругу, не различая их. Если серверу A по очереди достались два «тяжёлых» запроса подряд, а серверу B — два лёгких, оба сервера формально получили «поровну» — по два запроса каждый. Но по факту A занят надолго, а B давно освободился и мог бы принять ещё пяток запросов, пока ждёт своей следующей очереди.
Проблема усугубляется тем, что балансировщик выбирает следующий бэкенд заранее, в момент прихода запроса — он не видит, сколько запрос будет выполняться, потому что это неизвестно на старте. Даже теоретически идеальный round-robin не может предсказать тяжесть запроса, у него просто нет для этого входных данных. Это врождённое ограничение самого принципа «раздавай по кругу, не глядя на содержимое».
Бэкенды в разном состоянии: одинаковых серверов не бывает в моменте
Второй источник перекоса — не запросы, а сами бэкенды. Даже если в пуле формально одинаковое железо и одинаковый код, в конкретный момент времени серверы почти никогда не находятся в одинаковом состоянии:
- один бэкенд ещё дорабатывает пачку запросов с прошлого круга, другой уже свободен;
- на одном идёт сборка мусора в рантайме (GC-пауза), на другом — нет;
- один только что перезапустился и его кеш ещё холодный, обращения к базе идут мимо кеша и дольше;
- на одном фоново крутится плановая задача — бэкап, ротация логов, миграция — которая отъедает CPU и I/O.
Round-robin ничего этого не измеряет. Он не спрашивает бэкенд «ты сейчас свободен?», он просто идёт по списку. В результате запрос может прийти на сервер, который объективно перегружен, только потому, что подошла его очередь — а сервер, который давно освободился, ждёт своей очереди ещё несколько шагов.
Отдельно стоит развести две смежные вещи: то, что описано выше, — про распределение нагрузки между живыми бэкендами, а не про то, что сервер полностью упал и должен быть выведен из ротации. Второй случай закрывают health check'и, и это отдельная механика — она проверяет живой/мёртвый бэкенд, но не различает «жив и свободен» от «жив, но по уши занят». Про то, как устроена эта проверка и что бывает, когда она ошибается, у нас есть отдельный разбор: балансировщик слал трафик на мёртвую ноду.
Что учитывают альтернативы, а round-robin — нет
У проблемы два разных корня — значит, и решения два, и они не взаимозаменяемы.
Least connections решает проблему «серверы в разном состоянии». Вместо того чтобы идти по кругу, балансировщик держит счётчик активных (ещё не завершённых) соединений на каждый бэкенд и отправляет новый запрос туда, где сейчас меньше всего открытых соединений:
upstream backend {
least_conn;
server 10.0.0.1:8080;
server 10.0.0.2:8080;
server 10.0.0.3:8080;
}
Логика простая и честная в другом, более полезном смысле: сервер, который уже занят пятью долгими запросами, не получит шестой, пока рядом есть бэкенд с одним активным соединением. Это не идеальная метрика нагрузки — число открытых соединений не равно точному значению CPU или памяти, которые сервер тратит прямо сейчас, — но она куда ближе к реальному состоянию бэкенда, чем позиция в круговом списке. У least_conn есть и своя цена: балансировщику приходится хранить и обновлять счётчик на каждый connect/disconnect, то есть это уже stateful-алгоритм с небольшим, но ненулевым накладным расходом по сравнению с round-robin.
Weighted round-robin решает другую проблему — «серверы изначально не равны по мощности». Если в пуле старый сервер на 4 ядрах и новый на 16, честное распределение «поровну по числу запросов» — это не честность, а перегрузка слабого узла. Weighted round-robin позволяет задать вес каждому бэкенду, и более мощный получает пропорционально больше запросов:
upstream backend {
server 10.0.0.1:8080 weight=1;
server 10.0.0.2:8080 weight=3;
server 10.0.0.3:8080 weight=3;
}
В этом примере на каждые 7 запросов первый сервер получит примерно 1, а второй и третий — примерно по 3. Веса задаёт человек, вручную, на основе известной разницы в характеристиках железа — CPU, память, диск. Алгоритм по-прежнему ничего не знает о текущей загрузке бэкенда в моменте, он просто честнее делит нагрузку между заведомо разными по мощности серверами. Weighted round-robin и least connections можно комбинировать — в nginx это least_conn вместе с весами на серверах, в HAProxy — режим balance leastconn с параметром weight в строке сервера. Тогда балансировщик учитывает и разную мощность бэкендов, и их текущую загрузку одновременно.
Есть и более тяжёлые схемы — балансировка по времени ответа (учитывает, как быстро бэкенд отвечал на последние запросы), consistent hashing (для случаев, когда важно постоянство привязки клиента к серверу, например для кеша), и адаптивные алгоритмы, которые опрашивают бэкенды напрямую. Они точнее, но и сложнее в настройке и отладке — каждый добавленный уровень интеллекта в балансировщике означает больше состояния, которое надо поддерживать согласованным, особенно если сама схема балансировки хранит что-то локально на одном узле, а не общее для всех. Подробнее о том, к чему это приводит на практике, — в разборе состояние было локальным, а балансировщик об этом не знал.
Что выбрать на практике
Единого правильного ответа нет — выбор алгоритма это компромисс между простотой и точностью, и он зависит от профиля трафика:
| Ситуация | Что подходит | Почему |
|---|---|---|
| Однотипные короткие запросы, одинаковое железо | round-robin | Разница в нагрузке между серверами не накапливается, усложнять незачем |
| Запросы сильно различаются по времени обработки (API + отчёты + экспорт) | least_conn | Учитывает, что бэкенд уже занят долгими запросами, а не просто «была его очередь» |
| Бэкенды разной мощности в одном пуле (старое железо + новое) | weighted round-robin | Компенсирует разницу в CPU/памяти долей трафика |
| Разной мощности бэкенды + сильно разные по тяжести запросы | least_conn с весами | Закрывает оба источника перекоса сразу |
| Нужна привязка клиента к одному серверу (сессии, кеш) | ip_hash / consistent hashing | Другая задача — не распределение нагрузки, а стабильность маршрута |
Практический совет: не меняйте алгоритм балансировки заранее «на всякий случай». Начните с round-robin как самого простого и предсказуемого варианта, и переходите на least_conn или weighted round-robin, когда увидите реальный симптом перекоса — один бэкенд стабильно показывает более высокую задержку или загрузку CPU, чем соседние, при формально равном числе запросов. Это конкретный, измеримый повод сменить алгоритм, а не гипотеза «а вдруг где-то не так».
Если вы разворачиваете балансировку с нуля, у нас есть пошаговый разбор настройки — как установить и настроить балансировку нагрузки на VPS, а если ещё не определились с инструментом — сравнение HAProxy или nginx для балансировки. Оба инструмента поддерживают все алгоритмы из таблицы выше, разница в основном в синтаксисе конфига и в глубине встроенной статистики по бэкендам.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Round-robin вообще стоит использовать, или он всегда хуже альтернатив?
Стоит — для однородного трафика на одинаковых серверах он даёт предсказуемый результат почти без накладных расходов балансировщика. Проблема не в алгоритме как таковом, а в применении его там, где запросы или серверы неоднородны.
least_conn полностью решает проблему неравномерной загрузки?
Нет, он решает только ту часть, которая видна через число открытых соединений. Долгий запрос с низким потреблением CPU и короткий запрос с тяжёлыми вычислениями могут дать балансировщику одинаковую картину по счётчику соединений, но совершенно разную реальную нагрузку на сервер.
Можно ли комбинировать least_conn и weight одновременно?
Да, и в nginx, и в HAProxy это штатно поддерживается: вес задаёт базовую пропорцию между серверами разной мощности, а least_conn поверх него распределяет запросы с учётом того, кто сейчас реально свободнее.
Как понять, что текущий алгоритм балансировки уже не подходит?
По асимметрии метрик между бэкендами при формально равном распределении запросов: если один сервер в пуле стабильно показывает более высокую задержку ответа, более высокую загрузку CPU или память ближе к пределу, чем остальные, при похожем числе запросов — это сигнал перейти на least_conn или пересмотреть веса.
Меняется ли алгоритм балансировки без простоя сервиса?
В nginx и HAProxy — да, это меняется правкой конфига upstream/backend и мягкой перезагрузкой (nginx -s reload или аналог для HAProxy), без разрыва уже установленных соединений. Изменение начинает действовать для новых запросов сразу после перезагрузки.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →