TCP Tuning

Предпосылки: TCP (соединение, handshake, окно, congestion control, завершение).

TCP | DNS

Интерактивное приложение отправляет короткое сообщение: Redis-команду, короткий запрос к сервису, служебное сообщение проверки связи, маленький игровой пакет. Полезных данных десятки байт, а задержка внезапно измеряется уже не микросекундами или единицами миллисекунд, а десятками миллисекунд. Причина часто не в “медленной сети”, а в дефолтных механизмах TCP, которые стараются экономить пакеты и бережно обращаться с сетью.

Nagle и TCP_NODELAY: задержки при малых сообщениях

Представим сценарий: Redis-клиент отправляет команду GET user:123 — всего 14 байт. Отправлять 14 байт полезной нагрузки отдельным TCP-сегментом невыгодно: заголовки и служебные расходы оказываются больше самих данных. Алгоритм Nagle решает эту проблему так: если в сети уже есть неподтверждённые данные, новые мелкие записи временно буферизуются, пока не придёт ACK или пока не соберётся более крупный сегмент.

Для bulk-передачи это полезно: меньше мелких пакетов, выше утилизация сети. Для интерактивного обмена короткими сообщениями это может стать проблемой. Клиент отправил короткую команду, следующая короткая запись ждёт ACK предыдущей — и задержка начинает расти не из-за пропускной способности, а из-за ожидания подтверждений.

TCP_NODELAY — опция сокета, отключающая алгоритм Nagle. Данные отправляются немедленно, без накопления. Redis, SSH, игровые серверы, биржевые терминалы — все устанавливают TCP_NODELAY.

Цена: больше мелких пакетов в сети. Но для приложений, чувствительных к латентности, это приемлемый компромисс.

Delayed ACK: вторая половина проблемы

Nagle работает на стороне отправителя. На стороне получателя есть симметричная оптимизация: Delayed ACK. Получатель не обязан подтверждать каждый сегмент мгновенно — он может немного подождать, чтобы либо подтвердить сразу несколько сегментов, либо совместить ACK с ответными данными. Стандарт задаёт только верхнюю границу: задержка не должна превышать 500 ms. Реальные стеки обычно используют задержки порядка десятков миллисекунд.

Nagle + Delayed ACK вместе создают патологическую ситуацию: отправитель ждёт ACK, чтобы отправить следующий маленький кусок, а получатель ждёт, не появятся ли ответные данные, чтобы не слать отдельный ACK. Результат — пауза на каждом коротком обмене. TCP_NODELAY часто разрывает этот deadlock, но не отменяет общий принцип: мелкие синхронные записи особенно чувствительны к TCP-эвристикам.

SO_KEEPALIVE: обнаружение мёртвых соединений

TCP-соединение может существовать бесконечно без обмена данными — это by design. Но если удалённая сторона упала (выдернули кабель, процесс убит, машина перезагрузилась), локальная сторона об этом не узнает. Соединение висит, занимая ресурсы.

SO_KEEPALIVE — опция сокета, включающая периодические проверочные пакеты. Важно понимать границу: по стандарту keepalive вообще необязателен и по умолчанию должен быть выключен. Когда он включён, конкретные интервалы уже зависят от ОС. На Linux обычно настраивают три параметра:

  • tcp_keepalive_time — через сколько секунд бездействия начать проверку (типичный Linux default: 7200 = 2 часа).
  • tcp_keepalive_intvl — интервал между проверочными пакетами (типичный Linux default: 75 секунд).
  • tcp_keepalive_probes — сколько неответов считать обрывом (типичный Linux default: 9).

На Linux это означает, что обнаружение “мёртвого” соединения по умолчанию занимает больше двух часов. Для базы данных, брокера сообщений или долгоживущих соединений между сервисами это слишком долго, поэтому keepalive обычно включают и сокращают интервалы.

Socket buffers: пропускная способность

Каждый TCP-сокет имеет буферы отправки и приёма. Размер буфера ограничивает количество неподтверждённых данных “в полёте”. Для соединения с высокой пропускной способностью и большим RTT (длинный “толстый канал”) маленький буфер становится бутылочным горлышком: отправитель заполняет буфер быстрее, чем приходят ACK.

Оптимальный размер буфера = пропускная способность × RTT. Это называют bandwidth-delay product (BDP). Для канала 1 Gbps с RTT 100 ms: BDP = 125 MB/s × 0.1s = 12.5 MB. По умолчанию буфер сокета значительно меньше.

Современные ОС используют автонастройку буферов (TCP autotuning), но верхняя граница всё равно ограничена системными параметрами. На Linux это, например, net.core.rmem_max и net.core.wmem_max. Для высокоскоростных соединений с большим RTT маленькие лимиты превращаются в искусственный потолок пропускной способности.

Listen backlog: очередь входящих соединений

Когда сервер вызывает listen(), ядро обычно работает как минимум с двумя очередями. SYN queue хранит соединения в процессе рукопожатия (получен SYN, отправлен SYN-ACK, ждём финальный ACK). Accept queue хранит уже установленные соединения, которые ждут, пока приложение вызовет accept().

На Linux параметр backlog в listen(fd, backlog) ограничивает прежде всего accept queue и сверху ещё обрезается значением somaxconn. Для незавершённых TCP-соединений есть отдельный лимит tcp_max_syn_backlog. Это важное различие: “увеличить backlog” и “увеличить память под незавершённые SYN” — не одно и то же.

Если accept queue переполнена, поведение зависит от стека и настроек. На Linux клиент чаще увидит повторные попытки и таймаут; при tcp_abort_on_overflow=1 ядро может отправлять RST сразу. При всплесках нагрузки маленькие очереди приводят к ложным отказам даже при достаточной CPU-мощности.

TIME_WAIT: последствия закрытия

После четырёхстороннего закрытия TCP-соединения сторона, инициировавшая закрытие (отправившая первый FIN), переходит в состояние TIME_WAIT на 2 × MSL (Maximum Segment Lifetime). Это нужно, чтобы запоздавшие пакеты от старого соединения не были ошибочно приняты новым соединением на тех же адресах и портах. На Linux TIME_WAIT обычно держится около 60 секунд.

Проблема в том, что сервис с большим числом коротких соединений быстро накапливает TIME_WAIT-сокеты. Первый правильный ответ почти всегда один и тот же: меньше создавать короткоживущих соединений. Повторное использование одного TCP-соединения несколькими запросами, клиентские пулы соединений, постоянные соединения между прокси и сервером обычно дают больший эффект, чем ковыряние системных параметров ядра.

Да, в Linux есть ручки вроде net.ipv4.tcp_tw_reuse, но документация ядра прямо предупреждает, что их не стоит менять без понимания последствий. Такие параметры — финальная настройка после того, как архитектурная причина избытка коротких соединений уже устранена.

Sources


TCP | DNS