Когда данные летят от вашего сервиса к соседнему, за доставку между двумя процессами отвечает транспортный слой. И на этом слое есть выбор из двух протоколов: TCP и UDP. Они решают одну и ту же задачу — донести байты от отправителя к получателю — но с прямо противоположной философией. TCP похож на заказное письмо с уведомлением о вручении: медленнее, зато точно дойдёт и в правильном порядке. UDP — как открытка, брошенная в почтовый ящик: улетела мгновенно, но дошла ли она и в каком порядке — никто не гарантирует.
Разберёмся, как работает каждый, и — главное — когда backend выбирает тот или другой. Потому что выбор не про «какой лучше», а про то, что важнее для конкретной задачи: надёжность или скорость.
Проще всего увидеть этот размен на одной шкале времени: обе стороны отправляют три пакета, и сеть теряет второй.
Одна и та же потеря — два исхода. TCP платит 40 мс рукопожатия, а пропажу второго пакета замечает по подтверждению на третий и повторяет его примерно за один такой же оборот, зато отдаёт все три по порядку. UDP начинает с нулевой миллисекунды, но дырку на месте второго пакета закрывать придётся самому приложению — или жить с ней.
TCP: доставка с гарантией
TCP (Transmission Control Protocol) построен вокруг понятия соединения. Прежде чем отправить хоть один байт полезных данных, две стороны договариваются, что они готовы общаться. И дальше протокол берёт на себя кучу забот, чтобы данные дошли целиком, без потерь и в том порядке, в котором были отправлены.
Сеть теряет пакеты, путает их порядок и портит байты по дороге, а ваш код хочет получить ответ базы ровно таким, каким он был отправлен. Ровно три этих беды TCP и закрывает. Пакет потерялся: TCP это заметит и отправит его заново, и с точки зрения кода данные просто «дошли», механика переотправки скрыта внутри. Пакеты пришли вперемешку, разными путями и с разной задержкой: TCP нумерует их и на приёмной стороне собирает обратно в исходной последовательности. Байты испортились: каждый кусок проверяется контрольной суммой, битые данные отбрасываются и запрашиваются заново.
За всё это платят задержкой и накладными расходами: нужны номера, подтверждения, буферы. Но для большинства backend-задач это ровно то, что нужно — вы отправили запрос и уверены, что он дойдёт таким, каким был.
Three-way handshake: как открывается соединение
Установка TCP-соединения — это короткий обмен из трёх сообщений, его называют three-way handshake (тройное рукопожатие). На пальцах это выглядит как знакомство по телефону:
- SYN — клиент звонит: «Привет, я хочу с тобой поговорить, слышишь меня?»
- SYN-ACK — сервер отвечает: «Слышу тебя. А ты меня слышишь?»
- ACK — клиент подтверждает: «Слышу. Начинаем».
После этих трёх шагов обе стороны убедились, что канал работает в обе стороны, и договорились о стартовых номерах, по которым будут собирать пакеты. Только теперь пойдут реальные данные.
Полезные данные уходят только после трёх служебных пакетов SYN, SYN-ACK и ACK: смотрите на выделенный шаг, до него проходит целый оборот до сервера и обратно.
Здесь прячется важная для backend деталь: рукопожатие стоит один round-trip — один полный оборот «туда-обратно» до сервера, прежде чем уйдёт первый полезный байт. Этот оборот называют RTT (round-trip time). Если сервер за океаном, один только RTT — это десятки миллисекунд, и они добавляются к каждому новому соединению ещё до того, как вы отправили запрос. Отсюда практический вывод: соединения дорого открывать, поэтому их переиспользуют — не открывают новое на каждый запрос, а держат пул готовых. Подробнее эту механику разбираем в статье про соединения и их переиспользование.
Подтверждения и переотправка
Как TCP понимает, что пакет дошёл? По подтверждениям. Отправив данные, TCP ждёт от другой стороны ACK — «получил». Если ACK не пришёл за отведённое время, протокол считает пакет потерянным и отправляет его заново.
Грубая аналогия — разговор по рации: сказал фразу и ждёшь «принял». Не услышал подтверждения — повторяешь.
Отправитель Получатель
|-- пакет 1 -------->|
|<----- ACK 1 -------|
|-- пакет 2 -------->| (потерялся!)
| ...ждём... |
|-- пакет 2 (повтор)>|
|<----- ACK 2 -------|
Ждать таймаут, правда, приходится редко — и хорошо, потому что он длинный: в Linux меньше 200 мс он не бывает, а на плохой линии вырастает в секунды. Обычно потерю ловят раньше и без часов. Получатель подтверждает то, что дошло, и, если пакет 2 пропал, а пакет 3 приехал, подтверждения начинают упрямо повторять «жду второй». Отправитель видит эту дырку и шлёт пакет 2 заново, не дожидаясь срока, — примерно за один оборот «туда-обратно». Механизм так и называют: быстрый повтор (fast retransmit). Именно поэтому редкие потери на нормальной сети почти не чувствуются, а таймаут остаётся запасным вариантом — на случай, когда подтверждений вообще нет, потому что пропало всё.
Заодно TCP следит, чтобы не завалить сеть и получателя данными: если пакеты начинают теряться, он сбавляет темп, а на спокойной линии — разгоняется. Это называется контролем перегрузки, и именно поэтому скорость TCP-соединения не постоянна, а подстраивается под состояние сети. Что делать, когда соединение всё же рвётся или тормозит, — тема статьи про надёжность передачи.
Откуда берётся «пакет»: MTU и размер сегмента
Слово «пакет» скрывает важное ограничение: сеть передаёт куски ограниченного размера. Предел называется MTU и в обычной сети равен 1500 байтам — это весь канальный кадр. За вычетом заголовков IP и TCP на данные остаётся около 1460 байт, и это максимальный размер сегмента.
Отсюда следствия. Ответ на 100 килобайт — это не «один пакет», а примерно семьдесят сегментов; потеря любого из них означает переотправку. И TCP сам нарезает поток на сегменты — приложение об этом не думает, но оно и не управляет границами: отправили десять раз по сто байт, а на другой стороне может прийти одна тысяча байт за раз (поэтому в TCP всегда нужен свой разделитель сообщений).
Когда MTU меньше. Внутри туннеля или VPN внешний конверт занимает место, и полезный размер уменьшается до 1400 байт и ниже. Дальше начинается интересное: узел, которому пакет «не влезает», должен сообщить отправителю уведомлением о необходимости фрагментации — но эти уведомления часто блокируют межсетевые экраны. Результат — классический симптом: короткие запросы работают, а большие ответы зависают навсегда, потому что отправитель не получает ни подтверждения, ни ошибки. Проверяется это отправкой пакета фиксированного размера с запретом фрагментации (ping -M do -s 1400 хост), лечится настройкой MTU на интерфейсе или подгонкой размера сегмента на маршрутизаторе.
Почему первый запрос медленный, а дальше быстро
У этого две причины, и рукопожатие — только первая.
Вторая — медленный старт. TCP не знает, какую скорость выдержит сеть, поэтому начинает с небольшого окна (несколько сегментов) и удваивает его каждый круг подтверждений, пока не начнутся потери. Значит, первые десятки килобайт уходят заметно медленнее, чем могли бы: канал ещё «разогревается». На быстром канале с большой задержкой (между континентами) разогрев занимает несколько кругов, то есть сотни миллисекунд.
Отсюда практические следствия. Постоянное соединение экономит не только рукопожатие, но и уже разогнанное окно — это и есть главная причина, по которой пул соединений даёт больше, чем кажется. Разбивать один ответ на десять соединений медленнее, чем отдать в одном: каждое начинает разогрев заново. А «медленно только первый раз» на графиках — это не кэш и не прогрев кода, а обычная работа транспорта.
Полуоткрытые соединения и keepalive
Самая коварная особенность TCP: соединение может быть живым для одной стороны и мёртвым для другой. Если сервер перезагрузился или посредник (межсетевой экран, преобразователь адресов) выбросил запись о соединении, клиент об этом не узнает: TCP не проверяет линию, пока по ней не пишут. Соединение в таком состоянии называют полуоткрытым, и обнаруживается оно только при попытке отправить данные — а иногда и тогда не сразу, потому что данные уходят в никуда и переотправляются минутами.
Именно поэтому в пулах соединений есть проверка живости: соединение, простоявшее без дела, могло умереть молча. Механизм TCP, который эту проблему решает на уровне транспорта, — keepalive: операционная система периодически посылает пустой пакет-проверку и, не получив подтверждения несколько раз, закрывает соединение. По умолчанию первая проверка идёт через два часа простоя, что бесполезно для приложений, поэтому значения ставят своими (десятки секунд до первой проверки).
Практический вывод для приложения: на долгоживущих соединениях нужны либо настроенный keepalive, либо своё сердцебиение на прикладном уровне, либо и то и другое. Что происходит без этого — в статье про два пула, где вся авария начинается с того, что сервер молча закрыл линию.
UDP: быстро и без обещаний
UDP (User Datagram Protocol) — полная противоположность. Никакого соединения, никаких рукопожатий, никаких подтверждений. Взял данные, приписал порт получателя, выбросил в сеть — и забыл. Дошло ли, в каком порядке, дошло ли вообще — протокол не проверяет и не сообщает.
Звучит как недостаток, но именно в этом сила UDP:
- Мгновенный старт. Нет рукопожатия — нет лишнего round-trip. Первый пакет с данными уходит сразу.
- Минимум накладных расходов. Заголовок крошечный, состояние соединения нигде не хранится.
- Свобода поведения. Приложение само решает, что делать с потерями: переспросить, подставить заглушку или просто пропустить.
Плата за это — все гарантии теперь на вашей совести. Если пакет потерялся, UDP об этом не узнает. Если два пакета пришли в обратном порядке — так они и лягут. Поэтому UDP хорош там, где потеря отдельного кусочка либо терпима, либо приложение умеет её обработать само.
Важно, что «без обещаний» не означает «без надёжности в системе». Приложение, которому нужна надёжность, строит её поверх UDP — и делает это выборочно, а не для всего потока.
Так устроены все известные применения. QUIC (о нём ниже) реализует подтверждения, переотправку и контроль перегрузки заново, но уже со своими правилами и без блокировки одного потока другим. Протоколы реального времени для голоса и видео нумеруют пакеты, чтобы восстановить порядок и заметить потери, — но потерянный пакет не переспрашивают: он уже не нужен, лучше проиграть следующий. Игровые протоколы подтверждают только важные события (выстрел, смерть), а положение игрока отправляют без подтверждений, потому что через 50 миллисекунд придёт новое.
Отсюда правильное понимание: UDP — это не «урезанный TCP», а чистый транспорт без встроенной политики. Он позволяет выбрать, что именно гарантировать: порядок без переотправки, переотправку без порядка, надёжность для части сообщений. TCP такого выбора не даёт: он гарантирует всё сразу и потому платит задержкой на ожидание потерянного сегмента.
Когда что выбирать
TCP выбирают, когда данные нельзя терять:
- HTTP и весь веб — страница или ответ API должны прийти целиком, без пропущенных кусков.
- Базы данных — потерять половину запроса или ответа недопустимо.
- Передача файлов, платежи, почта — всё, где важна каждая единица данных и её порядок.
UDP выбирают, когда важнее не опоздать:
- DNS-запросы — крошечный вопрос и такой же ответ; потерялся — просто спросим ещё раз, это дешевле, чем городить соединение. Правда, «DNS = UDP» верно не всегда: если ответ не влезает в один пакет (исторический предел — 512 байт) или речь о переносе целой зоны между серверами имён, DNS уходит в TCP на тот же порт 53.
- Видеозвонки и голос — если один кадр потерялся, ждать его переотправку бессмысленно: пока он дойдёт, разговор уже уедет вперёд. Лучше пропустить и показать следующий.
- Онлайн-игры, метрики, телеметрия — поток частых обновлений, где важна свежая величина, а не каждое отдельное значение. Потерянную метрику перекроет следующая через секунду.
Заметьте закономерность: TCP — там, где данные самоценны; UDP — там, где данные быстро устаревают и опоздавший пакет уже никому не нужен.
Отсюда правило: важна целостность каждого байта — TCP; важнее скорость и свежесть, а потери терпимы — UDP.
QUIC: UDP с надёжностью сверху
У этой картины есть современное продолжение. Долгое время выбор был жёстким: хочешь гарантии — плати рукопожатием TCP; хочешь скорость — бери UDP и разбирайся с потерями сам. QUIC ломает эту дилемму.
QUIC — это протокол, который работает поверх UDP, но сам реализует всё, чем силён TCP: подтверждения, переотправку потерянного, порядок, шифрование. Получается «UDP снаружи, надёжность внутри». Зачем так? Чтобы обойти ограничения, зашитые в TCP: например, QUIC умеет устанавливать защищённое соединение быстрее, за меньшее число round-trip, и не спотыкается, когда теряется один из параллельных потоков данных.
Именно на QUIC построен HTTP/3 — новейшая версия протокола, на который постепенно переходит веб. Так что UDP, который вроде бы «без гарантий», оказался фундаментом для самого надёжного и быстрого HTTP на сегодня. Как менялся HTTP от версии к версии — в статье про версии HTTP.
Где это применяется
Для backend разница TCP и UDP — это в первую очередь про то, где искать причину проблемы и почему код устроен так, а не иначе.
- Пул соединений — это про стоимость TCP-рукопожатия. Когда вы настраиваете пул к базе или HTTP-клиенту, вы платите за то, чтобы не делать three-way handshake на каждый запрос. Понимая, что установка стоит round-trip, вы понимаете, зачем вообще нужен пул.
- «Медленно с первого запроса, потом быстро» — часто это рукопожатие. Первый вызов к холодному сервису включает установку соединения (а с HTTPS — ещё и TLS поверх); последующие идут по уже открытому каналу.
- DNS «иногда не отвечает» — это UDP-природа. DNS-запрос по UDP может потеряться без всякого сигнала; клиент просто повторяет его по таймауту. Если таймаут большой, задержка становится заметной.
Где спотыкаются начинающие:
- Считают UDP «сломанным TCP». UDP не хуже — он про другое. Отсутствие гарантий здесь не баг, а сознательный размен на скорость.
- Думают, что UDP всегда быстрее. На чистой линии — да, но при потерях приложению всё равно приходится что-то делать с пропавшими данными, и наивная переотправка поверх UDP легко оказывается медленнее TCP.
- Открывают новое TCP-соединение на каждый запрос. Каждый раз платится round-trip рукопожатия. Пул соединений существует ровно затем, чтобы этого не делать.
- Путают «соединение» и «сессию приложения». TCP-соединение — это транспортный канал; пользовательская сессия с токенами и куками живёт слоем выше и к handshake отношения не имеет.
Глубже: что всплывает в проде: MTU, окно, очередь соединений и keepaliveрасширенное
Рукопожатие и подтверждения выше объясняют, как TCP работает. Четыре его детали не нужны, пока всё хорошо, и всплывают ровно в тот момент, когда «сеть странно себя ведёт».
MTU и фрагментация. Кадр в сети имеет предел размера, обычно 1500 байт; всё, что больше, режется на части или отбрасывается. Туннели (VPN, оверлейная сеть Kubernetes) добавляют свои заголовки, и предел внутри туннеля меньше, 1450 или 1400. Если по дороге стоит узел, который большие пакеты отбрасывает и не сообщает об этом, получается классический симптом: маленькие запросы проходят, большие ответы (страница, файл, ответ API на сто килобайт) зависают навсегда, ping работает, curl висит. Проверка: ping -M do -s 1400 host с разными размерами показывает, где начинается потеря; лечение это правильный MTU на интерфейсе туннеля или включённый MSS clamping на шлюзе.
Окно и постепенный разгон. TCP не шлёт всё сразу: получатель объявляет окно, сколько он готов принять без подтверждения, а отправитель ещё и сам начинает с малого и удваивает темп, пока не увидит потери (медленный старт). Отсюда два эффекта. Новое соединение первые несколько обменов работает медленнее прогретого, и это второй аргумент за keep-alive из статьи про соединения. И при большой задержке (другой континент) пропускная способность одного соединения ограничена окном, делённым на время оборота: при 100 мс и окне 64 КБ это 5 Мбит/с, сколько бы ни было полосы. Размер окна ядро подстраивает само, но настройка net.ipv4.tcp_rmem и tcp_wmem ограничивает его сверху.
Очередь ожидающих соединений. Пока приложение не вызвало accept, завершённые рукопожатия лежат в очереди ядра, и у неё есть длина: минимум из backlog, который задал сервер, и net.core.somaxconn (4096 в новых ядрах, 128 в старых). Переполнилась, и новые соединения либо молча отбрасываются, либо получают отказ; клиент видит таймаут подключения к живому серверу. Так выглядит всплеск нагрузки на сервер, у которого потоки заняты: ss -ltn показывает Recv-Q у слушающего сокета, растущая цифра означает, что приложение не успевает принимать. Лечение не в очереди, а в том, чтобы принимать быстрее или отвечать отказом раньше.
TCP keepalive против HTTP keep-alive. Названия похожи, смысл разный. HTTP keep-alive это «не закрывай соединение после ответа, будут ещё запросы». TCP keepalive это зонд ядра: на молчащем соединении раз в tcp_keepalive_time (два часа по умолчанию) ядро отправляет пустой пакет и по отсутствию ответа закрывает соединение. Без него соединение через NAT или балансировщик, который молча забыл о нём через пять минут простоя, остаётся для приложения «открытым» и умирает на первом же запросе долгим таймаутом. Поэтому у пулов соединений к базе и к очереди TCP keepalive включают явно и с интервалом в минуту, меньше, чем таймаут простоя у промежуточных узлов, а лучше ставят проверку соединения перед выдачей из пула.
Коротко
- TCP гарантирует доставку и порядок: рукопожатие из трёх шагов, подтверждения и повтор потерянного; UDP шлёт без обещаний и подходит там, где потеря дешевле ожидания.
- QUIC это UDP с надёжностью и шифрованием сверху, на нём стоит HTTP/3.
- В проде всплывают MTU (большие пакеты зависают в туннеле), окно и медленный старт (новое соединение медленнее прогретого), очередь
somaxconn(таймауты к живому серверу под всплеском) и TCP keepalive (мёртвые соединения через NAT). - Рукопожатие TCP это три пакета до первого байта данных, и на дальней линии оно стоит десятки миллисекунд; поэтому соединения переиспользуют.
- Сеть передаёт куски до 1500 байт (около 1460 на данные), TCP нарезает поток сам, а внутри туннеля предел меньше — отсюда «короткое работает, большое зависает».
- Первый запрос медленнее не только из-за рукопожатия: TCP разгоняет окно постепенно, поэтому постоянное соединение экономит и разогрев.
- Соединение бывает полуоткрытым: одна сторона считает его живым, другая уже нет. Лечат keepalive с разумными значениями или своим сердцебиением.
- UDP — не урезанный TCP, а транспорт без политики: надёжность строят поверх и выборочно, как в QUIC, протоколах реального времени и играх.
Что почитать дальше
TCP и UDP — это транспорт, но у него есть соседи по слоям, которые стоит понимать вместе. Ниже — адресация: IP-адреса и порты, которые как раз и определяют, до какой машины и какого процесса дойдёт TCP- или UDP-пакет. Прикладной слой поверх TCP — это DNS (который, кстати, живёт на UDP) и версии HTTP, где QUIC доводит историю до HTTP/3. А чтобы понять, почему соединения переиспользуют и что происходит при обрывах, — статьи про соединения и надёжность передачи.