Сетевая производительность: RTT, keep-alive, сжатие, батчинг
Все предыдущие статьи трека объединяет одно допущение: время тратит работа. Процессор считает, аллокатор ищет блок, база данных перебирает страницы. Оптимизация означала «сделать меньше работы». Сеть ломает это допущение целиком: здесь главный расход времени — ожидание, и объём работы на него не влияет. Пакет в 40 байт и пакет в 1400 байт летят из Москвы во Франкфурт одинаково долго. Ускорить свет нельзя. Единственное, чем вы управляете, — сколько раз вы заставляете данные слетать туда и обратно.
Отсюда весь остальной текст. Сетевая оптимизация — это почти всегда арифметика round-trip’ов, и только потом всё прочее: сжатие, форматы, TCP-тюнинг. Инженер, который начинает с замены JSON на protobuf в системе, делающей 40 последовательных вызовов по RTT 30 мс, экономит 3% и теряет неделю. Инженер, который сначала измерил, видит 1200 мс ожидания и один вечер работы на батчинг.
Дисциплина трека не меняется: https://courses.digitable.life/post/performance/01-measuring/ дал перцентили, https://courses.digitable.life/post/performance/02-benchmarking/ — недоверие к одиночному замеру, https://courses.digitable.life/post/performance/06-io-and-syscalls/ — понимание, что ожидание не видно CPU-профайлеру. Сеть — самый «off-CPU» слой из всех. Механику протоколов разбирает отдельный трек (https://courses.digitable.life/post/networking/03-tcp/, https://courses.digitable.life/post/networking/06-http/, https://courses.digitable.life/post/networking/07-tls/); здесь мы занимаемся только одним: как это измерить и что с этим сделать.
1. Модель времени: из чего складывается сетевой вызов
Держите в голове одну формулу. Она не точная, она достаточная:
T ≈ N_rtt × RTT (задержка: сколько раз слетали туда-обратно)
+ Bytes / Bandwidth (пропускная способность: сколько байтов протолкнули)
+ T_serialize (упаковка и распаковка на обоих концах)
+ T_server (полезная работа)
+ T_queue (очереди: пул соединений, прокси, буферы устройств)
Первое слагаемое доминирует почти всегда, когда данные маленькие, а расстояние большое: типичный API-вызов. Второе доминирует, когда данных много: выгрузка отчёта, репликация, картинки. Эти два режима лечатся противоположными способами, и первый шаг диагностики — понять, в каком вы.
Простой признак: удвойте размер полезной нагрузки. Если время почти не изменилось — вы в режиме задержки, и вам нужен батчинг и keep-alive. Если время выросло примерно вдвое — вы в режиме пропускной способности, и вам нужно сжатие, меньше полей и другой формат.
namelookup / connect /
appconnect / starttransfer / total"} B -- "большой namelookup" --> C["DNS: кэш резолвера, TTL,
ndots в Kubernetes, предразрешение"] B -- "большие connect+appconnect" --> D{"Соединение переиспользуется?"} D -- "нет" --> E["Keep-alive и пул: раздел 4.
Самая дешёвая победа"] D -- "да, но всё равно долго" --> F["RTT физически велик:
ближе точка присутствия, CDN,
реплика в регионе клиента"] B -- "большой starttransfer
при малом теле" --> G{"Сколько вызовов
на одну операцию?"} G -- "десятки" --> H["N×RTT: батчинг, конвейеризация,
мультиплексирование. Раздел 7"] G -- "один" --> I["Ждём сервер: это не сеть.
Профилируем бэкенд"] B -- "большой transfer
при большом теле" --> J{"Растёт линейно с объёмом?"} J -- "да" --> K["Полоса и объём: сжатие,
меньше полей, другой формат. Раздел 6"] J -- "нет, ступеньками" --> L["Окно и потери: ss -ti,
retrans, cwnd. Раздел 5"]
Эта схема — протокол на всю статью, каждая ветка ниже развёрнута.
2. RTT: единственная величина, которую нельзя оптимизировать
Свет в вакууме идёт 300 000 км/с, в оптическом волокне — примерно две трети от этого, около 200 000 км/с. Значит, 1000 километров в одну сторону — 5 мс, туда-обратно — 10 мс. Это пол. Реальный маршрут добавляет к этому полу коэффициент 1,3–2,0: волокно не проложено по прямой, на пути маршрутизаторы, коммутаторы и обработка в них.
Полезные ориентиры (проверяйте у себя, а не верьте таблице):
| Маршрут | Расстояние по прямой | Физический пол RTT | Реальный RTT |
|---|---|---|---|
| loopback | 0 | 0 | 20–60 мкс |
| внутри стойки / зоны доступности | метры | ~0 | 0,1–0,5 мс |
| между зонами одного региона | десятки км | ~0,5 мс | 0,5–2 мс |
| Москва — Франкфурт | ~1900 км | ~19 мс | 30–45 мс |
| Нью-Йорк — Лондон | ~5570 км | ~56 мс | 70–85 мс |
| Европа — западное побережье США | ~9000 км | ~90 мс | 130–160 мс |
| мобильная сеть 4G, первый пакет | — | — | +30–100 мс сверху |
| геостационарный спутник | 2 × 35 786 км | ~480 мс | 550–650 мс |
Здесь уместна оговорка про знаменитый список «Latency Numbers Every Programmer Should Know» (Jeff Dean, около 2010 года, colin-scott.github.io/personal_website/research/interactive_latency.html). Его до сих пор цитируют как истину, но он устарел неравномерно. Строки про диск и SSD устарели радикально: «случайное чтение с диска 10 мс» верно для HDD и бессмысленно для NVMe, где речь про десятки микросекунд. Строки про память и кэш сместились умеренно. А вот строка «отправить пакет Калифорния → Нидерланды → Калифорния: 150 мс» не устарела вообще и не устареет, потому что её задаёт скорость света, а не индустрия. Пользуйтесь списком именно так: сетевые строки — почти закон физики, все остальные требуют перепроверки на вашем железе.
Практический вывод жёсткий: RTT нельзя уменьшить кодом, его можно только перестать платить лишний раз. Есть ровно три способа. Первый — сократить географию: точка присутствия, CDN, реплика ближе к клиенту. Второй — сократить число round-trip’ов: keep-alive, батчинг, мультиплексирование. Третий — совместить RTT с полезной работой: параллелизм, предзапрос, спекулятивная загрузка.
Как измерить RTT честно
ping измеряет ICMP, а его на многих узлах обрабатывают по остаточному принципу или режут вовсе.
Прибор точнее — RTT, который меряет сам TCP-стек по данным вашего соединения:
# RTT глазами ядра для реальных соединений процесса, а не ICMP
ss -tin state established '( dport = :443 )'
ESTAB 0 0 10.0.3.14:51234 93.184.216.34:443
cubic wscale:7,7 rto:236 rtt:34.812/2.104 mss:1448 cwnd:24 ssthresh:19
bytes_acked:18422 bytes_received:241093 delivery_rate 6.21Mbps
retrans:0/2 rcv_space:14480 minrtt:33.905
Читать так: rtt:34.812/2.104 — сглаженное RTT и его разброс, minrtt — лучшее наблюдение,
близкое к физическому полу маршрута. Разница между rtt и minrtt — это очереди на пути:
если сглаженное вдвое больше минимального, где-то буфер переполнен. cwnd:24 — окно перегрузки
в сегментах, то есть в полёте разрешено 24 × 1448 ≈ 34 КБ. retrans:0/2 — текущие и суммарные
повторы. delivery_rate — фактическая скорость доставки, которую ядро наблюдало.
3. Арифметика холодного соединения
Один HTTPS-запрос к серверу, с которым вы ещё не разговаривали, стоит не один RTT, а четыре.
Схема выше показывает главную мысль статьи в одной картинке. Полезной работы во всех трёх сценариях ровно 25 мс. Разница между холодным HTTP/1.1 и тёплым соединением — 180 мс, и в этих 180 мс нет ни одной строчки вашего кода.
Что из этого можно вернуть:
- DNS. Кэшируется резолвером и обычно стоит ноль. Но в Kubernetes настройка
ndots: 5по умолчанию превращает каждое разрешение внешнего имени в 4–5 неудачных запросов по search-доменам перед успешным. Проверяется одной командой:curl -w '%{time_namelookup}\n' -o /dev/null -s https://api.example.com. Если там 20+ мс на каждый вызов — это чинится строчкойdnsConfig.options: [{name: ndots, value: "1"}]или точкой в конце имени (api.example.com.). - TCP. Один RTT, убирается только переиспользованием соединения (раздел 4) или TCP Fast Open, который на практике почти не развернут из-за промежуточных узлов.
- TLS. TLS 1.3 сократил полное рукопожатие с двух RTT до одного, а возобновление сессии —
до нуля (0-RTT, с известными оговорками про повтор запросов; подробности —
https://courses.digitable.life/post/networking/07-tls/). Если в
curl -wразницаtime_appconnect - time_connectу вас равна двум RTT, вы всё ещё на TLS 1.2. - QUIC/HTTP3. Совмещает транспортное и криптографическое рукопожатие, экономя один RTT на холодном старте, и умеет 0-RTT при возобновлении (https://courses.digitable.life/post/networking/04-udp-and-quic/).
curl как измерительный прибор
Это самый недооценённый прибор в списке: он раскладывает запрос на фазы без всякой обвязки.
curl -o /dev/null -s https://api.example.com/v1/orders \
-w 'dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect}
ttfb=%{time_starttransfer} total=%{time_total}
байт=%{size_download} http=%{http_version} новых_соединений=%{num_connects}\n'
dns=0.023411 conn=0.064102 tls=0.104883
ttfb=0.187204 total=0.301882
байт=344112 http=2 новых_соединений=1
Каждое число — накопительное с начала запроса, а не длительность фазы. Чтобы получить
длительности, вычитайте: TLS занял 104,9 - 64,1 = 40,8 мс, ожидание сервера —
187,2 - 104,9 = 82,3 мс, скачивание тела — 301,9 - 187,2 = 114,7 мс. По этим трём числам уже
понятно, что чинить. Отдельно смотрите на num_connects: на повторном запросе к тому же хосту
там должен быть ноль, иначе keep-alive не работает.
4. Keep-alive: самая дешёвая оптимизация в этой статье
Переиспользование соединения убирает три RTT из четырёх и вдобавок сохраняет прогретое окно перегрузки. Это буквально одна настройка, дающая эффект больше, чем недели работы над кодом. И именно её чаще всего ломают по недосмотру.
Четыре типичных способа потерять keep-alive, все встречались в проде:
Первый — не дочитать тело ответа. В Go соединение возвращается в пул только когда тело прочитано
до EOF и закрыто. Ранний выход по коду ошибки выглядит корректным (утечки нет, Close вызван),
но открывает новое соединение на каждый неуспешный ответ:
resp, err := client.Do(req)
if err != nil {
return err
}
// ПЛОХО: defer resp.Body.Close() — при выходе по коду ошибки тело не прочитано,
// соединение не вернётся в пул, следующий запрос заплатит 3 RTT заново.
// ХОРОШО: осушить тело перед выходом по любой ветке.
defer func() {
io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) // лимит — защита от гигантского тела ошибки
resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("статус %d", resp.StatusCode)
}
Второй — лимит на хост. У Go http.DefaultTransport поле MaxIdleConnsPerHost равно 2
(константа DefaultMaxIdleConnsPerHost), при том что MaxIdleConns — 100. Сервис, который бьёт
в один бэкенд в 50 горутин, будет держать в пуле два соединения и открывать заново все остальные.
Симптом характерный: в ss -s растёт число timewait, в netstat виден шквал коротких соединений,
а time_connect в трассировке скачет.
// Транспорт под высокий параллелизм к малому числу хостов.
tr := &http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100, // главный параметр; по умолчанию всего 2
MaxConnsPerHost: 200, // 0 = без ограничения, риск съесть сокеты
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second, // TCP keepalive-пробы, не путать с HTTP keep-alive
}).DialContext,
}
client := &http.Client{Transport: tr, Timeout: 10 * time.Second}
Тонкость: net.Dialer.KeepAlive и HTTP keep-alive — разные вещи с одинаковым названием. Первое —
TCP-пробы, которые не дают NAT и файрволам молча выбросить простаивающее соединение. Второе —
переиспользование соединения между HTTP-запросами. Нужны оба.
Третий — новый клиент на каждый вызов. В Python requests.get(...) создаёт и выбрасывает сессию,
а с ней и пул:
import requests
from requests.adapters import HTTPAdapter
session = requests.Session() # один на процесс, живёт всё время жизни приложения
adapter = HTTPAdapter(pool_connections=20, # число пулов (по одному на хост)
pool_maxsize=50) # соединений в пуле на хост; по умолчанию 10
session.mount("https://", adapter)
# Дальше — только session.get / session.post. Никаких requests.get напрямую.
r = session.get("https://api.example.com/v1/orders", timeout=(3.05, 10))
В Node.js до версии 19 у http.globalAgent было keepAlive: false, и половина сервисов на Node
годами платила лишние RTT по умолчанию. С Node 19 умолчание изменилось на keepAlive: true —
но если вы создаёте Agent руками, задайте keepAlive и maxSockets явно.
Четвёртый — рассогласование таймаутов. Если сервер закрывает простаивающее соединение через
5 секунд, а клиент считает его живым 90 секунд, то клиент периодически будет отправлять запрос
в уже закрывающееся соединение и получать ошибку на ровном месте. Правило: idle timeout
на клиенте должен быть заметно меньше, чем на сервере. У nginx это keepalive_timeout (по
умолчанию 75 с), у Go-сервера — Server.IdleTimeout, у ALB в AWS — 60 с. Классический баг
«редкие 502 без всякой закономерности» — почти всегда именно это.
5. Полоса, окно и медленный старт
Второй режим: данных много. Здесь важна не только объявленная скорость канала, но и произведение полосы на задержку (bandwidth-delay product) — сколько байтов физически помещается «в трубе».
BDP = Bandwidth × RTT
ЦОД: 1 Гбит/с × 0,5 мс = 62 КБ
Континент: 100 Мбит/с × 150 мс = 1,8 МБ
Отправитель не может держать в полёте больше, чем разрешает минимум из окна перегрузки (cwnd,
считает отправитель) и окна приёма (rwnd, объявляет получатель). Если это окно меньше BDP,
канал простаивает: отправитель отправил всё, что можно, и ждёт подтверждений.
Именно поэтому холодная передача по длинной трубе идёт мучительно медленно. Соединение начинает
с начального окна в 10 сегментов (около 14,6 КБ — это результат
исследования Google,
после которого initcwnd 10 стал стандартом де-факто, RFC 6928) и удваивает окно раз в RTT.
Чтобы заполнить трубу на 1,8 МБ, нужно примерно 7 удвоений, то есть 7 × 150 мс ≈ 1050 мс —
и всё это время канал недозагружен.
Отсюда три следствия, которые ловят почти всех:
- Первые ~15 КБ ответа приезжают за один RTT, следующие — нет. Это причина, по которой во фронтенде так носятся с «критическими 14 КБ» (https://courses.digitable.life/post/frontend/14-web-performance/).
- Тёплое соединение быстрее не только на 3 RTT рукопожатий, но и потому, что у него уже раскрыто окно. Это вторая, менее очевидная половина выгоды от keep-alive.
- Одна потеря пакета на длинной трубе стоит непропорционально дорого. CUBIC при потере режет
окно, и восстановление занимает секунды. Алгоритм BBR
(queue.acm.org/detail.cfm?id=3022184) ведёт себя на
длинных трубах с потерями заметно лучше, потому что оценивает полосу и RTT напрямую, а не
трактует потерю как сигнал перегрузки. Включается одной строкой:
sysctl -w net.ipv4.tcp_congestion_control=bbr. Но включать имеет смысл только после того, как вы увиделиretransвss -ti— иначе это карго-культ.
Отдельная патология — bufferbloat: раздутые буферы в промежуточном оборудовании принимают
пакеты, которые давно пора было отбросить. Полоса при этом отличная, а задержка растёт в разы.
Диагностируется просто: minrtt в ss -ti низкое, rtt под нагрузкой в 5–10 раз выше. Подробно —
bufferbloat.net.
6. Сжатие: когда оно окупается, а когда вредит
Сжатие меняет байты на процессорное время. Это выгодная сделка ровно в одном случае: когда время на передачу сэкономленных байтов больше, чем время на сжатие и распаковку.
Выгодно, если: (S_исходный - S_сжатый) / Bandwidth > T_сжатия + T_распаковки
Подставьте числа. Ответ 200 КБ, JSON сжимается примерно в 5 раз (до 40 КБ), экономия 160 КБ.
- Клиент на мобильной сети, 5 Мбит/с: экономия = 160 × 8 / 5000 ≈ 256 мс. Сжатие обязано быть.
- Клиент в том же ЦОД, 10 Гбит/с: экономия = 160 × 8 / 10 000 000 ≈ 0,13 мс. Сжатие gzip‑6 займёт больше. Сжимать вредно.
Один и тот же ответ, противоположные решения. Вот почему «включите gzip везде» — плохой совет, а «включите gzip на границе, для клиентов из интернета» — хороший.
Ориентиры по алгоритмам (порядок величины на одном ядре современного x86, обязательно перемеряйте на своих данных):
| Алгоритм | Степень сжатия JSON | Скорость сжатия | Скорость распаковки | Где уместен |
|---|---|---|---|---|
| gzip -1 | ~4× | ~150 МБ/с | ~400 МБ/с | динамика, экономный режим |
| gzip -6 (умолчание) | ~5× | ~50 МБ/с | ~400 МБ/с | универсальный компромисс |
| gzip -9 | ~5,1× | ~20 МБ/с | ~400 МБ/с | почти никогда: +2% за 2,5× время |
| brotli -4 | ~5,3× | ~120 МБ/с | ~350 МБ/с | динамика в HTTP |
| brotli -11 | ~6,5× | ~1 МБ/с | ~350 МБ/с | только статика, сжатая заранее |
| zstd -3 (умолчание) | ~5× | ~450 МБ/с | ~1200 МБ/с | внутренний трафик, логи, RPC |
| zstd -19 | ~6,3× | ~5 МБ/с | ~1200 МБ/с | архивы, статика |
Читать таблицу нужно так: уровень сжатия — не шкала «лучше-хуже», а точка на кривой компромисса. Разница между gzip -6 и gzip -9 составляет проценты по размеру и разы по времени. Разница между brotli -4 и brotli -11 огромна в обе стороны, поэтому brotli -11 применяют только к файлам, которые сжимаются один раз при сборке и раздаются миллионы раз.
Практические правила:
- Не сжимайте уже сжатое — JPEG, PNG, MP4, ZIP. Потратите процессор и иногда получите файл
больше исходного. В nginx список
gzip_typesдолжен быть явным, а не «всё подряд». - Не сжимайте мелочь. Ответ в 200 байт станет 180 байт ценой syscall и такта.
gzip_min_length 1024— разумное умолчание. - Сжимайте статику заранее.
gzip_static on/brotli_static onраздают готовый.gz/.brс диска: стоимость уходит в сборку, и brotli -11 становится бесплатным. - Смотрите, кто платит за процессор. Сжатие на прикладном сервере обменивает сеть на CPU в самом дорогом месте; переносите на nginx/Envoy/CDN на границе.
- Внутри ЦОД смотрите на zstd, а не на gzip: быстрее на порядок при сопоставимом размере, поддержан в gRPC, Kafka, ClickHouse, PostgreSQL.
- Оговорка про безопасность. Сжатие ответа, где смешаны секрет и управляемые атакующим данные, — это BREACH; такие страницы настраивают отдельно.
Половина инцидентов «у нас медленно грузится» лечится проверкой, что сжатие вообще доехало:
curl -s -o /dev/null -D - -H 'Accept-Encoding: br, gzip' \
-w 'на_проводе=%{size_download} всего=%{time_total}s\n' \
https://example.com/api/report | grep -i 'content-encoding\|vary'
Если Content-Encoding в ответе нет — прокси по пути срезал Accept-Encoding (типичное поведение
старых балансировщиков) или gzip_types не покрывает ваш Content-Type.
7. Батчинг: превратить N × RTT в один RTT
Это самая мощная оптимизация в статье, и работает она везде: HTTP API, база данных, Redis, очередь сообщений, вызовы к внешнему сервису.
Три родственных приёма, которые часто путают:
- Батчинг — сложить N логических операций в один запрос. Требует поддержки на сервере.
- Конвейеризация (pipelining) — отправить N запросов не дожидаясь ответов, по одному
соединению. Не требует поддержки формата, требует поддержки протокола: работает в Redis
(
MULTI/pipeline), в PostgreSQL (расширенный протокол), в HTTP/1.1 — формально да, на практике нет из-за head-of-line blocking и сломанных прокси. - Мультиплексирование — то же самое, но с независимыми потоками: HTTP/2 и HTTP/3, gRPC, любой протокол с идентификаторами запросов. Отличие от конвейеризации в том, что ответы могут приходить не по порядку, и медленный ответ не блокирует остальные.
Здесь всплывает важная деталь: HTTP/2 решает head-of-line blocking на уровне HTTP, но не решает на уровне TCP. Все потоки идут по одному TCP-соединению, и одна потерянная дейтаграмма останавливает доставку всем потокам сразу. На чистом канале HTTP/2 быстрее HTTP/1.1 с шестью соединениями, на канале с 2% потерь — может быть медленнее. Ровно эту проблему решает QUIC, где потоки независимы на транспортном уровне (https://courses.digitable.life/post/networking/04-udp-and-quic/).
Батчер с окном: обмен латентности на пропускную способность
Батчинг «по требованию» работает, когда у вас есть список идентификаторов. Но чаще запросы приходят
поодиночке из разных мест кода. Тогда ставят батчер с окном: копим запросы до max_size
элементов или max_wait времени, что наступит раньше.
import asyncio
from typing import Any, Awaitable, Callable, Sequence
class WindowBatcher:
"""Копит одиночные запросы и отправляет их пачкой: k round-trip'ов превращаются в один."""
def __init__(
self,
fetch_many: Callable[[Sequence[str]], Awaitable[dict[str, Any]]],
max_size: int = 100,
max_wait: float = 0.005, # 5 мс: сильно меньше RTT, значит почти бесплатно
) -> None:
self._fetch_many, self._max_size, self._max_wait = fetch_many, max_size, max_wait
self._pending: dict[str, list[asyncio.Future]] = {}
self._timer: asyncio.TimerHandle | None = None
self._loop = asyncio.get_event_loop()
async def get(self, key: str) -> Any:
fut = self._loop.create_future()
# Дедупликация: два одновременных запроса одного ключа — один поход в сеть.
self._pending.setdefault(key, []).append(fut)
if len(self._pending) >= self._max_size:
self._flush()
elif self._timer is None:
self._timer = self._loop.call_later(self._max_wait, self._flush)
return await fut
def _flush(self) -> None:
if self._timer is not None:
self._timer.cancel()
self._timer = None
batch, self._pending = self._pending, {}
if batch:
asyncio.create_task(self._run(batch))
async def _run(self, batch: dict[str, list[asyncio.Future]]) -> None:
try:
result = await self._fetch_many(list(batch))
except Exception as exc: # ошибку получают все ждущие, иначе они зависнут навсегда
for waiters in batch.values():
for f in waiters:
if not f.done():
f.set_exception(exc)
return
for key, waiters in batch.items():
for f in waiters:
if not f.done():
f.set_result(result.get(key))
Сложность: O(k) по времени на пачку из k ключей плюс один сетевой round-trip вместо k;
по памяти O(k) на буфер ожидающих. Ключевой параметр — max_wait: он в худшем случае
добавляется к латентности каждого запроса. 5 мс при RTT 30 мс — это +17% к худшему случаю
за пятнадцатикратное сокращение числа вызовов, отличная сделка. 50 мс при RTT 1 мс внутри ЦОД —
катастрофа для p99 при нулевой выгоде. Это решение о бюджете задержки, а не деталь реализации.
Именно на этой идее построены DataLoader из GraphQL-мира, MGET в Redis, SELECT ... WHERE id = ANY($1)
в PostgreSQL и batchGet в облачных API. Тот же приём в разрезе базы данных разобран
в https://courses.digitable.life/post/performance/08-database-performance/ как лечение N+1: там это выглядит как N+1 запросов,
здесь — как N+1 round-trip’ов, но природа проблемы одна.
Nagle и delayed ACK: батчинг, который вам не нужен
Классическая патология, которая до сих пор выстреливает. Алгоритм Нейгла (RFC 896) на стороне отправителя копит мелкие данные, пока не подтверждён предыдущий сегмент. Отложенное подтверждение на стороне получателя копит ACK, чтобы отправить его вместе с данными — до 40 мс на Linux и до 200 мс на Windows. Вместе они образуют взаимную блокировку.
Ловушка срабатывает на шаблоне write-write-read: приложение пишет заголовок, потом тело, потом
ждёт ответ. Второй write Нейгл придерживает (первый ещё не подтверждён), получатель придерживает
ACK (ждёт данных, чтобы отправить попутно). Результат — ровная полка в 40 мс на каждом запросе,
которая не зависит ни от нагрузки, ни от размера данных. Симптом опознаётся мгновенно: латентность
подозрительно близка к круглому числу и не реагирует на нагрузку.
Лечение — любое из двух, лучше оба:
import socket, struct
# 1. Отключить Нейгла. В Go для TCP это умолчание, в C и Python — нет.
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# 2. Убрать сам шаблон write-write-read: собрать сообщение и отправить одним вызовом.
payload = body.encode()
header = struct.pack(">I", len(payload))
sock.sendall(header + payload) # одна отправка вместо двух — Нейглу нечего придерживать
Второй способ надёжнее: он убирает не симптом, а причину, и заодно экономит системный вызов (https://courses.digitable.life/post/performance/06-io-and-syscalls/). Сам Джон Нейгл в известном комментарии на Hacker News писал, что проблема не в его алгоритме, а в отложенных подтверждениях и в том, что приложения делают две записи там, где хватило бы одной.
8. Полезная нагрузка: размер, формат, лишние поля
Когда вы уже в режиме пропускной способности, есть четыре рычага, в порядке отношения выгоды к риску:
- Не отдавать лишнее. Самая частая находка: эндпоинт списка отдаёт полные объекты со всеми
вложенными сущностями, а UI показывает три поля. Экономия 5–20× за час работы. Проверяется
глазами по
curl | jq 'keys'и сравнением с версткой. - Пагинация и лимиты. Ответ, растущий линейно с базой данных, — это отложенный инцидент. Жёсткий верхний предел на размер страницы обязателен.
- Сжатие. Раздел 6.
- Смена формата. JSON → protobuf/MessagePack/Avro даёт обычно 20–50% по размеру после сжатия (до сжатия разница драматичнее, но gzip неплохо съедает повторяющиеся ключи JSON). Выигрыш на сериализации бывает больше выигрыша на размере: разбор JSON стоит процессора заметно больше, чем разбор бинарного формата. Это рычаг с самой высокой ценой изменения — требует схем, кодогенерации, версионирования (https://courses.digitable.life/post/networking/09-rpc-and-grpc/).
Порядок здесь не случаен. Замена формата — красивая инженерная работа, которая часто даёт меньше, чем удаление трёх ненужных полей из ответа.
9. Как врут сетевые бенчмарки
Сеть — рекордсмен по количеству способов измерить не то. Каждый пункт ниже встречался в реальных отчётах «мы всё проверили, всё быстро», после которых прод лежал.
Тест на localhost. Он врёт про всё сразу: RTT там 30 мкс вместо 30 мс (в тысячу раз меньше), потерь нет вообще, MTU у loopback обычно 65536 вместо 1500, TLS-рукопожатие не платит за географию, а окно перегрузки нерелевантно. Бенчмарк на localhost не измеряет сеть — он измеряет сериализацию и обработку. Это тоже полезно, но называть это сетевым тестом нельзя.
Минимальное лекарство — эмулировать реальный канал ядром:
# Задержка 75 мс ± 10 мс, 0,2% потерь, полоса 20 Мбит/с на исходящем интерфейсе
sudo tc qdisc add dev eth0 root netem delay 75ms 10ms distribution normal loss 0.2% rate 20mbit
tc qdisc show dev eth0 # проверить, что применилось
sudo tc qdisc del dev eth0 root # снять после теста, иначе будете искать причину полдня
Прогретые соединения в измерении холодного пути. Тест открывает пул на старте и пять минут гоняет запросы по тёплым соединениям. Реальный пользователь приходит раз в день и платит все четыре RTT: вы измерили сценарий C с картинки из раздела 3, а он живёт в сценарии A. Мерьте оба и отдельно.
Один клиент на одной машине. Он упирается в собственный процессор, в число эфемерных портов (по умолчанию около 28 тысяч), в очередь сетевой карты. Вы измерили генератор нагрузки, а не сервис. Признак: при добавлении второй машины-генератора суммарный RPS растёт больше чем на 50%.
Coordinated omission. Генератор ждёт ответа, прежде чем отправить следующий запрос. Когда сервис тормозит, генератор автоматически снижает нагрузку — и самые медленные интервалы просто не попадают в выборку: хвосты срезаны, p99 выглядит прекрасно. Термин ввёл Gil Tene; проблема разобрана в https://courses.digitable.life/post/performance/01-measuring/, а лечение — open-model генератор с фиксированной частотой отправки — в https://courses.digitable.life/post/performance/11-load-testing/.
Ошибка выжившего. В статистику попадают только завершившиеся запросы. Клиенты, у которых случился таймаут, разорвалось соединение или упало мобильное приложение, в отчёт не попадают вообще — а это ровно те, кому было хуже всех. Проверка простая: сумма успешных и ошибочных ответов должна совпадать с числом отправленных запросов.
Кэш на пути. Ответ отдал CDN, прокси или кэш браузера, а вы думаете, что измерили сервис.
Проверяйте Age, X-Cache, CF-Cache-Status и %{num_connects}.
Один регион и один ключ. Замер из Франкфурта и замер из Сан-Паулу — два разных эксперимента, а не два наблюдения одного; средняя латентность по регионам не значит ничего. А один и тот же ключ в каждом запросе прогревает все кэши на пути и даёт цифры, которых в проде не будет никогда: распределение ключей должно повторять реальное, обычно степенное (https://courses.digitable.life/post/performance/09-caching/).
10. Приборы: что чем смотреть
| Вопрос | Инструмент | Что смотреть |
|---|---|---|
| Где время внутри одного запроса | curl -w, DevTools → Network |
time_namelookup / connect / appconnect / starttransfer |
| Каково RTT и есть ли потери | ss -tin, mtr -rwzbc 200 host |
rtt/minrtt, retrans, cwnd, потери по хопам |
| Переиспользуется ли соединение | %{num_connects} в curl, ss -s |
num_connects = 0 на повторных, число timewait |
| Что реально ушло в провод | tcpdump -i any -w /tmp/d.pcap, tshark |
размеры сегментов, повторы, время между пакетами |
| Как ведёт себя браузер | DevTools → Network waterfall | фазы Queued/Stalled/DNS/TLS/TTFB/Content Download |
| Где время в распределённом вызове | трассировка (OpenTelemetry) | span’ы client/server, разница = сеть + очередь |
| Сколько держит нагрузку | k6, wrk2, vegeta | RPS, p95/p99, доля ошибок при фиксированной частоте |
Две отдельные заметки.
DevTools waterfall. Столбик «Stalled» — это не сеть, это ожидание слота в пуле браузера (HTTP/1.1 держит 6 соединений на хост). Длинный Stalled означает, что вы упёрлись в параллелизм и вам нужен HTTP/2 либо меньше запросов. Столбик «Waiting (TTFB)» — сервер плюс один RTT. «Content Download» — полоса и окно.
Трассировка. Разница между длительностью клиентского span’а и серверного — это ровно то, что не принадлежит ни одному приложению: сеть, очередь в пуле, время в прокси. Если она стабильно больше RTT, ищите очередь (https://courses.digitable.life/post/distributed-systems/12-observability/). Оттуда же берётся главная проверка на N+1: посчитайте исходящие span’ы на одну пользовательскую операцию. Больше пяти — почти всегда повод для батчинга.
11. Порядок действий
Порядок отсортирован по отношению выгоды к риску:
- Измерьте разложение.
curl -wдля одного запроса, трассировка для распределённого. Пока вы не знаете, где секунды, любое действие — угадывание. - Посчитайте round-trip’ы на одну пользовательскую операцию. Это число важнее остальных.
- Включите keep-alive и проверьте, что он работает —
num_connectsна повторных запросах должен быть нулём. Три бесплатных RTT. - Сбатчите то, что батчится. N последовательных вызовов → один. Обычно крупнейший выигрыш.
- Уберите лишнее из ответа: поля, вложенные сущности, отсутствующая пагинация.
- Включите сжатие там, где канал узкий, и проверьте, что оно доехало.
- Приблизьте данные к клиенту, если после всего выше упёрлись в RTT: CDN, кэш на границе, реплика в регионе.
- И только потом трогайте транспорт: HTTP/2 и HTTP/3, congestion control, sysctl. Здесь эффект меньше, а способов навредить больше.
Правило остановки то же, что во всём треке (https://courses.digitable.life/post/performance/12-optimization-workflow/): вы закончили, когда время сетевого слоя опустилось ниже бюджета и следующий шаг стоит дороже получаемой миллисекунды. Если после keep-alive и батчинга бюджет соблюдён — не трогайте protobuf.
Мини-итог
- Сеть — слой, где время тратит ожидание, а не работа. Оптимизировать надо не байты, а число round-trip’ов.
- RTT задан скоростью света и географией; кодом он не уменьшается — уменьшается только количество раз, которое вы его платите.
- Холодный HTTPS-запрос стоит примерно четыре RTT, тёплый — один. Keep-alive убирает три RTT
и сохраняет раскрытое окно перегрузки. Ломается он обычно по недосмотру: не дочитанное тело,
MaxIdleConnsPerHost= 2, клиент на каждый вызов, рассогласованные idle-таймауты. - Произведение полосы на задержку объясняет, почему на длинных каналах скорость определяют окно и медленный старт, а не купленная полоса.
- Сжатие — обмен байтов на процессор. На мобильном канале выгодно почти всегда, внутри ЦОД — почти никогда. Считайте точку безубыточности, а не следуйте лозунгу.
- Батчинг превращает N × RTT в один RTT и обычно даёт больше, чем все остальные приёмы вместе;
max_waitв батчере с окном — осознанное решение о бюджете задержки. - Бенчмарк на localhost не измеряет сеть. Прогретые соединения, один генератор нагрузки, coordinated omission и ошибка выжившего дорисовывают картину до полностью ложной.
Источники
- Ilya Grigorik, High Performance Browser Networking — hpbn.co, полностью бесплатна онлайн. Лучшая книга по теме этой статьи; главы про TCP, TLS и HTTP/2 обязательны.
- RFC 9110 — HTTP Semantics, RFC 9111 — HTTP Caching, RFC 9114 — HTTP/3, RFC 9000 — QUIC.
- RFC 896 — Congestion Control in IP/TCP — исходная статья Нейгла; RFC 6928 — обоснование initcwnd 10.
- BBR: Congestion-Based Congestion Control, ACM Queue.
- Latency numbers every programmer should know — интерактивная версия с поправкой на год; пользуйтесь сетевыми строками, остальные перепроверяйте.
- bufferbloat.net — раздутые буферы и алгоритмы очередей (fq_codel, CAKE).
- Cloudflare Blog: Brotli и Zstandard — замеры сжатия на живом трафике.
- curl: –write-out variables и Chrome DevTools: Network reference — документация двух главных приборов из этой статьи.
- k6: HTTP-specific metrics — какие сетевые метрики k6 умеет разделять.
- Комментарий Джона Нейгла на Hacker News — первоисточник про взаимодействие его алгоритма с delayed ACK.
Что дальше
Мы научились разбирать один запрос по фазам и убирать из него лишние round-trip’ы. Но система ломается не на одном запросе, а на потоке: под нагрузкой очереди насыщаются, пул заканчивается, хвосты распределения расползаются, и приёмы из этой статьи начинают вести себя иначе. Чтобы это увидеть, нужен генератор нагрузки, который не врёт про хвосты, и умение читать его вывод: Нагрузочное тестирование: профили нагрузки, k6, чтение результатов.