Что балансировщик успевает сделать с запросом за две миллисекунды
Когда в логах балансировщика request time показывает пару миллисекунд, кажется, что там просто переслали пакет с одного порта на другой. На деле за эти миллисекунды происходит цепочка отдельных решений: принять соединение, разобраться с шифрованием, выбрать конкретный бэкенд из списка живых, убедиться, что он действительно жив прямо сейчас, отправить туда запрос и дождаться ответа. Каждый шаг может пойти не так, и именно поэтому балансировщик — не «труба», а маленькая программа с состоянием и логикой принятия решений.
Содержание
- Что вообще происходит за эти миллисекунды
- Приём соединения: сокет, очередь, воркер
- TLS-терминация: если это HTTPS
- Выбор бэкенда: алгоритм балансировки принимает решение
- Проверка здоровья: почему выбранного бэкенда ещё раз проверяют
- Проксирование запроса и ожидание ответа
- Что из этого на самом деле стоит времени
Что вообще происходит за эти миллисекунды
Возьмём типичный случай: nginx или HAProxy перед несколькими бэкенд-серверами приложения. Клиент открывает соединение, отправляет HTTP-запрос — и через считаные миллисекунды получает ответ. Внутри этого промежутка укладывается примерно такая последовательность:
- Приём TCP-соединения и постановка в очередь на обработку воркером.
- Если это HTTPS — TLS-рукопожатие или переиспользование уже установленной сессии.
- Разбор HTTP-запроса: метод, путь, заголовки.
- Выбор бэкенда по настроенному алгоритму балансировки.
- Быстрая проверка, что выбранный бэкенд не помечен как недоступный.
- Установка или переиспользование соединения к бэкенду, отправка запроса.
- Ожидание ответа, копирование его обратно клиенту.
Число «две миллисекунды» в заголовке — не измеренная величина, а ориентир: на практике оно сильно зависит от того, есть ли уже открытое keepalive-соединение к бэкенду, нужна ли новая TLS-сессия, насколько быстро отвечает само приложение и какой алгоритм балансировки выбран. У кого-то это будет доля миллисекунды, у кого-то — на порядок больше, если бэкенд «думает» под нагрузкой. Дальше разберём каждый шаг отдельно.
Приём соединения: сокет, очередь, воркер
Балансировщик слушает порт (обычно 80 и 443) через один или несколько слушающих сокетов. В nginx это worker_processes плюс listen ... reuseport, в HAProxy — bind внутри frontend. Когда приходит новое TCP-соединение, ядро кладёт его в очередь accept-сокета, а один из воркеров забирает его оттуда.
worker_processes auto;
events {
worker_connections 4096;
multi_accept on;
}
server {
listen 443 ssl reuseport;
listen [::]:443 ssl reuseport;
server_name example.com;
...
}
Флаг reuseport важен: без него все воркеры конкурируют за один и тот же listen-сокет, и один загруженный воркер может «отбирать» соединения у других — это создаёт неравномерность, которую легко принять за проблему балансировки бэкендов, хотя на деле неравномерно распределяются сами входящие соединения. multi_accept разрешает воркеру забрать сразу несколько соединений из очереди за один проход событийного цикла вместо одного за раз.
Здесь же решается первый нетривиальный вопрос: если очередь accept переполнена (клиентов больше, чем балансировщик успевает разбирать), новые соединения будут либо ждать, либо получат RST. Параметр net.core.somaxconn в sysctl и listen ... backlog=N в конфиге nginx определяют, насколько глубокая эта очередь. Значение по умолчанию в современных дистрибутивах обычно в районе нескольких сотен — для сервиса с всплесками трафика этого может не хватать, и стоит сверять оба значения (sysctl и backlog) вместе, а не только одно из них.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверTLS-терминация: если это HTTPS
Если соединение защищено TLS, до того как балансировщик увидит хоть один байт HTTP-запроса, должно завершиться TLS-рукопожатие. Мы разбирали этот процесс подробно в статье про TLS-рукопожатие и что происходит до первого байта — здесь важно то, что рукопожатие с нуля (full handshake) заметно дороже, чем возобновление уже известной сессии.
Балансировщик старается избежать полного рукопожатия там, где можно:
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1h;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache хранит параметры уже проведённых рукопожатий, и если клиент возвращается в течение ssl_session_timeout, балансировщик может пропустить большую часть криптографического обмена и сразу перейти к передаче данных. TLS 1.3 сокращает число раундов обмена по сравнению с TLS 1.2 в принципе, что тоже экономит время именно на этом шаге.
Отдельное решение, которое принимается здесь же: терминировать TLS на балансировщике (и дальше слать на бэкенды обычный HTTP по внутренней сети) или пробрасывать TLS насквозь до самого приложения. Первый вариант проще в администрировании — сертификат живёт в одном месте, — но означает, что балансировщик тратит CPU на расшифровку каждого запроса. Второй снимает эту нагрузку с балансировщика, но усложняет управление сертификатами и не даёт балансировщику заглянуть в содержимое запроса для маршрутизации по пути или заголовкам.
Выбор бэкенда: алгоритм балансировки принимает решение
Дальше начинается собственно балансировка. У nginx и HAProxy есть несколько встроенных алгоритмов, и это не формальность — от выбора алгоритма реально зависит, насколько равномерно распределится нагрузка и насколько предсказуемо будет вести себя система при отказе одного из узлов.
upstream backend {
least_conn;
server 10.0.0.11:8080 weight=3;
server 10.0.0.12:8080 weight=2;
server 10.0.0.13:8080 weight=1 backup;
keepalive 64;
}
Основные варианты:
| Алгоритм | Как выбирает бэкенд | Когда уместен |
|---|---|---|
| Round robin | По кругу, каждый следующий запрос — следующему серверу | Бэкенды одинаковы по мощности и запросы примерно одной цены |
| Weighted round robin | По кругу, но с учётом веса — более мощный сервер получает больше запросов | Бэкенды разной мощности (например, разные тарифы VPS) |
| Least connections | Выбирается сервер с наименьшим числом активных соединений прямо сейчас | Запросы сильно различаются по времени обработки |
| IP hash / consistent hash | Клиент с одного IP или ключа стабильно попадает на один и тот же сервер | Нужна привязка к серверу (сессии, локальный кэш) |
Round robin — самый простой вариант, но у него есть слабое место: он не знает, насколько сервер реально загружен прямо сейчас, а просто идёт по кругу. Мы отдельно разбирали, почему это может создавать перекос нагрузки, несмотря на формальную честность алгоритма. Least connections умнее в этом смысле, но требует, чтобы балансировщик постоянно отслеживал количество открытых соединений к каждому бэкенду — это дополнительная структура данных, которую нужно обновлять на каждый запрос и каждый ответ.
Выбор бэкенда для конкретного запроса — это, по сути, обращение к структуре в памяти воркера (список серверов, их веса, счётчики соединений) и один проход алгоритма. Именно поэтому он занимает микросекунды, а не миллисекунды: это чтение и сравнение чисел, без обращения к диску или сети.
Проверка здоровья: почему выбранного бэкенда ещё раз проверяют
Мало выбрать бэкенд по алгоритму — нужно убедиться, что он не помечен как недоступный. Балансировщик держит в памяти состояние каждого сервера, обновляемое двумя разными механизмами.
Активные health-check запросы балансировщик отправляет сам, независимо от реального трафика:
# nginx plus / open-source вариант через отдельный модуль
upstream backend {
server 10.0.0.11:8080 max_fails=3 fail_timeout=10s;
server 10.0.0.12:8080 max_fails=3 fail_timeout=10s;
}
backend app_servers
option httpchk GET /healthz
http-check expect status 200
server app1 10.0.0.11:8080 check inter 2s fall 3 rise 2
server app2 10.0.0.12:8080 check inter 2s fall 3 rise 2
inter 2s — интервал между проверками, fall 3 — сколько подряд неудачных проверок нужно, чтобы объявить сервер мёртвым, rise 2 — сколько успешных, чтобы вернуть его обратно в пул. Пассивные проверки в open-source nginx работают иначе: сервер помечается недоступным не по отдельному health-check запросу, а по факту неудачи реального пользовательского запроса (max_fails таких неудач за fail_timeout).
Важный нюанс — то, какое состояние балансировщик держит в памяти, не всегда совпадает с реальным состоянием бэкенда прямо в момент запроса. Между двумя проверками сервер может успеть упасть, а балансировщик узнает об этом только на следующей проверке или на первой неудачной попытке реального запроса. Это одна из причин, почему трафик иногда какое-то время продолжает идти на уже мёртвый узел — подробнее в статье о том, как балансировщик слал трафик на мёртвую ноду. Именно поэтому выбор бэкенда и проверка его состояния — два разных шага: сначала алгоритм говорит «вот подходящий сервер», а состояние в памяти (обновляемое отдельно, асинхронно от текущего запроса) либо подтверждает выбор, либо заставляет балансировщик пропустить сервер и взять следующий по списку.
Проксирование запроса и ожидание ответа
Когда бэкенд выбран и подтверждён живым, балансировщику нужно фактически передать ему запрос. Здесь решается ещё одна практическая задача: открывать новое TCP-соединение к бэкенду на каждый запрос или переиспользовать уже открытое.
upstream backend {
server 10.0.0.11:8080;
keepalive 64;
keepalive_timeout 60s;
}
server {
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Без keepalive в блоке upstream каждый запрос к бэкенду означает новое TCP-соединение: SYN, SYN-ACK, ACK — и только потом сам HTTP-запрос. С пулом keepalive-соединений балансировщик держит несколько уже открытых TCP-соединений к каждому бэкенду и переиспользует их, экономя именно на установлении соединения. proxy_http_version 1.1 вместе с обнулением заголовка Connection обязательны, чтобы keepalive к бэкенду вообще заработал — иначе nginx по умолчанию использует HTTP/1.0 для проксирования и закрывает соединение после каждого запроса.
Дальше запрос уходит на бэкенд, и балансировщик переходит в режим ожидания ответа. Здесь у него есть несколько таймаутов, которые стоит различать:
proxy_connect_timeout 5s; # сколько ждать установления TCP-соединения к бэкенду
proxy_send_timeout 60s; # сколько ждать между операциями записи запроса
proxy_read_timeout 60s; # сколько ждать между операциями чтения ответа
proxy_connect_timeout — это именно про установление соединения, а не про ответ приложения; если бэкенд принял TCP-соединение, но «завис» с ответом, сработает уже proxy_read_timeout. Путаница между этими таймаутами — частая причина, когда 502 или 504 возникают не там, где их ожидают. Мы отдельно разбирали похожую ситуацию в статье про 504 Gateway Timeout в nginx.
Получив ответ, балансировщик по умолчанию буферизует его в память (или временный файл, если ответ большой), а затем отдаёт клиенту — это отдельная тема, связанная с тем, что скорость чтения клиентом и скорость ответа бэкенда — независимые процессы, и балансировщик служит буфером между ними. На этом цепочка замыкается: соединение с клиентом либо закрывается, либо остаётся открытым в режиме keepalive для следующего запроса.
Что из этого на самом деле стоит времени
Если сопоставить шаги друг с другом, видно, что не все они одинаково «дорогие»:
- Выбор бэкенда по алгоритму — операция в памяти, обращение к структуре данных, микросекунды.
- Проверка состояния бэкенда по кэшированным данным health-check — тоже чтение из памяти, а не сетевой запрос в моменте.
- Установление нового TCP-соединения к бэкенду — заметно дороже, если нет keepalive-пула: полный трёхэтапный handshake.
- Полное TLS-рукопожатие с клиентом — самая дорогая из регулярных операций, если сессия не переиспользуется.
- Само время ответа бэкенда — то, что балансировщик не контролирует вообще: он лишь ждёт.
Отсюда практический вывод: если вы хотите ускорить именно балансировщик (не приложение за ним), в первую очередь стоит смотреть на переиспользование TLS-сессий и keepalive-пул к бэкендам — это то, что реально экономит миллисекунды на каждом запросе, а не выбор между round_robin и least_conn, разница между которыми в большинстве нагрузок практически не заметна на глаз в логах.
# посмотреть, используются ли keepalive-соединения к бэкенду
ss -tn state established '( dport = :8080 )' | wc -l
# посмотреть текущие TLS-сессии в кэше nginx (косвенно, через статус модуль)
curl -s http://127.0.0.1/nginx_status
Если на нагруженном сервисе число установленных соединений к бэкенду скачет пропорционально RPS, а не держится примерно постоянным — вероятно, keepalive-пул не работает так, как задуман, и каждый запрос платит цену нового TCP-соединения.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему в заголовке именно «две миллисекунды» — это реальное измерение?
Нет, это иллюстративный ориентир, а не замер. Реальное время зависит от того, есть ли готовое TLS- и TCP-соединение к бэкенду, какой алгоритм балансировки выбран и насколько быстро отвечает само приложение — на разных стендах цифра будет своя.
Можно ли пропустить проверку здоровья и просто слать запрос по алгоритму?
Технически да, если не настраивать health-check вообще, но тогда балансировщик узнает о падении бэкенда только по неудаче реального пользовательского запроса — часть трафика в этот момент получит ошибку.
Активные и пассивные health-check — нужны оба сразу?
Не обязательно, но они закрывают разные случаи: активные проверки находят проблему до того, как её увидит пользователь, пассивные — это подстраховка на случай, если активная проверка отвечает нормально, а реальные запросы почему-то падают.
Влияет ли выбор round robin vs least_conn на задержку одного конкретного запроса?
Сам выбор бэкенда — операция в памяти и занимает исчезающе мало времени независимо от алгоритма. На задержку сильнее влияет то, попал ли запрос на перегруженный сервер, а не то, каким способом его выбрали.
Стоит ли терминировать TLS на балансировщике или пробрасывать до приложения?
Терминация на балансировщике проще в эксплуатации (один сертификат, один конфиг) и дешевле для бэкендов по CPU, но требует доверенного внутреннего канала до приложения; сквозной TLS до приложения нужен там, где это доверие обеспечить нельзя.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →