UDP и dgram: датаграммы, границы сообщений и когда отказаться от надёжности
UDP через dgram шлёт независимые датаграммы без соединения, без порядка и без гарантии доставки. Каждый send — один пакет, ограниченный MTU; ты меняешь надёжность на задержку, и это выигрывает для метрик, discovery, игр и DNS.
Команда переносит метрики приложения с синхронных HTTP POST на StatsD-агент по UDP, и p99 задержки запроса падает на 30 мс за ночь. Причина груба и проста: HTTP-вызовы метрик ждали TCP-рукопожатие, надёжную доставку и ответ — на горячем пути каждого запроса. UDP-send() не делает ничего из этого. Он отдаёт одну датаграмму ядру и возвращается; если пакет потерян, метрика потеряна, и это нормально. Они осознанно обменяли крошечную долю точности метрик на то, чтобы больше никогда не блокировать запрос пользователя ради телеметрии.
Датаграммы — это сообщения, а не поток
TCP — надёжный упорядоченный поток байтов; UDP — противоположная точка дизайна. Модуль dgram даёт бессоединительный сокет, который шлёт и принимает дискретные датаграммы — самодостаточные пакеты, каждый маршрутизируется независимо. Нет рукопожатия, нет состояния соединения, нет порядка и нет подтверждения. Датаграмма либо приходит целиком, либо не приходит вовсе.
Это переворачивает проблему фрейминга из net с ног на голову. В net приходилось добавлять границы сообщений, потому что TCP их стирает; в dgram границы сохраняются бесплатно — один send() порождает ровно одно событие 'message' у получателя ровно с теми байтами. Никогда не получишь частичную датаграмму и никогда не получишь две склеенные.
const dgram = require("node:dgram");
const server = dgram.createSocket("udp4");
server.on("message", (msg, rinfo) => {
// msg — целая датаграмма; rinfo = { address, port, size }
console.log(`${rinfo.address}:${rinfo.port} -> ${msg.length} байт`);
});
server.bind(8125);
const client = dgram.createSocket("udp4");
client.send(Buffer.from("requests:1|c"), 8125, "127.0.0.1"); // выстрелил и забылЗаметь, чего нет: ни connect, ни accept, ни объекта сокета на клиента. Сервер bind-ит порт и принимает от кого угодно; rinfo говорит, кто прислал каждую датаграмму. send() — выстрелил-и-забыл: его колбэк срабатывает, когда датаграмма отдана ОС, а не когда она доставлена, потому что у UDP нет понятия подтверждения доставки.
Ни доставки, ни порядка, ни дедупа — по замыслу
Прежде чем выбрать UDP, спроси себя: реально ли твоему протоколу нужна каждая из гарантий TCP, или ты платишь за надёжность, которую никогда не используешь? Таблица ниже делает компромисс наглядным.
UDP не даёт ни одной из гарантий TCP, и в этом весь смысл.
| Свойство | TCP (net) | UDP (dgram) |
|---|---|---|
| Доставка | Гарантирована (ретрансмит) | Best-effort — может быть потеряна |
| Порядок | По порядку | Любой порядок |
| Дубликаты | Убраны | Возможны |
| Границы | Стёрты (фреймишь сам) | Сохранены (1 send = 1 message) |
| Соединение | Stateful рукопожатие | Бессоединительное |
Если приложению нужна надёжность или порядок поверх UDP, ты реализуешь их сам (порядковые номера, ACK, ретрансмит) — ровно это и делают QUIC (протокол HTTP/3, строящий надёжность поверх UDP) и надёжные UDP-протоколы игр. Причина стартовать с UDP и добавлять лишь нужное — задержка: нет круга рукопожатия, нет head-of-line блокировки (потерянный TCP-сегмент стопорит всё за ним; потерянная датаграмма затрагивает только себя) и нет состояния соединения, которым надо управлять на масштабе.
Размер сообщения и обрыв на MTU
Датаграмма ограничена. Теоретический максимум UDP-payload для IPv4 — 65 507 байт, но в продакшене важно не это число. Важное число — path MTU — обычно ~1500 байт на Ethernet, часто ~1400 после туннелей/VPN. Датаграмма больше path MTU должна быть IP-фрагментирована на несколько пакетов, и вот обрыв: если потерян хоть один фрагмент, вся датаграмма отбрасывается — IP не может пересобрать частичную датаграмму. Поэтому датаграмма 4 КБ по теряющему пути имеет куда выше эффективную потерю, чем 1 КБ, потому что это три фрагмента, которые все должны выжить.
Практическое правило: держи датаграммы под path MTU — безопасная переносимая цель ~1200 байт payload (рекомендация QUIC/DNS). Нужно больше — фрагментируй на уровне приложения на датаграммы размером с MTU со своим порядком, или используй TCP. socket.send() не разбивает за тебя против твоего желания — он отдаёт весь буфер ОС, которая фрагментирует на уровне IP с описанной хрупкостью.
▸Почему это работает
Почему один потерянный фрагмент убивает всю датаграмму? IP-фрагментация разбивает датаграмму по пакетам, но только первый фрагмент несёт UDP-заголовок, а пересборке нужен каждый фрагмент, чтобы восстановить оригинал. В UDP нет ретрансмита по фрагментам, поэтому одна потеря значит, что получатель держит неполные куски, которые таймаутятся и выбрасываются. Если оставаться под MTU, каждая датаграмма — один пакет: атомарно, без пересборки, без усиленной потери.
Broadcast и multicast: один send, много получателей
UDP может адресовать группы, чего TCP принципиально не может. Broadcast шлёт одну датаграмму каждому хосту локальной подсети (socket.setBroadcast(true), отправка на broadcast-адрес подсети) — полезно для discovery «есть кто живой?» в LAN. Multicast — дисциплинированная версия: получатели делают addMembership(groupAddr), чтобы вступить в multicast-группу (224.0.0.0/4), и один send() на адрес группы достигает ровно вступивших членов, а сеть дублирует пакет лишь там, где пути расходятся. setMulticastTTL ограничивает число хопов. Так работают service discovery (mDNS/Bonjour), gossip некоторых кластерных кэшей и веерная раздача в стиле IPTV — один send, N получателей, без соединения на каждого.
Ты выбираешь транспорт для высокочастотных метрик приложения на горячем пути запроса.
Ты шлёшь одну датаграмму 4 КБ через публичный интернет, и она часто не доходит целой, тогда как 1 КБ в основном доходят. Почему?
В dgram когда socket.send(buf, port, host, cb) вызывает cb?
- 01Почему UDP-датаграмма больше path MTU теряется намного чаще маленькой?
- 02Когда UDP — правильный выбор вместо TCP и что ты теряешь?
UDP, доступный через Node-овский dgram, — бессоединительная противоположность надёжному стриму net. Ты создаёшь сокет, bind-ишь порт и принимаешь целые датаграммы как события 'message' с rinfo, описывающим отправителя — без рукопожатия, без сокета на клиента, без порядка и без гарантии доставки. Проблема фрейминга инвертируется: где TCP стирает границы сообщений, UDP их сохраняет, поэтому один send() — ровно одно 'message', а колбэк send() срабатывает на передаче ОС, а не на доставке, потому что подтверждать нечего. Датаграммы ограничены path MTU, и датаграмма больше него IP-фрагментируется на пакеты, где один потерянный фрагмент отбрасывает всё целиком — поэтому держи payload под ~1200 байт или фрагментируй и нумеруй сам. Уникальная сила UDP — групповая адресация: broadcast в подсеть и multicast вступившим членам дают одному send достичь многих получателей, чего TCP не может. Выбирай UDP, когда задержка и отсутствие head-of-line блокировки важнее надёжности — метрики, discovery, игры, голос, DNS — и принимай, что любую нужную надёжность ты строишь поверх. Теперь, когда увидишь метрики или service-discovery по TCP, сразу знаешь вопрос: нужна ли этому трафику гарантированная доставка, или он просто платит за неё по умолчанию?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.