Балансировка нагрузки
Балансировщик распределяет запросы по пулу серверов. Выбор между L4 и L7 и между round-robin и алгоритмами с учётом нагрузки решает твой хвост задержки — а наивный health-check на failover может устроить давку выживших и второй сбой.
Команда держала шесть одинаковых API-узлов за обычным round-robin балансировщиком и спала спокойно — пока один узел не ушёл в долгие паузы сборки мусора. Round-robin продолжал слать ему один запрос из шести, идеально равномерно, прямо в сервер, который отвечал теперь 4 секунды вместо 40 мс. Дашборды показывали пять здоровых узлов и один больной, но p99 всего сервиса шёл за больным узлом, потому что шестую часть всех пользователей балансировщик направлял на него — балансировщик, который считал запросы и ни разу не посмотрел, вернулись ли они. «Равномерное распределение» оказалось ровно неправильной целью: им нужно было распределение с учётом нагрузки, и нужно было, чтобы балансировщик замечал медленный узел раньше пользователей.
Что балансировщик на самом деле делает
Балансировщик нагрузки (load balancer) стоит перед пулом одинаковых серверов и решает, для каждого соединения или запроса, какой из них его обслужит. Он покупает три вещи сразу: горизонтальное масштабирование (пул растёт за одним адресом), отказоустойчивость (мёртвый сервер выводится из ротации, трафик течёт на остальные) и стабильную точку входа (клиенты обращаются к одному виртуальному адресу, а не к отдельным машинам). Это та парадная дверь, которая делает масштабирование stateless-уровня из прошлого юнита реально работающим — «добавлять одинаковые узлы» можно, только если что-то раскидывает по ним трафик.
Два главных решения — на каком уровне он работает и по какому правилу выбирает сервер. Ошибись в любом — и симптом один: нагрузка, которая выглядит сбалансированной по числу запросов, но дико несбалансированна по реальной работе, и хвост задержки, который не объясняется метриками ни одного отдельного сервера.
L4 против L7: где балансировщик читает запрос
Уровень 4 (транспортный) балансирует по TCP/UDP-соединению: IP и порт источника/назначения. Он пересылает пакеты (или сшивает соединение), вообще не разбирая байты внутри. Это делает его крайне быстрым и не зависящим от протокола — он двигает что угодно поверх соединения и не имеет мнения о HTTP. Цена в том, что он не может принимать решения по содержимому: не маршрутизирует /api в один пул, а /images в другой, не читает cookie, не терминирует TLS, чтобы увидеть путь.
Уровень 7 (прикладной) терминирует соединение, читает HTTP-запрос и маршрутизирует по его содержимому: путь, заголовок Host, метод, cookie. Это открывает маршрутизацию по контенту, пулы на маршрут, переписывание заголовков, терминацию TLS и ретраи на уровне запроса. Цена — CPU и задержка: он делает реальный парсинг на каждом запросе и становится местом, где скапливаются прикладные заботы. (Терминация TLS здесь связана с уроком про TLS из трека networking; мы остаёмся на уровне композиции — важно где заканчивается рукопожатие, а не как оно устроено.)
Старшее правило большого пальца: L4, когда нужна сырая пропускная способность и независимость от протокола (базы данных, gRPC-стримы, всё, где не нужно заглядывать внутрь); L7, когда решение о маршрутизации зависит от содержимого запроса (веб-API, шлюзы микросервисов, всё, что зависит от пути или заголовка).
▸Почему это работает
Почему L4 стоит так дёшево, а L7 так дорого? L4-балансировщик часто может передать соединение и вообще выйти из пути данных (direct server return) или просто гонять пакеты — несколько микросекунд работы на соединение. L7-балансировщик обязан быть на пути весь запрос: он терминирует TCP и TLS, буферизует и парсит HTTP-сообщение, применяет правила маршрутизации, открывает отдельное соединение к апстриму и проксирует тело в обе стороны. Это два соединения, парсинг и копирование на каждый запрос вместо одной пересылки. Этот налог ты платишь за право принимать решения по содержимому — потому и заносишь L7-функции (аутентификацию, переписывание, агрегацию) внутрь только тогда, когда тебе реально нужно видеть запрос.
Алгоритмы — и почему они решают твой хвост
Когда сервер выбран, как именно — важнее, чем думают. Распространённые алгоритмы, от слепых к учитывающим нагрузку:
- Round-robin — отдавать каждый новый запрос следующему серверу по кругу. Просто, равномерно по числу, слепо к нагрузке. Годится, когда каждый запрос стоит одинаково и серверы идентичны; опасно в момент, когда один запрос дорогой или один узел медленный (хук).
- Взвешенный round-robin — давать более крупным серверам бóльшую долю. Справляется с разнородным парком, всё ещё слеп к текущим условиям.
- Least-connections (наименьшее число соединений) — слать следующий запрос серверу с наименьшим числом активных соединений. Дешёвый прокси для «наименее занят» и куда лучше round-robin, когда длительности запросов разнятся: медленный узел накапливает открытые соединения и естественно перестаёт получать новые.
- Least-response-time / EWMA — отслеживать недавнюю задержку каждого сервера (часто экспоненциально-взвешенное скользящее среднее) и предпочитать самый быстрый. Именно это спасло бы команду из хука: узел, начавший отвечать 4 с, получает меньше трафика, а не свою честную шестую часть. Это учёт нагрузки в самом честном смысле — реакция на то, как сервер ведёт себя прямо сейчас.
- Консистентное хеширование (consistent hashing) — хешировать ключ (IP клиента, ID пользователя, ключ кэша) на кольцо серверов так, чтобы один ключ попадал на один сервер в большинстве случаев. Незаменимо, когда бэкенд держит состояние по ключу — кэш, сессию, шард — потому что минимизирует, сколько ключей переедет при входе или выходе сервера (переотображается только
1/Nключей, а не все).
Вместе эти алгоритмы образуют спектр от «считай и забудь» до «реагируй в реальном времени». Прежде чем выбрать алгоритм, спроси себя: все ли мои запросы стоят одинакового объёма работы? Если нет — round-robin тихо убивает твой хвост задержки, и least-connections или EWMA — это фикс.
Health-checks и sticky-сессии
Балансировщик хорош ровно настолько, насколько верна его картина того, кто здоров. Health-checks опрашивают каждый сервер (TCP-коннект или, лучше, HTTP /healthz, реально дёргающий приложение) и выкидывают падающие узлы из ротации. Тонкость — активные против пассивных: активные пробы спрашивают «ты жив?» по таймеру; пассивные смотрят реальный трафик и выкидывают узел, начавший ошибаться или таймаутить. Лучшие системы делают и то, и другое — и, что важно, health-check должен проверять цепочку зависимостей, нужную серверу (достаёт ли он БД?), а не просто «процесс поднят», иначе ты радостно направишь трафик на узел, мгновенно отдающий 500-е.
Sticky-сессии прикрепляют клиента к тому же бэкенду (через cookie или хеш IP-источника), чтобы состояние пользователя, лежащее на этом узле, оставалось доступным. Прошлый юнит уже отметил, почему это костыль: он заново вводит единую точку отказа на пользователя и ломает чистую ребалансировку. Чистая альтернатива — stateless-уровень: вынести состояние сессии в общее хранилище или подписанный токен, чтобы любой сервер обслуживал любой запрос и stickiness никогда не понадобилась. Используй консистентное хеширование для локальности кэша (оптимизация производительности, которую можно безопасно потерять), а не для корректности (которую stickiness подразумевает и которая укусит, когда узел умрёт).
▸Частая ошибка
Дорогой режим отказа — давка (thundering herd) на failover. Один узел из десяти умирает; балансировщик корректно перераспределяет его трафик — но теперь девять узлов мгновенно вбирают по ~11% лишней нагрузки каждый, и если они уже были у колена кривой очередей, этот толчок может опрокинуть следующий. Он падает, его нагрузка перераспределяется на восемь — и получается каскадный коллапс, где восстановление и вызывает сбой. С ретраями хуже: каждый запрос, бывший на мёртвом узле, ретраит разом (синхронизированный всплеск), и ещё хуже с холодным кэшом у выживших. Защиты — те, что AWS документирует для ретраев: запас ёмкости, чтобы выжившие вобрали долю соседа, джиттер вместо синхронных ретраев, и circuit breaker / сброс нагрузки (load shedding), чтобы перегруженный выживший сбрасывал новую работу, а не умирал. Планируй ёмкость на N−1 (или N−2), иначе твой failover и есть твой сбой.
Упорядочи шаги диагностики и устранения высокого p99 в сервисе за балансировщиком, когда большинство узлов выглядят здоровыми:
- 1 Проверить метрики задержки по каждому узлу — найти, у какого p99 резко отличается от остальных, раскрывая медленный аутлайер, которому round-robin продолжает слать трафик.
- 2 Подтвердить алгоритм балансировки — убедиться, что используется round-robin (счёт запросов, без учёта нагрузки), а не алгоритм с учётом нагрузки, такой как least-connections или EWMA.
- 3 Исследовать медленный узел на первопричину — GC-паузы, насыщение CPU или downstream-зависимость, удерживающая соединения открытыми.
- 4 Переключить алгоритм на least-connections или least-response-time/EWMA, чтобы балансировщик автоматически обходил медленный узел вместо равномерной его загрузки.
- 5 Добавить пассивный health-check или latency-aware пробу, чтобы медленный, но принимающий соединения узел помечался и исключался или депрайоритизировался до того, как пользователи это заметят.
Один узел в round-robin пуле из шести начинает отвечать 4 с на запрос (было 40 мс), но всё ещё принимает соединения. Что делает балансировщик и какой алгоритм лучше?
Нужно маршрутизировать по URL-пути (/api в один пул, статику в другой) и читать cookie маршрутизации. На каком уровне обязан работать балансировщик и какова цена?
Чтобы слать данный ключ кэша или ключ шарда на тот же бэкенд в большинстве случаев — и переотображать лишь ~1/N ключей при входе или выходе сервера — балансировщик использует _______ хеширование вместо простого остатка от деления, который перетасовывает каждый ключ при смене состава.
- 01Сравни L4 и L7 балансировку — что каждая может и не может, и когда какую брать.
- 02Назови алгоритмы балансировки от слепых к учитывающим нагрузку и какой бережёт хвост задержки.
- 03Что такое thundering herd на failover и как защищаться?
Балансировщик — это парадная дверь, делающая горизонтальное масштабирование реальным: он раскидывает запросы по пулу, выкидывает мёртвые узлы и даёт клиентам один стабильный адрес. Доминируют два решения. Уровень: L4 маршрутизирует по IP/порту — быстро, слеп к протоколу, не видит внутри; L7 терминирует и парсит запрос, чтобы маршрутизировать по пути/заголовку/cookie и терминировать TLS, ценой CPU. Алгоритм: round-robin и взвешенный RR слепы к нагрузке, тогда как least-connections и least-response-time/EWMA учитывают нагрузку и берегут хвост задержки, обходя медленный узел вместо подачи ему равной доли; консистентное хеширование шлёт ключ на тот же бэкенд и переотображает лишь ~1/N ключей при смене состава — потому кэш- и шард-ключевая маршрутизация на нём и держится. Health-checks должны проверять реальную цепочку зависимостей, а не просто «процесс поднят»; sticky-сессии — костыль, заново создающий SPOF на пользователя — вынеси состояние и держи уровень stateless. И всегда планируй давку на failover: размер на N−1, джиттер ретраев и сброс нагрузки, иначе восстановление одного мёртвого узла станет каскадным сбоем. Теперь, когда встретишь сервис с необъяснимо высоким p99 при «сбалансированном» трафике, первый вопрос — какой алгоритм использует балансировщик и видит ли он вообще, насколько медленен один из его узлов.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.