UDP и QUIC: когда без TCP лучше и что изменил QUIC
В предыдущей статье TCP выглядел готовым ответом: надёжность, порядок, управление потоком и перегрузкой приложены к сокету бесплатно. Проблема в том, что «бесплатно» здесь значит «без выбора». Вы не скажете TCP: «этот кадр видеозвонка устарел, не ретранслируй его». Не скажете: «отдай что пришло, дырку переживу». Не сохраните соединение при переходе с Wi-Fi на LTE. И не обновите TCP: он живёт в ядрах миллиардов устройств и в памяти миллионов middlebox’ов, уверенных, что знают, как выглядит правильный сегмент.
Статья — про две реакции на эти ограничения. Старая: не пользоваться транспортом вообще. UDP (RFC 768, 1980, три страницы) — это IP плюс два номера порта и контрольная сумма. Новая: построить свой транспорт поверх UDP в user space. Так появился QUIC (RFC 9000, 2021) — не «TCP на UDP», а TCP + TLS + часть HTTP/2, переосмысленные и слитые в один протокол, который выкатывается релизом браузера, а не сменой поколения роутеров.
Аналогия на всю статью: TCP — заказное письмо с уведомлением, вы платите за трек-номер, повторную доставку и строгую очерёдность. UDP — открытка, брошенная в ящик: дёшево, быстро, без обещаний. QUIC — курьерская служба, которую вы наняли и настроили сами: возит открытки, но по вашим правилам нумерации, срокам и шифрованию, и правила вы меняете, когда хотите.
Часть I. UDP: транспорт, который почти ничего не делает
Восемь байт и полторы гарантии
Весь UDP — четыре 16-битных поля. Что он добавляет к IP:
| Что даёт | Как | Цена |
|---|---|---|
| Демультиплексирование | номера портов отправителя и получателя | 4 байта |
| Обнаружение порчи (слабое) | 16-битная сумма по данным и псевдозаголовку IP | 2 байта; в IPv4 отключается значением 0 |
| Границы сообщений | одна датаграмма = один recvfrom() |
размер ограничен путём |
Всё. Ни номеров последовательности, ни подтверждений, ни таймеров, ни состояния соединения в ядре, ни рукопожатия, ни управления потоком и перегрузкой. UDP-сокет — именованная точка входа в IP: «доставь как получится, получателю сообщи, кому отдавать».
Три следствия, которые надо принять сразу. Датаграмма атомарна: приходит целиком или не приходит; фрагментированный на IP-уровне пакет собирает ядро, и потеря одного фрагмента убивает всю датаграмму. Порядок не гарантирован: при ECMP-балансировке по разным путям перестановки происходят регулярно, а не теоретически. Дубликаты возможны — их создаёт не UDP, а сеть: петля маршрутизации, дублирование на туннеле, ретрансмиссия на канальном уровне.
Про контрольную сумму: в IPv4 она необязательна (0 = «не считал»), в IPv6 обязательна (RFC 8200), потому что в IPv6-заголовке нет собственной суммы; исключение — туннели (RFC 6935). Даже включённая сумма ловит не всё: 16 бит — одна пропущенная ошибка на 65 тысяч испорченных пакетов. Поэтому серьёзные протоколы поверх UDP считают собственную криптографическую целостность (QUIC, WireGuard, DTLS).
Демультиплексирование и connect() на UDP-сокете
TCP-соединение определяется 4-кортежем. UDP-сокет по умолчанию определяется 2-кортежем (локальный адрес, локальный порт) и принимает датаграммы от кого угодно — отсюда обязанность приложения смотреть на адрес отправителя, который возвращает recvfrom(), и не доверять ему.
Но есть важнейший приём — connect() на UDP-сокете. Он не отправляет ни одного пакета, а лишь фиксирует в ядре удалённый 2-кортеж:
$ ss -u -a -n
State Recv-Q Send-Q Local Address:Port Peer Address:Port
UNCONN 0 0 0.0.0.0:68 0.0.0.0:* # DHCP-клиент, слушает всех
UNCONN 0 0 127.0.0.53:53 0.0.0.0:* # systemd-resolved
ESTAB 0 0 192.168.1.10:51423 1.1.1.1:53 # «подключённый» сокет резолвера
- Сокет принимает датаграммы только от этого пира — ядро фильтрует за вас.
- Можно
send()вместоsendto(): маршрут ищется один раз и кэшируется, на высоких пакетных скоростях экономия заметна. - Начинают доставляться ICMP-ошибки. Это главное: без
connect()ядру некому отдать ICMP «port unreachable», и ошибка молча теряется.
Что происходит, когда никто не слушает
$ sudo tcpdump -n -i lo -c 2 'udp port 9999 or icmp'
14:22:07.551204 IP 127.0.0.1.42311 > 127.0.0.1.9999: UDP, length 12
14:22:07.551239 IP 127.0.0.1 > 127.0.0.1: ICMP 127.0.0.1 udp port 9999 unreachable, length 48
ICMP type 3 code 3. Приложение увидит это как ECONNREFUSED, но только на connected-сокете и только на следующей операции:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("127.0.0.1", 9999)) # ни одного байта в сети не появилось
s.send(b"hello") # успешно: датаграмма ушла
s.settimeout(1.0)
try:
s.recv(1024) # здесь прилетит ICMP-ошибка от предыдущего send
except ConnectionRefusedError:
print("порт закрыт")
Асинхронность ошибок — фундаментальное свойство UDP: об ошибке предыдущей отправки вы узнаёте во время следующей операции, а часто не узнаёте вовсе, потому что ICMP массово фильтруется на фаерволах (см. NAT, фаерволы и VPN). Никогда не стройте логику только на ICMP — нужен собственный таймаут.
Размер датаграммы: 65507, 1472, 1232 и 512
Теоретический максимум полезной нагрузки в IPv4 — 65535 − 20 (IP) − 8 (UDP) = 65507 байт. Практический куда меньше:
потеря = потеря одного пакета"] B -- "да, DF не стоит" --> D["IP-фрагментация"] B -- "да, DF стоит" --> E["ICMP Fragmentation Needed
или чёрная дыра"] D --> F["Потеря ЛЮБОГО фрагмента =
потеря всей датаграммы"] D --> H["Фаерволы и NAT часто роняют
не-первые фрагменты: в них нет портов"] E --> I["Классический блэкхол:
мелкие пакеты идут, крупные висят"]
Числа, которые стоит помнить: 1472 — максимум UDP-данных в IPv4 при MTU 1500; 1452 — то же для IPv6; 1200 — размер, который QUIC требует поддерживать на любом пути; 1232 — рекомендованный максимум ответа DNS по UDP (1280 − 40 − 8), закреплённый DNS Flag Day 2020; 512 — исторический предел ответа DNS без EDNS0 (RFC 1035).
Правило простое: фрагментация IP — не оптимизация, а поломка. Датаграмма 4 КБ идёт тремя фрагментами, и при потере пакета 1% вероятность потери датаграммы почти 3%. Проверить MTU пути:
$ ping -M do -s 1472 -c 1 1.1.1.1 # -M do = запретить фрагментацию
1480 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=8.34 ms
$ ping -M do -s 1473 -c 1 1.1.1.1
ping: local error: message too long, mtu=1500
Через туннель MTU меньше: типично 1420 для WireGuard, 1450 для VXLAN. Если приложение шлёт по 1472 байта, а трафик заворачивается в туннель — вы платите фрагментацией за каждый пакет.
Что вы обязаны написать сами
Выбирая UDP, вы не «избавляетесь от накладных расходов» — вы берёте на себя обязательства, которые TCP выполнял за вас:
Всё в этой карте TCP реализует давно, тщательно и на миллиардах устройств. Ваша реализация будет проще и почти наверняка хуже — особенно управление перегрузкой, которое легко написать «работает у меня» и очень трудно написать «не убивает сеть под нагрузкой». Отсюда правило:
Если вам нужна надёжная доставка поверх UDP — вам нужен не UDP, а QUIC. Библиотеки написаны, RFC отлажены.
UDP оправдан ровно тогда, когда какая-то из гарантий TCP вам вредна, а не просто не нужна.
Когда UDP действительно лучше
повторной доставки старых?"} Q1 -- "да" --> UDP1["UDP: голос, видео, позиции в игре,
телеметрия, метрики"] Q1 -- "нет" --> Q2{"Один короткий запрос
и один короткий ответ?"} Q2 -- "да" --> UDP2["UDP: DNS, NTP, RADIUS,
обнаружение сервисов"] Q2 -- "нет" --> Q3{"Многопоточность, мобильность,
потери на пути?"} Q3 -- "да" --> QUIC["QUIC: веб, API, мобильные клиенты"] Q3 -- "нет" --> Q4{"Нужен broadcast
или multicast?"} Q4 -- "да" --> UDP3["UDP: только он это умеет —
mDNS, SSDP, биржевые фиды"] Q4 -- "нет" --> TCP["TCP: скучно, надёжно,
с разгрузкой в железе"]
- DNS. Пакет туда, пакет обратно; рукопожатие TCP удвоило бы задержку каждого резолва. Отсюда предел 512 байт и переход на TCP при усечении ответа (флаг
TC). - Игры реального времени. Позиция игрока, отправленная 200 мс назад, бесполезна: пришла новая. Ретрансмиссия старого состояния только съест канал. Типичный подход — слать полное состояние 20–60 раз в секунду и не переспрашивать вовсе.
- Голос и видео (RTP/RTCP, RFC 3550). Потерянный аудиокадр маскируется интерполяцией за микросекунды; ожидание ретрансмиссии в 100 мс слышно как заикание. Это база WebRTC — см. реальное время.
- Метрики и логи (statsd, syslog по UDP). Потеря части сэмплов приемлема, блокировка приложения на медленном сборщике — нет: UDP никогда не заблокирует отправителя.
- Туннели и оверлеи: WireGuard, VXLAN, GENEVE, GTP-U, IPsec/NAT-T. Здесь UDP проходит через NAT и не создаёт «TCP поверх TCP» — патологию, при которой два механизма ретрансмиссии борются друг с другом и латентность взрывается.
- Синхронизация времени (NTP, PTP): важна не надёжность, а минимальная и симметричная задержка; очередь ретрансмиссий испортила бы измерение.
UDP в проде: где именно теряются пакеты
Неочевидный факт: на нормальном сервере большая часть потерь UDP происходит не в сети, а в буфере приёма вашего же сокета. Приложение не успевает читать, буфер переполняется, ядро молча выбрасывает датаграммы. Ни ошибки в логах, ни ICMP — просто пропавшие данные.
$ nstat -az | grep -Ei 'udp(indatagrams|noports|inerrors|rcvbuferrors)'
UdpInDatagrams 482913377 0.0
UdpNoPorts 1204 0.0 # прилетело на порт, который никто не слушает
UdpInErrors 93172 0.0
UdpRcvbufErrors 93172 0.0 # <-- переполнение буфера сокета: не успеваем читать
# Кто именно переполняется: -m показывает память сокета, d = дропы этого сокета
$ ss -u -a -n -m -p
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
UNCONN 212992 0 0.0.0.0:8125 0.0.0.0:* users:(("statsd",pid=2117,fd=12))
skmem:(r212992,rb212992,t0,tb212992,f0,w0,o0,bl0,d93172)
Recv-Q равен rb (лимит буфера) — сокет забит под завязку; d93172 — сколько датаграмм уже выброшено. Лечение по убыванию эффективности:
- Читать быстрее: отдельный поток, который только принимает и складывает в очередь; обработка — дальше по конвейеру.
recvmmsg()/sendmmsg()— пачками, один системный вызов на десятки датаграмм. На 100k+ пакетов/с разница кратная.SO_REUSEPORT— N процессов на одном порту, ядро раскладывает по хэшу 4-кортежа.- Увеличить буфер — последнее средство, оно лишь сдвигает момент переполнения:
sudo sysctl -w net.core.rmem_max=8388608 # потолок для SO_RCVBUF
sudo sysctl -w net.core.rmem_default=1048576 # дефолт для сокетов, которые ничего не просили
sudo sysctl -w net.core.netdev_max_backlog=5000 # очередь между NIC и стеком, сюда упираются при burst
Ещё два места потерь и их счётчики: очередь сетевой карты (ip -s link show eth0, ethtool -S eth0 | grep -i drop) и conntrack, если на пути NAT или фаервол (nf_conntrack: table full, dropping packet в dmesg).
Код: «надёжный слой», который придётся писать всегда
def request(sock, addr, payload, attempts=3, timeout=0.3):
"""Запрос-ответ поверх UDP: свой идентификатор, свой таймаут, свой ретрай."""
req_id = int(time.time() * 1000) & 0xFFFFFFFF
packet = struct.pack("!I", req_id) + payload
for attempt in range(attempts):
sock.sendto(packet, addr)
wait = timeout * (2 ** attempt) # 0.3, 0.6, 1.2 с
deadline = time.monotonic() + wait
sock.settimeout(wait)
while time.monotonic() < deadline:
try:
data, src = sock.recvfrom(65535) # буфер меньше датаграммы = молча отрезанный хвост
except socket.timeout:
break
if src != addr: # чужой отправитель — игнорируем
continue
if struct.unpack("!I", data[:4])[0] != req_id: # ответ на ПРОШЛУЮ попытку
continue # ...ради этого и нужен req_id
return data[4:]
raise TimeoutError("нет ответа после %d попыток" % attempts)
Две строчки здесь встречаются в багрепортах чаще всего: проверка отправителя и проверка идентификатора запроса. Без второй запоздавший ответ на первую попытку будет принят за ответ на вторую — тихая рассинхронизация, которую невозможно воспроизвести. Сервер обязан быть идемпотентным: дубликаты неизбежны (см. семантику доставки).
Тёмная сторона: усиление и отражение
UDP не имеет рукопожатия, а значит, не проверяет адрес отправителя. Подделав исходный IP, атакующий заставляет сервер отвечать жертве. Если ответ больше запроса — получается усиление:
| Протокол | Усиление | Механизм |
|---|---|---|
| memcached (UDP, 11211) | до 50 000× | stats items возвращает мегабайты |
NTP monlist |
~550× | список последних 600 клиентов |
| DNS ANY / DNSSEC | 30–60× | большой ответ на 60-байтный запрос |
| CLDAP, SSDP | 30–50× | ответ на короткий запрос |
Так были получены рекордные DDoS: 1.35 Тбит/с на GitHub в 2018 году — memcached-усиление. Выводы для любого UDP-сервиса: не отвечайте больше, чем получили, пока адрес не подтверждён (ровно это QUIC зафиксировал как правило трёх крат); ограничивайте скорость ответов по адресу источника (в DNS это RRL); не выставляйте UDP-сервисы наружу без нужды; включайте BCP38 (RFC 2827) на границах — не выпускать пакеты с чужим исходным адресом. Подробнее — в треке по безопасности.
Отдельно про темп. Внесём потери и посмотрим, что будет:
$ sudo tc qdisc add dev eth0 root netem loss 3% delay 80ms 20ms
$ iperf3 -c 10.0.0.5 -u -b 50M -t 10
[ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams
[ 5] 0.00-10.00 sec 59.6 MBytes 50.0 Mbits/sec 0.412 ms 1301/43142 (3%)
$ sudo tc qdisc del dev eth0 root
Битрейт остался ровно 50 Мбит/с: UDP не замедляется от потерь. Это и свобода, и опасность — приложение с фиксированной скоростью при перегрузке продолжает давить, вытесняя честный TCP-трафик. Отсюда прямое требование RFC 8085: любое приложение на UDP, посылающее больше пары пакетов, обязано реализовать управление перегрузкой.
Часть II. QUIC: транспорт, который можно обновлять
Почему TCP нельзя было починить
Проблема не в идеях TCP, а в том, что он окостенел (ossification). Два независимых механизма. Первый: TCP живёт в ядре, новая опция требует апдейта ОС на обоих концах, средний срок проникновения фичи — 10–15 лет (TCP Fast Open опубликован в 2014-м и до сих пор часто выключен). Второй: middlebox’ы читают и правят заголовки — NAT, фаерволы, «оптимизаторы», DPI в сотовых сетях. Они вырезают неизвестные опции, переписывают номера последовательности, рвут соединения с непонятными флагами; Multipath TCP не проходил примерно через треть путей в интернете.
Урок SCTP и DCCP решающий: новый протокол поверх IP развернуть невозможно, потому что для NAT и фаерволов существуют только TCP, UDP и ICMP. Поэтому QUIC спрятался внутрь UDP. А чтобы middlebox’ы не начали «оптимизировать» и его, QUIC шифрует почти весь свой заголовок — это не только приватность, но и защита от окостенения на будущее.
Что такое QUIC в одном абзаце
QUIC — надёжный, мультиплексированный, всегда зашифрованный транспорт поверх UDP в user space. Он забирает себе: установку соединения (как TCP), шифрование и аутентификацию (TLS 1.3 — встроенный, а не «поверх»), надёжную доставку и управление перегрузкой (как TCP, но точнее), мультиплексирование независимых потоков (как HTTP/2, но без head-of-line blocking) и идентификатор соединения, не привязанный к IP-адресу (чего нет нигде). Опорные документы: RFC 8999 — инварианты, единственное, что обещано всем версиям навсегда; RFC 9000 — транспорт; RFC 9001 — встроенный TLS 1.3; RFC 9002 — потери и перегрузка.
Анатомия пакета: что видно в проводе
Иерархия, которую часто путают: датаграмма UDP может содержать несколько QUIC-пакетов (coalescing — так Initial и Handshake едут вместе); QUIC-пакет имеет номер и содержит фреймы; фрейм — единица смысла: STREAM, ACK, CRYPTO (данные TLS), MAX_DATA, PING, CONNECTION_CLOSE, NEW_CONNECTION_ID, PATH_CHALLENGE и ещё около двадцати типов.
Главное архитектурное различие с TCP: номер пакета и позиция данных в потоке — разные вещи. В TCP номер последовательности означает одновременно «какой пакет» и «какое место в потоке», отсюда неоднозначность ретрансмиссии: получив ACK, вы не знаете, на оригинал он или на копию (отсюда алгоритм Карна и загрубление оценки RTT). В QUIC номер пакета строго растёт и никогда не переиспользуется: потерянные данные едут заново в новом пакете с новым номером. Каждое подтверждение однозначно, каждый замер RTT точен.
Рукопожатие: сколько стоит первый байт
Экономия в один RTT кажется мелочью, пока не посчитать: при RTT 150 мс (мобильная сеть, межконтинентальный путь) это 150 мс на каждом новом соединении, то есть половина воспринимаемой задержки загрузки страницы с ресурсами на нескольких доменах.
Про 0-RTT важен риск, а не только выгода. Данные шифруются ключом из билета прошлой сессии и отправляются до того, как сервер подтвердил свою свежесть — значит, атакующий может записать пакет и воспроизвести позже, сервер не отличит. Отсюда правило: в 0-RTT только идемпотентные запросы. HTTP/3 даже завёл код 425 Too Early, которым сервер просит повторить запрос уже в защищённом режиме (RFC 9001, раздел 9.2).
Потоки: устранение head-of-line blocking
HTTP/2 научился мультиплексировать запросы в одно TCP-соединение — и уткнулся в то, что TCP про эти потоки ничего не знает. Одна потеря останавливает выдачу всем потокам, потому что ядро обязано соблюсти порядок байтов. На хорошей сети выигрыш от мультиплексирования перевешивает; на сети с 2% потерь HTTP/2 умеет проигрывать шести параллельным соединениям HTTP/1.1.
QUIC переносит понятие потока в транспорт. Идентификатор потока — переменной длины, младшие два бита кодируют вид:
| Биты | Тип потока | Инициатор | Пример |
|---|---|---|---|
0x00 |
двунаправленный | клиент | запрос-ответ HTTP/3 |
0x01 |
двунаправленный | сервер | зарезервировано, используется редко |
0x02 |
однонаправленный | клиент | управляющий поток, QPACK-энкодер |
0x03 |
однонаправленный | сервер | server push, QPACK-декодер |
Потоков могут быть тысячи, создание потока ничего не стоит (нет рукопожатия — просто первый STREAM-фрейм с новым ID), а порядок гарантируется внутри потока, а не между потоками. Честная оговорка: HOL-блокировка не исчезает полностью — QPACK (RFC 9204) со сжатием заголовков через динамическую таблицу вносит зависимость между потоками, и в нём есть режимы, позволяющие торговать степенью сжатия на независимость. Детали — в статье про HTTP.
Жизненный цикл потока
Возможность отменить один поток недооценена. Пользователь ушёл со страницы, а картинка на 2 МБ ещё качается: HTTP/3 шлёт RESET_STREAM, сервер прекращает передачу, канал освобождается для нужного.
Надёжность: чем QUIC точнее TCP
RFC 9002 описывает механизмы, которые в TCP появлялись двадцать лет отдельными заплатками, — здесь они встроены с первого дня:
| Механизм | В TCP | В QUIC |
|---|---|---|
| Селективное подтверждение | опция SACK, договаривается в SYN, до 3 блоков | ACK-фрейм всегда несёт диапазоны, их до 2^62 |
| Неоднозначность ретрансмиссии | есть; лечится алгоритмом Карна ценой точности | нет: номера пакетов не переиспользуются |
| Задержка ACK | неявная, приёмник её не сообщает | поле ACK Delay в микросекундах — RTT считается точно |
| Обнаружение потери | 3 дубликата ACK + RACK-TLP (RFC 8985) | порог по номеру пакета (3) и по времени (9/8 RTT) сразу |
| Таймаут | RTO, минимум обычно 200 мс | PTO с зондами, без резкого обнуления окна |
| ECN | опционально, часто ломается на пути | счётчики ECT0/ECT1/CE прямо в ACK-фрейме |
| Пространства номеров | одно | три: Initial, Handshake, Application |
Управление перегрузкой при этом то же самое: RFC 9002 описывает NewReno как эталон, реальные реализации берут CUBIC или BBR — те же алгоритмы, что в TCP, и канал QUIC делит с ним честно. Принципиальная разница в том, что алгоритм теперь в вашем процессе: смена CUBIC на BBR — флаг в конфиге библиотеки, а не sysctl и тем более не апдейт ядра у клиента.
Connection ID и миграция соединения
TCP-соединение — это 4-кортеж. Сменился IP клиента (Wi-Fi → LTE, смена NAT-порта, засыпание телефона) — соединение мертво: рукопожатие, TLS, slow start заново. QUIC идентифицирует соединение Connection ID, к адресу отношения не имеющим: пакет с знакомым CID с нового адреса — то же соединение.
адрес меняется на 10.44.7.9 C->>S: пакет с нового адреса, но с ЗАПАСНЫМ CID Note over S: узнал соединение по CID,
адрес пока не подтверждён S->>C: PATH_CHALLENGE (случайные 8 байт) C->>S: PATH_RESPONSE (те же 8 байт) Note over S: путь подтверждён, лимит усиления снят,
окно перегрузки сброшено — путь новый S->>C: данные идут дальше, видео не прервалось C->>S: RETIRE_CONNECTION_ID для старого CID
Две детали, которые легко упустить. Зачем менять CID при миграции: оставь клиент старый, наблюдатель на пути связал бы «домашний» и «мобильный» адреса одного пользователя — поэтому сервер заранее раздаёт пул CID, а клиент берёт свежий; это встроенная защита от корреляции. Окно перегрузки сбрасывается: новый путь — новые характеристики, переносить cwnd было бы опасно; миграция экономит рукопожатие и TLS, но не разгон.
Есть ещё stateless reset: потеряв состояние (перезапуск, попадание на другой инстанс), сервер не может послать CONNECTION_CLOSE — у него нет ключей. Вместо этого он шлёт пакет, оканчивающийся заранее выданным токеном сброса; клиент узнаёт токен и честно закрывает соединение вместо ожидания таймаута. Аналог RST, но не подделываемый.
Защита от злоупотреблений и управление потоком
QUIC-сервер отвечает на первый же пакет сертификатом — готовый усилитель, если не поставить ограничитель. RFC 9000, раздел 8 вводит два правила. Правило трёх крат: сервер не отправляет более трёх объёмов полученного, пока адрес не подтверждён; поэтому датаграмма с Initial обязана быть дополнена паддингом до 1200 байт, иначе сертификат не уложится в лимит и рукопожатие затянется на лишние круги. Retry: при подозрении на атаку сервер отвечает пакетом Retry с криптографическим токеном, не создавая состояния; клиент повторяет Initial с токеном — адрес подтверждён. Прямой аналог SYN cookies, но встроенный в протокол, а не пришитый сбоку.
Управление потоком в QUIC двухуровневое, и это необходимо: без лимита на поток один жадный поток съел бы весь буфер соединения и заблокировал остальные (ровно та проблема, которую HTTP/2 решал своим flow control поверх TCP-окна, получив два независимых механизма, мешающих друг другу). MAX_STREAM_DATA — сколько байт можно отправить в конкретный поток, MAX_DATA — сколько суммарно по соединению, MAX_STREAMS — сколько потоков разрешено открыть. Симметрично есть DATA_BLOCKED и STREAM_DATA_BLOCKED: отправитель явно сообщает «я упёрся в ваш лимит». Диагностически это золото — в TCP аналогичная ситуация (zero window) видна только по дампу.
Чем платим за QUIC
- Процессор. Каждый пакет расшифровывается отдельно, сборка потоков идёт в user space, системных вызовов больше. Первые продакшн-замеры Google давали примерно двукратный расход CPU на байт против TLS/TCP; сегодня разрыв меньше благодаря UDP GSO/GRO и
sendmmsg, но он есть (SIGCOMM 2017). - Нет разгрузки в железе. У TCP есть TSO/LRO/checksum offload, а часто и kTLS; для QUIC аппаратная поддержка только появляется.
- UDP блокируют и тротлят. В части корпоративных и мобильных сетей UDP/443 закрыт или ограничен по скорости, поэтому любой QUIC-клиент обязан падать обратно на TCP — браузеры гоняют попытки параллельно.
- Отладка сложнее:
tcpdumpпоказывает только «UDP, length 1252»; нужны ключи сессии илиqlog. - Балансировка сложнее: L4-балансировщик по 4-кортежу ломает миграцию.
- Меньше видимости для инфраструктуры: IDS и корпоративные прокси не видят внутрь. Для кого-то фича, для кого-то блокирующее требование комплаенса.
Практика: включить, проверить, отладить
Нужен curl, собранный с ngtcp2/quiche (curl --version должен показывать HTTP3 в Features). Живой запрос и что в нём видно:
$ curl -sv --http3-only -o /dev/null https://cloudflare-quic.com/ 2>&1 | grep -E '^[*<>]' | head -14
* Trying 104.18.6.161:443...
* QUIC cipher selection: TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
* Connected to cloudflare-quic.com (104.18.6.161) port 443
* using HTTP/3
* [HTTP/3] [0] OPENED stream for https://cloudflare-quic.com/
> GET / HTTP/3
< HTTP/3 200
< alt-svc: h3=":443"; ma=86400
Сравнение версий одной командой — самый убедительный аргумент в спорах:
$ for v in --http1.1 --http2 --http3-only; do
printf "%-14s " "$v"
curl -s -o /dev/null $v -w 'connect=%{time_connect} appconnect=%{time_appconnect} ttfb=%{time_starttransfer}\n' \
https://cloudflare-quic.com/
done
--http1.1 connect=0.031 appconnect=0.074 ttfb=0.108
--http2 connect=0.031 appconnect=0.073 ttfb=0.106
--http3-only connect=0.000 appconnect=0.038 ttfb=0.071
# ^^^^^^^^^^^^^ у QUIC нет отдельной фазы TCP-соединения:
# транспорт и криптография — одно рукопожатие
Как клиент узнаёт, что сервер умеет HTTP/3? Заголовком Alt-Svc: h3=":443"; ma=86400 в ответе по HTTP/1.1 или HTTP/2 (тогда следующее соединение пойдёт по QUIC) либо заранее — из DNS-записи HTTPS (RFC 9460), что убирает лишний круг:
$ dig +short HTTPS cloudflare.com
1 . alpn="h3,h2" ipv4hint=104.16.132.229,104.16.133.229
# ^^^^^^^^^^ сервер заявляет h3 ещё до первого соединения
Что видно в tcpdump — и почему этого мало:
$ sudo tcpdump -n -i any -c 6 'udp port 443'
15:41:02.118374 IP 192.168.1.10.49221 > 104.18.6.161.443: UDP, length 1252
15:41:02.118512 IP 192.168.1.10.49221 > 104.18.6.161.443: UDP, length 1252
15:41:02.147201 IP 104.18.6.161.443 > 192.168.1.10.49221: UDP, length 1252
15:41:02.147203 IP 104.18.6.161.443 > 192.168.1.10.49221: UDP, length 1252
15:41:02.147204 IP 104.18.6.161.443 > 192.168.1.10.49221: UDP, length 706
Первые две датаграммы ровно по 1252 байта — это Initial с паддингом; три ответных подряд — сертификат, упирающийся в лимит трёхкратного усиления. Больше tcpdump не скажет ничего: ни номеров пакетов, ни ACK, ни потерь. ss тоже бесполезен — покажет один UNCONN-сокет без состояния, потому что состояния соединения в ядре просто нет, оно в памяти процесса.
Что видно в Wireshark
Без ключей фильтр quic показывает версию, Connection ID, тип пакета — и, что удивляет новичков, полностью разобранный пакет Initial, включая ClientHello с SNI и ALPN. Это не дыра: ключи Initial выводятся из DCID по опубликованной в RFC 9001, раздел 5.2 соли, чтобы промежуточные узлы могли отвечать корректными сообщениями об ошибке. Всё после Initial — шифротекст. С ключами — классический приём:
export SSLKEYLOGFILE=/tmp/quic-keys.log # curl, Chrome и Firefox пишут секреты сессии сюда
curl --http3-only https://cloudflare-quic.com/ -o /dev/null
# Wireshark: Preferences -> Protocols -> TLS -> (Pre)-Master-Secret log filename
После этого видны фреймы, и работают полезные фильтры:
quic.long.packet_type == 0 # только Initial
quic.frame_type == 0x02 # ACK-фреймы
quic.stream.stream_id == 0 # первый запрос HTTP/3
quic.connection.id # проследить миграцию: тот же CID, другой IP
http3 # разобранный HTTP/3 поверх расшифрованного QUIC
Потеря пакета в расшифрованном дампе выглядит как пропуск номера пакета, за которым следует ACK-фрейм с диапазоном без этой дырки; данные приезжают заново в пакете с новым номером. Сравните с TCP, где ретрансмиссия несёт тот же seq и Wireshark рисует [TCP Retransmission].
Есть и то, чего у TCP не было никогда, — qlog: реализации пишут структурированный лог событий транспорта (отправленные пакеты, признанные потерянными, эволюция cwnd и RTT) в JSON, а qvis рисует по нему интерактивные графики. Это отладка изнутри стека, а не догадки по дампу: QLOGDIR=/tmp/qlog ./client https://example.com/ умеют quic-go, quiche и ngtcp2.
Прод: что настроить на сервере
server {
listen 443 ssl; # HTTP/1.1 и HTTP/2 по TCP
listen 443 quic reuseport; # HTTP/3 по UDP; reuseport — ровно на одном server-блоке
http2 on;
http3 on;
ssl_protocols TLSv1.3; # QUIC требует именно TLS 1.3
add_header Alt-Svc 'h3=":443"; ma=86400' always; # иначе браузер не узнает про h3
ssl_early_data off; # 0-RTT включают осознанно: см. риск повтора
}
Обязательный системный тюнинг — про буферы UDP, потому что стек QUIC читает сокет из user space и штрафуется за каждую задержку планировщика:
# Рекомендация авторов quic-go и большинства реализаций: не меньше 7.5 МБ
sudo sysctl -w net.core.rmem_max=7500000
sudo sysctl -w net.core.wmem_max=7500000
# Если UdpRcvbufErrors растёт — ваш QUIC-сервер «видит» потери, которых в сети не было,
# и душит окно перегрузки. Медленно, без единой ошибки в логах.
watch -n1 "nstat -az | grep -E 'UdpRcvbufErrors|UdpInErrors'"
sudo ethtool -K eth0 rx-gro-list on # UDP GRO: ядро отдаёт пачку датаграмм одним recvmsg
Со стороны приложения тот же смысл имеет UDP_SEGMENT (GSO): один sendmsg на 64 КБ, ядро режет на MTU; quic-go, quiche и msquic используют это автоматически, если ядро умеет.
Балансировка. Классический L4-балансировщик хэширует 4-кортеж — и ломает миграцию: после смены адреса пакеты уедут на бэкенд, который про соединение ничего не знает. Правильный способ — кодировать идентификатор сервера внутрь Connection ID (подход QUIC-LB), тогда маршрутизация идёт по CID и миграция работает; то же требуется для anycast. Пока балансировщик не QUIC-aware, миграция у вас на бумаге, и обнаружится это только на мобильных клиентах. Тема продолжается в прокси и балансировке и CDN и edge.
Когда QUIC выигрывает, а когда нет
| Условия | Кто быстрее | Почему |
|---|---|---|
| Мобильная сеть, RTT 100–300 мс, потери 1–5% | QUIC заметно | нет HOL-блокировки, экономия RTT, лучшее восстановление |
| Много мелких объектов в одном соединении | QUIC | потоки независимы, отмена дешёвая |
| Переключение Wi-Fi ↔ LTE, роуминг | QUIC | миграция соединения без пересоздания |
| Короткие API-запросы к новому хосту | QUIC | 1-RTT вместо 2, 0-RTT при повторе |
| Датацентр, RTT меньше 1 мс, потерь нет | TCP | выигрыш от RTT нулевой, offload и kTLS решают |
| Крупные файлы на CPU-bound сервере | TCP | sendfile и TSO против расшифровки каждого пакета |
| Сеть, где UDP тротлится | TCP | QUIC упрётся в policer |
Вывод: QUIC — протокол для интернета с плохими и меняющимися путями, а не универсальный ускоритель. Внутри датацентра gRPC поверх HTTP/2 и TCP остаётся разумным выбором (см. RPC и gRPC). Решает измерение, а не вера; методика — в треке про производительность.
Из QUIC уже выросло больше, чем HTTP/3: DATAGRAM-фреймы (RFC 9221) дают UDP-семантику внутри защищённого соединения — с шифрованием, управлением перегрузкой и обходом NAT «в комплекте»; на них построен WebTransport (см. реальное время); MASQUE проксирует UDP и IP внутри QUIC, и на этом стоят механизмы приватного релея, где прокси не знает содержимого, а сервер не знает адреса клиента.
Типичные ошибки
- «UDP быстрее TCP». UDP быстрее ровно до момента, когда вам понадобится надёжность. Наивные ретрансмиссии поверх UDP обычно медленнее и злее к сети, чем TCP, который полировали сорок лет.
- Отправлять датаграммы больше MTU пути. Фрагментация умножает вероятность потери, а не-первые фрагменты часто отбрасываются фаерволами. Держитесь ниже 1400 байт, на туннелях — ниже 1300.
- Считать
sendto()без ошибки успехом. Ошибка предыдущей отправки прилетит на следующей операции, а безconnect()не прилетит вообще. - Не проверять адрес отправителя и идентификатор запроса. Первое — открытая дверь для подмены, второе — источник невоспроизводимых рассинхронизаций.
- Отвечать больше, чем получили, непроверенному адресу. Так строятся усилители DDoS.
- Диагностировать «сеть теряет пакеты», не посмотрев
UdpRcvbufErrors. В большинстве случаев теряет ваш собственный сокет. - Слать с фиксированной скоростью, игнорируя потери. RFC 8085 требует управления перегрузкой не из вежливости: без него вы вытесняете чужой трафик.
- Включить HTTP/3, не проверив, что балансировщик понимает Connection ID. Миграция сломается на мобильных клиентах, а в датацентре вы этого не увидите никогда.
- Отправлять неидемпотентные запросы в 0-RTT. Повтор записанного пакета выполнит операцию дважды.
- Ждать от
tcpdumpиssкартины QUIC-соединения. Состояния в ядре нет; нужныSSLKEYLOGFILEилиqlog.
Мини-итог
- UDP — это IP плюс порты, контрольная сумма и границы сообщений. Всё остальное вы пишете сами, и это обязательство, а не свобода.
- UDP оправдан, когда гарантии TCP вредны: свежесть важнее полноты (медиа, игры), один запрос-ответ (DNS, NTP), broadcast и multicast, туннели, метрики.
- Главный источник потерь UDP на сервере — переполнение буфера приёма сокета. Смотрите
UdpRcvbufErrorsиss -mдо того, как обвинять сеть. - Отсутствие рукопожатия делает UDP усилителем DDoS. Правило «не отвечай больше, чем получил, непроверенному адресу» универсально.
- QUIC появился не потому, что TCP плох, а потому, что TCP нельзя обновить: он в ядрах и в middlebox’ах. Спрятавшись в UDP и зашифровав заголовок, QUIC вернул транспорту способность эволюционировать.
- QUIC даёт: 1-RTT (и 0-RTT) вместо 2-RTT, потоки без head-of-line blocking, точный RTT без неоднозначности ретрансмиссии, миграцию соединения по Connection ID, обязательное шифрование.
- Платим процессором, отсутствием аппаратной разгрузки, сложностью отладки и балансировки, необходимостью держать fallback на TCP.
- Выигрыш QUIC максимален на длинных путях с потерями и на мобильных клиентах, близок к нулю внутри датацентра.
Источники
- RFC 768 — User Datagram Protocol — три страницы, которые стоит прочитать целиком хотя бы раз.
- RFC 8085 — UDP Usage Guidelines — что обязано делать приложение поверх UDP; лучший документ по теме.
- RFC 8999 — Invariants, RFC 9000 — Transport, RFC 9001 — TLS для QUIC, RFC 9002 — Loss Detection, RFC 9114 — HTTP/3, RFC 9221 — Datagram Extension.
- Langley et al. The QUIC Transport Protocol: Design and Internet-Scale Deployment, SIGCOMM 2017 — как это работало у Google на реальном трафике, с числами по CPU и задержкам.
- Robin Marx. HTTP/3 Core Concepts — лучшее объяснение HOL-блокировки на пальцах, и его же qvis для разбора qlog.
- Daniel Stenberg. HTTP/3 explained — бесплатная книга от автора curl.
- Cloudflare Blog: QUIC и HTTP/3 — практические отчёты о развёртывании, включая UDP GSO и балансировку.
Что дальше
Мы разобрали два транспорта без гарантий: голый UDP, где всё на вас, и QUIC, где поверх UDP построен полноценный шифрованный транспорт с потоками и миграцией. Оба начинаются с шага, который мы всё это время принимали как данность: где-то надо взять IP-адрес по имени. И именно этот шаг — самый частый источник загадочных «у нас всё работает, а у клиента нет».
DNS: иерархия, типы записей, резолвинг, кэширование, DoH/DoT — про распределённую базу, которая держится на UDP и кэшах, про то, что на самом деле означает TTL, и про то, как записи HTTPS из этой статьи позволяют браузеру узнать про HTTP/3 ещё до первого пакета.