Балансировка нагрузки: кто выбирает сервер и что он в этот момент знает
Чем раньше принято решение, тем меньше о нём известно. DNS выбирает сервер за минуты до запроса и не знает про него ничего; L7-балансировщик выбирает в момент запроса и знает всё. Из этого следует и разделение слоёв, и главный результат модели очередей: least connections и least response time — лучший выбор ровно до тех пор, пока сигнал о загрузке свежий.
Полное техническое изложение
Коротко: кто и когда выбирает сервер
Представьте супермаркет с шестнадцатью кассами. Запрос — это покупатель, бэкенд — касса. Вся балансировка сводится к одному вопросу: кто говорит покупателю, в какую кассу встать, и что этот кто-то видит в момент совета.
Дальше всё различие — в том, насколько рано даётся совет.
DNS: совет у входа в торговый центр
DNS отвечает раньше всех — за минуты до того, как запрос вообще случится. Он не видит ни очередей, ни того, работает ли касса. Он умеет одно: раздавать разным людям разные адреса, меняя их от ответа к ответу.
Это проверено на живых доменах. www.google.com отдаёт восемь адресов и
каждый раз перемешивает порядок — выбор оставлен клиенту. github.com отдаёт
по одному адресу, но каждый раз другой — выбор сделал сервер имён.
Главная ловушка: DNS раскладывает не запросы, а обращения за адресом. Браузер спросил адрес один раз, открыл соединение и отправил по нему тысячу запросов — все они ушли на один сервер. Поэтому DNS хорошо разводит трафик по городам и плохо выравнивает нагрузку между машинами.
Отдельно про geoDNS — «отвечать разными адресами в разных странах». Он бывает настоящий и мнимый, и это видно по одному полю в ответе. Настоящий помечает ответ как «годится только для этой части интернета»; мнимый отвечает всем одинаково. Проверены четыре сайта: у CloudFront адреса действительно разные для США, Германии, Японии и Бразилии; у Bing, Microsoft и Netflix — одинаковые для всех. У последних география тоже есть, просто сделана она не через DNS.
L4 и L7: распорядитель у входа и администратор зала
L4 смотрит только на «откуда и куда» — адреса и порты. Он направляет покупателя один раз, при входе, и дальше не вмешивается. Быстро: одна обычная машина заполняет канал в 10 гигабит.
L7 читает сам запрос: что просят, какие заголовки, какая кука. Он может направлять по-разному разные запросы одного и того же клиента и повторить неудачный запрос на другой кассе.
Разница, из-за которой чаще всего ошибаются: L4 распределяет соединения, а L7 — запросы. Если один клиент открыл одно соединение и шлёт по нему девяносто процентов всего трафика, L4 честно отправит это соединение на одну кассу — и она встанет, пока остальные пятнадцать пустуют. Поэтому обычно ставят оба: L4 впереди, чтобы раскидать соединения, L7 позади, чтобы раскладывать запросы.
Алгоритмы: чем советчик руководствуется
Для сравнения построена модель: шестнадцать касс, каждая обслуживает по одному человеку, остальные ждут в очереди. Ниже — сколько всего тратят самые невезучие (один из ста): ожидание в очереди плюс само обслуживание, в условных единицах. Меньше — лучше.
| как выбираем кассу | у самых невезучих уходит |
|---|---|
| наугад | 25,07 |
| по кругу, каждой по очереди | 17,00 |
| ту, где меньше всего людей | 6,06 |
| ту, где меньше всего людей и очередь движется быстрее | 5,50 |
| смотрим на две случайные, идём в ту, где короче | 8,86 |
Главное здесь — первая и третья строки: разница вчетверо, при полностью одинаковых кассах. Обычное «если серверы одинаковые, сойдёт любой алгоритм» неверно. Кассы одинаковые, а покупатели — нет: у одного две позиции в чеке, у другого полная тележка. «По кругу» раздаёт всем поровну людей, а не работы.
Когда «где меньше людей» перестаёт работать
Совет «иди туда, где короче очередь» хорош, пока советчик действительно видит очереди. Советчиков обычно много. Пока каждый считает только своих покупателей, совет остаётся хорошим. Ломается он в другом случае — когда все смотрят на одно общее табло, а оно обновляется не мгновенно.
Проверено и это. Пока табло обновляется мгновенно, «где меньше людей» выигрывает у всех: 6,06. Когда табло отстаёт на время двух покупок, тот же совет даёт 19,95 — хуже, чем тупое «по кругу» (17,00). Причина понятная: все советчики видят одно и то же устаревшее число, показывают на одну и ту же кассу и создают там толпу.
А совет «посмотри на две случайные, иди в ту, где короче» за то же время ухудшается всего с 8,86 до 10,52. Он не пытается найти лучшую кассу — и потому ему нечего терять, когда данные врут.
Отсюда простое правило:
- Советчик один и сам всё видит → «где меньше людей».
- Советчиков много, но каждый считает только своих → «где меньше людей» по-прежнему годится.
- Данные приходят со стороны и запаздывают → «лучшая из двух случайных».
- Данных нет вовсе → «по кругу», но тогда за настройками придётся следить руками.
Закреплённые сессии: за что платим
Иногда покупателя нужно всегда отправлять к одной и той же кассе — например, его корзина лежит только там. Это называется закреплением сессии, и оно работает, но перестаёт быть балансировкой: раскладываются уже не покупатели, а целые группы.
В той же модели без закрепления самые невезучие ждали 6,06. С пятью тысячами закреплённых групп — 26,90. С пятьюстами — 206,92. С пятьюдесятью — восемь тысяч, то есть балансировки просто нет.
Вывод не в том, что закрепление плохое, а в том, что это плата за состояние на сервере. Если корзину можно хранить в общем месте, платить не нужно вовсе.
TL;DR
Балансировка — это цепочка решений, и у каждого своя цена. Чем раньше выбран сервер, тем меньше о нём известно в момент выбора. DNS отвечает за минуты до запроса и не знает ни загрузки, ни живости; L4 видит адреса и порты; L7 читает запрос целиком и знает про бэкенды всё.
Алгоритм имеет значение сильно больше, чем принято думать. В модели из шестнадцати бэкендов при загрузке 80% задержка p99 отличается вчетверо: 25,07 у случайного выбора против 6,06 у least connections. Round robin — 17,00, то есть ближе к случайному, чем к разумному.
Но у least connections есть условие, и оно важнее самого алгоритма: сигнал о загрузке должен быть свежим. Как только состояние собирается в общий снимок с задержкой, least connections деградирует — при задержке в две средние обработки его p99 (19,95) хуже, чем у round robin (17,00). Выбор лучшего из двух случайных за то же время сдвигается с 8,86 до 10,52 и становится лучшим вариантом на столе.
Зачем это знать
Балансировщик выглядит инфраструктурой, которую настраивают один раз. Это верно ровно до первого инцидента, в котором «нагрузка распределена равномерно», а хвост задержек утроился, — и оказывается, что равномерно распределены запросы, а не работа.
Разбор ниже устроен по одной оси: в какой момент принимается решение и что в этот момент известно. Она объясняет и то, зачем нужны сразу несколько слоёв балансировки, и то, почему алгоритмы нельзя сравнивать в отрыве от того, откуда балансировщик берёт данные.
Всё, что здесь названо числом, получено одним из трёх способов: живым запросом к DNS, прогоном модели очередей или взято из документа, ссылка на который стоит в источниках. Числа из модели помечены как модельные — им нельзя верить как замеру живого кластера, и ниже сказано, почему и в чём именно.
Клиентская и серверная балансировка
Разделение проходит по тому, у кого лежит список бэкендов.
При серверной балансировке клиент знает один адрес. За ним стоит балансировщик, у которого есть список, проверки живости и алгоритм. Клиент не участвует и не может ошибиться — но и не может обойти балансировщик, если тот стал узким местом.
При клиентской балансировке список получает сам клиент — из service discovery, из xDS, из SRV-записей — и выбирает сервер сам. Лишнего сетевого перехода нет, отказ одного клиента не задевает остальных. Цена — в том, что логика балансировки размазана по всем клиентам сразу: чтобы поменять алгоритм, нужно выкатить их все. Отсюда и sidecar-прокси: список и алгоритм остаются рядом с клиентом, но обновляются отдельно от него.
Есть и третий случай, который клиентской балансировкой обычно не называют, а это она и есть. Когда DNS возвращает несколько адресов, выбор делает клиент.
DNS: выбор за минуты до запроса
DNS-балансировка — самый ранний и самый слепой слой. Замер на живых доменах, двадцать запросов подряд к каждому:
| домен | адресов в ответе | TTL | какой адрес оказался первым |
|---|---|---|---|
www.google.com | 8 | 2…253 с | все восемь, 1–4 раза каждый |
github.com | 1 | 37…60 с | четыре разных адреса за 20 запросов |
www.cloudflare.com | 2 | 300 с | два адреса, 12 и 8 раз |
Видно две разные стратегии. Google отдаёт весь список и перемешивает порядок — решение оставлено клиенту. GitHub отдаёт по одному адресу, но разный: решение принято сервером имён, клиенту выбирать не из чего.
Оба приёма старше многих читателей. RFC 1794 описал их в апреле 1995 года и там же назвал ограничение, которое никуда не делось:
the DNS protocol doesn't guarantee ordering
протокол DNS не гарантирует порядок
Порядок записей — это рекомендация, а не обязательство. Резолвер провайдера, библиотека в языке и операционная система вправе его переставить, и переставляют. А главное ограничение в том же документе сформулировано ещё прямее:
There is no use in handing out information with TTLs of an hour, when the
conditions for ordering the RRs changes minutely.
Нет смысла раздавать сведения с часовым TTL, когда условия, определяющие порядок записей, меняются ежеминутно.
Здесь всё противоречие DNS-балансировки. Чтобы решение было свежим, TTL должен быть маленьким. Чтобы кеширование работало, он должен быть большим. И даже маленький TTL не спасает: никто не обязан его соблюдать — ни резолвер провайдера, ни браузер с собственным кешем, ни соединение, которое уже установлено и живёт часами.
Последнее стоит проговорить отдельно, потому что оно ломает интуицию. DNS-балансировка распределяет не запросы, а обращения за адресом. Клиент, который открыл HTTP/2-соединение и держит его открытым, обратился за адресом один раз на тысячу запросов. Балансировка по DNS в этом случае разложила по серверам не нагрузку, а моменты установки соединений.
geoDNS: и как проверить, настоящий ли он
Идея geoDNS проста: отвечать разными адресами в зависимости от того, откуда пришёл вопрос. Сложность в том, что авторитетный сервер видит адрес не клиента, а резолвера — и если человек в Токио пользуется публичным резолвером в США, географическая близость определяется неправильно.
Для этого и придуман RFC 7871: резолвер добавляет к запросу кусок адреса
клиента. Ответ содержит SCOPE PREFIX-LENGTH — the leftmost number of
significant bits of ADDRESS that the response covers
(число старших значащих бит адреса, которые покрывает ответ). Ноль означает, что
ответ подходит всем.
Значит, настоящий geoDNS отличается от ненастоящего одним наблюдаемым полем. Проверка: один и тот же вопрос с подстановкой четырёх разных клиентских подсетей.
| домен | Нью-Йорк | Франкфурт | Токио | Сан-Паулу | scope |
|---|---|---|---|---|---|
d1.awsstatic.com | 18.164.124.… | 3.160.150.… | 3.166.244.… | 18.67.145.… | /24 |
www.bing.com | 23.195.81.144 | тот же | тот же | тот же | /0 |
www.microsoft.com | 23.217.78.102 | тот же | тот же | тот же | /0 |
www.netflix.com | 207.45.72.1 | тот же | тот же | тот же | /0 |
CloudFront отвечает четырьмя разными наборами адресов и честно помечает
ответ /24: «этот ответ годится только для этой подсети». Остальные три
отвечают одинаково и ставят /0.
Из этого не следует, что Bing и Netflix не умеют в географию. Следует ровно одно: они не используют для этого EDNS Client Subnet. Anycast решает ту же задачу на уровне маршрутизации, и тогда одному адресу соответствуют разные машины в разных точках мира — а DNS-ответ действительно один на всех.
Стоит помнить и то, что сам RFC 7871 относится к своему механизму без энтузиазма: он рекомендует выключать его по умолчанию и усекать адрес до /24, потому что иначе запрос к DNS начинает рассказывать о местоположении пользователя больше, чем нужно.
L4 и L7: сколько балансировщик видит и сколько за это платит
Оба слоя стоят между клиентом и бэкендом, но смотрят на разное.
L4 работает с транспортом: адрес, порт, флаги. Он не разбирает
HTTP-запрос — часто и не может, потому что тот зашифрован. Решение
принимается один раз, при установке соединения, и все последующие пакеты идут
по уже выбранному пути. Отсюда и производительность: Maglev, балансировщик
Google, is able to saturate a 10Gbps link with small packets
(способен насытить канал 10 Гбит/с мелкими пакетами) на одной
обычной машине без специального железа.
L7 терминирует соединение и читает запрос. Ему доступны метод, путь, заголовки, кука — и, значит, любой критерий маршрутизации. Он видит границы запросов, поэтому балансирует именно запросы, а не соединения. Он же умеет повторить неудавшийся запрос на другом бэкенде — на L4 это невозможно в принципе: соединение уже установлено, его не переиграть.
Разница, которую чаще всего упускают, — единица балансировки.
На L4 единица — соединение. Клиент с одним долгим HTTP/2-соединением попадает на один бэкенд и остаётся там навсегда, сколько бы запросов он ни отправил. Десять клиентов, из которых один шлёт 90% трафика, разложатся по десяти бэкендам поровну — по соединению на каждый, и один бэкенд получит девять десятых работы.
На L7 единица — запрос, и та же нагрузка разложится ровно. Цена — расшифровка, разбор и повторная сборка каждого запроса.
Отсюда обычная конструкция: L4 впереди — чтобы принять и размазать соединения на много L7-балансировщиков; L7 позади — чтобы принимать осмысленные решения. И у этой конструкции есть последствие, к которому мы вернёмся: L7-балансировщиков получается много, и каждый видит только свою часть картины.
Алгоритмы: модель и что она показывает
Дальше числа из модели. Устройство её такое: шестнадцать бэкендов, каждый — одна очередь FIFO с одним обслуживающим прибором. Это классическая модель G/G/1: запросы ждут своей очереди и обрабатываются по одному. Никакой придуманной «формулы деградации» здесь нет — замедление под нагрузкой возникает само, из ожидания.
Поток заявок пуассоновский, время обработки логнормальное (тяжёлый хвост — обычная форма для веб-запросов). Балансировщик видит ровно то, что видел бы настоящий: число незавершённых запросов и скользящее среднее времени ответа. Будущего он не знает. Отброшены первые 20 000 запросов из 300 000, результат усреднён по пяти прогонам.
Чего эта модель не умеет и о чём по ней нельзя судить: у неё нет сети и её задержек, нет разрывов соединений, нет проверок живости, нет кеша на бэкенде (а он делает повторный запрос к тому же серверу дешевле), нет ограничений по памяти. Она отвечает на один вопрос — как распределение запросов влияет на ожидание в очередях, — и только на него.
Как читать числа в таблицах ниже. В ячейке — полное время запроса в
системе: сколько он ждал в очереди плюс сколько обрабатывался, — измеренное в
единицах одной средней обработки. Значит, 1,0 — это «столько, сколько в
среднем занимает обработать один запрос»; 0,83 — быстрее средней обработки
(запрос почти не стоял в очереди), 25,07 — в двадцать пять раз дольше.
Абсолютные секунды тут не нужны: в этих единицах числа не зависят от того,
быстрое железо или медленное.
Столбцы — это перцентили, а не среднее. p50 — медиана: половина запросов
уложилась быстрее этого значения. p95 — девяносто пятый перцентиль: медленнее
оказался только каждый двадцатый. p99 — девяносто девятый: медленнее только
каждый сотый. Среднее здесь бесполезно нарочно: балансировщик выбирают по
хвосту — по тем немногим запросам, которым не повезло, — а хвост виден именно в
p95 и p99, а не в среднем.
Одинаковые серверы, загрузка 80%
| алгоритм | p50 | p95 | p99 |
|---|---|---|---|
| random | 3,36 | 16,15 | 25,07 |
| round robin | 1,78 | 10,09 | 17,00 |
| least connections | 0,83 | 3,48 | 6,06 |
| least response time | 0,83 | 3,24 | 5,50 |
| power of two choices | 1,49 | 5,57 | 8,86 |
Первое, что стоит заметить: разброс вчетверо на одинаковых серверах. Обычное объяснение «если серверы одинаковые, сойдёт и round robin» неверно, и причина не в серверах, а в запросах: они разной длины. Round robin распределяет их поровну по счёту, а не по работе, и сервер, которому подряд достались три длинных запроса, встаёт в очередь, пока сосед простаивает. Least connections этого не допускает, потому что смотрит на то, что ещё не завершилось.
Случайный выбор хуже round robin ровно по той причине, по которой шары падают в корзины неравномерно, — к этому вернёмся в разделе про выбор из двух.
Взвешенный round robin и цена ошибки в весе
Теперь один сервер из шестнадцати вчетверо медленнее остальных — обычная ситуация: другое поколение железа, шумный сосед, деградировавший диск.
| настройка | p50 | p95 | p99 |
|---|---|---|---|
| round robin, веса не трогали | 1,98 | 13 050 | 43 946 |
| взвешенный, вес угадан (1 против 4) | 3,42 | 13,85 | 22,63 |
| взвешенный, медленному оставили вдвое больше веса, чем нужно (1 против 2 вместо 1 против 4) | 2,51 | 16,13 | 10 969 |
| least connections, весов нет вообще | 0,91 | 4,03 | 7,70 |
Числа в тысячах — не задержка, а отсутствие устойчивости. Арифметика простая: шестнадцать серверов при загрузке 80% дают 0,8 запроса на сервер за время одной средней обработки; медленному серверу каждый запрос обходится вчетверо дороже, то есть его собственная загрузка — 3,2. Больше единицы. Очередь на нём растёт без предела, и напечатанное число говорит лишь о том, где остановилась модель, а не о том, сколько ждал бы читатель.
Дальше видно две вещи. Правильно выставленный вес чинит устойчивость, но проигрывает least connections втрое по p99 — статический вес не знает, что происходит прямо сейчас. А вес, выставленный неточно, — худший из вариантов: p95 выглядит здоровым (16,13), и только p99 показывает, что сервер балансирует на грани. Мониторинг по среднему и по p95 такую настройку не поймает.
Least connections и least response time
Least connections в nginx описан дословно так: запрос идёт to the server with
the least number of active connections, taking into account weights of
servers
(серверу с наименьшим числом активных соединений, с учётом весов серверов). Least response time добавляет к этому время: у nginx это
least_time с выбором, считать ли время до заголовка (header) или до
последнего байта (last_byte).
В модели выше разница между ними невелика: 6,06 против 5,50 по p99 на одинаковых серверах и 7,70 против 6,48 на кластере с медленным сервером. Least response time чуть лучше — и это ожидаемо: число соединений не отличает быстрый бэкенд от медленного, а время отличает.
Разница вырастает там, где число соединений вообще перестаёт быть мерой работы. Соединение, по которому качают файл на гигабайт, и соединение с пустым health-check выглядят для least connections одинаково.
Least bandwidth и least packets
Это уже не про очереди, а про канал. NetScaler определяет least bandwidth как
выбор сервиса, that is currently serving the least amount of traffic,
measured in megabits per seconds
(который в данный момент обслуживает наименьший объём трафика, измеряемый в мегабитах в секунду), а least packets — the least packets in the
last 14 seconds
(наименьшее число пакетов за последние 14 секунд).
Мерить стоит то, что кончается первым. Для API, где ответ — килобайт JSON, кончится процессор, и мерить надо очереди. Для раздачи видео кончится полоса, и там least connections введёт в заблуждение: десять соединений с битрейтом 100 кбит/с и одно соединение на 50 Мбит/с — это не «десять против одного», это «один против пятидесяти».
Модели для этих двух алгоритмов здесь нет намеренно: чтобы её построить, пришлось бы моделировать сеть, а не очереди, и результат говорил бы больше о предположениях про сеть, чем о балансировке. Поэтому здесь только определение из документации вендора и правило выбора, а числа — нет.
Sticky sessions: сколько стоит закрепление
Закрепление сессии за бэкендом нужно, когда состояние лежит на сервере. Цена — балансировщик перестаёт балансировать: он раскладывает не запросы, а сессии.
| что балансируем | p50 | p95 | p99 |
|---|---|---|---|
| запросы (least connections) | 0,83 | 3,48 | 6,06 |
| 5000 живых сессий | 3,38 | 16,76 | 26,90 |
| 500 живых сессий | 3,77 | 118,75 | 206,92 |
| 50 живых сессий | 30,90 | 6025 | 8408 |
Даже при пяти тысячах сессий p99 хуже в четыре с половиной раза. Дальше зависимость обваливается: чем меньше сессий, тем крупнее «зерно», которым раскладывается нагрузка, и тем сильнее случайный перекос. При пятидесяти сессиях на шестнадцать серверов балансировка перестаёт существовать — работает закон малых чисел.
Отсюда практическое правило, которое стоит любых настроек: закрепление сессии — это компенсация за состояние на сервере, а не способ балансировки. Если состояние можно вынести в общее хранилище или в подписанную куку, закрепление не нужно вовсе. Если нельзя — стоит хотя бы понимать, что за него заплачено хвостом задержек.
Технически закрепление делают по-разному, и разница существенна. ip_hash в
nginx берёт the first three octets of the client IPv4 address
(первые три октета IPv4-адреса клиента) — то есть все
клиенты за одним NAT попадают на один бэкенд. Закрепление по куке точнее, но
работает только для браузеров. Consistent hashing (hash ... consistent в
nginx, ring hash и Maglev в Envoy) отличается от обычного хеша тем, что при
добавлении или удалении сервера переезжает малая часть ключей, а не почти все:
в документации nginx это сказано прямо — only a few keys will be remapped
(переотображены будут лишь немногие ключи).
Power of two choices: почему два, а не все
Начнём с чистой задачи: n шаров бросают в n корзин. Если каждый шар летит в
случайную корзину, максимальная загрузка is approximately log n / log log n
(составляет примерно log n / log log n).
Если каждый шар смотрит на две случайные корзины и падает в менее полную, она
становится log log n / log d + O(1)
. Считаем на модели шаров и корзин — пятнадцать прогонов на каждое n, среднее и
стандартное отклонение:
В ячейке — максимальная загрузка: сколько шаров оказалось в самой полной
корзине (среднее по пятнадцати прогонам; число после ± — стандартное
отклонение). n — это и число шаров, и число корзин сразу. Первые два столбца —
два способа бросать шар: в случайную корзину и в менее полную из двух случайных.
Последние два — асимптотические формулы из обзора, посчитанные по натуральному
логарифму: с ними сравниваем.
| n | случайно | лучшая из двух | ln n / ln ln n | ln ln n / ln 2 |
|---|---|---|---|---|
| 100 | 4,40 ± 0,63 | 2,60 ± 0,63 | 3,02 | 2,20 |
| 1 000 | 5,47 ± 0,52 | 3,00 ± 0,00 | 3,57 | 2,79 |
| 10 000 | 6,67 ± 0,62 | 3,13 ± 0,35 | 4,15 | 3,20 |
| 100 000 | 7,67 ± 0,62 | 3,53 ± 0,52 | 4,71 | 3,53 |
Тысячекратный рост числа корзин почти удваивает максимум при случайном выборе — и практически не двигает его при выборе из двух: 2,60 → 3,53. Асимптотическая формула на конечных n занижает оценку для случайного выбора (она асимптотическая, это нормально), а для выбора из двух держится рядом с моделью и на ста тысячах корзин сходится с ней вплотную: 3,53 против 3,53.
Почему берут именно два, а не три и не десять, сказано в том же обзоре: each
additional choice beyond two decreases the maximum load by just a constant
factor
(каждый следующий выбор сверх двух уменьшает максимальную загрузку лишь на постоянный множитель). Первый шаг от одного к двум меняет асимптотику, все следующие — только
множитель. HAProxy это и закрепил: число жеребьёвок настраивается, а по
умолчанию равно двум. Envoy формулирует так же: selects N random available
hosts as specified in the configuration (2 by default) and picks the host which
has the fewest active requests
(выбирает N случайных доступных узлов, как задано в настройках (по умолчанию два), и берёт тот, у которого меньше всего активных запросов).
Но в очередях, а не в корзинах, картина другая — и в этом главная неожиданность
всего разбора. Вернитесь к первой таблице: p99 у power of two — 8,86, у least
connections — 6,06. Выбор из двух проигрывает. И это не артефакт модели:
автор HAProxy, измеряя на живом стенде из шести машин, получил тот же знак —
least connections дал about 4% higher
(примерно на 4 % выше) запросов в секунду, а пики соединений
у него оказались about 30% lower
(примерно на 30 % ниже).
Так зачем тогда power of two?
Главный результат: свежесть сигнала важнее алгоритма
Least connections требует знать, сколько запросов сейчас в работе у каждого бэкенда. Пока балансировщик один, он это знает точно — он сам их и отправил. Как только балансировщиков много, точного знания нет ни у кого.
Обычное объяснение — стадный эффект: все балансировщики видят один и тот же
«самый свободный» сервер и одновременно бросаются на него. Owen Garrett
описывает это через очередь на паспортном контроле: all the guides notice that
one queue is momentarily shorter and faster, and all send travelers to that
queue
(все сотрудники разом замечают, что одна очередь сейчас короче и движется быстрее, и все направляют людей именно в неё). Envoy называет ту же причину как основание для выбора: resistance to
herding behavior
(устойчивость к стадному эффекту).
Объяснение звучит убедительно, поэтому оно проверено двумя опытами. Верным оно оказалось наполовину.
Опыт первый: у каждого балансировщика свои счётчики. Шестнадцать бэкендов, несколько независимых балансировщиков; каждый видит только собственные отправленные запросы, про соседей не знает ничего.
В ячейках — p99 в тех же единицах средней обработки, что и выше; строка —
сколько независимых балансировщиков работает одновременно.
| балансировщиков | round robin | least connections | power of two |
|---|---|---|---|
| 1 | 16,77 | 6,01 | 8,95 |
| 4 | 16,89 | 8,75 | 10,99 |
| 16 | 17,76 | 13,27 | 14,28 |
| 64 | 19,72 | 18,38 | 18,87 |
Стада нет. Least connections плавно теряет преимущество и к шестидесяти четырём балансировщикам сравнивается с round robin — 18,38 против 19,72, — но ни разу не становится хуже него. Причина понятна, если сформулировать: приватный счётчик — это несмещённая выборка из общей картины. Балансировщик судит по неполным данным, но не по неправильным, и ошибки разных балансировщиков не сговариваются.
Опыт второй: общий снимок состояния с задержкой. Теперь балансировщики
читают одну общую картину, которая обновляется раз в refresh (в единицах
среднего времени обработки) — так устроены схемы, где метрики бэкендов
собираются и раздаются.
В ячейках снова p99; строка — насколько устарел общий снимок: 0 —
мгновенный, 2,0 — отстаёт на две средние обработки.
| задержка снимка | random | round robin | least connections | power of two |
|---|---|---|---|---|
| 0 (мгновенно) | 25,07 | 17,00 | 6,06 | 8,86 |
| 0,1 | 25,07 | 17,00 | 6,88 | 8,94 |
| 0,5 | 25,07 | 17,00 | 10,29 | 9,26 |
| 2,0 | 25,07 | 17,00 | 19,95 | 10,52 |
Вот теперь видно всё. При задержке в две средние обработки least connections даёт p99 = 19,95 — хуже round robin и почти на уровне случайного выбора. Power of two за то же время сдвигается с 8,86 до 10,52, то есть на пятую часть.
Значит, дело не в количестве балансировщиков как таковом. Разрушает least connections общий устаревший сигнал: все читают одно и то же неверное число и делают одну и ту же ошибку одновременно. Приватные неполные счётчики такого не дают, а общий снимок — даёт, и чем он старше, тем хуже.
Отсюда правило, которое сформулировано в терминах данных, а не в терминах названий алгоритмов:
- Балансировщик один и считает сам — least connections или least response time, и ничего лучше не нужно.
- Балансировщиков много, но каждый считает свои запросы — least connections всё ещё разумен; его преимущество тает, но до round robin он не опускается.
- Состояние приходит со стороны и с задержкой — выбор лучшего из двух, и чем больше задержка, тем очевиднее выигрыш.
- Состояния нет вовсе — взвешенный round robin, и тогда за весами придётся следить руками; ошибка в весе стоит дороже, чем кажется.
Когда балансировать уже нечего: отбрасывать нагрузку
Модель выше показала точку, за которой алгоритм перестаёт что-либо решать: загрузка больше единицы, очередь растёт без предела. Это не край модели, а рабочая ситуация, и у неё есть отдельный ответ, к балансировке отношения не имеющий.
Avoiding overload is a goal of load balancing policies. But no matter how
efficient your load balancing policy, eventually some part of your system will
become overloaded. Gracefully handling overload conditions is fundamental to
running a reliable serving system.
Избежание перегрузки — цель политик балансировки нагрузки. Но какой бы эффективной ни была ваша политика балансировки, рано или поздно какая-то часть системы окажется перегруженной. Аккуратная обработка условий перегрузки принципиальна для работы надёжной обслуживающей системы.
Ответ называется отбрасыванием нагрузки, и определение у него короткое:
Load shedding drops some proportion of load by dropping traffic as the server
approaches overload conditions.
Отбрасывание нагрузки сбрасывает некоторую долю нагрузки, отбрасывая трафик по мере приближения сервера к условиям перегрузки.
Зачем отказывать, если можно поставить в очередь, объясняет вторая половина нашей же модели. Очередь — это не бесплатное ожидание, а потраченная память и добавленная задержка:
Queued requests consume memory and increase latency. For example, if the queue
size is 10x the number of threads, the time to handle the request on a thread is
100 milliseconds. If the queue is full, then a request will take 1.1 seconds to
handle, most of which time is spent on the queue.
Запросы в очереди потребляют память и увеличивают задержку. Например, если размер очереди в 10 раз больше числа потоков, а время обработки запроса потоком — 100 миллисекунд, то при полной очереди обработка запроса займёт 1,1 секунды, бо́льшая часть которых проведена в очереди.
И — главное — работа в такой очереди уже никому не нужна: If a user's web search is slow because an RPC has been queued for 10 seconds, there's a good chance the user has given up and refreshed their browser, issuing another request: there's no point in responding to the first one, since it will be ignored
(Если пользовательский веб-поиск медленный, потому что RPC простоял в очереди 10 секунд, велика вероятность, что пользователь сдался и обновил браузер, отправив новый запрос: отвечать на первый нет смысла — ответ проигнорируют).
То есть за порогом сервер тратит ресурсы на ответы, которые никто не прочтёт.
Отброс возвращает их тем, кто ещё ждёт:
The goal of load shedding is to keep latency low for the requests that the server
decides to accept so that the service replies before the client times out.
Цель отбрасывания нагрузки — удерживать низкую задержку для тех запросов, которые сервер решил принять, чтобы сервис ответил раньше, чем у клиента истечёт таймаут.
Механика. Она использует ровно ту величину, которой уже оперирует least connections из таблицы выше — число незавершённых запросов:
One straightforward way to shed load is to do per-task throttling based on CPU,
memory, or queue length […] For example, one effective approach is to return an
HTTP 503 (service unavailable) to any incoming request when there are more than a
given number of client requests in flight.
Прямолинейный способ отбрасывать нагрузку — ограничивать её на уровне задачи по процессору, памяти или длине очереди […] Например, один действенный подход — возвращать HTTP 503 (сервис недоступен) на любой входящий запрос, когда число клиентских запросов в работе превышает заданное.
В Envoy это два разных механизма, и различать их стоит. Размыкание цепи —
max_requests и max_pending_requests на кластер — защищает того, к кому
идут. Менеджер перегрузки — того, кто стоит посередине: This is distinct from circuit breaking which is primarily aimed at protecting upstream services
(Это отличается от размыкания цепи, которое нацелено прежде всего на защиту вышестоящих сервисов).
У второго пороги заданы по давлению ресурса: сливать соединения при 92 %
использования кучи, прекращать принимать запросы при 95 %.
Порог можно и не выбирать вручную. Адаптивная конкурентность выводит его из
задержки: измеряет идеальное время обхода minRTT, сравнивает с текущим и
двигает предел по градиенту — This gradient value has a useful property, such that it decreases as the sampled latencies increase
(У этого градиента есть полезное свойство: он уменьшается по мере роста измеренных задержек).
Цена честно названа там же: во время окна измерения возможен заметный рост числа
503, потому что предел на это время прижимается к минимуму.
Две оговорки, без которых совет вреден.
Первая — про метрику. Считать ёмкость в запросах в секунду ненадёжно: modeling capacity as "queries per second" … often makes for a poor metric
(моделирование ёмкости как „запросов в секунду“ … часто оказывается плохой метрикой).
Причина та же, что в нашей модели: запросы неодинаковы по стоимости, и именно
разброс стоимости, а не их число, растил хвост.
Вторая — про то, что отброс сам должен работать. Ветка, которой не пользуются, обычно сломана:
Remember that the code path you never use is the code path that (often) doesn't
work. In steady-state operation, graceful degradation mode won't be used,
implying that you'll have much less operational experience with this mode and any
of its quirks, which increases the level of risk.
Помните: ветка кода, которой вы никогда не пользуетесь, — это (часто) ветка, которая не работает. В установившемся режиме плавная деградация не используется, а значит, у вас будет намного меньше эксплуатационного опыта с этим режимом и его особенностями, что повышает уровень риска.
Повторы: почему «попробовать ещё раз» умножается
В разделе про L4 и L7 выше повтор на другом бэкенде назван достоинством L7 — и это верно. Не сказана вторая половина: под перегрузкой повтор перестаёт помогать и начинает вредить.
When failures are caused by overload, retries that increase load can make matters
significantly worse. They can even delay recovery by keeping the load high long
after the original issue is resolved.
Когда отказы вызваны перегрузкой, повторы, увеличивающие нагрузку, могут заметно ухудшить положение. Они могут даже задержать восстановление, удерживая высокую нагрузку долго после того, как исходная проблема устранена.
Опаснее всего то, что повторы перемножаются по слоям, а не складываются:
Avoid amplifying retries by issuing retries at multiple levels: a single request
at the highest layer may produce a number of attempts as large as the product of
the number of attempts at each layer to the lowest layer. If the database can't
service requests because it's overloaded, and the backend, frontend, and
JavaScript layers all issue 3 retries (4 attempts), then a single user action may
create 64 attempts (4^3) on the database.
Избегайте усиления повторов, выполняя их на нескольких уровнях: один запрос на верхнем слое может породить число попыток, равное произведению числа попыток на каждом слое вплоть до нижнего. Если база не может обслуживать запросы из-за перегрузки, а слои бэкенда, фронтенда и JavaScript делают по 3 повтора (4 попытки), то одно действие пользователя может создать 64 попытки (4³) к базе.
AWS считает тот же множитель на пять слоёв и получает 243: If each layer retries independently, the load on the database will increase 243x, making it unlikely to ever recover
(Если каждый слой повторяет независимо, нагрузка на базу вырастет в 243 раза, и восстановиться она вряд ли сможет).
Оба числа — арифметика, а не замер, и это здесь важнее замера: показатель степени
равен числу слоёв, а слои в реальной системе никто не считает.
Соедините это с моделью выше. Загрузка выросла до единицы, задержки поползли, клиенты начали повторять — и нагрузка на нижний слой выросла не на проценты, а в несколько раз: множитель здесь возводится в степень числа слоёв, а не умножается на него. Три слоя по три повтора — это 64 попытки вместо одной; пять слоёв — 243. Точка невозврата, которая на графике выглядела далёкой, оказывается за этим множителем.
Три меры. Две названы первоисточниками прямо, третья из них следует.
Разнести повторы во времени. Always use randomized exponential backoff when scheduling retries
(Всегда используйте рандомизированную экспоненциальную выдержку при планировании повторов).
Случайность здесь не украшение: без неё повторы синхронизируются и приходят
пачкой — Jitter adds some amount of randomness to the backoff to spread the retries around in time
(Джиттер добавляет к выдержке некоторую случайность, чтобы разнести повторы во времени).
Ограничить повторы бюджетом, а не счётчиком на запрос. Consider having a server-wide retry budget. For example, only allow 60 retries per minute in a process, and if the retry budget is exceeded, don't retry; just fail the request
(Подумайте о бюджете повторов на уровне сервера. Например, разрешайте только 60 повторов в минуту в процессе, а если бюджет исчерпан — не повторяйте, просто отказывайте в запросе).
Разница принципиальна: счётчик на запрос ограничивает одного клиента, бюджет —
всех сразу, а умножается именно суммарный объём.
Повторять только на одном слое. Это следствие первой цитаты, и в настройке Envoy оно записано так:
In general we recommend using retry budgets; however, if static circuit breaking
is preferred it should aggressively circuit break retries. This is so that
retries for sporadic failures are allowed, but the overall retry volume cannot
explode and cause large scale cascading failure.
В целом мы рекомендуем использовать бюджеты повторов; однако если предпочтительно статическое размыкание, оно должно агрессивно размыкать повторы. Это делается для того, чтобы повторы при спорадических отказах были разрешены, но общий объём повторов не мог взорваться и вызвать масштабный каскадный отказ.
Близость против равномерности: маршрутизация по зонам
Вся модель выше молчаливо считала бэкенды одинаково доступными. В нескольких зонах это неверно: у запроса в свою зону задержка меньше, а трафик между зонами ещё и оплачивается отдельно. Возникает конфликт с тем, ради чего балансировщик и нужен, и Envoy формулирует его без попытки сгладить:
The purpose of zone aware routing is to send as much traffic to the local zone in
the upstream cluster as possible while roughly maintaining the same number of
requests per second across all upstream hosts (depending on load balancing
policy).
Цель маршрутизации с учётом зон — отправить в локальную зону вышестоящего кластера как можно больше трафика, сохраняя при этом примерно одинаковое число запросов в секунду по всем вышестоящим узлам.
«Как можно больше» и «примерно одинаково» — это две цели, и вторая ограничивает первую. Что происходит, когда они расходятся, описано конкретно:
The originating cluster local zone percentage is greater than the one in the
upstream cluster. In this case we cannot route all requests from the local zone
of the originating cluster to the local zone of the upstream cluster because that
will lead to request imbalance across all upstream hosts. Instead, Envoy
calculates the percentage of requests that can be routed directly to the local
zone of the upstream cluster. The rest of the requests are routed cross zone.
Процент локальной зоны у исходящего кластера больше, чем у вышестоящего. В этом случае мы не можем направить все запросы из локальной зоны исходящего кластера в локальную зону вышестоящего, потому что это приведёт к дисбалансу запросов по всем вышестоящим узлам. Вместо этого Envoy вычисляет процент запросов, которые могут быть направлены прямо в локальную зону вышестоящего кластера. Остальные запросы маршрутизируются между зонами.
То есть локальность — не переключатель, а доля, и считается она из соотношения ёмкостей зон.
Второй подход противоположен по устройству и с первым несовместим. Веса расположений задаются не эвристикой балансировщика, а управляющим сервером:
This approach is mutually exclusive with zone aware routing, since in the case of
locality aware LB, we rely on the management server to provide the locality
weighting, rather than the Envoy-side heuristics used in zone aware routing.
Этот подход взаимоисключающ с маршрутизацией с учётом зон, поскольку при балансировке с учётом расположения мы полагаемся на управляющий сервер, который сообщает веса расположений, а не на эвристики на стороне Envoy, используемые в маршрутизации с учётом зон.
Здесь важно, как веса ведут себя при отказах. Вес корректируется не сразу, а с запасом — коэффициент избыточного выделения 1,4, — и таблица из документации показывает, где именно начинается перелив (расположение X с весом 1 против Y с весом 2):
| здоровых узлов в X | доля трафика в X | доля трафика в Y |
|---|---|---|
| 100 % | 33 % | 67 % |
| 70 % | 33 % | 67 % |
| 69 % | 32 % | 68 % |
| 50 % | 26 % | 74 % |
| 25 % | 15 % | 85 % |
| 0 % | 0 % | 100 % |
Между 100 % и 70 % не меняется ничего: коэффициент 1,4 и означает «пока недоступно меньше 30 %, считаем расположение целым». Дальше доля падает пропорционально. Это числа документации Envoy, не наш замер.
И самое важное для главной оси статьи — про момент решения. Алгоритм из таблицы выше выбирает узел не первым, а третьим:
- Pick priority level. 2. Pick locality (as described in this section) within
priority level from (1). 3. Pick endpoint using cluster specified load balancer
within locality from (2).
1. Выбрать уровень приоритета. 2. Выбрать расположение внутри уровня приоритета из (1). 3. Выбрать конечную точку заданным для кластера балансировщиком внутри расположения из (2).
То есть спор «round-robin или least connections», которому посвящена модель, разыгрывается внутри уже выбранного расположения. Два предыдущих шага отсекают большую часть узлов до того, как алгоритм вообще получит слово, — и влияют на итоговое распределение сильнее, чем выбор между ним и соседним.
Немного истории
| Версия | Изменение | Что стало возможным |
|---|---|---|
| 1995 | RFC 1794 описывает распределение нагрузки через DNS и там же называет его пределы: порядок записей не гарантирован, а TTL в час бессмысленен, если условия меняются ежеминутно. | Балансировка без обратной связи |
| 1999 | Azar, Broder, Karlin и Upfal доказывают, что выбор лучшей из d корзин даёт максимум log log n / log d + O(1) вместо log n / log log n. | Два выбора вместо одного |
| 2008 | Google запускает Maglev — балансировщик четвёртого уровня на обычных серверах. Статья выйдет только в 2016-м, на NSDI. | L4 без специального железа |
| 2016 | RFC 7871 вводит EDNS Client Subnet и поле SCOPE PREFIX-LENGTH — по нему теперь можно проверить, зависит ли ответ от подсети клиента. | geoDNS стал наблюдаемым |
| 2018 | nginx получает random two, а вместе с ним и публичное объяснение, зачем это нужно при нескольких балансировщиках. | Power of two в проксях |
| 2019 | HAProxy добавляет число жеребьёвок в balance random; значение по умолчанию — два. Автор тут же публикует замеры, из которых следует, что при одном балансировщике least connections всё равно лучше. | Настраиваемое число выборов |
| 2025 | В nginx 1.31.0 least_time перестаёт быть частью платной подписки. | Least response time всем |
Что здесь стандарт, а что решение конкретного продукта
Разделение того же рода, что «гарантия языка против детали реализации», и путать их так же опасно.
Стандарт, на который можно опираться: формат DNS-ответа и семантика TTL
(RFC 1035, RFC 1794); формат EDNS Client Subnet и смысл SCOPE PREFIX-LENGTH
(RFC 7871); теоретические границы для случайного выбора и выбора из двух — это
математика, она не зависит от продукта.
Решение конкретного продукта, которое меняется от версии к версии: набор
алгоритмов и их названия; что именно nginx считает «активным соединением»;
сколько жеребьёвок делает HAProxy по умолчанию (два — но это настройка);
формула Envoy для неравных весов; то, что ip_hash берёт первые три октета;
размер таблицы Maglev (65537). Ни одно из этих чисел не свойство
балансировки как таковой — все они выбраны авторами и записаны в документации,
на которую стоит ссылаться при споре.
Как воспроизвести числа
Все числа в статье получены тремя способами.
Живые DNS-замеры — обычными запросами к резолверу, с подстановкой EDNS Client
Subnet для четырёх подсетей; результат зависит от того, где вы находитесь и
через какой резолвер ходите, поэтому у вас адреса будут другие, а поле
scope — тем же: именно оно и проверяется. Скрипты:
bench/load-balancing/dns2.py и bench/load-balancing/ecs.py.
Модель очередей — дискретное событийное моделирование, описанное выше:
шестнадцать очередей FIFO, пуассоновский поток, логнормальное время обработки,
300 000 запросов, первые 20 000 отброшены, пять прогонов с разными зёрнами.
Алгоритмы считает bench/load-balancing/sim2.py, веса и закрепление —
bench/load-balancing/sim3.py, много балансировщиков сразу —
bench/load-balancing/herd.py и bench/load-balancing/herd2.py.
Задача о шарах и корзинах — bench/load-balancing/bins.py.
Числа из модели воспроизводимы точно; числа из DNS — только по смыслу.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Балансировка — это цепочка решений, и у каждого своя цена. Чем раньше выбран сервер, тем меньше о нём известно в момент выбора. DNS отвечает за минуты до запроса и не знает ни загрузки, ни живости; L4 видит адреса и порты; L7 читает запрос целиком и знает про бэкенды всё.
- Алгоритм имеет значение сильно больше, чем принято думать. В модели из шестнадцати бэкендов при загрузке 80% задержка p99 отличается вчетверо: 25,07 у случайного выбора против 6,06 у least connections. Round robin — 17,00, то есть ближе к случайному, чем к разумному.
- Но у least connections есть условие, и оно важнее самого алгоритма: сигнал о загрузке должен быть свежим. Как только состояние собирается в общий снимок с задержкой, least connections деградирует — при задержке в две средние обработки его p99 (19,95) хуже, чем у round robin (17,00). Выбор лучшего из двух случайных за то же время сдвигается с 8,86 до 10,52 и становится лучшим вариантом на столе.
На самом деле
- Серверы одинаковые, а запросы — нет. Round robin раскладывает их поровну по счёту, а не по работе, и сервер, которому подряд достались три длинных запроса, стоит в очереди, пока сосед простаивает. В модели из шестнадцати одинаковых бэкендов при загрузке 80% p99 у round robin — 17,00, у least connections — 6,06. Втрое, при полностью одинаковом железе.
- Только пока сигнал свежий. В модели с общим снимком состояния, обновляемым раз в две средние обработки, least connections даёт p99 = 19,95 — хуже round robin (17,00) и почти вровень со случайным выбором (25,07). Выбор лучшего из двух за то же время сдвигается с 8,86 всего до 10,52. Ломает least connections не количество балансировщиков, а общий устаревший сигнал: все читают одно и то же неверное число и ошибаются одинаково.
- При одном балансировщике с точными счётчиками он проигрывает: p99 = 8,86 против 6,06. Тот же знак получил автор HAProxy на живом стенде — least connections дал about 4% higher запросов в секунду и пики соединений about 30% lower. Выбор из двух выигрывает не точностью, а устойчивостью к плохим данным: он не полагается на полную картину и потому не рушится, когда она устаревает.
- Решает, если вес угадан, и создаёт худший из вариантов, если промахнуться. В модели с сервером вчетверо медленнее: правильный вес даёт p99 = 22,63, отсутствие веса — расходящуюся очередь, а вес занижённый вдвое — p95 = 16,13 при p99 = 10 969. То есть по среднему и по p95 настройка выглядит здоровой, а сервер стоит на самой границе устойчивости. Least connections тот же случай закрывает без единого веса: p99 = 7,70.
- Она распределяет обращения за адресом, а не запросы. Клиент, открывший HTTP/2-соединение и держащий его часами, обратился за адресом один раз на тысячи запросов. Плюс TTL никто не обязан соблюдать: RFC 1794 ещё в 1995 году отметил, что the DNS protocol doesn't guarantee ordering, а кеши резолверов, операционной системы и браузера живут своей жизнью. DNS годится, чтобы развести трафик по площадкам, и не годится, чтобы выравнивать нагрузку между машинами.
- Не обязательно, и это проверяется одним полем. RFC 7871 требует, чтобы ответ нёс
SCOPE PREFIX-LENGTH: ноль означает «ответ годится всем адресам». Замер с подстановкой четырёх клиентских подсетей:d1.awsstatic.comотвечает четырьмя разными наборами адресов и ставит scope/24, аwww.bing.com,www.microsoft.comиwww.netflix.comотвечают всем одинаково и ставят/0. Последнее не значит, что у них нет географии, — значит, что она сделана не через DNS, а маршрутизацией (anycast). - Они балансируют разные единицы. На L4 решение принимается один раз, при установке соединения, поэтому единица — соединение: десять клиентов, из которых один шлёт 90% трафика, разложатся по десяти бэкендам поровну, и один получит девять десятых работы. На L7 единица — запрос, и та же нагрузка разложится ровно. Скорость L4 при этом реальна: Maglev на одной обычной машине is able to saturate a 10Gbps link with small packets. Поэтому их обычно ставят вместе, а не выбирают между ними.
- Она превращает балансировку запросов в балансировку сессий, и цена видна сразу. В модели на одинаковых серверах: без закрепления p99 = 6,06; при пяти тысячах живых сессий — 26,90; при пятистах — 206,92; при пятидесяти — 8408. Чем меньше сессий, тем крупнее зерно раскладки и тем сильнее случайный перекос. Закрепление — компенсация за состояние на сервере, а не способ распределять нагрузку.
По версиям
- 1995
- RFC 1794 описывает распределение нагрузки через DNS и там же называет его пределы: порядок записей не гарантирован, а TTL в час бессмысленен, если условия меняются ежеминутно.<
- 1999
- Azar, Broder, Karlin и Upfal доказывают, что выбор лучшей из
dкорзин даёт максимумlog log n / log d + O(1)вместоlog n / log log n.< - 2008
- Google запускает Maglev — балансировщик четвёртого уровня на обычных серверах. Статья выйдет только в 2016-м, на NSDI.<
- 2016
- RFC 7871 вводит EDNS Client Subnet и поле
SCOPE PREFIX-LENGTH— по нему теперь можно проверить, зависит ли ответ от подсети клиента.< - 2018
- nginx получает
random two, а вместе с ним и публичное объяснение, зачем это нужно при нескольких балансировщиках.< - 2019
- HAProxy добавляет число жеребьёвок в
balance random; значение по умолчанию — два. Автор тут же публикует замеры, из которых следует, что при одном балансировщике least connections всё равно лучше.< - 2025
- В nginx 1.31.0
least_timeперестаёт быть частью платной подписки.<
Что разобрано
- Зачем это знать
- Клиентская и серверная балансировка
- DNS: выбор за минуты до запроса
- geoDNS: и как проверить, настоящий ли он
- L4 и L7: сколько балансировщик видит и сколько за это платит
- Алгоритмы: модель и что она показывает
- Главный результат: свежесть сигнала важнее алгоритма
- Когда балансировать уже нечего: отбрасывать нагрузку
- Повторы: почему «попробовать ещё раз» умножается
- Близость против равномерности: маршрутизация по зонам
- Немного истории
- Что здесь стандарт, а что решение конкретного продукта
- Как воспроизвести числа
Частые заблуждения
«Если серверы одинаковые, сойдёт и round robin».
Серверы одинаковые, а запросы — нет. Round robin раскладывает их поровну по счёту, а не по работе, и сервер, которому подряд достались три длинных запроса, стоит в очереди, пока сосед простаивает. В модели из шестнадцати одинаковых бэкендов при загрузке 80% p99 у round robin — 17,00, у least connections — 6,06. Втрое, при полностью одинаковом железе.
«Least connections всегда лучше, это же учёт реальной загрузки».
Только пока сигнал свежий. В модели с общим снимком состояния, обновляемым раз в две средние обработки, least connections даёт p99 = 19,95 — хуже round robin (17,00) и почти вровень со случайным выбором (25,07). Выбор лучшего из двух за то же время сдвигается с 8,86 всего до 10,52. Ломает least connections не количество балансировщиков, а общий устаревший сигнал: все читают одно и то же неверное число и ошибаются одинаково.
«Power of two choices лучше least connections — это же современный алгоритм».
При одном балансировщике с точными счётчиками он проигрывает: p99 = 8,86 против 6,06. Тот же знак получил автор HAProxy на живом стенде — least connections дал about 4% higher
(примерно на 4 % выше) запросов в секунду и пики соединений about 30% lower
(примерно на 30 % ниже). Выбор из двух выигрывает не точностью, а устойчивостью к плохим данным: он не полагается на полную картину и потому не рушится, когда она устаревает.
«Взвешенный round robin решает проблему медленного сервера».
Решает, если вес угадан, и создаёт худший из вариантов, если промахнуться. В модели с сервером вчетверо медленнее: правильный вес даёт p99 = 22,63, отсутствие веса — расходящуюся очередь, а вес занижённый вдвое — p95 = 16,13 при p99 = 10 969. То есть по среднему и по p95 настройка выглядит здоровой, а сервер стоит на самой границе устойчивости. Least connections тот же случай закрывает без единого веса: p99 = 7,70.
«DNS-балансировка распределяет запросы между серверами».
Она распределяет обращения за адресом, а не запросы. Клиент, открывший HTTP/2-соединение и держащий его часами, обратился за адресом один раз на тысячи запросов. Плюс TTL никто не обязан соблюдать: RFC 1794 ещё в 1995 году отметил, что the DNS protocol doesn't guarantee ordering
(протокол DNS не гарантирует порядок), а кеши резолверов, операционной системы и браузера живут своей жизнью. DNS годится, чтобы развести трафик по площадкам, и не годится, чтобы выравнивать нагрузку между машинами.
«Если сайт отвечает разными адресами в разных странах, это geoDNS».
Не обязательно, и это проверяется одним полем. RFC 7871 требует, чтобы ответ нёс SCOPE PREFIX-LENGTH: ноль означает «ответ годится всем адресам». Замер с подстановкой четырёх клиентских подсетей: d1.awsstatic.com отвечает четырьмя разными наборами адресов и ставит scope /24, а www.bing.com, www.microsoft.com и www.netflix.com отвечают всем одинаково и ставят /0. Последнее не значит, что у них нет географии, — значит, что она сделана не через DNS, а маршрутизацией (anycast).
«L4 быстрее L7, поэтому лучше».
Они балансируют разные единицы. На L4 решение принимается один раз, при установке соединения, поэтому единица — соединение: десять клиентов, из которых один шлёт 90% трафика, разложатся по десяти бэкендам поровну, и один получит девять десятых работы. На L7 единица — запрос, и та же нагрузка разложится ровно. Скорость L4 при этом реальна: Maglev на одной обычной машине is able to saturate a 10Gbps link with small packets
(способен насытить канал 10 Гбит/с мелкими пакетами). Поэтому их обычно ставят вместе, а не выбирают между ними.
«Sticky sessions — это просто настройка, она ничего не стоит».
Она превращает балансировку запросов в балансировку сессий, и цена видна сразу. В модели на одинаковых серверах: без закрепления p99 = 6,06; при пяти тысячах живых сессий — 26,90; при пятистах — 206,92; при пятидесяти — 8408. Чем меньше сессий, тем крупнее зерно раскладки и тем сильнее случайный перекос. Закрепление — компенсация за состояние на сервере, а не способ распределять нагрузку.
Проверка знаний
Сервис за L4-балансировщиком. Клиенты используют HTTP/2 и держат соединения открытыми. Нагрузка на бэкенды разошлась в разы, хотя соединений у всех поровну. Наиболее вероятная причина?
Источники и что читать дальше
13 ИСТОЧНИКОВ
- RFC 1794 — DNS Support for Load BalancingОфициальная документация. Informational, апрель 1995, T. Brisco (Rutgers). Документ, в котором распределение нагрузки через DNS названо и разобрано впервые. Там же названо и его ограничение: «the DNS protocol doesn't guarantee ordering» (протокол DNS не гарантирует порядок), и отдельно — про TTL: «There is no use in handing out information with TTLs of an hour, when the conditions for ordering the RRs changes minutely» (Нет смысла раздавать сведения с часовым TTL, когда условия, определяющие порядок записей, меняются ежеминутно).https://www.rfc-editor.org/rfc/rfc1794.html
- RFC 7871 — Client Subnet in DNS QueriesОфициальная документация. Informational, май 2016. Определяет SCOPE PREFIX-LENGTH — «the leftmost number of significant bits of ADDRESS that the response covers» (число старших значащих бит адреса, которые покрывает ответ) — и оговаривает, что ноль «indicates that the answer is suitable for all addresses in FAMILY» (означает, что ответ пригоден для всех адресов данного семейства). Именно это поле отличает настоящий geoDNS от одинакового ответа всем. Там же — рекомендация выключать механизм по умолчанию и усекать адрес до /24.https://www.rfc-editor.org/rfc/rfc7871.html
- nginx — модуль ngx_http_upstream_moduleОфициальная документация. Формулировки алгоритмов дословно: least_conn «passes a request to the server with the least number of active connections, taking into account weights» (передаёт запрос серверу с наименьшим числом активных соединений, с учётом весов); random «two» — «randomly select two servers and then choose a server using the specified method» (случайно выбирает два сервера, а затем выбирает между ними указанным методом), по умолчанию least_conn. Там же least_time с режимами header и last_byte и оговорка, что до версии 1.31.0 он был только в платной подписке.https://nginx.org/en/docs/http/ngx_http_upstream_module.html
- HAProxy — коммит, добавивший число жеребьёвок в balance randomИсходный код. «MINOR: backend: make the random algorithm support a number of draws» (MINOR: backend: научить случайный алгоритм произвольному числу жеребьёвок). Значение по умолчанию — два (`lbprm.arg_opt1 = 2`), и в самом сообщении коммита приём назван своим именем: Power of Two Random Choices.https://github.com/haproxy/haproxy/commit/21c741a665f
- Willy Tarreau — Test driving «power of two random choices» (пер.: «сила двух случайных выборов») load balancingИсточник. Блог HAProxy, 15 февраля 2019. Автор HAProxy меряет на шести ARM-машинах и приходит к неудобному выводу: при одном балансировщике least connections остаётся лучше — «about 4% higher» (примерно на 4 % выше) по запросам в секунду, — а пики соединений у него «about 30% lower» (примерно на 30 % ниже). Смысл power of two раскрывается не здесь, а в распределённой схеме.https://www.haproxy.com/blog/power-of-two-load-balancing
- Owen Garrett — NGINX and the «Power of Two Choices» Load-Balancing AlgorithmИсточник. 12 ноября 2018. Объяснение стадного эффекта на аналогии с очередями на паспортном контроле: «all the guides notice that one queue is momentarily shorter and faster, and all send travelers to that queue» (все сотрудники разом замечают, что одна очередь сейчас короче и движется быстрее, и все направляют людей именно в неё). Рекомендация — «for very high-performance environments and for distributed load-balancing scenarios» (для сред с очень высокими требованиями к производительности и для распределённой балансировки), прямо назван случай нескольких ingress-контроллеров.https://www.f5.com/company/blog/nginx/nginx-power-of-two-choices-load-balancing-algorithm
- Envoy — Supported load balancersОфициальная документация. «An O(1) algorithm which selects N random available hosts as specified in the configuration (2 by default) and picks the host which has the fewest active requests» (Алгоритм за O(1): выбирает N случайных доступных узлов, как задано в настройках (по умолчанию два), и берёт тот, у которого меньше всего активных запросов), и там же названа причина выбора: «P2C selection is particularly useful for load balancer implementations due to its resistance to herding behavior» (Выбор из двух особенно полезен в балансировщиках нагрузки благодаря устойчивости к стадному эффекту). Отдельно — что при неравных весах Envoy переключается на другую формулу.https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/load_balancers
- Mitzenmacher, Richa, Sitaraman — The Power of Two Random Choices: A Survey of Techniques and ResultsКнига. Теоретическая часть: при случайном выборе максимальная загрузка «is approximately log n/ log log n with high probability» (составляет примерно log n / log log n с высокой вероятностью), при выборе лучшей из d — «log log n/ log d + O(1)», результат Azar, Broder, Karlin и Upfal. И вывод, который объясняет, почему берут именно два: «each additional choice beyond two decreases the maximum load by just a constant factor» (каждый следующий выбор сверх двух уменьшает максимальную загрузку лишь на постоянный множитель).https://www.eecs.harvard.edu/~michaelm/postscripts/handbook2001.pdf
- Maglev: A Fast and Reliable Software Network Load BalancerИсточник. USENIX NSDI 16, Eisenbud и др. Балансировщик четвёртого уровня на обычных серверах, без специального железа: «A single Maglev machine is able to saturate a 10Gbps link with small packets» (Одна машина Maglev способна насытить канал 10 Гбит/с мелкими пакетами). В строю у Google с 2008 года; устойчивость к переездам соединений обеспечивают consistent hashing и трекинг соединений вместе, а не по отдельности.https://research.google/pubs/maglev-a-fast-and-reliable-software-network-load-balancer/
- Citrix NetScaler Load Balancing Algorithms (воспроизведение документации вендора)Источник. База знаний Висконсинского университета. Отсюда определения least bandwidth — «the service that is currently serving the least amount of traffic, measured in megabits per seconds» (сервис, который в данный момент обслуживает наименьший объём трафика, измеряемый в мегабитах в секунду) — и least packets, «the least packets in the last 14 seconds» (наименьшее число пакетов за последние 14 секунд). Помечено как воспроизведение: собственная страница вендора на момент написания не открылась, и выдавать пересказ за первоисточник нельзя.https://kb.wisc.edu/ns/page.php?id=13201
- Google SRE Book — Handling Overload и Addressing Cascading FailuresИсточник. Мост от балансировки к перегрузке: «no matter how efficient your load balancing policy, eventually some part of your system will become overloaded» (какой бы эффективной ни была ваша политика балансировки, рано или поздно какая-то часть системы окажется перегруженной). Определение отброса: «Load shedding drops some proportion of load by dropping traffic as the server approaches overload conditions» (Отбрасывание нагрузки сбрасывает некоторую долю нагрузки, отбрасывая трафик по мере приближения сервера к условиям перегрузки), конкретная механика через число незавершённых запросов и число 64 = 4³ для усиления повторов — оттуда же. Плюс предупреждение про метрику: «modeling capacity as „queries per second“ … often makes for a poor metric» (моделирование ёмкости как „запросов в секунду“ … часто оказывается плохой метрикой).https://sre.google/sre-book/addressing-cascading-failures/
- AWS Builders' Library — Using load shedding to avoid overload; Timeouts, retries, and backoff with jitterИсточник. Цель отброса, сформулированная через таймаут клиента: «The goal of load shedding is to keep latency low for the requests that the server decides to accept so that the service replies before the client times out» (Цель отбрасывания нагрузки — удерживать низкую задержку для тех запросов, которые сервер решил принять, чтобы сервис ответил раньше, чем у клиента истечёт таймаут). И второе число усиления повторов: «If each layer retries independently, the load on the database will increase 243x» (Если каждый слой повторяет независимо, нагрузка на базу вырастет в 243 раза), а также джиттер и ведро токенов как меры против него.https://aws.amazon.com/builders-library/using-load-shedding-to-avoid-overload/
- Envoy — Circuit breaking, Adaptive concurrency, Overload manager, Zone aware routing, Locality weighted LBОфициальная документация. Пять страниц, на которых стоят разделы про отброс, повторы и зоны. Различие двух защит: «This is distinct from circuit breaking which is primarily aimed at protecting upstream services» (Это отличается от размыкания цепи, которое нацелено прежде всего на защиту вышестоящих сервисов). Конфликт близости и равномерности: «send as much traffic to the local zone … while roughly maintaining the same number of requests per second across all upstream hosts» (отправить в локальную зону как можно больше трафика, сохраняя при этом примерно одинаковое число запросов в секунду по всем вышестоящим узлам). Таблица 33/67 … 0/100 и коэффициент избыточного выделения 1,4 — числа этой документации. Оттуда же порядок решения: приоритет, расположение, и только потом алгоритм.https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/zone_aware