Тема 09

Сети

Секция, где backend-разработчика проверяют не на знание RFC, а на способность объяснить, что физически происходит между двумя процессами. Здесь почти нет «выучил — ответил»: почти каждый вопрос раскручивается вглубь на два-три шага, пока не упрётся в байты на проводе.

Что реально проверяют этой секцией

Есть ли у тебя сквозная картинка: от Enter в адресной строке до пикселей на экране, без разрывов. Кандидат, который знает «TCP надёжный, UDP нет», и кандидат, который объясняет, почему надёжность TCP порождает head-of-line blocking (застрял первый пакет в очереди — ждут и все, кто за ним), почему из-за этого придумали QUIC и почему TIME_WAIT на балансировщике съедает порты — это два разных уровня. Сети — ещё и лучший полигон для «а что если?»: обрыв, потеря, дубликат, задержка.

9.1Базовый уровень

Уровни, адреса, имена. Всё, что происходит до того, как твой http.Client отправит первый байт. Сюда же попал главный вопрос секции — «что происходит после Enter».

Зачем вообще уровни

Идея слоёной модели одна: каждый уровень решает свою задачу и не знает, что внутри у соседа. Ethernet понятия не имеет об IP-адресах, он доставляет кадр соседу по проводу. IP не знает про соединения и доставляет отдельный пакет «куда-то туда, по возможности». TCP даёт поток байт, а про HTTP не знает. Самому HTTP неважно, что под ним: TCP или QUIC.

OSI, семиуровневая референсная модель ISO, живёт в учебниках и в разговорах («L7-балансировщик», «L3-связность»). В интернете реально работает четырёхуровневая TCP/IP (она же DoD-модель). На собесе называют обе и показывают соответствие: уровни OSI 5–6–7 в реальном стеке слиты в «прикладной».

Слово «инкапсуляция» будет дальше на каждой странице, договоримся о нём сразу. Инкапсуляция похожа на упаковку в конверт. Уровень берёт всё, что пришло сверху, целиком считает это полезной нагрузкой (payload — данные, внутрь которых он не заглядывает), приписывает спереди свой заголовок (служебные поля: адреса, флаги, длины) и передаёт получившееся вниз. HTTP-запрос оказывается внутри TCP-сегмента, сегмент внутри IP-пакета, а пакет внутри Ethernet-кадра. Матрёшка. На приёме всё разбирается в обратном порядке, это декапсуляция: каждый уровень снимает свой заголовок и отдаёт остаток наверх.

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

OSI Стек TCP/IP Примеры протоколов Единица данных L7 Прикладной L6 Представления L5 Сеансовый L4 Транспортный L3 Сетевой L2 Канальный L1 Физический Прикладной Транспортный Межсетевой Канальный HTTP, DNS, gRPC, SMTP, SSH, AMQP данные TLS, сжатие, кодировки, сериализация данные сессии, RPC-сессии, NetBIOS данные TCP, UDP, QUIC (внутри UDP), SCTP сегмент / датаграмма IP, ICMP, IPsec; ARP на стыке L2/L3 пакет Ethernet, Wi-Fi, PPP, MAC-адреса кадр (frame) медь, оптика, радио, разъёмы, вольты биты Инкапсуляция: сверху вниз каждый уровень дописывает свой заголовок L7 данные GET / HTTP/1.1 Host: ... L4 сегмент TCP 20 Б данные L3 пакет IP 20 Б TCP данные L2 кадр Eth 14 Б IP TCP данные FCS Обратный путь на приёме — декапсуляция: каждый уровень снимает свой заголовок и отдаёт остаток наверх. Полезная нагрузка на Ethernet — 1500 байт (MTU), из них 40 съедают IP+TCP: остаётся 1460 (MSS).
Стек и инкапсуляция. Каждый уровень видит содержимое верхнего как непрозрачную полезную нагрузку. Поэтому L4-балансировщик не может посмотреть на URL, а L7 может.
Формулировка, которая сразу читается как «понимает»

«L2 доставляет кадр внутри одного сегмента сети по MAC-адресу. L3 доставляет пакет между сетями по IP-адресу, без гарантий. L4 добавляет понятие процесса (порт) и, в случае TCP, надёжность и порядок. На L7 появляется смысл: методы, ресурсы, заголовки». Дальше можно прицепить практику: L4-балансировщик (LVS, AWS NLB) видит только пятёрку и молотит пакеты, L7-балансировщик (nginx, Envoy, ALB) терминирует TCP+TLS и распределяет по URL, заголовкам, куки, но стоит дороже.

IP-адреса, маски и подсети

IPv4-адрес занимает 32 бита и записывается четырьмя октетами. Логически он делится на сетевую часть и хостовую, границу задаёт маска. Запись CIDR 10.0.5.17/24 означает: под сеть отведены первые 24 бита, под хост оставшиеся 8. Маска /24 = 255.255.255.0.

10.0.5.17/24
  адрес сети      10.0.5.0        все хостовые биты в 0
  broadcast       10.0.5.255      все хостовые биты в 1
  хостов          2^8 - 2 = 254   минус адрес сети и broadcast
  диапазон        10.0.5.1 - 10.0.5.254

Маска нужна вот для чего: хост смотрит на адрес назначения и по маске решает, сосед ли это по канальному сегменту или пакет надо отдать шлюзу. Если dst & mask == my & mask, ищем MAC соседа через ARP и шлём напрямую. Иначе шлём на MAC шлюза по умолчанию (default gateway), а IP-адрес назначения при этом не меняется.

ДиапазонЧто этоГде встречается
10.0.0.0/8приватная сеть (RFC 1918)корпоративные сети, VPC, поды Kubernetes
172.16.0.0/12приватная (RFC 1918)сеть Docker по умолчанию — 172.17.0.0/16
192.168.0.0/16приватная (RFC 1918)домашние роутеры
127.0.0.0/8loopback127.0.0.1, весь /8 — тоже loopback
169.254.0.0/16link-local (APIPA)DHCP не ответил; 169.254.169.254 — метадата облака
100.64.0.0/10CGNAT (RFC 6598)операторский NAT, Tailscale
224.0.0.0/4multicastOSPF, mDNS, IPTV
0.0.0.0/0маршрут по умолчанию; как bind — «все интерфейсы»ListenAndServe(":8080")
Классическая ловушка в контейнерах

Сервис слушает 127.0.0.1:8080 внутри контейнера, а снаружи ничего не работает, хотя «порт же проброшен». Loopback виден только внутри сетевого namespace. Слушать надо 0.0.0.0:8080 (в Go достаточно ":8080"). Бывает и обратный вариант вопроса: в поде Kubernetes два контейнера, и один ходит к другому по localhost. Вот это работает, потому что сетевой namespace у них общий.

NAT: как приватные адреса выходят в интернет

Приватные адреса не маршрутизируются в интернете. Роутер на границе делает NAPT (он же PAT, он же «NAT overload»): подменяет в исходящем пакете src IP:port на свой публичный адрес и свободный порт, а соответствие записывает в таблицу трансляций. По этой таблице роутер узнаёт ответный пакет и подменяет адрес обратно.

внутри              таблица NAT на роутере            снаружи
192.168.1.5:51514   192.168.1.5:51514 -> 203.0.113.8:40001   203.0.113.8:40001 -> 142.250.74.68:443
192.168.1.9:51514   192.168.1.9:51514 -> 203.0.113.8:40002   203.0.113.8:40002 -> 142.250.74.68:443

Отсюда три следствия, о них и спрашивают. Первое: соединение может начать только внутренняя сторона. Снаружи в таблице записи нет, поэтому входящие блокируются (нужен port forwarding, UPnP или hole punching через STUN/TURN). Второе: записи протухают по таймауту — обычно через 30–120 секунд для UDP и через десятки минут для TCP. Из-за этого рвутся долгие простаивающие соединения, и ровно поэтому включают TCP keep-alive. Наконец, число портов ограничено: за одним публичным адресом больше ~64 тысяч одновременных исходящих соединений на один dst IP:port не спрячешь.

DNS: как имя превращается в адрес

DNS устроен как распределённая иерархическая база. Полное имя читается справа налево: в www.google.com. точка в конце обозначает корень, за ней зона com, потом google.com. Клиент (stub resolver внутри libc или Go) сам иерархию не обходит: он задаёт рекурсивный вопрос ближайшему резолверу (провайдерскому, 8.8.8.8, 1.1.1.1, корпоративному). Серию итеративных запросов делает уже резолвер.

Браузер + ОС Резолвер Корневой (.) TLD (.com) Авторитативный 1 A? www.google.com (RD=1) 2 A? www.google.com 3 NS для com + glue 4 A? www.google.com 5 NS для google.com 6 A? www.google.com 7 A 142.250.74.68, TTL 300, AA=1 8 ответ + TTL, кладём в кэши Кэши на пути — первый, у кого есть живая запись, отвечает сразу, и шагов 2-7 не будет: кэш браузера (десятки секунд) - кэш ОС / systemd-resolved - /etc/hosts - кэш рекурсивного резолвера. TTL задаёт владелец зоны: A обычно 60-300 с, NS - часы. NXDOMAIN тоже кэшируется, по SOA minimum.
Рекурсивный резолвинг. Клиент задаёт один рекурсивный вопрос; всю итеративную работу по иерархии делает резолвер. Флаг AA в ответе означает «отвечает авторитативный сервер зоны».
ТипЧто возвращаетЗачем на практике
AIPv4-адресосновной тип; несколько A-записей = примитивная балансировка round-robin
AAAAIPv6-адресHappy Eyeballs: клиент пробует v6 и v4 параллельно и берёт, что быстрее
CNAMEалиас на другое имяуказать на CDN/балансировщик. Нельзя на вершине зоны (apex) рядом с SOA/NS
MXпочтовый сервер + приоритетмаршрутизация почты
TXTпроизвольный текстSPF, DKIM, DMARC, подтверждение владения доменом (ACME/Let's Encrypt)
SRVимя + порт + приоритет + весservice discovery: Consul, Kubernetes headless-сервисы, SIP
NSавторитативные серверы зоныделегирование зоны
SOAпараметры зонысерийник, TTL отрицательного кэширования
PTRимя по адресуобратная зона in-addr.arpa, антиспам-проверки
Глубже, чем спросят: DNS в Go

У Go два резолвера. Чистый Go-резолвер сам читает /etc/resolv.conf и шлёт UDP; cgo-резолвер зовёт getaddrinfo из libc и умеет NSS, mDNS, sssd. Go выбирает сам, а форсировать можно через GODEBUG=netdns=go / netdns=cgo. Практическое следствие: Go не кэширует DNS сам — кэш есть только у ОС и резолвера. А о смене DNS соединение из пула keep-alive не узнаёт вовсе: адрес спрашивают только при dial, и долгоживущий http.Client может месяцами держать соединение к IP, который давно выведен из ротации. Transport.IdleConnTimeout и CloseIdleConnections() закрывают только простаивающие соединения, а ограничения на возраст занятого у http.Transport нет — его делают обёрткой в DialContext или на сервере.

ARP: от IP-адреса к MAC

IP-пакет нельзя просто положить в провод: туда кладут кадр Ethernet, а у кадра адреса MAC, не IP. Этот разрыв закрывает ARP (Address Resolution Protocol): «у кого IP 10.0.5.1, сообщите свой MAC». Запрос идёт широковещательно на ff:ff:ff:ff:ff:ff, а отвечают юникастом. Результат живёт в ARP-кэше десятки секунд (ip neigh в Linux).

$ ip neigh
10.0.5.1     dev eth0 lladdr 02:42:ac:11:00:01 REACHABLE
10.0.5.23    dev eth0 lladdr 02:42:ac:11:00:17 STALE

$ arping -I eth0 10.0.5.1        # ручной ARP-запрос
$ tcpdump -n -i eth0 arp         # посмотреть широковещательные who-has

Для ответа запомни одно: ARP работает только внутри одного канального сегмента. Если адрес назначения в другой сети, хост никогда не спрашивает про него ARP — он спрашивает MAC шлюза. IP-адреса в пакете при этом не меняются от источника до получателя (если нет NAT), а MAC-адреса переписываются на каждом роутере. В IPv6 роль ARP играет NDP поверх ICMPv6.

Порт против сокета

Порт всего лишь 16-битное число (0–65535) в заголовке TCP/UDP, метка «какому процессу отдать». Сокет принадлежит операционной системе. Это файловый дескриптор, за которым стоят буферы, состояние и пятёрка (5-tuple): {протокол, локальный IP, локальный порт, удалённый IP, удалённый порт}. Уникальна именно пятёрка, а не порт.

Порт - одно число в заголовке. Сокет - пятёрка {протокол, src IP, src port, dst IP, dst port}. Клиент A 203.0.113.7 Клиент B 198.51.100.9 Клиент A, вкладка 2 203.0.113.7 TCP 203.0.113.7:51514 -> 10.0.0.5:443 TCP 198.51.100.9:40122 -> 10.0.0.5:443 TCP 203.0.113.7:51515 -> 10.0.0.5:443 Сервер 10.0.0.5 один listen-сокет :443 три установленных сокета accept() вернул три fd Все три соединения живут на ОДНОМ серверном порту 443 - ядро различает их по пятёрке, а не по порту.
Порт против сокета. Серверу не нужно «по порту на клиента»: порт один, сокетов столько, сколько соединений. Лимит упирается не в порты, а в файловые дескрипторы и память ядра.
Практика: где реально кончаются ресурсы
  • У сервера кончаются не порты, а file descriptors (ulimit -n), память под сокеты и очередь accept (somaxconn, backlog).
  • У клиента кончаются эфемерные порты: в Linux по умолчанию net.ipv4.ip_local_port_range = 32768 60999, это ~28 тысяч. Но лимит считается на пару (dst IP, dst port), поэтому к одному бэкенду выйдет 28 тысяч, а к десяти разным 280 тысяч.
  • Порты 0–1023 привилегированные: для них нужен root или CAP_NET_BIND_SERVICE.

Маршрутизация: как пакет находит дорогу

У каждого хоста есть таблица маршрутов. Маршрут выбирают по правилу longest prefix match: из всех подходящих побеждает тот, у которого длиннее маска. У 0.0.0.0/0 префикс самый короткий, поэтому он означает «всё остальное», то есть маршрут по умолчанию.

$ ip route
default via 192.168.1.1 dev wlan0            # 0.0.0.0/0  - шлюз по умолчанию
192.168.1.0/24 dev wlan0 proto kernel        # своя подсеть, без шлюза
172.17.0.0/16 dev docker0                    # мост докера

$ ip route get 142.250.74.68
142.250.74.68 via 192.168.1.1 dev wlan0 src 192.168.1.5

Дальше пакет идёт по цепочке роутеров. В IPv4-заголовке есть поле TTL (в IPv6 это Hop Limit): каждый роутер уменьшает его на 1, а на нуле пакет уничтожают и шлют источнику ICMP Time Exceeded. Так сеть защищается от петель, и на этом же работает traceroute: он шлёт пакеты с TTL 1, 2, 3… и собирает адреса тех, кто ответил ICMP-ошибкой.

Что происходит, когда вводишь google.com и жмёшь Enter

Вопрос-каркас: по нему интервьюер смотрит, насколько глубоко ты можешь зайти и где остановишься. Отвечай крупными блоками, называй, что дорого по времени, а потом по просьбе углубляйся в любой из них.

Холодный старт: во что уходит время до первого пикселя кэш браузера, HSTS DNS-резолвинг TCP handshake 1 RTT TLS 1.3 handshake 1 RTT, либо 0-RTT по билету HTTP GET, TTFB обработка на сервере + 1 RTT приём HTML парсинг: DOM + CSSOM подресурсы CSS/JS/img HTTP/2 мультиплекс layout, paint, composite 0 100 мс 200 мс 300 мс 400 мс
Куда уходит время. На холодном старте два RTT уходят на рукопожатия ещё до того, как сервер увидел запрос, третий — на сам запрос. Отсюда весь тюнинг: 0-RTT в TLS 1.3, QUIC, preconnect, CDN поближе к пользователю.
  1. Разбор ввода. Браузер решает, это URL или поисковый запрос. Нормализует ввод и сверяется со списком HSTS preload: если домен там есть, схема принудительно становится https ещё до какого-либо трафика.
  2. Проверка кэшей. HTTP-кэш браузера (может отдать страницу вообще без сети), Service Worker, кэш DNS браузера, кэш DNS ОС, /etc/hosts.
  3. DNS. Рекурсивный запрос к резолверу, дальше корень → TLD → авторитативный (см. схему выше). Обычно по UDP/53, а большой ответ приходит усечённым с флагом TC, и запрос повторяют по TCP. В ответе набор A/AAAA; клиент по Happy Eyeballs пробует v6, а через ~250 мс и v4 (в Go — через 300 мс).
  4. Маршрутизация и ARP. Хост по маске понимает, что адрес чужой, берёт шлюз по умолчанию, узнаёт его MAC по ARP, кладёт IP-пакет в Ethernet-кадр.
  5. TCP handshake. SYN → SYN-ACK → ACK, один RTT. Handshake (по-русски рукопожатие) называют короткий обмен служебными сообщениями перед полезными данными: стороны договариваются о параметрах связи и убеждаются, что собеседник жив и слышит. Здесь же согласуются MSS, window scale, SACK. По дороге домашний роутер делает NAT.
  6. TLS handshake. ClientHello с SNI (Server Name Indication — имя запрашиваемого сайта; оно едет в открытую, и по нему сервер понимает, чей сертификат отдать, когда на одном IP висят сотни доменов) и ALPN (Application-Layer Protocol Negotiation — список прикладных протоколов, которые понимает клиент, h2 и http/1.1; сервер выбирает один прямо в рукопожатии, без лишнего круга), ответ сервера, проверка цепочки сертификатов, вывод общего секрета через ECDHE. В TLS 1.3 это один RTT, при возобновлении с early data — ноль.
  7. HTTP-запрос. GET / HTTP/1.1 плюс заголовки (или HEADERS-фрейм в HTTP/2). На пути стоят CDN, балансировщик, реверс-прокси, и каждый может ответить из кэша.
  8. Ответ и рендер. Браузер строит DOM из HTML и CSSOM из CSS, объединяет в render tree, считает layout (геометрию), paint (пиксели слоёв) и composite (сборку слоёв на GPU). Параллельно preload scanner уже тянет подресурсы, а синхронный <script> без defer/async останавливает парсер.
Чем выделиться на этом вопросе

Помогают три приёма. Назови стоимость в RTT: «до первого байта три круга — TCP, TLS и сам запрос; QUIC схлопывает первые два». Перечисли места, где ответ может прийти раньше: кэш браузера, Service Worker, кэш DNS, CDN edge, кэш реверс-прокси. И скажи, что ломается: DNS отдал протухший IP, у сервера нет промежуточного сертификата, SNI не совпал с именем, MTU меньше 1500, а PMTUD заблокирован файрволом.

Вопросы

6
Суть: каждый уровень решает ровно одну задачу и видит содержимое верхнего как непрозрачные байты. L2 — доставка кадра соседу по MAC, L3 — доставка пакета между сетями по IP без гарантий, L4 — адресация процесса (порт) плюс, у TCP, надёжность и порядок, L7 — смысл данных.

Уровни и что на них живёт

  • L1 физический — вольты, свет, радио; по нему бегут биты.
  • L2 канальный — Ethernet, Wi-Fi (802.11), PPP. Адресует по MAC, передаёт кадры и не выходит за пределы одного широковещательного сегмента. Здесь же VLAN и коммутаторы.
  • L3 сетевой — IP, ICMP, IPsec, протоколы маршрутизации (OSPF, BGP). Адресует по IP, передаёт пакеты. Гарантий никаких, только best effort: пакет может пропасть, задублироваться, прийти не в том порядке. Здесь работают роутеры.
  • L4 транспортный — TCP, UDP, QUIC (формально поверх UDP), SCTP. Здесь появляется порт, то есть адрес конкретного процесса на хосте. TCP добавляет надёжность, порядок, управление потоком и перегрузкой, UDP не добавляет ничего.
  • L5/L6 в OSI — сеансовый и представления (сессии, шифрование, кодировки). В реальном стеке их отдельно нет: TLS формально «L6», а на деле библиотека между TCP и HTTP.
  • L7 прикладной — HTTP, gRPC, DNS, SMTP, SSH, AMQP, Redis-протокол, Postgres wire protocol.

Зачем это знать на практике

Почти любой вопрос про инфраструктуру задают в терминах уровней. L4-балансировщик (LVS/IPVS, AWS NLB, HAProxy в режиме tcp) видит только пятёрку адрес/порт и перекладывает пакеты. Он дешёвый и держит миллионы соединений, но не знает ни URL, ни заголовка Host, ни куки. L7-балансировщик (nginx, Envoy, ALB, Traefik) терминирует TCP и TLS, разбирает HTTP и умеет маршрутизировать по пути, хосту, заголовкам, делать ретраи отдельных запросов и circuit breaking — но ест процессор и добавляет задержку. Из той же оперы правило безопасности «фильтруем на L3/L4 файрволом, на L7 — WAF».

Про инкапсуляцию — обязательно проговорить

На отправке каждый уровень дописывает свой заголовок вокруг данных верхнего: HTTP-текст оборачивается в TCP-сегмент, сегмент в IP-пакет, пакет в Ethernet-кадр. На приёме всё разбирается обратно. Отсюда MTU: в Ethernet-кадр влезает 1500 байт полезной нагрузки, IP-заголовок 20 и TCP-заголовок 20 съедают 40, остаётся MSS 1460. Если у кого-то на пути MTU меньше (VPN, PPPoE, туннели в облаке), а ICMP-сообщения «Fragmentation Needed» режет файрвол, получаешь классический баг: рукопожатие проходит, мелкие запросы работают, а большой POST висит намертво. Это чёрная дыра PMTU.

На чём ловят

«На каком уровне работает TLS?» Правильный ответ не «на шестом», а «формально между транспортом и приложением; в реальном стеке TCP/IP это часть прикладного уровня, отдельного уровня для него нет». «А ARP на каком?» — на стыке L2 и L3: он использует L2-широковещание, чтобы разрешить L3-адрес, поэтому его относят то к L2, то к L2.5.

Суть: маска делит 32 бита адреса на сетевую и хостовую часть; по ней хост решает «сосед по проводу или отдавать шлюзу». NAT — подмена адреса и порта на границе сети, чтобы много приватных адресов вышли в интернет через один публичный.

Маска и подсеть

10.0.5.17/24: 24 бита — сеть, 8 — хост. В адресе сети все хостовые биты нулевые (10.0.5.0), в broadcast единичные (10.0.5.255), полезных адресов 2^8 - 2 = 254. Чем короче префикс, тем больше сеть: в /16 65534 хоста, в /30 два (классический линк между роутерами), /32 означает один конкретный адрес.

$ ipcalc 10.0.5.17/24
Address:   10.0.5.17    00001010.00000000.00000101. 00010001
Netmask:   255.255.255.0 = 24
Network:   10.0.5.0/24
Broadcast: 10.0.5.255
HostMin:   10.0.5.1     HostMax: 10.0.5.254

Маршрут выбирают по правилу longest prefix match: если адрес назначения после наложения маски совпадает с собственной сетью, шлём напрямую соседу (узнав его MAC через ARP), иначе отдаём кадр на MAC шлюза по умолчанию, не трогая IP назначения.

Приватные диапазоны

Диапазоны RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) в интернете не маршрутизируются. Ещё полезно помнить 127.0.0.0/8 (loopback целиком, не только 127.0.0.1), 169.254.0.0/16 (link-local; сюда входит и 169.254.169.254, метадата-сервис в AWS/GCP/Yandex Cloud, откуда при SSRF утекают креды) и 100.64.0.0/10 (CGNAT).

NAT

Почти всегда это NAPT: роутер подменяет src IP:port на свой публичный адрес и свободный порт, запись кладёт в таблицу трансляций и по ней разворачивает обратный трафик. Услышать хотят три следствия:

  • Соединение может инициировать только внутренняя сторона — снаружи записи в таблице нет. Поэтому p2p требует port forwarding, UPnP или hole punching через STUN/TURN.
  • Записи протухают: у UDP обычно через 30–120 с, у TCP через минуты и десятки минут. Из-за этого простаивающее соединение «внезапно» перестаёт работать; лечат это TCP keep-alive с интервалом меньше таймаута NAT.
  • Порты кончаются: за одним публичным IP на одну пару (dst IP, dst port) больше ~64 тысяч одновременных соединений не спрячешь. На NAT-шлюзе перед большим кластером это реальная беда: SNAT port exhaustion в Kubernetes случается сплошь и рядом.
Смежный вопрос: почему IPv6 «не взлетел», хотя адреса кончились

Потому что NAT оказался достаточно хорошим костылём и снял остроту дефицита. Заплатили за это сквозной адресуемостью (end-to-end principle), а вокруг обхода NAT вырос целый пласт технологий. В IPv6 адресов хватает всем, NAT не нужен, а роль ARP играет NDP поверх ICMPv6.

Суть: клиент задаёт один рекурсивный вопрос резолверу; резолвер делает цепочку итеративных запросов корень → TLD → авторитативный сервер зоны. Всё кэшируется на каждом шаге по TTL, который назначает владелец зоны.

Порядок разрешения имени

  1. Кэш браузера (десятки секунд), затем кэш ОС (systemd-resolved, nscd), затем /etc/hosts — до сети дело может и не дойти.
  2. Stub resolver шлёт вопрос с флагом RD=1 («сделай за меня всю работу») на резолвер из /etc/resolv.conf. Ходит он по UDP/53; если ответ не влез, приходит флаг TC (truncated), и клиент повторяет запрос по TCP/53. С EDNS0 UDP-ответ может быть больше 512 байт.
  3. Резолвер идёт по иерархии: корневой сервер отдаёт NS для com, TLD-сервер отвечает NS для google.com, а авторитативный возвращает саму A-запись с флагом AA=1.
  4. Ответ кладётся в кэши на всём обратном пути на время TTL.

TTL и отрицательное кэширование

TTL задаёт владелец зоны: у A-записей обычно 60–300 секунд, у NS часы. Перед миграцией TTL заранее опускают до 60 секунд, переключают запись, ждут и потом поднимают обратно. NXDOMAIN тоже кэшируется, срок задаёт поле minimum в SOA. Поэтому «создал запись, а её не видно» часто означает, что ты успел спросить имя до создания и отрицательный ответ осел в кэше.

Типы записей

  • A / AAAA хранят IPv4 / IPv6. Несколько A-записей дают round-robin-балансировку «для бедных»: порядок ротируется, но клиент может закэшировать одну и сидеть на ней.
  • CNAME — алиас на другое имя, разрешение продолжается по цепочке. Нельзя на вершине зоны (рядом с SOA и NS), поэтому провайдеры придумали ALIAS/ANAME.
  • MX указывает почтовый сервер с приоритетом (чем меньше число, тем выше приоритет).
  • В TXT кладут произвольный текст: SPF, DKIM, DMARC, подтверждение владения доменом для ACME/Let's Encrypt (_acme-challenge).
  • SRV: _service._proto.name → приоритет, вес, порт и хост. Порт отдают она и записи SVCB/HTTPS (RFC 9460), а на SRV держится service discovery в Consul и headless-сервисы Kubernetes.
  • NS — делегирование зоны, SOA — параметры зоны, PTR — обратное разрешение.
Добивка про Go

Go не кэширует DNS сам: кэш только у ОС и резолвера. А соединение из пула http.Transport о смене DNS не узнаёт вовсе: адрес спрашивают только при dial, и клиент может месяцами держать соединение к IP, который давно выведен из ротации. IdleConnTimeout закрывает только простаивающие соединения; возраст занятых ограничивают обёрткой в DialContext или на сервере. И ещё: у Go два резолвера, чистый Go (сам читает /etc/resolv.conf) и cgo (getaddrinfo, умеет NSS/mDNS), а переключают их через GODEBUG=netdns=go|cgo.

Суть: протокол разрешения IP-адреса в MAC-адрес внутри одного канального сегмента. Запрос широковещательный («у кого 10.0.5.1 — скажите свой MAC»), ответ юникастом, результат кладётся в ARP-кэш.

Решает он вот что: в провод кладут не IP-пакет, а Ethernet-кадр, и адреса у кадра MAC. Значит, перед отправкой хосту нужно знать MAC следующего узла. ARP-запрос уходит на ff:ff:ff:ff:ff:ff, его видят все в сегменте, а отвечает только владелец адреса.

$ ip neigh                 # ARP-кэш в Linux
10.0.5.1   dev eth0 lladdr 02:42:ac:11:00:01 REACHABLE
10.0.5.23  dev eth0 lladdr 02:42:ac:11:00:17 STALE

$ tcpdump -n -i eth0 arp
ARP, Request who-has 10.0.5.1 tell 10.0.5.17, length 28
ARP, Reply 10.0.5.1 is-at 02:42:ac:11:00:01, length 46

Главная мысль ответа

ARP не работает через роутер. Если адрес назначения в другой сети, хост никогда не спрашивает про него ARP, он спрашивает MAC своего шлюза. По дороге IP-адреса источника и назначения не меняются (пока нет NAT), а MAC-адреса переписываются на каждом роутере. Так коротко и объясняют разницу между L2 и L3.

Что ещё бывает вокруг ARP

  • При Gratuitous ARP хост сам объявляет «этот IP теперь мой». Так переезжает виртуальный IP при failover (keepalived/VRRP), чтобы коммутаторы и соседи обновили кэш.
  • Proxy ARP — роутер отвечает за чужой адрес.
  • ARP spoofing возможен, потому что у протокола нет аутентификации: кто угодно в сегменте может ответить «это я» и устроить MITM. Защищаются Dynamic ARP Inspection, статическими записями, 802.1X.
  • В IPv6 ARP заменён на NDP поверх ICMPv6 (Neighbor Solicitation/Advertisement) и использует multicast вместо broadcast.
Суть: порт — это 16-битное число в заголовке TCP/UDP, метка «кому отдать». Сокет — объект ядра с буферами и состоянием, идентифицируемый пятёркой {протокол, локальный IP, локальный порт, удалённый IP, удалённый порт}.

Отсюда вывод: сервер обслуживает тысячи клиентов на одном порту. Порт 443 один, а сокетов столько, сколько соединений, и ядро различает их по пятёрке. Классический неверный ответ на собесе: «на каждого клиента сервер выделяет новый порт» — нет, новый порт выделяет клиент, а серверный остаётся тем же.

ln, _ := net.Listen("tcp", ":8080")   // один слушающий сокет
for {
    conn, err := ln.Accept()          // каждый Accept -> новый fd на том же :8080
    if err != nil { continue }
    go handle(conn)                   // conn.RemoteAddr() у всех разный
}

Два типа сокетов у TCP

  • Слушающий: пятёрка неполная ({tcp, *, 8080, *, *}), у него две очереди — SYN queue (полуоткрытые) и accept queue (готовые, длина = backlog, ограничена net.core.somaxconn). Когда accept queue переполнена, SYN молча отбрасываются, а клиент ловит «непонятные» таймауты.
  • Установленный: полная пятёрка, свои буферы приёма и отправки в ядре.

Где реально кончаются ресурсы

  • Сервер упирается в файловые дескрипторы (ulimit -n), память ядра под буферы и длину accept-очереди, но не в порты.
  • Клиент упирается в эфемерные порты: net.ipv4.ip_local_port_range = 32768 60999, то есть ~28 тысяч. Лимит считается на пару (dst IP, dst port): к одному бэкенду 28 тысяч, к десяти разным уже 280 тысяч. Этот лимит и выжигает TIME_WAIT, когда нет keep-alive.
  • Порты 0–1023 привилегированные: нужен root или CAP_NET_BIND_SERVICE.
Добивка

Сокеты бывают не только сетевые. У unix domain socket (net.Dial("unix", "/var/run/x.sock")) тот же API, но нет сетевого стека, портов и checksum: на одной машине он ощутимо быстрее, а доступ к нему разграничивают правами файловой системы. Это и есть ответ на «как ускорить локальный вызов, если оба процесса на одном хосте».

Суть: отвечать блоками и в каждом называть, во что уходит время. Скелет: разбор ввода → кэши → DNS → маршрутизация и ARP → TCP-рукопожатие → TLS-рукопожатие → HTTP-запрос → прокси/CDN/балансировщик → ответ → парсинг и рендер. До первого байта на холодную — три RTT.

1. Разбор строки и HSTS

Браузер решает, это URL или поисковый запрос, нормализует (punycode для кириллицы, схема, порт по умолчанию). Затем проверяет HSTS: если домен есть в preload-списке или в локальном кэше HSTS, схема принудительно становится https ещё до какого-либо трафика. Так браузер защищается от downgrade-атаки, и редиректа 301 не будет.

2. Кэши: сети может и не быть вообще

По очереди: HTTP-кэш браузера (свежий ответ отдаётся мгновенно), Service Worker (может ответить из своего кэша офлайн), кэш DNS браузера, кэш DNS операционной системы, /etc/hosts. Скажи это вслух: здесь впервые отвечают на вопрос «почему быстро/медленно».

3. DNS

Stub resolver шлёт рекурсивный запрос (RD=1) резолверу по UDP/53. Резолвер обходит иерархию: корень → NS зоны com → NS зоны google.com → авторитативный сервер отдаёт A/AAAA с TTL. Если ответ не влез в датаграмму, ставится флаг TC и запрос повторяют по TCP. Клиент получает набор адресов и по Happy Eyeballs (RFC 8305) начинает соединяться по IPv6, а через четверть секунды и по IPv4, оставляя того, кто ответил первым. В жизни ответ для google.com придёт из кэша резолвера за единицы миллисекунд, а адрес будет ближайшим GeoDNS/anycast-узлом.

4. Маршрутизация, ARP, NAT

Хост накладывает маску: адрес не свой — берём default gateway. ARP даёт MAC шлюза, IP-пакет уходит в Ethernet-кадре. Домашний роутер делает NAPT: подменяет src на публичный адрес и свободный порт. Дальше пакет идёт по цепочке роутеров, каждый уменьшает TTL и переписывает MAC-адреса, а IP остаются прежними.

5. TCP-рукопожатие — 1 RTT

SYN (со своим ISN, опциями MSS, window scale, SACK permitted, timestamps) → SYN-ACK → ACK. После третьего пакета сокет в состоянии ESTABLISHED, и данные можно слать прямо с ним. Если сервер за CDN и находится в соседней стране, это уже 20–40 мс.

6. TLS-рукопожатие — 1 RTT (TLS 1.3)

ClientHello везёт версии, шифронаборы, SNI (по нему сервер выбирает сертификат, когда на одном IP много доменов), ALPN (h2, http/1.1; h3 бывает только в TLS внутри QUIC) и уже свою долю ключа ECDHE. Сервер отвечает ServerHello, сертификатом и цепочкой, подписью и Finished — дальше трафик шифруется. Клиент проверяет цепочку до доверенного корня, срок действия, соответствие имени в SAN, отзыв (OCSP stapling). Асимметрика нужна, только чтобы аутентифицировать сервер и договориться об общем секрете, а сами данные шифруются симметрично (AES-GCM или ChaCha20-Poly1305). При возобновлении сессии по билету бывает 0-RTT.

7. HTTP-запрос и путь до приложения

Браузер шлёт GET / HTTP/1.1 с Host, User-Agent, Accept, Accept-Encoding, Cookie, а если ALPN договорился о h2, то HEADERS-фрейм с заголовками, сжатыми HPACK. По пути обычно стоят CDN edge (может ответить из кэша), L4-балансировщик, реверс-прокси/L7-балансировщик, который терминирует TLS и выбирает бэкенд, и только потом само приложение. Каждый прокси добавляет X-Forwarded-For/Forwarded и, если всё сделано правильно, X-Request-ID для сквозной трассировки.

8. Ответ и рендер

Приходят статус, заголовки и тело (обычно Transfer-Encoding: chunked или DATA-фреймы в h2, часто со сжатием gzip/br). Браузер потоково парсит HTML в DOM и CSS в CSSOM, объединяет их в render tree, считает layout (геометрию), paint (растеризацию слоёв) и composite (сборку слоёв на GPU). Параллельно preload scanner уже тянет подресурсы. Синхронный <script> без defer/async блокирует парсер, CSS блокирует рендер, и отсюда растут все метрики FCP/LCP.

Чем добить, чтобы ответ выделялся
  • Стоимость в RTT: «на холодную до первого байта три круга: TCP, TLS, сам запрос. QUIC схлопывает первые два в один, а по билету в ноль».
  • Где ответ может прийти раньше: кэш браузера, Service Worker, кэш DNS, CDN edge, кэш реверс-прокси.
  • Что ломается: протухший IP в кэше при живом keep-alive; сервер не отдал промежуточный сертификат (в браузере работает, в curl из контейнера — нет); имя не совпало с SAN; MTU меньше 1500 и PMTUD зарезан файрволом; IPv6-адрес есть, а связности нет — спасает Happy Eyeballs.

9.2TCP и UDP

Транспортный уровень: как из ненадёжной доставки пакетов получается надёжный поток байт, сколько это стоит и почему иногда надёжность вредна. Здесь же разобраны TIME_WAIT, окно (сколько байт разрешено отправить, не дожидаясь подтверждения) и head-of-line blocking (блокировка головой очереди: первый застрял — стоят и все за ним) — три вещи, которые чаще всего всплывают в реальных инцидентах.

Что именно TCP добавляет поверх IP

IP обещает ровно одно: «постараюсь доставить пакет по адресу». Пакет может пропасть, продублироваться, прийти вторым из трёх, побиться. TCP превращает это в надёжный упорядоченный поток байт между двумя процессами и делает это пятью механизмами:

  • Соединение — обе стороны согласуют начальное состояние (рукопожатие), а потом и завершение.
  • Порядковые номера и подтверждения: нумеруется каждый байт, приёмник сообщает, до какого места он всё получил.
  • Ретрансмиссии: всё, что не подтвердили, уходит заново.
  • Управление потоком (flow control) — чтобы не завалить приёмник.
  • Управление перегрузкой (congestion control) — чтобы не завалить сеть между вами.
# Заголовок TCP, 20 байт без опций
src port (2)   dst port (2)
sequence number (4)          # номер первого байта в сегменте
acknowledgment number (4)    # следующий ожидаемый байт (кумулятивно)
data offset + flags (2)      # SYN ACK FIN RST PSH URG ECE CWR
window (2)                   # свободное место в буфере приёма, до 65535 без wscale
checksum (2)   urgent (2)
опции: MSS, window scale, SACK permitted, timestamps, TCP Fast Open

TCP передаёт поток байт, а не сообщения. Один Write на 10 КБ может приехать пятью Read, а три Write по 100 байт могут прийти одним. Границ сообщений в TCP нет, их задают сами: длиной префикса, разделителем или заголовком Content-Length. Это, кстати, самый частый практический вопрос по TCP на собесе.

Трёхстороннее рукопожатие

Открытие соединения: три пакета, один RTT Клиент Сервер CLOSED LISTEN SYN seq=x опции: MSS 1460, window scale 7, SACK permitted, timestamps SYN_SENT SYN_RECEIVED SYN + ACK seq=y, ack=x+1 ESTABLISHED ACK ack=y+1 (сюда же можно класть данные) ESTABLISHED 1 RTT Зачем именно три шага Каждая сторона должна сообщить свой начальный номер ISN и получить подтверждение, что его услышали. Это четыре события, но SYN сервера и ACK на SYN клиента едут одним пакетом - отсюда три, а не четыре. ISN выбирается псевдослучайно: иначе заблудившийся сегмент старого соединения с той же пятёркой сойдёт за свой. TCP Fast Open умеет класть данные прямо в SYN - по куки, выданной при прошлом соединении.
Три пакета, один RTT. Данные можно отправлять уже с третьим пакетом, поэтому «рукопожатие стоит один круг», а не полтора.
Глубже: две очереди слушающего сокета и SYN flood

На сервере полуоткрытые соединения живут в SYN queue, а готовые к accept() ждут в accept queue длиной backlog (реально min(backlog, net.core.somaxconn)). При атаке SYN flood поток SYN с подделанных адресов забивает SYN queue; ответом стали SYN cookies: сервер не хранит состояние, а зашивает его в ISN в SYN-ACK и восстанавливает из ACK клиента. И уже без всяких атак: если accept queue переполнена (приложение медленно делает Accept), ядро молча дропает SYN, и клиент видит долгий таймаут «на ровном месте». Смотреть в ss -lnt, колонки Recv-Q (текущая длина) и Send-Q (лимит), плюс счётчик ListenOverflows в nstat.

Порядковые номера, подтверждения, ретрансмиссии

Нумеруются байты, не пакеты. seq в сегменте несёт номер его первого байта, ack в обратном пакете — номер следующего ожидаемого байта. Подтверждение кумулятивное: ack=5000 означает «всё до 4999 включительно у меня есть», и это подтверждение перекрывает все предыдущие. Поэтому потеря одного ACK не фатальна: следующий всё закроет.

Потерю замечают двумя способами, и по смыслу они сильно разные:

  • Таймаут RTO (retransmission timeout). Отправитель держит таймер; сработал — сегмент уходит заново. RTO не константа: ядро считает сглаженное SRTT и разброс RTTVAR по алгоритму Джекобсона, RTO = SRTT + 4 × RTTVAR, с нижней границей (в Linux 200 мс). Повторный таймаут удваивает RTO (экспоненциальный откат), а по алгоритму Карна замеры RTT по ретрансмиссиям не учитываются, иначе оценка поедет.
  • Три дублирующих ACKfast retransmit. Приёмник, получая сегменты «с дыркой», на каждый шлёт один и тот же ack (номер начала дырки). Три одинаковых ack подряд означают «один сегмент потерялся, остальные идут», и отправитель повторяет его сразу, не дожидаясь таймера.

SACK (selective acknowledgment, RFC 2018) чинит главную слабость кумулятивного ACK: без него приёмник не может сказать «мне не хватает только байтов 5000–6460, а 6460–20000 уже есть». С SACK он перечисляет полученные диапазоны в опциях, и отправитель повторяет ровно дырки, а не всё начиная с потерянного места. Без SACK на канале с потерями и большим окном пропускная способность падает в разы. Договариваются о нём в рукопожатии (SACK permitted).

Подвох: Nagle плюс delayed ACK

Алгоритм Нейгла копит мелкие записи, чтобы не слать пакет на каждый байт: пока есть неподтверждённые данные, новый маленький сегмент придерживается. Delayed ACK на приёмнике придерживает подтверждение до 40–200 мс, надеясь приклеить его к ответным данным. Вместе они дают залипание ровно на этот таймер: классические «непонятные 40 мс» в latency у request-response протоколов поверх TCP. Лечится отключением Нейгла через TCP_NODELAY. В Go он выключен по умолчанию для net.TCPConn (то есть SetNoDelay(true) уже применён), и упомянуть это на собесе полезно. Отсюда правило: делай один Write целым сообщением или пиши через bufio.Writer с явным Flush, а не по полю за раз.

Управление потоком: окно получателя

Сначала разберёмся с «окном»: дальше это слово будет в каждом абзаце. Окном называют разрешение отправителю: сколько байт можно послать и не ждать, пока их подтвердят. Байты, которые уже ушли, но ещё не подтверждены, называют «в полёте». Набрал полное окно — стой и жди ACK; пришёл ACK — окно «скользит» вперёд, и можно слать дальше. Без окна отправитель либо ждал бы подтверждение после каждого сегмента (и упёрся бы в один сегмент за RTT), либо завалил бы получателя.

Окон на самом деле два, и действует меньшее из них: rwnd (receive window) говорит, сколько готов принять получатель, cwnd (congestion window) — сколько отправитель считает безопасным для сети между вами. Этот раздел про первое, следующий про второе.

Приёмник в каждом ACK объявляет rwnd, свободное место в своём буфере приёма. Отправитель не имеет права держать «в полёте» больше этого объёма. Так защищается медленное приложение-получатель: если оно не вызывает Read, буфер ядра заполняется, rwnd падает, и в пределе объявляется нулевое окно: отправитель замолкает и периодически шлёт window probe, пока окно не откроется.

Скользящее окно над потоком байтов у отправителя SND.UNA SND.NXT предел окна подтверждено в полёте, ACK не пришёл можно слать прямо сейчас за окном: слать нельзя размер окна = min(rwnd приёмника, cwnd отправителя) Приёмник: rwnd - это свободное место в буфере приёма приложение ещё не прочитало свободно = rwnd объявляется в каждом ACK Приложение не читает -> буфер полон -> rwnd = 0 -> отправитель молчит и шлёт window probe до открытия окна. Опция window scale поднимает потолок окна с 64 КБ до 1 ГБ - без неё быстрый канал с большим RTT не разогнать.
Окно отправителя. Слева направо: подтверждённое, в полёте, разрешённое к отправке и запрещённое. Окно «скользит» вправо по мере прихода ACK.

Отсюда формула: максимальная пропускная способность одного TCP-соединения ограничена window / RTT. При RTT 100 мс и окне 64 КБ это ~5 Мбит/с, сколько бы гигабит ни было в канале. Это и есть bandwidth-delay product, поэтому на длинных «толстых» линках включают window scaling и поднимают net.ipv4.tcp_rmem/tcp_wmem.

Управление перегрузкой

Окно получателя ничего не знает о том, что творится между хостами. Перегрузку сети TCP оценивает сам, по косвенным признакам (потери, а в современных алгоритмах ещё и рост задержки), и держит второе окно, cwnd. Реально в полёте может быть min(rwnd, cwnd) байт.

cwnd время, RTT ssthresh slow start: cwnd удваивается каждый RTT (экспонента) congestion avoidance: +1 MSS за RTT (линейно) три dup ACK -> fast retransmit, cwnd падает: Reno вдвое, CUBIC до 0,7 таймаут RTO: cwnd = 1, заново со slow start Потеря по dup ACK - сеть жива, значит окно режут умеренно. Потеря по таймауту - сеть, возможно, легла: откат в самое начало. CUBIC (по умолчанию в Linux) растёт кубически от времени последней потери. BBR не ждёт потерь, а меряет полосу и минимальный RTT.
Пила congestion window. Экспоненциальный разгон до ssthresh, дальше осторожный линейный рост, и два разных наказания за потерю — мягкое по dup ACK и жёсткое по таймауту.
ФазаЧто делаетКогда включается
Slow startcwnd += 1 MSS на каждый подтверждённый сегмент, то есть удвоение за RTTстарт соединения; после таймаута RTO; после долгого простоя
Congestion avoidancecwnd += 1 MSS за RTT — аддитивный росткогда cwnd дорос до ssthresh
Fast retransmitповтор потерянного сегмента сразу, по трём dup ACKпотеря одиночного сегмента
Fast recoveryssthresh = cwnd/2, cwnd = ssthresh, продолжаем без возврата в slow startсразу после fast retransmit
Таймаут RTOssthresh = cwnd/2, cwnd = 1, полный slow startACK не пришёл вообще — худший сценарий

Классическую схему (Reno/NewReno) называют AIMD: «аддитивный рост, мультипликативное падение». CUBIC, дефолт в Linux с 2006 года, заменяет линейный рост кубической функцией от времени с последней потери: быстро возвращается к прежнему окну и осторожно щупает выше, и на «толстых» каналах с большим RTT это заметно быстрее. BBR от Google меняет саму модель: он не считает потерю сигналом перегрузки (на Wi-Fi и мобильных потери бывают от помех), а постоянно оценивает доступную полосу и минимальный RTT и держит ровно столько данных в полёте, сколько нужно, чтобы не наполнять буферы промежуточных роутеров. Так он лечит bufferbloat — ситуацию, когда толстые очереди на роутерах дают гигантские задержки при формально нулевых потерях.

Flow control против congestion control — вопрос-разделитель

Их постоянно путают, а разница простая. Flow control защищает приёмник: сколько он готов принять прямо сейчас, знает только он сам, поэтому он и объявляет rwnd явно, в каждом ACK. Congestion control защищает сеть между вами: сколько она выдержит, никто не сообщает — отправитель угадывает сам по потерям и задержкам и держит свою оценку cwnd. Нужны оба: быстрый приёмник за узким каналом требует congestion control, широкий канал с медленным приёмником требует flow control. В полёте разрешено min(rwnd, cwnd).

Закрытие соединения и TIME_WAIT

Закрытие: четыре пакета и TIME_WAIT на активной стороне Активная сторона (закрыла первой) Пассивная сторона ESTABLISHED ESTABLISHED FIN seq=u вызван Close() FIN_WAIT_1 CLOSE_WAIT ACK ack=u+1 полузакрытое состояние: пассивная сторона ещё может слать данные FIN_WAIT_2 FIN seq=v когда её приложение тоже сделало Close() LAST_ACK ACK ack=v+1 CLOSED TIME_WAIT 2 × MSL CLOSED Почему 2 × MSL (в Linux зашито 60 секунд) 1) Дать последнему ACK шанс дойти: если он потеряется, пассивная сторона повторит FIN - и его должно быть кому подтвердить. 2) Дать заблудившимся сегментам этого соединения умереть, чтобы они не были приняты за данные нового соединения с той же пятёркой.
Четыре пакета вместо трёх. FIN закрывает только своё направление, поэтому подтверждение и встречный FIN разнесены во времени — их можно склеить, только если приложение закрылось сразу.

Закрытие четырёхстороннее, потому что TCP-соединение полнодуплексное: FIN говорит «я больше ничего не пришлю», но встречный поток остаётся живым. Это и есть half-close, в Go его делает conn.CloseWrite() на *net.TCPConn. Если приложение на пассивной стороне закрывает сокет сразу, ядро склеивает ACK и FIN, и пакетов получается три.

TIME_WAIT возникает только у того, кто закрыл первым. Это единственное состояние, которое живёт после того, как приложение уже забыло про сокет: ядро держит пятёрку занятой 2 × MSL (в Linux это константа 60 секунд, TCP_TIMEWAIT_LEN, меняется только пересборкой ядра).

Чем TIME_WAIT грозит на нагрузке и как лечится

Опасен он на стороне, которая закрывает первой. Если это клиент (или твой сервис в роли клиента к чужому API), каждое закрытое соединение на минуту занимает эфемерный порт. При ~28 тысячах портов и 60 секундах потолок выходит около 470 новых соединений в секунду к одному адресу и порту. Дальше connect() начинает возвращать EADDRNOTAVAIL — «cannot assign requested address», и в логах Go это dial tcp …: connect: cannot assign requested address.

  • Главное лечение — не создавать соединения. Переиспользуй их: настроенный http.Transport с достаточным MaxIdleConnsPerHost, пул к БД, gRPC-канал. Один keep-alive-канал вместо тысячи одноразовых снимает вопрос целиком.
  • Закрывать первым должен клиент, а не сервер, иначе TIME_WAIT копится на сервере (там он не выжигает порты, но ест память и слоты conntrack).
  • SO_REUSEADDR позволяет bind на порт, где висят TIME_WAIT-сокеты. Он решает задачу «перезапустить сервер сразу, а не ждать минуту», но не лечит исчерпание эфемерных портов у клиента.
  • Тюнинг: расширить net.ipv4.ip_local_port_range, включить net.ipv4.tcp_tw_reuse=1 (безопасно: переиспользует TIME_WAIT-сокет для нового исходящего соединения, опираясь на timestamps). А вот tcp_tw_recycle ломал клиентов за NAT и удалён из ядра с 4.12; назовёшь это на собесе, получишь большой плюс.

Про RST помни отдельно: это не «закрытие», а «аварийный сброс». Он прилетает, когда пишут в закрытый сокет, когда порт никто не слушает, при SO_LINGER с нулевым таймаутом. При переполнении очереди приёма Linux по умолчанию RST не шлёт, а молча выбрасывает SYN; RST будет, только если включён tcp_abort_on_overflow. В Go это connection reset by peer. От FIN он отличается сильно: FIN закрывает упорядоченно, данные в буферах дойдут, а RST выбрасывает всё немедленно.

UDP: что остаётся, если убрать всё

Заголовок UDP весит 8 байт: порт источника, порт назначения, длина, контрольная сумма. Состояния нет, соединения нет, номеров нет, буквально «IP плюс порты плюс checksum». Отсюда всё остальное:

  • Не гарантирует доставку: датаграмма может пропасть, и никто об этом не узнает.
  • Не гарантирует порядок — вторая может обогнать первую.
  • Не защищает от дублей: маршрутизация может размножить пакет.
  • Нет управления перегрузкой — UDP-приложение может утопить канал, ответственность на нём.
  • Сохраняет границы сообщений: один WriteTo = одна датаграмма = один ReadFrom. Это плюс, а не минус: не нужен фреймер.

Зачем он нужен, если такой «плохой»: когда опоздавшие данные бесполезны. В голосовом звонке повторять пакет, который опоздал на 300 мс, бессмысленно — лучше проиграть тишину и идти дальше. Когда запрос и ответ помещаются в одну датаграмму (DNS), рукопожатие ради одного вопроса даёт только накладные расходы. Когда нужен multicast/broadcast, которого в TCP нет вовсе. И когда надёжность хочется реализовать самому, по-своему, как сделал QUIC.

СвойствоTCPUDP
Соединениеесть, рукопожатие 1 RTTнет, шлём сразу
Доставка и порядокгарантированыне гарантированы
Границы сообщенийнет, поток байтесть, датаграмма
Управление потоком / перегрузкойесть обанет, делай сам
Заголовок20 байт и больше8 байт
Multicast / broadcastнетда
Поведение при потеревсё встаёт до ретрансмиссии (HOL)дырка в данных, поток идёт дальше
Где применяюREST и gRPC API, БД, очереди, передача файлов, SSH — всё, где важен каждый байтDNS, NTP, DHCP, метрики StatsD, VoIP и видеозвонки, игровые тики, QUIC/HTTP-3, service discovery по multicast
Как выбирать вслух на собесе

«Беру TCP по умолчанию. Ухожу на UDP, если выполняется хотя бы одно: устаревшие данные не нужны (реалтайм-медиа, позиции игроков), обмен помещается в одну датаграмму и рукопожатие дороже смысла (DNS, NTP), нужен multicast, либо я строю свой транспорт и мне мешает надёжность TCP (QUIC). Во всех этих случаях надёжность, если она нужна частично, я реализую на прикладном уровне: секвенсные номера, ACK на важные сообщения, forward error correction».

Keep-alive: TCP и HTTP — это разные вещи

Их называют одним словом, но они про разное, и это любимый уточняющий вопрос.

TCP keep-aliveHTTP keep-alive (persistent connection)
УровеньL4, механизм ядраL7, поведение протокола
Что делаетшлёт пустой пробный сегмент в простаивающее соединение, чтобы понять, жив ли пирне закрывает TCP-соединение после ответа, чтобы следующий запрос пошёл по нему же
Зачемобнаружить «мёртвого» пира и не держать зомби-сокеты; не дать NAT/файрволу выбросить запись о сессииубрать из каждого запроса рукопожатие TCP и TLS — экономия двух RTT
Настройкаtcp_keepalive_time (по умолчанию 7200 с!), _intvl, _probes; в Go — net.Dialer.KeepAlive / KeepAliveConfigв HTTP/1.1 включено по умолчанию, отключается Connection: close; в Go — Transport.MaxIdleConnsPerHost, IdleConnTimeout, у сервера — SetKeepAlivesEnabled
Кто «страдает» без неговисящие соединения через NAT и балансировщики отваливаются молчакаждый запрос платит 2–3 RTT, а клиент выжигает эфемерные порты через TIME_WAIT
// TCP keep-alive: ядро само пингует пира. Дефолт ОС в 2 часа почти всегда бесполезен.
d := &net.Dialer{
    Timeout: 3 * time.Second,
    KeepAliveConfig: net.KeepAliveConfig{ // Go 1.23+: раньше был только KeepAlive time.Duration
        Enable:   true,
        Idle:     30 * time.Second, // начать пробы после 30 с тишины
        Interval: 10 * time.Second, // интервал между пробами
        Count:    3,                // после 3 неудач соединение мертво
    },
}

// HTTP keep-alive: транспорт переиспользует соединения из пула.
tr := &http.Transport{
    DialContext:         d.DialContext,
    MaxIdleConns:        200,
    MaxIdleConnsPerHost: 100, // дефолт всего 2, главная причина всплеска TIME_WAIT
    IdleConnTimeout:     90 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 5 * time.Second}
Ловушка, которую спрашивают почти всегда

Соединение вернётся в пул только если тело ответа дочитано до конца и закрыто. Если ты сделал defer resp.Body.Close(), но вышел по ошибке, не прочитав тело, соединение уйдёт в помойку, и весь keep-alive перестанет работать: получишь тысячи TIME_WAIT и «cannot assign requested address». Правильный шаблон при досрочном выходе: io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) перед Close(). Второй нюанс: MaxIdleConnsPerHost по умолчанию равен двум, и при большой параллельности лишние соединения закрываются сразу после ответа. Третий, свежий: Go 1.27 частично закрыл эту дыру сам — HTTP/1-транспорт при Close() пробует дочитать недочитанный остаток и вернуть соединение в пул, но только если тело не длиннее 256 КиБ (или длина заранее неизвестна) и остаток успевает вычитаться за 50 мс. Большое или медленное тело по-прежнему стоит соединения, поэтому явный io.Copy(io.Discard, …) остаётся правильной привычкой, к тому же только он работает одинаково на всех версиях Go.

Head-of-line blocking

«Блокировка головой очереди» случается, когда первый элемент очереди тормозит всех, кто за ним, хотя они уже готовы. В сетях она встречается на двух разных уровнях, и на собесе их надо разделить.

  • HOL на уровне HTTP/1.1. В одном соединении запросы обрабатываются строго по очереди: следующий ответ нельзя начать, пока не дописан предыдущий. Обходили это открытием 6 параллельных соединений на домен и доменным шардингом. Конвейеризация (pipelining) должна была помочь, но ответы всё равно обязаны идти в порядке запросов, поэтому один медленный ответ блокировал остальные — её отключили во всех браузерах.
  • HOL на уровне TCP. Живёт ниже и не лечится прикладным протоколом. TCP обязан отдать приложению байты по порядку: если потерялся сегмент, все пришедшие после него сегменты лежат в буфере приёма и не отдаются наверх, пока потерянный не будет ретранслирован. Это стоит минимум одного RTT.

Дальше цепочка, которая и есть «правильный» ответ. Сначала термин: мультиплексированием называют передачу нескольких независимых логических потоков по одному каналу одновременно. Данные режут на куски, каждый кусок помечают номером его потока, и получатель по этим номерам раскладывает всё обратно. Очереди «сначала первый целиком, потом второй» нет, куски идут вперемешку. HTTP/2 мультиплексирует потоки внутри одного TCP-соединения и убирает HOL уровня HTTP — но, положив все потоки в один TCP, делает TCP-HOL больнее: потеря одного пакета останавливает все логические потоки сразу (в HTTP/1.1 встали бы только запросы одного из шести соединений). QUIC закрывает вопрос: он поверх UDP и ведёт порядок и ретрансмиссии отдельно для каждого потока, поэтому потеря пакета тормозит только тот поток, чьи байты потерялись.

Тот же эффект вне сетей

Head-of-line blocking свойственен любым очередям, и интервьюер может увести вопрос в сторону: партиция Kafka, где одно «ядовитое» сообщение блокирует всю партицию; пул воркеров с одной общей очередью, где долгая задача держит короткие; ordered-режим в RabbitMQ. Ответ на «как лечить» везде один: разделить поток на независимые очереди или разрешить обработку не по порядку.

Вопросы

6
Суть: TCP превращает ненадёжную доставку пакетов в надёжный упорядоченный поток байт. Цена — рукопожатие в 1 RTT, состояние на обеих сторонах и два независимых окна: rwnd защищает приёмник, cwnd защищает сеть.

Рукопожатие

SYN (клиент шлёт свой ISN и опции) → SYN-ACK (сервер шлёт свой ISN и подтверждает чужой) → ACK. Три пакета, потому что четыре логических события (два ISN и два подтверждения) склеиваются в три: SYN сервера и ACK на SYN клиента едут вместе. ISN обязательно псевдослучайный: иначе заблудившийся сегмент от старого соединения с той же пятёркой был бы принят за валидный, а ещё это защищает от подделки соединений извне. Данные можно класть уже в третий пакет, поэтому рукопожатие «стоит» ровно один RTT. TCP Fast Open кладёт их даже в SYN, по куки от прошлой сессии.

Состояния: CLOSED → SYN_SENT → ESTABLISHED у клиента, LISTEN → SYN_RECEIVED → ESTABLISHED у сервера. На сервере полуоткрытые лежат в SYN queue, готовые — в accept queue длиной backlog.

Гарантии и как они обеспечиваются

Нумеруются байты. ack кумулятивный: «всё до этого номера получено». Приёмник складывает пришедшее не по порядку в буфер и отдаёт приложению только непрерывный префикс: отсюда и порядок, и head-of-line blocking. Целостность проверяет контрольная сумма (слабая, 16 бит, поэтому поверх часто ещё TLS или CRC приложения).

Ретрансмиссии

  • По таймауту RTO: RTO = SRTT + 4 × RTTVAR, оценка по алгоритму Джекобсона, при повторе RTO удваивается, а по алгоритму Карна замеры по ретрансмиссиям не учитываются.
  • Fast retransmit: три дублирующих ACK подряд означают «одна дырка, остальное идёт», и сегмент повторяют сразу, без таймера.
  • SACK позволяет приёмнику перечислить полученные диапазоны, чтобы отправитель повторил ровно дырки, а не всё с потерянного места. На каналах с потерями это разница в разы.

Два окна

Flow control — приёмник в каждом ACK объявляет rwnd, свободное место в своём буфере. Ноль означает «замолчи», и отправитель шлёт window probe до открытия окна. Опция window scale поднимает потолок с 64 КБ до 1 ГБ, без неё быстрый канал с большим RTT не разогнать: скорость упирается в window / RTT.

Congestion control ведёт отправитель: сам оценивает состояние сети и держит cwnd. Slow start (удвоение за RTT) до ssthresh, дальше congestion avoidance (+1 MSS за RTT). При потере по трём dup ACK окно режут (Reno вдвое, CUBIC до 0,7) и продолжают (fast recovery), при таймауте cwnd = 1 и всё заново. В Linux по умолчанию CUBIC; BBR вместо потерь меряет полосу и минимальный RTT и лечит bufferbloat. В полёте разрешено min(rwnd, cwnd).

Добивка, которую любят

«TCP гарантирует доставку — значит, если Write вернул nil, данные у получателя?» Нет. Write означает только «скопировано в буфер отправки ядра». Соединение может умереть после этого, и приложение узнает об этом в лучшем случае на следующей операции. Сквозную гарантию даёт только прикладной уровень: подтверждение от самого приложения-получателя.

Суть: закрытие четырёхстороннее, потому что соединение дуплексное и каждая сторона закрывает своё направление отдельно. TIME_WAIT висит 2 × MSL (60 с в Linux) только у той стороны, что закрыла первой, и на нагруженном клиенте он выжигает эфемерные порты.

Последовательность

FIN от активной стороны (FIN_WAIT_1) → ACK от пассивной (та переходит в CLOSE_WAIT, активная — в FIN_WAIT_2) → пассивная сторона, когда её приложение тоже закроется, шлёт свой FIN (LAST_ACK) → активная подтверждает и уходит в TIME_WAIT на 2 × MSL. Между вторым и третьим пакетом соединение полузакрыто: пассивная сторона всё ещё может слать данные. Если она закрывается сразу, ACK и FIN склеиваются и пакетов получается три.

Зачем TIME_WAIT

  1. Дать последнему ACK шанс дойти. Если он потеряется, пассивная сторона по таймауту повторит FIN, и кто-то должен на него ответить. Иначе она получит RST и решит, что соединение оборвалось аварийно.
  2. Дать умереть заблудившимся сегментам. Сегмент старого соединения, застрявший в сети, не должен быть принят за данные нового соединения с той же пятёркой. MSL — это максимальное время жизни сегмента, а двойка нужна, потому что сегмент может блуждать в обе стороны.

Чем грозит

Сначала раздели два случая. TIME_WAIT на сервере (сервер закрывает первым, например отдаёт Connection: close) не съедает порты: порт-то один, 443. Он ест память ядра и записи в conntrack, и в масштабе сотен тысяч это заметно, но не смертельно. TIME_WAIT на клиенте гораздо злее: каждое закрытое соединение к одному (dst IP, dst port) занимает эфемерный порт на 60 секунд. При диапазоне 32768–60999 это ~28 тысяч портов, то есть потолок примерно 470 новых соединений в секунду к одному бэкенду. Дальше — EADDRNOTAVAIL, в Go connect: cannot assign requested address. Так и «внезапно ложится» сервис, который ходит к соседу без переиспользования соединений.

Как лечить, по убыванию правильности

  • Переиспользовать соединения. Keep-alive и нормальный пул: http.Transport с поднятым MaxIdleConnsPerHost (дефолт всего 2!), пул к БД, один gRPC-канал на процесс. Это убирает проблему, а не маскирует.
  • Обязательно дочитывать тело ответа перед Close(), иначе соединение не вернётся в пул и keep-alive не заработает.
  • Пусть закрывает клиент. Настроить сервер так, чтобы он не рвал соединения первым (аккуратно с IdleTimeout и Connection: close).
  • SO_REUSEADDR нужен, чтобы перезапущенный сервер сразу забиндился на порт, где остались TIME_WAIT-сокеты; Go ставит его на слушающие сокеты сам. Проблему клиентских портов не решает. SO_REUSEPORT про другое: несколько процессов слушают один порт, ядро балансирует между ними.
  • Тюнинг ядра: расширить ip_local_port_range, включить tcp_tw_reuse=1. tcp_tw_recycle использовать нельзя: он ломал клиентов за общим NAT и удалён из ядра начиная с 4.12.
// SO_REUSEADDR Go ставит на слушающий сокет сам; SO_REUSEPORT — через Control
lc := net.ListenConfig{
    Control: func(network, address string, c syscall.RawConn) error {
        var opErr error
        err := c.Control(func(fd uintptr) {
            opErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
        })
        if err != nil { return err }
        return opErr
    },
}
ln, err := lc.Listen(context.Background(), "tcp", ":8080")
Смежное состояние, про которое спрашивают следом

Много CLOSE_WAIT всегда означает баг приложения. Пир прислал FIN, ядро ответило ACK, но твой код не вызвал Close(): забыли defer resp.Body.Close(), потеряли conn, повисла горутина. Сокеты и файловые дескрипторы копятся до too many open files. TIME_WAIT — нормальная работа протокола, CLOSE_WAIT — утечка. На собесе разницу между этими двумя ответами видно сразу.

Суть: UDP — это IP плюс порты плюс контрольная сумма, 8 байт заголовка. Ни соединения, ни доставки, ни порядка, ни защиты от дублей, ни управления перегрузкой. Взамен — нулевая задержка на старте и сохранение границ сообщений.

Чего нет

  • Доставки: датаграмма может пропасть молча, отправитель не узнает.
  • Порядка: вторая может обогнать первую.
  • Дедупликации: сеть может размножить пакет, приложение получит два.
  • Flow и congestion control: UDP-приложение способно утопить и приёмник, и канал, и ответственность целиком на нём. Ядро просто выбросит датаграммы, если буфер приёма полон (счётчик RcvbufErrors в netstat -su).
  • Состояния: net.DialUDP ничего не открывает, он только запоминает адрес, чтобы можно было звать Write вместо WriteTo.

Что есть

Границы сообщений. Одна запись = одна датаграмма = одно чтение. Фреймер не нужен, «слипания» сообщений, как в TCP, нет. Цена: если буфер чтения меньше датаграммы, хвост просто отрезается. Плюс multicast и broadcast, которых в TCP нет в принципе.

conn, _ := net.ListenPacket("udp", ":9000")
buf := make([]byte, 65535)               // берём с запасом: короткий буфер отрежет хвост
for {
    n, addr, err := conn.ReadFrom(buf)   // ровно одна датаграмма за вызов
    if err != nil {
        if errors.Is(err, net.ErrClosed) { return }
        continue                         // прочие ошибки чтения на UDP обычно не фатальны
    }
    go handle(conn, addr, append([]byte(nil), buf[:n]...)) // копия: buf перезапишет следующий ReadFrom
}

Практический размер

Датаграмма теоретически вмещает до 65507 байт, но всё, что больше MTU, фрагментируется на IP-уровне, и потеря одного фрагмента убивает всю датаграмму. Поэтому в реальных протоколах держатся в пределах ~1400 байт: DNS до EDNS0 вообще ограничивал ответ 512 байтами, а QUIC сам следит за размером пакета.

Если надёжность всё-таки нужна

Её строят поверх UDP руками и частично: секвенсные номера, подтверждения только для важных сообщений, forward error correction (шлём избыточность вместо повторов), дедупликация по идентификатору. Так и работает QUIC, причём лучше TCP, потому что ведёт порядок отдельно по каждому потоку. Формулировка для собеса: «UDP выбирают не потому, что не нужна надёжность, а потому, что нужна своя надёжность».

Суть: TCP по умолчанию. UDP — когда опоздавшие данные бесполезны, когда обмен помещается в одну датаграмму, когда нужен multicast, или когда строишь свой транспорт.
СценарийЧто беруПочему
REST/gRPC API между сервисамиTCPнужен каждый байт и порядок; соединения долгоживущие, рукопожатие амортизируется
Работа с БД, очередями, S3TCPто же плюс пул соединений; потеря байта означает битые данные
Передача файлов, бэкапыTCPважна целостность, не важна задержка; congestion control сам займёт свободную полосу
DNS-запросUDP (fallback TCP)вопрос и ответ помещаются в одну датаграмму; рукопожатие ради 60 байт дороже самой работы. При TC=1 повтор по TCP; зонные трансферы AXFR — всегда TCP
NTP, DHCP, метрики StatsDUDPfire-and-forget: потерять один тик метрики дешевле, чем платить за соединение
VoIP, видеозвонок (WebRTC)UDPкадр, опоздавший на 300 мс, уже не нужен; лучше артефакт, чем пауза. Надёжность частичная, через FEC и джиттер-буфер
Быстрые игры (шутеры)UDPшлём состояние мира тиками; устаревшая позиция игрока бесполезна, ретрансмиссия только добавит лаг
Видеостриминг YouTube/NetflixTCP (HLS/DASH поверх HTTP)вопрос-ловушка: это не realtime, а загрузка сегментов в буфер на 10–30 секунд — целостность важнее задержки. Realtime-стрим (WebRTC, SRT) — уже UDP
Многопользовательская рассылка в LAN, service discovery (mDNS)UDPmulticast в TCP невозможен
HTTP/3UDPQUIC строит свою надёжность поверх UDP, чтобы обойти TCP-HOL и ускорить рукопожатие
Как аргументировать выбор, а не перечислять

Сильный ответ строится на одном критерии: полезны ли данные после задержки. Если да — TCP, повтор имеет смысл. Если нет — UDP, повтор только вредит, увеличивая задержку и занимая канал. Второй критерий касается размера обмена относительно накладных расходов: три пакета рукопожатия ради одного 60-байтного вопроса не окупаются. И сразу назови ловушку со стримингом: интервьюер часто ждёт «стриминг = UDP», а правильный ответ различает буферизованное видео (TCP) и realtime-связь (UDP).

Суть: одноимённые, но про разное. TCP keep-alive — механизм ядра, который пингует простаивающее соединение, чтобы понять, жив ли пир. HTTP keep-alive — договорённость прикладного уровня не закрывать соединение после ответа и слать по нему следующий запрос.

TCP keep-alive

Когда в соединении тишина дольше tcp_keepalive_time, ядро шлёт пустой пробный сегмент. Если после tcp_keepalive_probes попыток с интервалом tcp_keepalive_intvl ответа нет, соединение объявляется мёртвым, приложение получает ошибку. В Linux по умолчанию 7200 секунд, то есть два часа, и толку от этого почти нет, поэтому значение всегда переопределяют на уровне сокета. Реальных задач две: во-первых, обнаружить пира, который умер молча (выдернули провод, упала виртуалка, и FIN никто не прислал); во-вторых, не дать NAT или файрволу выбросить запись о сессии по таймауту неактивности.

HTTP keep-alive

В HTTP/1.0 нужно было явно просить Connection: keep-alive; в HTTP/1.1 соединение постоянное по умолчанию, а закрыть его просят через Connection: close. Так не приходится платить за TCP- и TLS-рукопожатие в каждом запросе: экономятся два RTT на запрос, и не тянется шлейф TIME_WAIT. В HTTP/2 и HTTP/3 отдельной такой настройки нет: там соединение изначально долгоживущее и мультиплексированное.

// сервер: свои таймауты и время жизни простаивающего соединения
srv := &http.Server{
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       120 * time.Second, // сколько держим keep-alive без запросов
}
// srv.SetKeepAlivesEnabled(false) выключает HTTP keep-alive, например перед graceful shutdown
На что ловят
  • «TCP keep-alive ускоряет работу» — нет, он ничего не ускоряет, он детектирует смерть пира. Ускоряет HTTP keep-alive, потому что убирает рукопожатия.
  • Заголовок Keep-Alive: timeout=5, max=1000 относится к HTTP и служит подсказкой, а не приказом; к TCP-опции отношения не имеет.
  • HTTP keep-alive без TCP keep-alive легко нарывается на зависшие соединения через NAT: клиент думает, что соединение живо, шлёт запрос — и висит до таймаута. Отсюда правило: IdleConnTimeout на клиенте должен быть меньше, чем IdleTimeout на сервере и таймаут NAT.
  • В Go у net.Dialer keep-alive включён по умолчанию (15 секунд) — в отличие от голого сокета в C.
Суть: первый элемент очереди задерживает всех за собой, хотя они уже готовы. В вебе это два разных явления: HOL прикладного уровня (HTTP/1.1 отвечает строго по очереди) и HOL транспортного уровня (TCP обязан отдавать байты по порядку, потеря пакета останавливает всё).

HOL в HTTP/1.1

В одном соединении ответы обязаны идти в порядке запросов. Медленный первый ответ держит остальные. Конвейеризация (pipelining) позволяла слать запросы, не дожидаясь ответов, но порядок ответов оставался жёстким: выигрыша почти не было, а багов в прокси набралось много, и во всех браузерах её выключили. Реальным обходом стали 6 параллельных соединений на домен и доменный шардинг.

HOL в TCP

Этот HOL сидит уровнем ниже, и прикладной протокол его не лечит. TCP гарантирует порядок, значит, при потере сегмента все пришедшие после него данные лежат в буфере приёма и не отдаются приложению, пока дырка не будет закрыта ретрансмиссией. Простой выходит минимум на один RTT, даже если приложению те, «застрявшие», байты были нужны прямо сейчас и относились к другому логическому запросу.

Цепочка, которую хотят услышать

  • HTTP/2 мультиплексирует независимые потоки внутри одного TCP-соединения и убирает HOL прикладного уровня: ответы приходят вперемешку, порядок не важен.
  • Но, сложив всё в одно TCP-соединение, HTTP/2 сделал транспортный HOL больнее: потеря одного пакета останавливает все потоки сразу. В HTTP/1.1 с шестью соединениями встали бы запросы только одного из них. На плохой сети HTTP/2 иногда проигрывает HTTP/1.1 именно поэтому.
  • QUIC (HTTP/3) решает обе проблемы: он поверх UDP, и порядок доставки у каждого потока свой — номера пакетов и подтверждения общие на соединение, но потерянные байты задерживают только свой поток. Потеря пакета тормозит только тот поток, чьи байты потерялись; остальные едут дальше.
Где HOL встречается за пределами HTTP

Так ведут себя любые упорядоченные очереди. В партиции Kafka одно «ядовитое» сообщение, которое не удаётся обработать, блокирует всю партицию; спасают dead letter queue и параллелизация по ключу. В пуле воркеров с одной общей очередью долгая задача держит короткие, и помогают отдельные очереди по классам задач. Коммутатор с input queuing: кадр, чей выходной порт занят, блокирует следующие — лечится virtual output queues. Если интервьюер уводит туда, общий рецепт один: разделить поток на независимые очереди либо разрешить обработку не по порядку.

9.3HTTP и TLS

Протокол, на котором ты пишешь каждый день, и криптография, в которую он завёрнут. Номера кодов наизусть не спрашивают, спрашивают семантику: почему именно этот код и этот метод, что TLS реально даёт, а чего нет.

Как HTTP выглядит на проводе

HTTP/1.1 гоняет поверх TCP обычный текст. Сообщение состоит из стартовой строки, набора заголовков вида Name: value, пустой строки и тела. Строки всегда разделяет CRLF (\r\n), а два CRLF подряд означают «заголовки кончились».

Байты HTTP-запроса по порядку GET /api/v1/users?limit=10 HTTP/1.1 стартовая строка: метод, request-target, версия Host: api.example.com обязателен в 1.1 - на одном IP много сайтов Authorization: Bearer eyJhbGciOi... заголовки, регистр имени не важен CRLF ПУСТАЯ строка: конец заголовков тело (у GET обычно отсутствует) длина - из Content-Length либо из chunked Тело неизвестной заранее длины: Transfer-Encoding: chunked 1a CRLF 26 байт данных + CRLF 10 CRLF 16 байт данных + CRLF 0 CRLF CRLF конец тела, дальше могут идти трейлеры Каждый кусок предваряется своей длиной в шестнадцатеричном виде - так стримят ответ, не зная его размера. Слать Content-Length и Transfer-Encoding одновременно нельзя: прокси и сервер разойдутся - это дыра request smuggling.
Анатомия сообщения. До пустой строки идут метаданные, после неё тело. Границу тела надо задать явно: TCP о границах сообщений ничего не знает.
$ printf 'GET /health HTTP/1.1\r\nHost: api.example.com\r\nAccept: application/json\r\n\r\n' | openssl s_client -quiet -connect api.example.com:443

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 25
Cache-Control: no-store
ETag: "9a1f3c"
X-Request-Id: 01HN2Q...

{"status":"ok","db":"up"}

У ответа отличается только стартовая строка: версия код текст. Текст статуса («OK», «Not Found») нужен для красоты, его никто не разбирает, а в HTTP/2 его нет вовсе.

Content-Length против chunked

Получателю нужно понять, где кончается тело. Вариантов ровно три: Content-Length (длина известна заранее), Transfer-Encoding: chunked (длины никто не знает, поэтому каждый кусок несёт свою, а нулевой означает конец) или «до закрытия соединения» (HTTP/1.0, при этом keep-alive невозможен и обрыв не отличить от нормального конца). Go выбирает сам: если ты вызвал w.Write и всё влезло в буфер до Flush, выставится Content-Length; если пишешь потоком или зовёшь Flusher.Flush(), уйдёт chunked. Поставишь Content-Length явно, а запишешь другое число байт, и клиент повиснет или получит битый ответ.

Методы: безопасные и идемпотентные

Безопасный (safe) метод не меняет состояние на сервере, это «только чтение». Идемпотентный выдерживает повторы: тот же самый запрос, отправленный ещё раз, оставит сервер в том же состоянии, что и первый. Оба свойства остаются обещанием семантики, их никто не проверяет: можно написать GET, который удаляет заказ, но тогда сломаются кэши, прокси, краулеры и ретраи.

МетодБезопасныйИдемпотентныйКэшируемыйКомментарий
GETдададатело в запросе не предусмотрено семантикой
HEADдададакак GET, но только заголовки
OPTIONSдаданетCORS preflight
PUTнетданетполная замена ресурса: сколько раз ни положи одно и то же — состояние одно
DELETEнетданетвторой вызов вернёт 404, но состояние то же — «ресурса нет»
POSTнетнетусловнодва одинаковых POST = два заказа
PATCHнетнет*нетзависит от формата патча: set status=paid идемпотентен, increment counter — нет
Почему POST не идемпотентен и что с этим делают

POST означает «обработай это по своим правилам», и какой идентификатор создать, обычно решает сервер. Повтор создаёт новый ресурс — отсюда два заказа при двойном клике или ретрае после таймаута. Это не теория: любая сетевая ошибка неоднозначна — ты не знаешь, то ли запрос не дошёл, то ли дошёл, а потерялся ответ. Лечится ключом идемпотентности: клиент генерирует UUID и шлёт его в Idempotency-Key, сервер сохраняет пару (ключ → результат) и на повтор отдаёт тот же ответ, ничего не создавая. Так работают Stripe, YooKassa и все нормальные платёжные API. Или сделай операцию PUT с идентификатором, который придумал клиент.

Три любимых уточнения
  • «DELETE идемпотентен, но второй раз возвращает 404 — противоречие?» Нет: идемпотентность касается состояния сервера, а не кода ответа.
  • «Идемпотентный значит безопасный?» Нет: PUT и DELETE идемпотентны, но состояние меняют. Обратное верно: все безопасные методы идемпотентны.
  • «Почему нельзя тело в GET?» Спецификация его не запрещает жёстко, но семантики у него нет: кэши, прокси и половина клиентов его выбросят. Для сложных запросов делают POST на /search или кладут фильтр в query.

Статус-коды: не «какой существует», а «какой отдать»

ГруппаСмыслКлючевые
1xxинформационные, промежуточные100 Continue, 101 Switching Protocols (WebSocket), 103 Early Hints
2xxуспех200 OK, 201 Created (+ Location), 202 Accepted (приняли в асинхронную обработку), 204 No Content, 206 Partial Content
3xxперенаправление301 Moved Permanently, 302 Found, 304 Not Modified, 307/308 (метод сохраняется)
4xxвиноват клиент, повтор без изменений не поможет400, 401, 403, 404, 405, 409, 410, 415, 422, 429
5xxвиноват сервер, повтор может помочь500, 501, 502, 503, 504
Пара, которую путаютКак выбрать
401 vs 403401 — «я не знаю, кто ты»: токена нет, он протух или подпись невалидна. Обязателен заголовок WWW-Authenticate. Смысл: перелогинься и приходи снова. 403 — «я знаю, кто ты, и тебе нельзя»: аутентификация прошла, не хватает прав. Повтор с тем же токеном бесполезен.
400 vs 422400 — сообщение вообще не разобрать: битый JSON, нет обязательного поля, не тот тип. 422 Unprocessable Entity — синтаксис корректен, но семантика невалидна: дата окончания раньше даты начала, отрицательная сумма, несуществующий статус. Многие API живут только на 400 — это допустимо, но 422 точнее.
404 vs 403 vs 410404 — нет ресурса (или мы не хотим раскрывать, что он есть — часто отдают вместо 403 для приватных объектов). 410 Gone — был и удалён навсегда, поисковикам сигнал выкинуть из индекса.
409 ConflictСостояние ресурса не позволяет выполнить операцию: дубликат уникального поля, конфликт версий при оптимистичной блокировке (If-Match не совпал — тогда точнее 412 Precondition Failed), попытка отменить уже отгруженный заказ.
429 Too Many RequestsRate limit. Обязательно отдавай Retry-After (секунды или дата) и по возможности X-RateLimit-Remaining/Reset — иначе клиент будет долбиться в цикле.
502 vs 503 vs 504502 Bad Gateway — прокси сходил на апстрим и получил мусор или разрыв (апстрим упал, отдал битый ответ). 503 Service Unavailable — сервис сам говорит «я сейчас не могу»: перегрузка, деплой, circuit breaker открыт; сюда же Retry-After. 504 Gateway Timeout — апстрим не ответил за отведённое время. Разница важна при разборе инцидента: 502 — «упал бэкенд», 504 — «бэкенд тормозит».
301 vs 302 vs 307/308301/308 — навсегда (301 кэшируется браузером агрессивно, откатить тяжело), 302/307 — временно. Ключевое отличие: 301 и 302 исторически позволяли клиенту сменить POST на GET, а 307 и 308 обязаны сохранить метод и тело. Для API всегда бери 307/308.
Правило для 4xx и 5xx на ретраях

Клиентская библиотека должна ретраить только идемпотентные запросы и только на 5xx, 429 и сетевых ошибках, причём с экспоненциальной задержкой и джиттером. Ретрай POST без ключа идемпотентности на 504 плодит дубли: таймаут не означает, что сервер ничего не сделал. И никогда не прячь ошибки за 200 с полем "error" в теле: сломаются кэши, ретраи, алерты и метрики по кодам.

Заголовки, которые надо знать наизусть

ЗаголовокЗачем
Hostобязателен в 1.1: виртуальный хостинг, на одном IP много доменов. В HTTP/2 — псевдозаголовок :authority
Content-Typeтип тела: application/json, application/x-www-form-urlencoded, multipart/form-data, application/grpc+proto. Плюс charset
Accept, Accept-Encoding, Accept-Languageсогласование содержимого (content negotiation). На них завязан Vary
AuthorizationBearer <jwt>, Basic base64(user:pass). В логи не писать никогда
Cache-Control, ETag, Last-Modified, Varyкэширование — отдельный раздел ниже
X-Request-Id / traceparentсквозная трассировка: генерируется на входе, прокидывается во все внутренние вызовы и пишется в каждый лог. traceparent — стандарт W3C Trace Context, его понимает OpenTelemetry
X-Forwarded-For / Forwardedреальный IP клиента за прокси. Доверять можно только своему прокси — клиент может подделать заголовок
Retry-Afterк 429 и 503: когда приходить снова
Locationк 3xx и к 201 Created — адрес созданного ресурса
Connection, Upgradeуправление соединением; Upgrade: websocket — переход на WebSocket
Set-Cookie / Cookieсостояние на клиенте; см. ниже
Set-Cookie: session=abc123; Path=/; Max-Age=86400; Secure; HttpOnly; SameSite=Lax

Secure     - только по HTTPS
HttpOnly   - недоступна из JavaScript: главная защита от кражи через XSS
SameSite   - Strict (не отправляется вообще при переходе с чужого сайта),
             Lax (дефолт в Chrome: отправляется при обычной навигации GET),
             None (отправляется всегда, ОБЯЗАТЕЛЬНО вместе с Secure) - защита от CSRF
Domain     - расширяет область на поддомены, по умолчанию только текущий хост

Из всех механизмов только куки браузер прикрепляет к запросу автоматически, и именно поэтому существует CSRF. У заголовка Authorization такой проблемы нет, его проставляют явно, зато токен в localStorage уязвим к XSS. Отсюда типовой выбор: браузерным приложениям подходит HttpOnly-кука с SameSite, мобильным и межсервисным — Authorization: Bearer.

HTTP/1.1 против HTTP/2 против HTTP/3

Три поколения: как по соединению идут запросы HTTP/1.1 одно соединение R1 ответ 1 R2 ответ 2 R3 ответ 3 HOL прикладного уровня: ответы строго по порядку. Обход - до 6 параллельных соединений на домен. HTTP/2 одно TCP, бинарные фреймы s1 s3 s5 s1 s3 s5 потеря все потоки ждут ретрансмиссию Потоки независимы для HTTP, но лежат в одном TCP: потеря одного пакета останавливает ВСЕ потоки. HTTP/3 QUIC поверх UDP s1 s3 s5 s1 потеря s3 s5 s1 s3 ждёт s5 остальные потоки едут дальше Нумерация и ретрансмиссии - отдельно по каждому потоку, поэтому TCP-HOL исчезает как класс. 1.1: текст, заголовки открытым текстом в каждом запросе, keep-alive есть, конвейеризация мертва. 2: бинарные фреймы, потоки, HPACK, приоритеты, server push (отменён). 3: QUIC = UDP + TLS 1.3, 0-RTT, миграция по Connection ID.
Эволюция по одной причине. Каждая версия чинит блокировку предыдущей: HTTP/2 убрал её на прикладном уровне, HTTP/3 — на транспортном.

HTTP/1.1

Текстовый, в соединении один запрос за раз. Версия дала Host (виртуальный хостинг), постоянные соединения по умолчанию, chunked, кэширование, Range-запросы. Конвейеризация (pipelining) разрешала слать следующий запрос, не дожидаясь ответа, но ответы всё равно обязаны были идти в порядке запросов, и медленный первый блокировал всех. Вдобавок кривые прокси теряли или переставляли ответы. В итоге её выключили во всех браузерах, а параллелизма добивались шестью соединениями на домен и доменным шардингом.

HTTP/2

  • Бинарный фрейминг. Сообщение разбито на фреймы (HEADERS, DATA, SETTINGS, WINDOW_UPDATE, RST_STREAM, PING, GOAWAY). Парсинг однозначный, без эвристик по пробелам.
  • Потоки и мультиплексирование. Внутри одного TCP-соединения живут независимые потоки со своими идентификаторами; фреймы разных потоков идут вперемешку. Отвечать можно в любом порядке — HOL прикладного уровня исчез.
  • HPACK. Сжимает заголовки: статическая таблица частых пар, динамическая таблица «уже виденных» плюс Хаффман. Повторные запросы к тому же хосту шлют заголовки почти бесплатно, а раньше с каждым запросом улетали одни и те же куки на килобайт.
  • Управление потоком на уровне потоков. У каждого stream своё окно, отдельно от TCP.
  • Server push. Сервер мог сам отправить ресурсы, которые клиент ещё не просил. Идея не взлетела: сервер не знает, что уже лежит в кэше браузера, и в среднем гнал трафик впустую. Браузеры его выключили (Chrome — в 2022 году), хотя в RFC 9113 он формально остался. На смену пришёл 103 Early Hints с Link: rel=preload: сервер подсказывает, а решает клиент.

HTTP/3 и QUIC

  • Транспорт поверх UDP, а всю надёжность QUIC реализует сам, в пользовательском пространстве. Почему UDP: новый транспортный протокол в интернет не протащить, промежуточные узлы (NAT, файрволы) пропускают практически только TCP и UDP.
  • Нет TCP-HOL: порядок, подтверждения и ретрансмиссии ведутся отдельно по каждому потоку.
  • TLS 1.3 встроен в рукопожатие. Транспортное и криптографическое рукопожатия слиты в одно: 1 RTT на первое соединение и 0 RTT при возобновлении. Открытым почти ничего не идёт, скрыты даже номера пакетов.
  • Миграция соединения. Соединение опознаётся не по пятёрке, а по Connection ID, поэтому переход с Wi-Fi на LTE не рвёт сессию: адрес поменялся, соединение то же.
  • QPACK вместо HPACK: сжимает заголовки так же, но не ломается, когда фреймы приходят не по порядку.
  • Минусы тоже есть. UDP местами режут корпоративные файрволы; CPU нагружается сильнее, потому что стек в user space (хотя есть offload); отлаживать сложнее, tcpdump покажет только шифрованный UDP.
Как клиент узнаёт, какую версию использовать

Про HTTP/2 договариваются через ALPN в TLS-рукопожатии: клиент присылает список h2, http/1.1, сервер выбирает. Лишнего круга нет. С HTTP/3 иначе: браузер сначала ходит по TCP+TLS, а сервер в ответе отдаёт Alt-Svc: h3=":443"; ma=86400, то есть «я умею h3, приходи по нему в следующий раз». Узнать про h3 ещё до первого соединения позволяет HTTPS-запись в DNS (type65). В Go net/http сервер поднимает HTTP/2 сам, если ты используешь TLS и не трогал TLSNextProto, а клиент — если не задавал свой TLSClientConfig или Dial (иначе нужен ForceAttemptHTTP2); HTTP/3 в стандартной библиотеке нет, берут quic-go.

TLS: что происходит в рукопожатии

TLS 1.2: два RTT до данных TLS 1.3: один RTT клиент сервер ClientHello версии, шифры, random, SNI, ALPN ServerHello, Certificate, ServerKeyExchange, HelloDone ClientKeyExchange, ChangeCipherSpec, Finished ChangeCipherSpec, Finished только теперь: HTTP-запрос 2 RTT клиент сервер ClientHello + key_share догадка о группе ECDHE, SNI, ALPN ServerHello + key_share, {Certificate, CertVerify, Finished} фигурные скобки = уже зашифровано {Finished} + HTTP-запрос 1 RTT при возобновлении по билету - 0 RTT Асимметрика (подпись RSA/ECDSA, обмен ECDHE) нужна только чтобы доказать личность сервера и выработать общий секрет. Дальше весь трафик шифруется симметрично: AES-GCM или ChaCha20-Poly1305 - на порядки быстрее асимметрики. TLS 1.3 выкинул RSA key transport, статический DH, CBC, сжатие и слабые наборы: осталось 5 шифронаборов, все AEAD, обмен ключами — эфемерный. SNI в ClientHello идёт открытым текстом (скрывает только ECH), там же ALPN выбирает h2 / http/1.1.
Один круг вместо двух. TLS 1.3 экономит RTT: клиент угадывает группу ECDHE (в Go с 1.24 по умолчанию — гибридную постквантовую X25519MLKEM768) и присылает свою долю ключа уже в первом сообщении.

Что даёт TLS и как

TLS даёт три свойства: конфиденциальность (по дороге никто не прочитает), целостность (никто не подменит: AEAD-шифры сразу и шифруют, и аутентифицируют) и аутентификацию сервера (ты действительно говоришь с тем доменом). Чего TLS не даёт: он не скрывает, к какому домену ты подключаешься (SNI открыт), не прячет размеры и тайминги трафика и не защищает от дырявого приложения на том конце.

Симметрика и асимметрика работают вместе, потому что асимметричная криптография в тысячи раз медленнее. Асимметрика нужна ровно дважды: сервер подписывает рукопожатие своим приватным ключом (так он доказывает, что владеет сертификатом), а стороны выполняют обмен ECDHE и получают общий секрет, который по проводу не передаётся. Из него выводятся симметричные ключи, и дальше данные шифруются AES-GCM или ChaCha20-Poly1305. Forward secrecy: ключи ECDHE эфемерные, поэтому даже если приватный ключ сервера утечёт завтра, записанный сегодня трафик им не расшифровать.

Сертификаты и цепочка доверия

Сертификат X.509 связывает доменное имя с публичным ключом и подписан удостоверяющим центром. Проверяют его по цепочке: листовой сертификат сервера подписан промежуточным, промежуточный — корневым, а корневой лежит в доверенном хранилище ОС или браузера. Клиент смотрит подписи по всей цепочке, срок действия каждого, соответствие имени полю SAN (Common Name как имя не используется с 2017 года), назначение ключа и отзыв (CRL или OCSP; Let's Encrypt в 2025 году выключил OCSP, и отзыв у него снова идёт через CRL).

Самая частая продовая ошибка с сертификатами

Сервер отдаёт только листовой сертификат и забывает промежуточный. В браузере всё работает (он кэширует промежуточные с других сайтов или дотягивает их по AIA), а curl из контейнера и Go-клиент падают с x509: certificate signed by unknown authority. Ловится командой openssl s_client -connect host:443 -showcerts: смотри, какой длины цепочка. Вторая по частоте ошибка: x509: certificate is valid for X, not Y. Имя в SAN не совпало, обычно из-за обращения по IP или по внутреннему DNS-имени.

// Клиент с проверкой и минимальной версией
tr := &http.Transport{
    TLSClientConfig: &tls.Config{
        MinVersion: tls.VersionTLS12,    // ниже 1.2 не соглашаемся
        // InsecureSkipVerify: true пиши только в тестах, в проде он снимает всю защиту
    },
    ForceAttemptHTTP2: true, // со своим TLSClientConfig HTTP/2 сам не включится
}

// Взаимный TLS: сервер требует сертификат и от клиента
srv := &http.Server{
    Addr: ":8443",
    TLSConfig: &tls.Config{
        ClientAuth: tls.RequireAndVerifyClientCert,
        ClientCAs:  clientCAPool, // CA, которыми подписаны сертификаты клиентов
        MinVersion: tls.VersionTLS12,
    },
}

SNI, ALPN, mTLS, возобновление сессии

  • SNI (Server Name Indication): клиент открытым текстом называет в ClientHello нужный домен. Без этого сервер не знает, какой сертификат предъявить, когда на одном IP висят сотни доменов (весь shared-хостинг и CDN). Побочный эффект: по SNI провайдер видит, куда ты идёшь. Закрывает эту дыру расширение ECH (Encrypted Client Hello).
  • ALPN (Application-Layer Protocol Negotiation) позволяет договориться о прикладном протоколе прямо в рукопожатии: h2, http/1.1, h3. Поэтому HTTP/2 не стоит ни одного лишнего RTT.
  • mTLS (взаимный TLS): сертификат предъявляет и сервер, и клиент, а сервер клиентский проверяет. Применяют для межсервисных вызовов внутри периметра (Istio/Linkerd делают это прозрачно), доступа к Kubernetes API, банковских и платёжных интеграций, IoT-устройств, доступа к внутренним админкам вместо VPN (BeyondCorp). Плюс: аутентификация без паролей и токенов. Минус: свой PKI, а с ним ротация и отзыв.
  • Возобновление сессии. В TLS 1.2 это session ID (состояние на сервере) или session ticket (состояние зашифровано и хранится у клиента). В TLS 1.3 это PSK-билет, дающий 0-RTT: данные едут в самом первом пакете. Беда 0-RTT в replay: перехваченный ранний запрос можно повторить, поэтому в 0-RTT кладут только идемпотентные запросы (GET) и никогда POST.

Кэширование HTTP

Что делает кэш, когда приложение просит ресурс запрос ресурса из приложения запись в кэше есть и ещё свежая? (max-age / Expires) да отдаём из кэша, запроса в сеть НЕТ (from disk cache) нет условный запрос на сервер If-None-Match: "9a1f3c" If-Modified-Since: ... 304 Not Modified тела нет, свежесть продлена 200 OK новое тело и новый ETag no-store - не сохранять вообще. no-cache - сохранять можно, но КАЖДЫЙ раз перепроверять условным запросом. must-revalidate - протухшее нельзя отдавать даже при недоступном сервере. immutable - не перепроверять никогда.
Два режима. Свежий ответ отдаётся вообще без сети. Протухший не выбрасывается, а перепроверяется условным запросом, и 304 обходится в ноль байт тела.
Директива Cache-ControlЧто значит
max-age=3600ответ считается свежим час с момента получения
s-maxage=60то же, но только для общих кэшей (CDN, прокси); перебивает max-age
public / privateможно ли класть в общий кэш. Ответы с Authorization по умолчанию не кэшируются общими кэшами — public это переопределяет (осторожно!)
no-cacheне «не кэшировать»: сохранять можно, но перед каждой отдачей обязательна ревалидация условным запросом
no-storeвот это «не кэшировать вообще»: не писать ни на диск, ни в память. Для персональных данных, банковских выписок, страниц с токенами
must-revalidateпосле истечения свежести отдавать старое запрещено, даже если сервер недоступен — лучше ошибка, чем устаревшие данные
immutableсодержимое не изменится никогда: не перепроверять даже при F5. Для файлов с хэшем в имени (app.7f3a1c.js)
stale-while-revalidate=30отдать протухшее сразу, а свежее подтянуть фоном — заметно улучшает воспринимаемую скорость

В ETag лежит версия содержимого, обычно хэш. Клиент присылает If-None-Match: "9a1f3c", сервер сравнивает и отвечает либо 304 Not Modified вообще без тела, либо 200 с новым содержимым. Бывает сильный (побайтовое совпадение) и слабый (W/"...", «семантически то же»). Last-Modified с If-Modified-Since делают то же самое по времени, с точностью до секунды, и два изменения в одну секунду не различат. Если есть оба, приоритет у ETag.

func handler(w http.ResponseWriter, r *http.Request) {
    body := render()
    etag := `"` + fmt.Sprintf("%x", sha256.Sum256(body))[:16] + `"`
    w.Header().Set("ETag", etag)
    w.Header().Set("Cache-Control", "private, max-age=60, must-revalidate")
    w.Header().Set("Vary", "Accept-Encoding, Authorization") // иначе отдадим чужой кэш

    if match := r.Header.Get("If-None-Match"); match == etag {
        w.WriteHeader(http.StatusNotModified) // 304, тело не пишем
        return
    }
    w.Write(body)
}
Про заголовок Vary забывают, и это дорого

Кэш ключуется по URL. Если ответ зависит ещё от чего-то (сжатия, языка, пользователя), перечисли эти заголовки в Vary, иначе общий кэш отдаст одному пользователю ответ, сгенерированный для другого. Самая опасная версия бага: персональный ответ без Cache-Control: private оседает на CDN и раздаётся всем. Правило: любой ответ, который зависит от Authorization или куки, помечай private (а лучше no-store) и всегда ставь Vary.

Вопросы

8
Суть: в HTTP/1.1 это обычный текст: стартовая строка, потом заголовки по одному в строке, потом пустая строка (CRLF CRLF), потом тело. Границу тела задаёт либо Content-Length, либо Transfer-Encoding: chunked. В HTTP/2 и HTTP/3 та же семантика, но это бинарные фреймы и сжатые заголовки.

Запрос

POST /api/v1/orders?dry=1 HTTP/1.1     <- стартовая строка: метод, target, версия
Host: api.example.com                  <- обязателен в 1.1, иначе 400
Content-Type: application/json
Content-Length: 21
Authorization: Bearer eyJhbGciOi...
X-Request-Id: 0f1c-88ad-...
                                       <- ПУСТАЯ строка = конец заголовков
{"sku":"A-1","qty":2}

Строки разделяются строго \r\n (CRLF), а не \n. Регистр имён заголовков не важен (content-type == Content-Type), и в Go http.Header приводит имя к канонической форме (X-Request-Id). Это аукнется, если лезешь руками в map[string][]string: ключ "x-request-id" там не найдётся, нужен Header.Get.

Ответ

HTTP/1.1 201 Created                   <- версия, код, reason phrase (в HTTP/2 её нет вообще)
Content-Type: application/json; charset=utf-8
Location: /api/v1/orders/42
Transfer-Encoding: chunked

18
{"id":42,"status":"new"}
0
                                       <- нулевой чанк = конец тела

Где кончается тело — три варианта

  • Content-Length: N велит прочитать ровно N байт. Длину надо знать заранее, то есть сначала собрать весь ответ.
  • Transfer-Encoding: chunked режет тело на куски «длина в hex, CRLF, данные», а конец отмечает чанк нулевой длины. Нужен для стриминга, когда длина неизвестна: логи, SSE, выгрузка большой таблицы. В Go включается сам, если ты не поставил Content-Length и данных больше буфера.
  • Закрытие соединения — легаси из HTTP/1.0: тело кончается там, где кончился TCP. Несовместимо с keep-alive.
На что ловят

«А что если прислать и Content-Length, и Transfer-Encoding?» Это классика request smuggling: фронт-прокси верит одному заголовку, бэкенд другому, и в поток бэкенда «протаскивается» лишний запрос, который приклеивается к следующему пользователю. По RFC при обоих заголовках побеждает Transfer-Encoding, а Content-Length надо удалить; безопасные реализации просто отвечают 400. Go-сервер поступает по RFC: удаляет Content-Length и читает тело как chunked, а 400 отдаёт, если заголовков Transfer-Encoding несколько или значение не chunked.

Чем добить

В HTTP/2 стартовой строки нет: метод, схема, авторитет и путь стали псевдозаголовками :method, :scheme, :authority, :path, а у ответа есть :status. Reason phrase («Not Found» после 404) выбросили за ненадобностью. Имена заголовков в HTTP/2 пишутся только в нижнем регистре, прописная буква считается протокольной ошибкой.

Суть: безопасный — не меняет состояние на сервере (GET, HEAD, OPTIONS). Идемпотентный — N одинаковых запросов дают то же состояние, что и один (GET, HEAD, PUT, DELETE, OPTIONS). POST и PATCH не идемпотентны. Всё безопасное автоматически идемпотентно, обратное неверно.
МетодБезопасныйИдемпотентныйКэшируемыйТело
GETдададанет (не должно быть)
HEADдададанет
OPTIONSдаданетредко
PUTнетданетда
DELETEнетданетобычно нет
POSTнетнетпочти нетда
PATCHнетнет (зависит от формата патча)нетда

Почему PUT идемпотентен, а POST нет

PUT /orders/42 означает «пусть по этому адресу лежит вот такое представление». Повтори хоть десять раз, состояние останется тем же. POST /orders означает «обработай вот эти данные», и адрес сервер выбирает сам, поэтому десять повторов дают десять заказов. Вот почему клиенты и прокси вправе автоматически ретраить GET/PUT/DELETE при сетевой ошибке, а POST нет.

Идемпотентность говорит о состоянии, а не об ответе

Первый DELETE /orders/42 вернёт 204, второй — 404. Коды разные, но состояние после обоих одинаковое: заказа нет. Это идемпотентно. Не путай с этим POST, который «случайно» ничего не сломал: идемпотентность сервер обещает по контракту метода, и неважно, что вышло в конкретный раз.

Как сделать POST идемпотентным на практике

Через ключ идемпотентности: клиент генерирует UUID и шлёт его в заголовке Idempotency-Key. Сервер в транзакции пытается вставить этот ключ в таблицу с уникальным индексом. Если вставка прошла, он выполняет операцию и сохраняет рядом результат; если ключ уже есть, возвращает сохранённый ответ и ничего не выполняет. Так делают Stripe, YooKassa и все платёжные API. Ключ живёт с TTL (сутки-неделя). Тонкое место: состояние «ключ вставлен, но операция ещё выполняется». Второй запрос в этот момент должен получить 409 или подождать, а не начать вторую операцию.

Классический подвох

«GET безопасен» — значит, GET-ручку можно дёргать сколько угодно. А теперь вспомни ручку GET /api/logout или GET /admin/users/5/delete. Такое сносил краулер поисковика и префетчер браузера. Правило: если метод меняет состояние, это не GET.

Суть: 1xx — информационные, 2xx — успех, 3xx — «ищи в другом месте или у тебя уже есть», 4xx — виноват клиент (повтор без изменений не поможет), 5xx — виноват сервер (повтор может помочь). Граница 4xx/5xx — это граница «кого будить ночью».

2xx

  • 200 OK — успех с телом.
  • 201 Created: ресурс создан, обычно с заголовком Location на его адрес.
  • 202 Accepted: приняли в обработку, результата ещё нет. Правильный код для асинхронных операций, к нему отдаём Location на статус-ресурс.
  • 204 No Content — успех без тела (удаление, PUT без возврата). Тело писать нельзя вообще.
  • 206 Partial Content отвечает на Range: докачка и стриминг видео.

3xx

  • 301 Moved Permanently переносит навсегда, браузеры кэшируют его агрессивно и надолго. Ошибёшься — пользователи месяцами будут ходить на старый адрес, пока не почистят кэш.
  • 302 Found / 307 Temporary Redirect переносят временно. Разница в том, что при 301/302 браузеры исторически меняли POST на GET, а 307 и 308 обязаны сохранять метод и тело. Нужен предсказуемый редирект — бери 307/308.
  • Go 1.26 переехал на 307 в стандартном роутере. Раньше, когда net/http.ServeMux сам перекидывал /x на зарегистрированный /x/, он отдавал 301. Это была мина: 301 браузер кэширует практически навсегда, и после переделки роутов пользователь всё равно шёл по старому адресу, пока не почистит кэш, а POST по дороге превращался в GET. С Go 1.26 здесь уходит 307: он не кэшируется и сохраняет метод с телом. Хорошая иллюстрация того, почему «навсегда» в вебе стоит дороже всего.
  • 303 See Other говорит «сходи туда методом GET»: канонический ответ на POST по паттерну Post/Redirect/Get, чтобы F5 не отправил форму дважды.
  • 304 Not Modified — ответ на условный запрос: тела нет, бери из кэша.

4xx — где ошибаются чаще всего

  • 400 Bad Request — запрос синтаксически сломан: не распарсился JSON, не тот тип поля.
  • 401 Unauthorized на самом деле значит «не аутентифицирован»: токена нет, он протух или подпись не сошлась. Обязан идти с WWW-Authenticate. Клиенту есть смысл обновить токен и повторить.
  • 403 Forbidden: «кто ты, понятно, но тебе нельзя». Повтор с тем же токеном бессмыслен. Путаница 401/403 остаётся самым частым ляпом в API.
  • 404 Not Found: ресурса нет. Иногда его намеренно отдают вместо 403, чтобы не подтверждать чужому пользователю, что объект существует.
  • 405 Method Not Allowed — путь есть, метод не тот; обязателен Allow.
  • 409 Conflict означает конфликт с текущим состоянием: оптимистическая блокировка не сошлась, дубликат по уникальному ключу, попытка повторно оплатить оплаченный заказ.
  • 412 Precondition Failed приходит, когда не выполнилось условие If-Match: ресурс изменили между чтением и записью.
  • 422 Unprocessable Content: JSON валидный и разобрался, но бизнес-валидация не прошла («qty должен быть больше нуля»). Разница с 400: синтаксис против семантики. Это соглашение, а не жёсткое требование, но команды его любят.
  • 429 Too Many Requests — рейт-лимит. Отдавай его с Retry-After, иначе клиенты начнут долбить сильнее и устроят тебе метастабильный отказ.

5xx

  • 500 Internal Server Error: «мы сломались», в 99% случаев из-за необработанной паники или неожиданной ошибки. Деталей клиенту наружу не отдаём.
  • 502 Bad Gateway отдаёт прокси, который сходил на апстрим и получил мусор или разрыв. Смотри бэкенд.
  • 503 Service Unavailable — временно не можем: перегрузка, деплой, отключён брейкером. Вместе с ним правильно отдавать Retry-After.
  • 504 Gateway Timeout: прокси не дождался апстрима. Классика: у nginx таймаут 60 с, а запрос идёт 90 с.
Правило выбора кода

Задай два вопроса. Первый: «поможет ли клиенту повторить запрос без изменений?» Да — 5xx или 429, нет — 4xx. Второй: «кто должен чинить?» Клиент — 4xx, мы — 5xx. Отсюда и правило: не отдавай 200 с {"error": ...} в теле. Такой ответ ломает мониторинг, ретраи, брейкеры и кэш, ведь вся инфраструктура смотрит на код.

Суть: Content-Type — как читать тело; Accept — что клиент готов принять; Authorization — кто спрашивает; Cache-Control и ETag — можно ли не ходить в сеть; X-Request-Id — сквозная нить в логах; куки — состояние, которое браузер сам таскает за тобой.

Content-Type и Accept

Content-Type: application/json; charset=utf-8 описывает текущее тело, Accept: application/json говорит, что клиент хочет в ответ (content negotiation). Не умеет сервер отдать запрошенный тип, отвечает 406; не понимает присланный — 415 Unsupported Media Type. На практике Content-Type из запроса решает, парсить форму или JSON, а без него браузер может начать MIME sniffing: угадает тип по содержимому и превратит загруженную картинку в исполняемый HTML. Лечится X-Content-Type-Options: nosniff.

Authorization

Схемы: Basic base64(user:pass) (только поверх TLS, пароль летит фактически открытым), Bearer <token> (JWT или opaque-токен, стандарт для API), Digest (легаси). Инфраструктуру он тоже задевает: с Authorization в запросе общим кэшам по умолчанию запрещено кэшировать ответ. И целиком этот заголовок в логи не пишут, только префикс или хэш.

X-Request-Id и трассировка

Балансировщик или самый первый сервис генерирует идентификатор запроса, кладёт в X-Request-Id и пишет в каждую строку лога. Дальше он прокидывается во все исходящие вызовы, в сообщения в брокер, в атрибуты спанов. Так по одной строке из тикета пользователя собирают весь путь запроса через десяток сервисов. Стандартом сейчас стал traceparent из W3C Trace Context (OpenTelemetry), но X-Request-Id живёт практически везде.

// Middleware: взять входящий id или создать свой, положить в контекст
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-Id")
        if id == "" {
            id = uuid.New().String()   // Go 1.27: uuid уже в стандартной библиотеке
        }
        w.Header().Set("X-Request-Id", id)                 // вернём клиенту
        ctx := context.WithValue(r.Context(), ctxKeyReqID, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
Go 1.27: за UUID больше не надо ходить в сторонний модуль

Годами uuid в примере означал github.com/google/uuid. С Go 1.27 пакет uuid есть в стандартной библиотеке (RFC 9562): uuid.New() даёт случайный (синоним uuid.NewV4()), uuid.NewV7()сортируемый по времени (в первых 48 битах миллисекундная метка, поэтому такие ключи ложатся в конец B-tree-индекса, а не разбрасываются по нему, как v4), плюс uuid.Parse, uuid.MustParse, uuid.Nil(), uuid.Max(). Тип UUID [16]byte сравнивается через ==, умеет String(), Compare и MarshalText/UnmarshalText (значит, и в JSON, и в ключах map). Отдельного NewString() нет, пишут uuid.New().String().

Куки

Сервер ставит Set-Cookie: sid=abc; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400, браузер потом сам шлёт Cookie: sid=abc на каждый подходящий запрос. Атрибуты:

  • HttpOnly: JS не видит куку через document.cookie, и при XSS сессию не украсть.
  • Secure — отправляется только по HTTPS.
  • SameSite бывает Strict (вообще не отправлять при переходах с чужих сайтов), Lax (отправлять при обычной навигации по ссылке, но не при POST/iframe/img; дефолт в Chrome) и None (отправлять всегда, обязателен Secure). Это главная встроенная защита от CSRF.
  • Domain / Path — область видимости; Max-Age / Expires — срок жизни, без них кука сессионная и живёт до закрытия браузера.
Куки против Bearer-токена

Куку браузер прикрепляет автоматически — отсюда CSRF и нужда в SameSite и CSRF-токенах. Заголовок Authorization ставят руками, поэтому CSRF ему не грозит, зато токен приходится где-то хранить, и localStorage для этого плохое место: любой XSS его прочитает. В SPA обычно держат refresh-токен в HttpOnly-куке, а access-токен в памяти JS.

Суть: в HTTP/1.1 одно соединение — одна очередь запросов, поэтому браузер открывает 6 соединений на домен. HTTP/2 — бинарный фрейминг: много логических потоков в одном TCP-соединении, заголовки жмутся HPACK. Но HOL-блокировка не исчезла, а переехала на уровень TCP: потеря одного сегмента тормозит все потоки сразу.

Что было в 1.1

Keep-alive (дефолт с 1.1) оставляет TCP открытым после ответа, чтобы заново не платить за рукопожатие и медленный старт. Pipelining разрешает слать несколько запросов, не дожидаясь ответов, но отвечать сервер обязан строго по порядку. Поэтому один медленный запрос запирает все следующие: это и есть HOL-блокировка прикладного уровня. Вдобавок кривые прокси ломали порядок, и браузеры конвейеризацию отключили — на практике её нет. Обходились «шардированием доменов» (img1.site.com, img2.site.com), склейкой CSS/JS и спрайтами. С HTTP/2 эти костыли стали вредны.

Что принёс HTTP/2

  • Бинарный фрейминг. Вместо текста идут фреймы фиксированной структуры: HEADERS, DATA, SETTINGS, WINDOW_UPDATE, RST_STREAM, GOAWAY, PING. Парсится однозначно и быстро.
  • Мультиплексирование. У каждого запроса свой stream id, фреймы разных потоков чередуются в одном соединении и собираются на той стороне. Сотне параллельных запросов хватает одного TCP-соединения и одного TLS-хендшейка.
  • HPACK. Сжимает заголовки статической таблицей (61 частый заголовок), динамической таблицей на соединение и кодом Хаффмана. Повторные Cookie и User-Agent схлопываются до пары байт, а в 1.1 они летели целиком в каждом запросе.
  • Приоритеты и управление потоком на уровне отдельных стримов (WINDOW_UPDATE), Server Push (оказался неудачным и де-факто мёртв).
Главная формулировка про HOL

HTTP/2 убрал HOL-блокировку на прикладном уровне, но добавил зависимость от одного TCP-соединения. TCP обязан отдавать байты приложению строго по порядку: если потерялся один сегмент, ядро держит в буфере всё, что пришло после него, и встают все стримы, даже те, чьи данные уже пришли. В HTTP/1.1 с шестью соединениями потеря била только по одному из них. Поэтому на плохой сети (мобильный интернет, потери 2–5%) HTTP/2 иногда проигрывает HTTP/1.1. Это и чинит QUIC: он уносит нумерацию и восстановление на уровень потоков.

HTTP/1.1HTTP/2HTTP/3
Форматтекстбинарные фреймыбинарные фреймы
ТранспортTCPTCPQUIC поверх UDP
Параллельностьнесколько TCP-соединенийстримы в одном соединениистримы в одном соединении
Заголовкитекст целикомHPACKQPACK
HOL прикладнойестьнетнет
HOL транспортныйесть (в рамках соединения)есть, общий на все стримынет
РукопожатиеTCP + TLS = 2–3 RTTTCP + TLS = 2–3 RTT1 RTT, 0 RTT по билету
Смена сетирвётсярвётсяпереживает (connection ID)
Глубже, чем спросят

В Go клиент включает HTTP/2 сам, если ты пользуешься дефолтным http.Transport и не трогал TLSClientConfig. При ручной настройке TLS автоапгрейд отключается, и надо либо ставить ForceAttemptHTTP2: true, либо звать http2.ConfigureTransport. О версии договариваются в ALPN внутри TLS-рукопожатия, так что лишнего раунда нет. HTTP/2 возможен и без TLS (h2c), но браузеры так не умеют: это вариант для межсервисного трафика, в том числе gRPC.

Суть: QUIC — это транспорт поверх UDP, который затащил внутрь себя надёжность, порядок по каждому потоку отдельно и TLS 1.3. Это убирает транспортную HOL-блокировку, схлопывает рукопожатие до 1 RTT (0 RTT при возобновлении) и позволяет пережить смену сети. HTTP/3 — это HTTP поверх QUIC.

Четыре вещи, которые QUIC решает

  • HOL на транспорте. Порядок и восстановление потерь у каждого стрима свои, и потерянный пакет стрима A не задерживает данные стрима B.
  • Стоимость установки. TCP+TLS 1.3 обходятся в 2 RTT (1 на SYN, 1 на TLS). QUIC совмещает транспортное и криптографическое рукопожатие в 1 RTT, а при возобновлении по билету данные летят в первом же пакете, за 0 RTT.
  • Смена сети. Соединение опознаётся не по пятёрке адрес/порт, а по connection ID. Переключился с Wi-Fi на LTE, IP сменился, а соединение живо: ни рукопожатия заново, ни обрыва загрузки.
  • Окостенение (ossification). TCP нельзя развивать: промежуточные железки режут незнакомые опции. QUIC шифрует почти весь заголовок, включая номера пакетов, поэтому middlebox видит только UDP и не мешает эволюции.

Цена

  • Процессор. Пакеты обрабатываются в userspace, а не в ядре: нет разгрузки на сетевую карту (частично её сейчас дают UDP GSO/GRO), больше системных вызовов. Раньше разрыв был в 2–3 раза, сейчас заметно меньше, но он есть.
  • UDP местами режут. Корпоративные файрволы и некоторые провайдеры блокируют UDP/443, поэтому нужен фолбэк на TCP: сервер объявляет поддержку заголовком Alt-Svc: h3=":443", а клиент пробует оба варианта.
  • Инструменты. Привычный tcpdump покажет только шифрованный UDP; отладка требует ключей и свежих версий Wireshark.
Где это уже работает

HTTP/3 стандартизован в RFC 9114 (2022), его поддерживают Chrome, Firefox, Safari, Cloudflare, Google и все крупные CDN. По разным замерам на HTTP/3 сейчас приходится порядка трети трафика в интернете. В стандартной библиотеке Go его нет: используют quic-go, а в модуле golang.org/x/net есть экспериментальный пакет quic. Ценят, если добавишь: 0-RTT уязвим к replay, поэтому туда кладут только идемпотентные запросы.

Суть: асимметрика работает ровно в рукопожатии — доказать личность сервера подписью и выработать общий секрет через ECDHE. Дальше весь трафик идёт симметричным AEAD-шифром, потому что он на порядки быстрее. TLS 1.3 укладывается в 1 RTT, TLS 1.2 — в 2.

Рукопожатие TLS 1.3 по шагам

  1. ClientHello: версии, список шифронаборов, поддерживаемые группы, SNI (какой домен нужен), ALPN (h2/http1.1) и сразу key_share, долю ключа для той группы, которую клиент угадал.
  2. ServerHello: выбранный шифронабор и свой key_share. С этого момента обе стороны уже могут вычислить общий секрет, и всё дальнейшее шифруется.
  3. Сервер шифрованно шлёт Certificate, CertificateVerify (подписывает хэш всего рукопожатия приватным ключом и так доказывает владение) и Finished.
  4. Клиент проверяет цепочку и подпись, шлёт Finished и в том же полёте может слать HTTP-запрос. Итого 1 RTT.

Если клиент не угадал группу, сервер отвечает HelloRetryRequest, и рукопожатие стоит 2 RTT. В TLS 1.2 полное рукопожатие — 2 RTT, есть RSA key transport без forward secrecy и живы CBC-режимы, на которые пришлась половина известных атак. TLS 1.3 всё это выкинул: осталось пять шифронаборов, все с AEAD, а обмен ключами только эфемерный, с forward secrecy (её нет лишь у возобновления по одному PSK и у 0-RTT).

Цепочка доверия

Сертификат X.509 связывает домен с публичным ключом и подписан удостоверяющим центром. Клиент строит цепочку (лист → промежуточный → корень из хранилища ОС/браузера) и проверяет подписи, сроки, соответствие имени полю SAN (Common Name как имя не используется с 2017 года), назначение ключа и отзыв (CRL; OCSP уходит — Let's Encrypt выключил его в 2025 году). Корневые ключи держат офлайн и подписывают ими только промежуточные, поэтому сервер обязан отдавать промежуточный.

Что реально ломается
  • x509: certificate signed by unknown authority: забыли промежуточный сертификат. Браузер прощает, curl и Go нет.
  • x509: certificate is valid for A, not B: обращались по IP или по внутреннему имени, которого нет в SAN.
  • Сертификат просрочился в 3 часа ночи, потому что автопродление молча сломалось. Лечится алертом «осталось меньше 14 дней».
  • InsecureSkipVerify: true в проде выключает всю защиту, и MITM становится тривиальным. Нужен внутренний CA? Добавь его в RootCAs, а не отключай проверку.

SNI и mTLS

SNI несёт имя домена открытым текстом в ClientHello. Без него на одном IP не удержать много сайтов с разными сертификатами: сервер не знает, какой предъявить. Побочный эффект: наблюдателю видно, куда ты идёшь. Закрывает это расширение ECH.

В mTLS (взаимный TLS) сертификат предъявляет и клиент, а сервер проверяет его по своему пулу CA. Где применяется: межсервисный трафик в service mesh (Istio, Linkerd делают это прозрачно через сайдкар), доступ к Kubernetes API, банковские и платёжные интеграции, IoT, доступ к внутренним системам вместо VPN (модель BeyondCorp). Плюс: аутентификация без паролей и токенов, личность привязана к криптографии. Минус: свой PKI, то есть выпуск, распространение, ротация (в mesh автоматом каждые 24 часа) и отзыв.

// Сервер, требующий клиентский сертификат
srv := &http.Server{
    Addr: ":8443",
    TLSConfig: &tls.Config{
        MinVersion: tls.VersionTLS12,
        ClientAuth: tls.RequireAndVerifyClientCert, // не Request и не Verify...IfGiven
        ClientCAs:  clientCAPool,
    },
}
// Клиент с внутренним CA вместо InsecureSkipVerify
cfg := &tls.Config{RootCAs: internalPool, MinVersion: tls.VersionTLS12}
Суть: два независимых механизма. Свежесть (Cache-Control: max-age) позволяет вообще не ходить в сеть. Валидация (ETag + If-None-Match) позволяет сходить дёшево: сервер отвечает 304 без тела.

Как кэш принимает решение

  1. Есть ли запись по ключу (URL + заголовки из Vary)? Нет — идём в сеть.
  2. Свежа ли она (возраст меньше max-age / до Expires)? Если да, отдаём мгновенно, в сеть не ходим вообще.
  3. Протухшую не выбрасываем, а делаем условный запрос с If-None-Match (или If-Modified-Since). На 304 продлеваем свежесть и отдаём старое тело, на 200 заменяем.

Директивы, которые путают

  • no-cache не значит «не кэшировать». Кэшировать можно, но перед каждой отдачей обязательна ревалидация.
  • А вот no-store значит «вообще не сохранять». Для персональных данных и страниц с токенами.
  • private пускает только в браузер, не в CDN. public пускает и в общие кэши (и даже перебивает запрет для ответов с Authorization, штука опасная).
  • immutable — не перепроверять никогда: для файлов с хэшем в имени.
  • stale-while-revalidate=30: отдать протухшее сразу, а обновить фоном.

ETag против Last-Modified

ETag несёт хэш или версию содержимого и точен побайтово; W/"..." помечает слабый, «то же по смыслу». Last-Modified несёт время с точностью до секунды и два изменения внутри одной секунды не различит. Если есть оба, приоритет у ETag. Бонусом тот же механизм даёт оптимистическую блокировку на запись: If-Match: "9a1f3c", и при несовпадении сервер отвечает 412, а не затирает чужие изменения.

Про Vary забывают, и это дорого

Кэш ключуется по URL. Если ответ зависит ещё от чего-то (Accept-Encoding, Accept-Language, Authorization), это надо перечислить в Vary. Иначе общий кэш отдаст одному пользователю ответ, сгенерированный для другого. Худший сценарий: персональный ответ без private оседает на CDN и раздаётся всем. Правило: всё, что зависит от пользователя, помечай private (лучше no-store) и ставь корректный Vary.

Рабочая стратегия для веб-приложения

Статике с хэшем в имени (app.7f3a1c.js) ставь max-age=31536000, immutable. HTML-точке входа — no-cache плюс ETag: проверяем всегда, но обычно получаем 304 в 150 байт. API — private, max-age=0, must-revalidate плюс ETag, чтобы мобильный клиент экономил трафик на неизменившихся списках. Публичным справочникам — public, s-maxage=300, stale-while-revalidate=60, чтобы CDN снял нагрузку.

9.4REST, RPC, gRPC

Самая «разговорная» глава секции: почти каждый вопрос здесь проверяет не память, а умение отличить термин от того, что за ним стоит. REST по Филдингу почти никто не делает. gRPC «быстрее» из-за конкретных инженерных решений, а не потому, что его так написали. WebSocket не «постоянный HTTP»: после одного HTTP-запроса по соединению идёт совсем другой протокол. Здесь же и самый практический вопрос главы: почему gRPC ломается о балансировщик, на котором REST работал годами.

REST: что это на самом деле

REST не формат, не «JSON по HTTP» и не «CRUD с красивыми URL». Это архитектурный стиль из диссертации Роя Филдинга (2000), одного из авторов HTTP. Филдинг описывал не «как делать API», а почему веб как система масштабируется: как миллиарды клиентов, кэшей и прокси работают вместе, не зная друг о друге. REST сводится к набору ограничений, и масштабируемость следует из них сама.

Шесть ограничений

  1. Клиент-сервер. Разделение ответственности: интерфейс отдельно, хранение отдельно, и стороны развиваются независимо.
  2. Stateless. Каждый запрос самодостаточен: между запросами сервер ничего не помнит о клиенте, всё нужное едет в самом запросе (токен, параметры, версия). Отсюда горизонтальное масштабирование: любой запрос обработает любой инстанс, а падение узла не рвёт «сессию».
  3. Кэшируемость. Ответ обязан явно говорить, кэшируемый он или нет. Поэтому кэши и CDN можно вставить между клиентом и сервером, не меняя ни того, ни другого.
  4. Единообразный интерфейс считается центральным ограничением и распадается на четыре: идентификация ресурсов через URI; манипуляция ресурсами через представления; самоописывающиеся сообщения (метод, Content-Type, кэш-заголовки); HATEOAS (гипермедиа как двигатель состояния приложения).
  5. Слоистая система. Клиент не знает, разговаривает он с сервером напрямую или через три прокси, CDN и балансировщик. Именно поэтому промежуточные узлы вообще возможны.
  6. Code on demand (необязательное). Сервер может прислать исполняемый код — исторически это JavaScript в браузере.

Ресурс и представление — не одно и то же

Ресурс — абстракция, «заказ №42», сущность с идентичностью. Представление фиксирует состояние ресурса в конкретный момент и в конкретном формате: JSON, XML, HTML, PDF, protobuf. Так работает content negotiation: один и тот же URI /orders/42 может отдавать разные представления в зависимости от Accept. Отсюда правило: URI идентифицирует ресурс, а не файл и не метод. А /getOrder?id=42 уже не ресурс, а вызов процедуры, замаскированный под URL.

HATEOAS — то, чего почти никто не делает

Идея: клиент знает одну точку входа, а дальше двигается по ссылкам, которые сервер присылает в ответах. Не «клиент захардкодил, что отменить заказ можно POST /orders/{id}/cancel», а «сервер прислал в ответе ссылку cancel — значит, отмена сейчас возможна; не прислал — значит, нет». Состояние переходов живёт на сервере, клиент его не дублирует. Так работает сам веб: браузер не знает структуру сайта, он знает, как ходить по ссылкам.

На практике HATEOAS почти не встречается, и на то есть причины. У машинного клиента нет «человека, который решает, куда кликнуть», так что код всё равно пишут под конкретные переходы; ответы раздуваются; кодогенерация и типизация из OpenAPI дают больше пользы, чем гипермедиа; а форматов гипермедиа несколько (HAL, JSON:API, Siren, Collection+JSON), и ни один не стал общепринятым. В 2008 году Филдинг даже написал отдельный пост «REST APIs must be hypertext-driven» и прямо сказал там, что почти всё, что называют REST, этого названия не заслуживает.

Модель зрелости Ричардсона Чем выше ступень, тем больше работы делает сам протокол HTTP, а не формат тела запроса Уровень 0 Один URL, один глагол POST /api SOAP, XML-RPC, JSON-RPC Уровень 1 Появились ресурсы POST /users/42/rename POST /users/42/delete URL много, метод один, ошибки в теле с кодом 200 Уровень 2 Глаголы HTTP и статус-коды GET /users/42 200 PUT /users/42 204 DELETE /users/42 204 POST /users 201 Заработали кэш, ретраи, идемпотентность, условные запросы, промежуточные узлы Уровень 3 HATEOAS: гипермедиа { "id": 42, "state": "new", "_links": { "self": "/orders/42", "pay": "/orders/42/pay", "cancel": "/orders/42/cancel" } } Клиент не хардкодит URL — идёт по ссылкам из ответа. Нельзя отменить — нет ссылки. здесь живёт 95% того, что называют REST REST по Филдингу Уровни 0 и 1 — это RPC, просто по HTTP: протокол используется как транспорт для конверта. Уровень 2 — точка окупаемости: дальше выгода падает, а цена для клиента растёт. Поэтому индустрия и остановилась на нём.
Четыре ступени. Вопросом «какой у вас уровень зрелости» проверяют, понимает ли кандидат, что уровень 2 даёт кэш, ретраи и идемпотентность бесплатно, а уровень 0 не даёт ничего, кроме туннеля поверх POST.
Как отвечать про RESTful и REST-like

Строго говоря, RESTful называют API, который соблюдает все ограничения, включая HATEOAS (уровень 3). У REST-like (он же «REST-ish», «HTTP API», уровень 2) ресурсные URI, правильные глаголы, правильные коды, но нет гипермедиа. Сильный ответ звучит так: «Мы делаем REST-like, уровень 2 по Ричардсону. HATEOAS сознательно не делаем: контракт описан в OpenAPI, клиенты генерируются, и гипермедиа не окупается. Это осознанный компромисс, а не незнание.» За ответ «у нас REST, потому что JSON и GET/POST» сразу ставят минус: JSON к REST отношения не имеет вообще.

REST против RPC: разные модели мышления

Разница не в технологиях, а в том, что считать единицей мышления. REST мыслит существительными: есть ресурс, у него есть состояние, а набор действий над ним фиксирован и задан протоколом (GET/POST/PUT/PATCH/DELETE). RPC мыслит глаголами: есть удалённая процедура с именем и аргументами, а сколько таких процедур и как они называются, решает автор сервиса.

RESTRPC (в т.ч. gRPC)
Единицаресурс (существительное)процедура (глагол)
Расширение APIновый ресурс или новое представлениеновый метод
Набор операцийфиксирован протоколомпроизволен, растёт бесконечно
Ошибкистатус-коды HTTPсвои коды в ответе
Кэшиз коробки, на любом промежуточном узлетолько руками у клиента
Открываемостьcurl, браузер, любой прокси понимаетнужен контракт и инструменты
Плохо ложится наоперации, которые не про состояние («пересчитать», «отправить письмо»)публичные API, кэшируемое чтение

Слабость у REST тоже есть: не всякое действие сводится к состоянию ресурса. «Перевести деньги», «пересчитать маршрут», «отправить письмо» приходится выворачивать в псевдоресурсы (POST /transfers, POST /route-calculations). Часто это нормальный ход: операция становится сущностью с идентификатором, её можно переспросить и сделать идемпотентной. Но иногда выходит натягивание совы на глобус, и честнее сказать «здесь у нас RPC-ручка».

Может ли REST жить не поверх HTTP

Теоретически может, и это правильный ответ. REST не упоминает HTTP: он требует идентификаторы ресурсов, единообразный интерфейс, statelessness, кэшируемость и слоистость. Годится любой протокол, который это даёт. Ровно так устроен CoAP (RFC 7252): REST-семантика (GET/POST/PUT/DELETE, коды ответов, URI, кэширование по Max-Age) поверх UDP для IoT-устройств. Похожее делают поверх AMQP и MQTT, а метод и путь кладут в свойства сообщения.

Практически же широко используется только HTTP, и не случайно: он уже даёт готовые URI, готовые глаголы с описанной семантикой, коды ответов, согласование содержимого и кэш-инфраструктуру целой планеты. Отсюда формулировка для ответа: «REST — стиль, HTTP — его единственная промышленная реализация; всё, что теряешь вне HTTP, придётся написать самому».

Контракт: OpenAPI и protobuf

Слабое место REST в том, что контракта внутри протокола нет: HTTP не знает, какие поля у твоего JSON. Поэтому контракт выносят наружу, в OpenAPI (бывший Swagger): это YAML/JSON-описание путей, методов, параметров, схем тел и кодов ответов. Из него получают интерактивную документацию (Swagger UI), клиентов и серверные заглушки (oapi-codegen, openapi-generator), валидацию запросов на лету, моки для фронтенда, контрактные тесты и проверку обратной совместимости в CI (oasdiff).

В gRPC контракт встроен: .proto и есть источник истины, без него сервис не собрать. Пожалуй, это главное практическое отличие. В REST схема остаётся необязательным артефактом и может протухнуть, а в gRPC она не протухнет по построению.

gRPC: что под капотом

gRPC — это RPC-фреймворк от Google: protobuf как язык описания интерфейса и формат сериализации, HTTP/2 как транспорт, кодогенерация клиента и сервера для десятка языков, плюс встроенные дедлайны, отмена, метаданные, стриминг, балансировка на клиенте и перехватчики. Букву «g» в каждом релизе официально расшифровывают по-новому, это внутренняя шутка проекта.

Технически каждый вызов едет обычным HTTP/2-запросом: POST на путь /пакет.Сервис/Метод, заголовок content-type: application/grpc+proto, в теле последовательность фреймов «1 байт флага сжатия + 4 байта длины + сообщение». Результат вызова приезжает не в статусе HTTP, а в трейлерах (grpc-status, grpc-message) после тела. HTTP-статус при этом почти всегда 200, даже если вызов провалился, и на этом регулярно ломаются наивные метрики на балансировщике.

Почему быстрее

  • Protobuf вместо JSON. Бинарный формат без имён полей на проводе: вместо {"user_id":300} едет 08 AC 02. Обычно в 3–8 раз меньше и в несколько раз быстрее в разборе: парсер читает длины и смещения, а не разбирает текст посимвольно с аллокацией строк.
  • HTTP/2 и мультиплексирование. В одном TCP-соединении идут сотни одновременных вызовов, без очереди и без «одно соединение — один запрос». Соединения не открываются заново, значит, нет ни рукопожатий, ни разогрева congestion window.
  • HPACK. Заголовки сжимаются и, что важнее, повторяющиеся передаются как индекс в таблице: во втором и последующих вызовах :authority, user-agent, content-type занимают байты, а не сотни байт.
  • Кодогенерация. Никакого ручного маппинга: protoc-gen-go генерирует описание сообщения, а сериализует рантайм по таблицам, собранным один раз (прямую запись полей генерирует vtprotobuf). В Go разница с encoding/json по аллокациям всё равно заметная.
  • Стриминг из коробки. Не нужно эмулировать поток через пагинацию или long polling.
Оговорка про «быстрее»

На типичной бизнес-ручке, где 20 мс уходит в базу, разница между JSON и protobuf теряется в шуме. gRPC выигрывает там, где много мелких вызовов (сервис-к-сервису, десятки тысяч RPS), где большие объёмы (выгрузки, телеметрия) или где дорогая сеть (мобильный клиент). Ответ «gRPC быстрее, поэтому берём везде» слабый. Сильный называет условия, при которых разница вообще заметна.

Как выглядит контракт и генерация

syntax = "proto3";
package order.v1;
option go_package = "github.com/acme/api/gen/order/v1;orderv1";

service OrderService {
  rpc GetOrder    (GetOrderRequest)    returns (Order);
  rpc ListOrders  (ListOrdersRequest)  returns (stream Order);
  rpc ImportOrders(stream Order)       returns (ImportSummary);
  rpc Watch       (stream WatchRequest) returns (stream OrderEvent);
}

message GetOrderRequest {
  int64 id = 1;
}

message Order {
  int64  id       = 1;
  string customer = 2;
  int64  amount   = 3;   // копейки, а не float
  Status status   = 4;
  reserved 5, 6;
  reserved "legacy_code";
}

enum Status {
  STATUS_UNSPECIFIED = 0;   // нулевое значение обязано быть «неизвестно»
  STATUS_NEW         = 1;
  STATUS_PAID        = 2;
}

// Остальные сообщения сервиса — по тому же образцу.
message ListOrdersRequest { string customer = 1; }
message ImportSummary     { int32  imported = 1; }
message WatchRequest      { int64  order_id = 1; }
message OrderEvent        { Order  order    = 1; }
# два плагина: один генерирует сообщения, другой сервисный код
protoc --go_out=. --go_opt=paths=source_relative \
       --go-grpc_out=. --go-grpc_opt=paths=source_relative \
       order/v1/order.proto

# на практике вместо protoc обычно берут buf: линтер, детектор ломающих
# изменений и реестр схем в одном инструменте
buf lint
buf breaking --against '.git#branch=main'
buf generate

Генератор выдаёт структуры сообщений, интерфейс клиента (OrderServiceClient) и интерфейс сервера (OrderServiceServer) плюс UnimplementedOrderServiceServer для встраивания. Встраивать его нужно всегда: именно он позволяет добавлять методы в .proto, не ломая компиляцию существующих серверов.

Четыре вида вызовов

Четыре формы одного HTTP/2-потока Unary Get(Req) -> Resp клиент сервер 1 сообщение 1 сообщение Обычный вызов: 95% всего трафика. Ретраи и дедлайны работают тривиально. Server streaming List(Req) -> stream клиент сервер N сообщений Выгрузка большого списка без пагинации, подписка на события. Client streaming Upload(stream) -> Resp клиент сервер 1 итог в конце Загрузка файла кусками, пакетная запись метрик, агрегация на сервере. Bidirectional Chat(stream) -> stream клиент сервер порядок независим Чат, торговый стакан, двусторонняя синхронизация, длинный диалог с состоянием. Во всех четырёх случаях это ровно один HTTP/2-поток внутри одного соединения. Разница только в том, сколько сообщений едет в каждую сторону до закрытия половины потока. Стриминг не требует ни нового соединения, ни другого порта, ни WebSocket.
Одна механика, четыре формы. Стриминг в gRPC не отдельная технология, а свойство того же самого потока HTTP/2, поэтому он дёшев.

Protobuf на проводе

Главная идея формата: имён полей на проводе нет. Есть номера. Сообщение устроено как последовательность пар «ключ, значение», где ключ кодирует номер поля и wire type (как читать значение). Парсер, встретив незнакомый номер, знает по wire type, сколько байт пропустить. Отсюда почти бесплатная прямая и обратная совместимость.

Дальше без перевода пойдут два жаргонных слова. Wire type («тип на проводе») не совпадает с типом из .proto: он отвечает на единственный вопрос «как найти конец значения». Вариантов всего шесть, поэтому wire type влезает в три бита ключа. Varint (variable-length integer, «целое переменной длины») записывает число так, чтобы мелкие числа занимали мало места: в каждом байте 7 полезных бит, а старший, восьмой, бит работает флагом «дальше есть ещё байт». Число 1 умещается в один байт, 300 в два, миллиард в пять.

Wire typeНазваниеКак читаетсяТипы
0VARINTбайты, пока старший бит = 1int32/64, uint32/64, sint32/64, bool, enum
1I64ровно 8 байтfixed64, sfixed64, double
2LENvarint длины, потом столько байтstring, bytes, вложенные сообщения, packed-массивы
3/4SGROUP/EGROUPгруппы: устарели, но встречаются в proto2 и в editions (DELIMITED)
5I32ровно 4 байтаfixed32, sfixed32, float
1. Что реально едет по сети message User { int32 id = 1; string name = 2; } при id = 300, name = "Go" 0x08 00001000 0xAC 10101100 0x02 00000010 0x12 00010010 0x02 00000010 0x47 'G' 0x6F 'o' ключ varint 300 ключ длина = 2 "Go" Семь байт. Для сравнения: {"id":300,"name":"Go"} — 22 байта, и это ещё без пробелов. 2. Ключ = (номер поля << 3) | wire type 0x12 0 0 0 1 0 0 1 0 MSB 0 номер поля 0010 = 2 wire type 010 = 2 (LEN) Ключ — это тоже varint. Пока номер поля ≤ 15, он вместе с тремя битами типа влезает в один байт: 15 << 3 | 7 = 127 ≤ 127 Уже поле 16 даёт 16 << 3 = 128 > 127 — ключ становится двухбайтовым. Поэтому номера 1–15 отдают горячим полям, которые есть в каждом сообщении, а редкие нумеруют с 16. 3. varint: как 300 превращается в AC 02 300 = 1 0010 1100 в двоичном (9 значащих бит — в один байт не влезает) Режем справа на группы по 7 бит и к каждой добавляем старший бит-продолжение: 1 0101100 младшие 7 бит (44) + флаг 1 = 0xAC 0 0000010 старшие биты (2) + флаг 0 = 0x02 Младшая группа идёт ПЕРВОЙ: AC 02, а не 02 AC Старший бит = 1 значит «читай следующий байт», = 0 значит «число кончилось». Числа до 127 — один байт, до 16383 — два. Обратная сторона: у int32 значение −1 в дополнительном коде — это 64 единицы, то есть 10 байт varint. Для чисел, которые бывают отрицательными, есть sint32/sint64 с zigzag: (n << 1) ^ (n >> 31), у sint64 сдвиг 63. Тогда 0→0, −1→1, 1→2, −2→3 — маленькое по модулю остаётся коротким.
Семь байт вместо двадцати двух. На этом вопросе любят проверять побитовый разбор: «покажи, почему 300 — это именно AC 02». Умение довести до битов отличает «читал про protobuf» от «понимаю формат».

Эволюция схемы

Из «на проводе только номера» следуют все правила совместимости. Безопасно: добавить новое поле с новым номером (старый читатель его проигнорирует, новый увидит нулевое значение); переименовать поле (имя на провод не попадает, но сломаются JSON-представление и код); удалить поле, если номер уйдёт в reserved; добавить значение в enum (при условии, что клиент корректно обрабатывает неизвестное); превратить одиночное поле в repeated того же типа для string, bytes и сообщений. Нельзя: менять номер существующего поля; менять тип на несовместимый (int32string); переиспользовать номер удалённого поля.

Чем опасно переиспользование номера

Допустим, было int64 user_id = 3, поле удалили, а через полгода добавили bool is_test = 3. Оба кодируются как VARINT, и парсер подмены не заметит. Старый сервис, которого забыли передеплоить, пришлёт user_id = 7712, а новый прочитает is_test = true, и никакой ошибки не будет: данные молча испортятся. От этого и спасает reserved 3; — компилятор не даст переиспользовать номер. Резервировать надо и имя (reserved "user_id";), чтобы не сломать JSON-маппинг. В proto3 к этому же относится правило про нулевое значение enum: элемент с номером 0 обязан быть первым и означать «не задано».

Ещё тонкость proto3: у скалярных полей нет различия между «ноль» и «не задано», на провод не пишется ни то, ни другое. Если разница важна (частичное обновление, «сбросить скидку в 0» против «не трогать скидку»), используют optional (вернулся в proto3 с версии 3.15 и даёт явное присутствие) либо обёртки google.protobuf.Int32Value, либо FieldMask со списком изменяемых путей.

Protobuf встречается далеко за пределами gRPC: формат хранения событий в Kafka (со Schema Registry), внутренний формат метрик Prometheus (remote write), Envoy xDS API, конфигурация в Kubernetes-совместимых системах, кэши и очереди, где важен размер, а также mobile-протоколы. Поэтому часто уточняют: «где protobuf используется, кроме gRPC». Ответ: «везде, где нужна компактная схема с версионированием, и транспорт RPC тут лишь частный случай».

Балансировка: почему gRPC ломается о L4

На собеседовании это любимый вопрос в духе «а что если». Суть в том, что два уровня не совпадают: L4-балансировщик распределяет соединения, а gRPC распределяет вызовы внутри одного соединения. Пока единица работы совпадала с единицей балансировки (HTTP/1.1: запрос ≈ соединение), всё сходилось. С HTTP/2 совпадение исчезло.

REST поверх HTTP/1.1: соединения короткие, их много клиент A клиент B клиент C L4 / NLB выбор по 5-кортежу при установке TCP pod 1 pod 2 pod 3 ≈33% ≈33% ≈33% Каждый новый запрос рано или поздно открывает новое соединение, а значит заново проходит через выбор пода. gRPC поверх HTTP/2 через тот же L4: одно соединение живёт часами клиент A клиент B клиент C L4 / NLB видит байты, не видит вызовы pod 1 pod 2 pod 3 100% RPS 0% 0% Балансировщик выбрал под один раз, при установке соединения. Дальше все RPC — это мультиплексированные потоки внутри уже выбранного TCP-соединения. Тот же баг с другой стороны: новый под после автоскейла или деплоя не получает трафик вообще, пока клиенты не переподключатся, а старый под после «слива» держит соединения до последнего. Классическая жалоба: «мы отскейлились с 3 до 30, а latency не упала». Лечится четырьмя способами: L7-балансировщик (Envoy, nginx, ALB), client-side LB через resolver, service mesh (sidecar), MAX_CONNECTION_AGE на сервере — принудительный GOAWAY, чтобы клиент периодически перевыбирал под.
Единица балансировки против единицы работы. L4 распределяет соединения, gRPC распределяет вызовы внутри соединения. Пока соединение и вызов совпадали, проблемы не было.

Четыре способа починить

  • L7-балансировщик. Envoy, nginx с grpc_pass, Traefik или AWS ALB терминируют HTTP/2, видят отдельные потоки и раскидывают каждый вызов. Плюс: клиенту ничего не надо знать. Минус: лишний хоп, ресурсы, и это ещё одна точка отказа.
  • Client-side load balancing. Клиент сам получает список адресов (в Kubernetes для этого есть headless service, у которого DNS отдаёт все IP подов, а не один ClusterIP) и держит по соединению к каждому, выбирая под на каждый вызов (grpc.WithDefaultServiceConfig с round_robin). Лишнего хопа нет, задержка минимальная, зато логика балансировки размазана по клиентам, а число соединений растёт как «клиенты × поды».
  • Service mesh. Istio/Linkerd ставят sidecar-прокси в каждый под, рядом с приложением: приложение ходит на localhost, а балансировку, ретраи, mTLS и телеметрию делает прокси. Фактически client-side LB, вынесенный из кода.
  • Ограничение времени жизни соединения. На сервере задают keepalive.ServerParameters{MaxConnectionAge: 30*time.Minute, MaxConnectionAgeGrace: 5*time.Minute}, и тогда сервер сам шлёт GOAWAY, клиент переподключается и заново проходит балансировку. Полноценным решением это не назовёшь, но страховка дешёвая, и включать её стоит всегда.
Как это звучит в ответе

«gRPC держит одно долгоживущее HTTP/2-соединение и мультиплексирует в нём вызовы. L4-балансировщик принимает решение один раз, на SYN. Дальше он просто перекладывает байты и про отдельные RPC не знает. Значит, весь трафик клиента приклеивается к одному поду: один горит, остальные простаивают, а новые поды после скейла не получают нагрузку вовсе. Чинится L7-прокси, client-side-балансировкой через headless service, mesh-ом или MaxConnectionAge. Кстати, ровно та же проблема бывает у долгоживущих WebSocket-соединений и у пулов подключений к базе.»

Реалтайм поверх HTTP: три подхода

Общая проблема: HTTP спроектирован как «клиент спросил — сервер ответил», а нужно «сервер сам сказал, когда что-то случилось». Исторически это решали тремя способами, и все три живы.

Long polling клиент сервер GET /events держит запрос 200 + событие пауза пауза по таймауту — 204, и клиент спрашивает снова Каждое событие — отдельный цикл запрос-ответ со всеми заголовками. Между ответом и следующим запросом события копятся на сервере. SSE — Server-Sent Events клиент сервер GET /stream, Accept: text/event-stream одно соединение остаётся открытым; ответ приходит бесконечным chunked-потоком data: {...} id: 17 Только сервер→клиент. Формат текстовый (data:, event:, id:, retry:). Переподключение и Last-Event-ID — в самом браузерном API. WebSocket клиент сервер GET /ws Upgrade: websocket 101 Switching Protocols дальше это уже не HTTP: кадры в обе стороны ping / pong Оверхед кадра — 2–14 байт. Заголовков нет вообще, куки не пересылаются, HTTP-кэш и HTTP-ретраи не работают: всё своё.
Три способа обмануть модель «запрос-ответ». Long polling эмулирует push циклом запросов, SSE держит один поток в одну сторону, WebSocket меняет протокол и открывает обе.
Long pollingSSEWebSocket
Направлениесервер→клиент (эмуляция)только сервер→клиентобе стороны
Протоколобычный HTTPобычный HTTP, text/event-streamws:///wss:// после Upgrade
Соединений на клиентанепрерывный цикл новыходноодно
Форматлюбойтолько текст (бинарь — base64)текст и бинарь
Переподключениеестественноеавтоматическое + Last-Event-IDпишешь сам
Прокси и корпоративные сетипроходит вездепроходит вездеиногда режут, спасает wss
Оверхед на сообщениеполный набор заголовковнесколько байт2–14 байт
Сжатие и HTTP-кэшработаютсжатие да, кэш потоку ни к чемусвоё (permessage-deflate)
Куда идётлегаси и совместимостьлента, уведомления, прогресс, токены LLMчат, игры, торги, совместное редактирование
Правило выбора

Нужен поток только вниз — SSE: дешевле, проще, переподключение бесплатно, работает через любой HTTP-прокси. Для настоящего двустороннего диалога с низкой задержкой нужен WebSocket. Между сервисами внутри инфраструктуры лучше gRPC-стриминг: он делает то же самое, но с контрактом, дедлайнами и кодами ошибок. Long polling остаётся запасным вариантом для клиентов, где ничего другого нет. А если событий мало и задержка в секунды терпима, самым надёжным и дешёвым в поддержке решением будет обычный polling раз в N секунд. Предложить его вслух — признак зрелости, а не лени.

Форматы сериализации: чем платишь за формат

ФорматСхемаРазмерСкоростьЧитаемостьГде уместен
JSONвнешняя (OpenAPI, JSON Schema) или никакойбазовый ориентирсредняядапубличные API, конфиги, логи, всё, что смотрят глазами
Protobufобязательная, .proto×0.2–0.4 от JSONвысокаянетмежсервисное, события в брокере, мобильные клиенты
MessagePackнет (как JSON, но бинарно)×0.8–0.95высокаянеткэши, Redis, когда менять контракт нельзя, а трафик жалко
gobсамоописывающийся, только Goпервое ×1.1–1.3 (описание типов), дальше ×0.2–0.4высокаянетGo↔Go, RPC внутри одной кодовой базы, снапшоты на диск
XMLXSD, очень строгая×1.3–2 от JSONнизкаяусловноSOAP, госинтеграции, банки, документооборот с подписью
YAMLвнешняянизкаялучшаятолько конфиги; для трафика не используют
Avroобязательная, схема едет отдельно×0.2–0.4высокаянетKafka + Schema Registry, аналитические пайплайны, Hadoop
Про gob спрашивают три вещи
  • Только Go. Другие языки его не читают, так что для любого внешнего API он отпадает сразу.
  • Самоописывающийся. В поток едет описание типов, поэтому первое сообщение выходит большим, а следующие однотипные стоят копейки. Значит, gob хорош для потока и плох для одиночных мелких значений в кэше.
  • Экспортируемые поля и нулевые значения. Кодируются только публичные поля; поля с нулевым значением по умолчанию не передаются вовсе. Для интерфейсных полей нужен gob.Register, иначе и кодирование, и декодирование вернут ошибку «type not registered».

Про YAML надо предупредить отдельно: мало того что он медленный, он ещё и опасный. Классическая «норвежская проблема»: country: NO в YAML 1.1 разбирается как булев false. А из якорей и ссылок (&anchor/*ref) собирают «YAML-бомбу», которая раскрывается экспоненциально. Так что YAML годится для конфигов, которые пишет человек, и никогда для трафика.

Вопросы

11
Суть: REST — архитектурный стиль из диссертации Филдинга, набор шести ограничений, из которых вытекает масштабируемость веба. Ключевые: stateless, кэшируемость, единообразный интерфейс (URI + глаголы + самоописывающиеся сообщения + HATEOAS), слоистость. Почти все «REST API» — это уровень 2 по Ричардсону, то есть REST-like: ресурсы и глаголы есть, HATEOAS нет.

Шесть ограничений и что каждое даёт

ОграничениеЧто запрещаетЧто за это получаешь
Клиент-серверсмешивать UI и хранениенезависимую эволюцию сторон
Statelessпомнить клиента между запросамигоризонтальное масштабирование, простые ретраи
Кэшируемостьмолчать про кэшируемость ответаCDN и прокси между клиентом и сервером
Единообразный интерфейссвои глаголы и свои кодылюбой посредник понимает семантику
Слоистостьклиенту знать, кто на том концепрокси, шлюзы, балансировщики, mesh
Code on demand (опц.)исполняемый код от сервера (JS в браузере)

Stateless — что это значит на практике

Это не значит «состояния нет вообще»: данные в базе никуда не деваются. Речь про состояние сессии клиента на конкретном инстансе. Всё нужное запрос обязан нести сам: токен вместо «сервер помнит, что ты залогинен», курсор пагинации вместо «сервер помнит, где ты остановился». Взамен любой инстанс обрабатывает любой запрос, sticky session не нужен, падение узла не рвёт работу пользователя, ретрай безопасен, а автоскейл честный.

Каверзный уточняющий: а куки и сессии нарушают REST?

Строго говоря, да. Серверная сессия по session_id нарушает statelessness: сервер хранит состояние клиента между запросами. Поэтому в API-мире и прижились самодостаточные токены (JWT), где всё нужное лежит в самом запросе. Но правильный ответ на этом не заканчивается: у stateless-токенов своя цена. Мгновенно их не отозвать (нужен чёрный список, а это снова состояние), весят они больше, и незаметно обновить их нельзя. Практическую середину даёт внешнее хранилище сессий (Redis): инстансы остаются взаимозаменяемыми, а состояние вынесено вовне.

Ресурс, представление, URI

  • Ресурс — сущность с идентичностью: «заказ 42». Представлением называют то, как её сериализовали сейчас: JSON, XML, CSV, PDF. Один URI отдаёт много представлений, нужное клиент выбирает через Accept.
  • URI именует существительное, а не действие. /orders/42 годится, а /getOrder?id=42 уже RPC в URL-костюме.
  • Коллекция и элемент: /orders и /orders/42. Множественное число никто не требует, это соглашение, но полезное.
  • Вкладывай не глубже одного уровня: /orders/42/items. Дальше (/users/7/orders/42/items/3/comments) начинается ад: элемент всё равно адресуется своим id, а длинные пути ломают кэш и роутинг.

RESTful vs REST-like

RESTful соблюдает все ограничения, включая гипермедиа: уровень 3. У REST-like есть ресурсные URI, корректные методы и статус-коды, но переходы клиент знает из документации, а не из ответов: уровень 2. Индустрия живёт на уровне 2, потому что там всё окупается: кэш, идемпотентность, ретраи и понятность для прокси уже есть, а гипермедиа машинному клиенту обходится дорого.

Три ловушки на этом вопросе
  • «REST — это JSON по HTTP». Нет, JSON к REST не относится: REST-ответ бывает XML, HTML или protobuf, а JSON-RPC вообще RPC, а не REST.
  • «REST — это CRUD». Тоже нет. Методы приятно совпадают с CRUD, но смысл REST в единообразном интерфейсе, а не в четырёх операциях над таблицей.
  • «У нас REST, потому что красивые URL». Красивые URL дают только уровень 1, самый бесполезный: ресурсы уже есть, а выгоды от протокола ещё нет.
Суть: REST мыслит ресурсами (существительными) с фиксированным набором операций из протокола; RPC мыслит процедурами (глаголами), набор которых произволен и растёт. Отсюда всё остальное: у REST кэш и семантика бесплатны от HTTP, у RPC — гибкость и выразительность. REST может жить не поверх HTTP (CoAP — живой пример), но практически не живёт, потому что теряет ровно то, ради чего его берут.

Одна и та же задача в двух моделях

# REST: состояние ресурса
GET    /orders/42            -> 200 Order
POST   /orders               -> 201 + Location
PATCH  /orders/42            -> 200
DELETE /orders/42            -> 204

# «отменить» тоже состояние:
PATCH  /orders/42
{"status": "cancelled"}
# или ресурс-действие:
POST   /orders/42/cancellation
# RPC: список процедур
POST /OrderService/GetOrder
POST /OrderService/CreateOrder
POST /OrderService/UpdateOrder
POST /OrderService/DeleteOrder
POST /OrderService/CancelOrder
POST /OrderService/RecalculateTotals
POST /OrderService/SendReceiptEmail
# в gRPC то же самое, но с контрактом
# и бинарным телом

Посмотри на нижние строки правой колонки: RecalculateTotals и SendReceiptEmail в RPC ложатся естественно, а в REST их приходится придумывать как ресурсы. Это честная слабость REST, и, признав её, ты только выиграешь. Обратный пример: «отдай мне заказ, и пусть CDN его закэширует на 5 минут». В REST хватит одной строки заголовка, в RPC нужна отдельная инфраструктура.

Разница в том, где живёт семантика

  • REST: семантика в протоколе. Посредник (прокси, CDN, WAF, балансировщик) видит GET /orders/42 и знает: это чтение, безопасно, идемпотентно, кэшируемо, можно ретраить. Ему не нужен твой контракт.
  • RPC: семантика в контракте. Для посредника любой вызов выглядит как POST с непрозрачным телом. Не зная твоего .proto, он не может ни ретраить, ни кэшировать, ни маршрутизировать по смыслу.
  • Отсюда и практическое правило: наружу — REST (открываемость, кэш, любой клиент), внутрь — RPC/gRPC (контракт, скорость, стриминг).

Может ли REST жить не поверх HTTP

Может: в диссертации HTTP ни разу не назван обязательным. Требуются идентификаторы ресурсов, единообразный набор операций с описанной семантикой, самоописывающиеся сообщения, statelessness, явная кэшируемость, слоистость.

  • CoAP (RFC 7252) даёт самый чистый пример: GET/POST/PUT/DELETE, URI вида coap://device/sensors/temp, коды ответов «2.05 Content», «4.04 Not Found», кэширование по Max-Age, подписки через Observe. Работает поверх UDP, потому что рассчитан на устройства с батарейкой и каналом в килобиты.
  • Поверх брокера (AMQP, MQTT, NATS): метод и путь кладут в свойства сообщения, а ответ шлют в reply-to. Формально похоже на REST, по сути это уже самодельный протокол.

Почему всё равно HTTP? Он уже даёт URI, глаголы с зафиксированной семантикой, коды ответов, согласование содержимого, условные запросы, диапазоны, сжатие, а главное, планетарную кэш-инфраструктуру и инструменты, которые понимают всё это без твоего участия. Вне HTTP всё это пишут руками, и обычно хуже.

Обратная сторона вопроса

Спрашивают и наоборот: «а gRPC — это обязательно HTTP/2?» Формально нет: gRPC описан как спецификация RPC, а HTTP/2 служит ей основным транспортом. Есть gRPC-Web (поверх HTTP/1.1 через прокси, потому что браузер не даёт управлять HTTP/2-фреймами и трейлерами) и реализации поверх QUIC/HTTP/3. Но в отличие от REST, gRPC привязан к транспорту гораздо сильнее: без мультиплексирования HTTP/2 половина его преимуществ исчезает.

Суть: REST кэшируется чужими руками — браузером, CDN, реверс-прокси, — потому что GET + URI + кэш-заголовки понятны всем посредникам. RPC — это всегда POST с непрозрачным телом, поэтому кэш строишь сам. С балансировкой ровно наоборот: REST на HTTP/1.1 раскидывается любым L4, а gRPC на одном долгоживущем HTTP/2-соединении «приклеивается» к одному поду, потому что L4 выбирает бэкенд один раз — при установке TCP.

Кэш: почему у REST он бесплатный

  • Ключом кэша служит URI плюс заголовки из Vary. У RPC ключа нет: у всех вызовов один путь и разные тела, а тело в ключ кэша не входит.
  • GET объявлен безопасным и идемпотентным, поэтому кэшировать его можно по умолчанию. POST кэшируем только в теории и практически никем.
  • Условные запросы (ETag + If-None-Match304) дают почти бесплатную ревалидацию: 150 байт вместо мегабайта.
  • Уровни кэша складываются: браузер → Service Worker → CDN → реверс-прокси → кэш приложения. Каждый снимает часть нагрузки, и ни один не знает про твой код.

В RPC/gRPC всё это делают вручную: кэш в Redis по ключу из имени метода и полей запроса, своя логика инвалидации, TTL своими руками. Исключения есть: кэшируемые методы можно объявить через google.api.http и выставить наружу как GET через grpc-gateway. Но это уже трансляция в REST, а не кэш gRPC.

Балансировка: где именно ломается

  1. Клиент открывает одно TCP-соединение. L4-балансировщик выбирает бэкенд по 5-кортежу (src IP, src port, dst IP, dst port, протокол) и делает это один раз.
  2. gRPC мультиплексирует в этом соединении сотни потоков. Все они, естественно, идут на выбранный бэкенд.
  3. Соединение живёт часами: gRPC его не рвёт, а keepalive-пинги не дают закрыться.
  4. В итоге один под получает весь трафик клиента, остальные простаивают. Отскейлились с 3 подов до 30, а нагрузка не перераспределилась: никто не переподключался.
Симптомы, по которым это узнают в проде
  • CPU на одном поде 90%, на остальных 5%, а HPA считает среднее и поэтому не скейлит.
  • После деплоя новые поды стоят пустые, пока старые не убьют.
  • Задержка не падает от добавления реплик.
  • В Kubernetes через обычный ClusterIP так будет гарантированно: kube-proxy работает на L4 (iptables, nftables или IPVS) и балансирует соединения, а не запросы.

Решения и их цена

СпособКак работаетЦена
L7-проксиEnvoy/nginx/ALB терминируют HTTP/2 и раскидывают каждый вызовлишний хоп, ресурсы, ещё одна точка отказа
Client-side LBheadless service + resolver отдаёт все IP, клиент держит по соединению к каждому и делает round-robin по вызовамлогика в каждом клиенте, соединений «клиенты × поды»
Service meshsidecar делает то же самое, но вне кода приложениясложность платформы, +1–2 мс на хоп, потребление памяти
MaxConnectionAgeсервер шлёт GOAWAY по возрасту соединения, клиент перевыбирает подпериодические переподключения, перебалансировка «раз в N минут», а не мгновенная
// 1. Сервер: не давать соединениям жить вечно + защита от «пинг-флуда»
srv := grpc.NewServer(
    grpc.KeepaliveParams(keepalive.ServerParameters{
        MaxConnectionAge:      30 * time.Minute, // потом GOAWAY
        MaxConnectionAgeGrace: 5 * time.Minute,  // доиграть текущие RPC
        Time:                  2 * time.Minute,  // пинг простаивающему клиенту
        Timeout:               20 * time.Second,
    }),
    grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
        MinTime:             1 * time.Minute,
        PermitWithoutStream: true,
    }),
)

// 2. Клиент: сам балансирует через headless service.
// dns:/// отдаёт все A-записи; перечитывает их резолвер при обрыве, а не по таймеру.
conn, err := grpc.NewClient(
    "dns:///orders.default.svc.cluster.local:9000",
    grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
    grpc.WithTransportCredentials(insecure.NewCredentials()),
)
Чем добить ответ

Скажи, что это не свойство gRPC, а свойство долгоживущих соединений. Ровно та же картина у WebSocket-серверов, у пулов подключений к Postgres через pgbouncer в режиме session, у Kafka-консьюмеров и у HTTP/2-клиентов на чистом REST. И упомяни обратную ловушку: при client-side LB число соединений равно «клиентов × реплик», так что на 200 клиентах и 50 подах получается 10 000 соединений, и тут уже выигрывает mesh или L7-прокси.

Суть: OpenAPI — машиночитаемое описание HTTP-API: пути, методы, параметры, схемы тел, коды ответов, аутентификация. Из него генерируют документацию, клиентов, серверные заглушки, моки, валидацию и проверку обратной совместимости в CI. Contract-first — спека источник истины, код генерируется; code-first — спека вытаскивается из кода аннотациями. Команды чаще выбирают contract-first там, где API — межкомандный контракт, и code-first там, где API внутренний и меняется быстро.

Терминология, на которой путаются

Swagger — исторический бренд: в конце 2015 года спецификацию Swagger передали в Linux Foundation, а с 2016-го она называется OpenAPI (3.0, 3.1, 3.2). Сейчас «Swagger» зовут инструменты (Swagger UI, Swagger Editor, Swagger Codegen), а формат называется OpenAPI. Главное в 3.1: он стал полностью совместим с JSON Schema 2020-12, так что одна схема годится и для валидации, и для документации.

Что реально даёт спека

  • Документация без ручной работы в Swagger UI, Redoc, Scalar. Уже одно это окупает спеку.
  • Кодогенерация клиентов для всех потребителей: фронтенд, мобилки, соседние команды. В Go есть oapi-codegen (умеет и сервер: типы, роутинг, валидацию под chi/echo/gin/std).
  • Валидация на входе из схемы, а не из руками написанных if-ов. Меньше кода, меньше расхождений документации с поведением.
  • Моки для фронтенда (Prism, WireMock): фронт пишут, не дожидаясь бэкенда.
  • Контрактные тесты и diff в CI: oasdiff breaking --fail-on ERR валит пайплайн, если удалили поле или сузили enum. Так спека из документа становится инструментом.
  • Импорт в шлюзы: Kong, APIM, AWS API Gateway настраиваются прямо из OpenAPI.

Contract-first против code-first

Contract-firstCode-first
Источник истиныopenapi.yaml в репозиториикод и аннотации над хендлерами
Порядок работысогласовали спеку → сгенерировали типы и роутинг → написали логикунаписали хендлеры → сгенерировали спеку → отдали потребителям
Параллельная работафронт стартует сразу с моковфронт ждёт готовый бэкенд
Расхождение кода и спекиневозможно: код из спекиобычное дело: забыли обновить аннотацию
Обратная совместимостьпроверяется diff-ом спеки в CIловится глазами на ревью
Скорость на стартемедленнее, спека — отдельный артефакт для ревьюбыстрее, спека «сама появляется»
Инструменты в Gooapi-codegen, ogenswaggo/swag, go-swagger
Что реально выбирают в командах

Правило простое: чем больше потребителей и чем дороже сломать клиента, тем сильнее аргумент за contract-first. Публичному API, API между командами и API с внешними партнёрами нужен contract-first: спеку ревьюят как код, breaking-changes ловит CI. Внутреннему сервису с одним потребителем в том же репозитории и API, которое меняется каждую неделю, code-first дешевле и честнее. Хуже всего code-first без автогенерации, когда спеку пишут руками отдельно от кода: за месяц она протухнет и начнёт вредить.

# contract-first в Go: генерируем типы + серверный интерфейс + валидацию
oapi-codegen -generate types,chi-server,spec -package api \
             -o internal/api/api.gen.go openapi.yaml

# CI проверяет обратную совместимость
oasdiff breaking origin/main:openapi.yaml openapi.yaml --fail-on ERR
Как это сравнивают с gRPC

В gRPC вопроса «contract-first или code-first» нет: единственный источник истины там .proto, и code-first в Go не встречается (в .NET есть protobuf-net.Grpc, но это экзотика). Это и есть главный аргумент за gRPC во внутренних API: контракт не может разойтись с реальностью, а buf breaking проверяет совместимость из коробки. Верно и обратное: contract-first в REST пытается повторить дисциплину, которую gRPC даёт по построению.

Суть: gRPC — RPC-фреймворк: контракт в .proto, сериализация protobuf, транспорт HTTP/2, кодогенерация клиента и сервера. Быстрее за счёт четырёх вещей: компактный бинарный формат без имён полей, мультиплексирование в одном долгоживущем соединении, сжатие заголовков HPACK, отсутствие рефлексии в сгенерированном коде. Плюс встроенные дедлайны, отмена, метаданные, стриминг и интерцепторы.

Что происходит при вызове

  1. Сгенерированный клиент сериализует запрос в protobuf.
  2. Открывается новый поток в уже существующем HTTP/2-соединении. Летят HEADERS: :method: POST, :path: /order.v1.OrderService/GetOrder, content-type: application/grpc, te: trailers, дедлайн в grpc-timeout, метаданные пользователя.
  3. DATA-фреймы несут «length-prefixed message»: 1 байт флага сжатия + 4 байта длины big-endian + сами байты сообщения. Если сообщений несколько, это и есть стриминг.
  4. Ответ приходит так же, а результат вызова лежит в трейлерах после тела: grpc-status: 0, при ошибке ещё grpc-message и grpc-status-details-bin.
HTTP-статус почти всегда 200

Ошибка gRPC едет в трейлере, а не в статусной строке. Поэтому дашборд «доля 5xx на балансировщике» для gRPC-сервиса показывает ноль, даже когда половина вызовов падает с INTERNAL. Мониторить надо grpc_status: через интерцептор с метриками или через L7-прокси, который умеет читать трейлеры (Envoy умеет). Отдельный подвох: если прокси отвечает не 200, клиент видит это как UNAVAILABLE (502, 503, 504) или UNIMPLEMENTED (404). Второе особенно путает, ведь метод-то реализован.

Откуда берётся скорость — по пунктам

МеханизмЧто экономитКогда заметно
protobuf вместо JSONразмер тела (×0,2–0,4 от JSON), CPU на разбормного мелких сообщений или большие объёмы
HTTP/2 мультиплексированиерукопожатия, разогрев congestion window, очередь запросоввысокий RPS, множество параллельных вызовов
HPACKповторяющиеся заголовки → индексы в таблицетысячи мелких вызовов с одинаковыми заголовками
Кодогенерациярефлексию и аллокации при маршалингегорячий путь, где сериализация видна в профиле
Бинарный фреймингпарсинг текставсегда, но мало

Трезвая оценка: на межсервисном вызове «получить объект по id» gRPC выигрывает единицы миллисекунд, а по трафику в разы. Если ручка делает три запроса в базу на 20 мс, выигрыш утонет. Поэтому правильный ответ всегда уточняет: «быстрее в чём и при каких условиях».

Минимальный сервер и клиент в Go

// сервер
type server struct {
    orderv1.UnimplementedOrderServiceServer // обязательно: даёт forward-совместимость
    repo *Repo
}

func (s *server) GetOrder(ctx context.Context, req *orderv1.GetOrderRequest) (*orderv1.Order, error) {
    o, err := s.repo.Get(ctx, req.GetId())      // GetXxx() безопасен для nil-приёмника
    if errors.Is(err, ErrNotFound) {
        return nil, status.Errorf(codes.NotFound, "order %d not found", req.GetId())
    }
    if err != nil {
        return nil, status.Error(codes.Internal, "internal error") // без утечки деталей наружу
    }
    return toProto(o), nil
}

func main() {
    lis, _ := net.Listen("tcp", ":9000")
    s := grpc.NewServer(grpc.ChainUnaryInterceptor(logging, recovery, metrics))
    orderv1.RegisterOrderServiceServer(s, &server{repo: repo})
    reflection.Register(s) // чтобы grpcurl/Postman видели схему; в проде только внутри периметра
    _ = s.Serve(lis)
}
// клиент: NewClient не подключается сразу, соединение ленивое
conn, err := grpc.NewClient("orders:9000", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { return err }
defer conn.Close()

cli := orderv1.NewOrderServiceClient(conn)

ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()

o, err := cli.GetOrder(ctx, &orderv1.GetOrderRequest{Id: 42})
if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound {
    // здесь обрабатываем именно NotFound
}
Что спросят следом
  • Один ClientConn на процесс, а не на вызов. Он потокобезопасен, внутри держит пул и умеет переподключаться. Типичная ошибка: создавать соединение на каждый запрос и терять на этом весь выигрыш.
  • Почему UnimplementedXxxServer обязателен. Он даёт реализацию по умолчанию для будущих методов: если в .proto добавили метод, старые серверы продолжают компилироваться и отвечают Unimplemented, а не падают.
  • Ограничение размера сообщения. По умолчанию приём до 4 МБ, превышение приходит как ResourceExhausted: grpc: received message larger than max. Лечат это стримингом, а не подъёмом лимита до 100 МБ.
  • Браузер напрямую в gRPC не может: fetch не даёт доступа к трейлерам и фреймам HTTP/2, поэтому нужен gRPC-Web и прокси (Envoy).
Суть: на проводе нет имён полей — только пары «ключ, значение», где ключ = (номер поля << 3) | wire type. Wire type говорит, сколько байт занимает значение, поэтому незнакомое поле можно молча пропустить — отсюда совместимость в обе стороны. Числа кодируются varint по 7 бит на байт со старшим битом-продолжением. Правила эволюции следуют из этого напрямую: номер поля — вечный, тип менять нельзя, удалённые номера в reserved.

Разбор конкретного сообщения

message User {
  int32  id   = 1;
  string name = 2;
}
// id = 300, name = "Go"   ->   08 AC 02 12 02 47 6F   (7 байт)

08 = 0000 1000 -> номер поля 1, wire type 0 (VARINT)
AC = 1010 1100 -> старший бит 1: читаем дальше; полезные 7 бит = 0101100
02 = 0000 0010 -> старший бит 0: конец числа;   полезные 7 бит = 0000010
     значение = (0000010 << 7) | 0101100 = 256 + 44 = 300
12 = 0001 0010 -> номер поля 2, wire type 2 (LEN)
02 -> длина 2
47 6F -> "Go"

// то же в JSON: {"id":300,"name":"Go"} = 22 байта, и это без пробелов

Из разбора видно: младшая группа varint идёт первой (little-endian по 7-битным группам), а числа 0–127 занимают ровно один байт. Ключ тоже varint: пока номер поля не больше 15, ключ влезает в один байт (15<<3|7 = 127), а с номера 16 занимает два. Поэтому в проектных гайдлайнах номера 1–15 берегут для полей, которые есть в каждом сообщении, а редкие поля нумеруют от 16.

Wire types и типы данных

WTЧтениеТипыЗамечание
0 VARINTбайты, пока MSB = 1int32/64, uint32/64, sint32/64, bool, enumотрицательные int32 — всегда 10 байт
1 I648 байтfixed64, sfixed64, doubleвыгоден, когда числа обычно большие
2 LENvarint длины + данныеstring, bytes, вложенные сообщения, packed-массивывложенность = просто байты внутри байтов
5 I324 байтаfixed32, sfixed32, floatдля хэшей и id фиксированной ширины
zigzag: зачем нужен sint32/sint64

int32 x = -1 в дополнительном коде расширяется до 64 бит из одних единиц, и varint занимает 10 байт вместо одного. sint32 применяет zigzag: (n << 1) ^ (n >> 31), то есть 0→0, −1→1, 1→2, −2→3, 2→4, и маленькие по модулю числа остаются короткими при любом знаке. Правило такое: если поле бывает отрицательным, бери sint; если никогда не отрицательное, int или uint; если всегда большое (хэш, id-снежинка), fixed.

Эволюция схемы

ДействиеМожно?Почему
Добавить поле с новым номеромдастарый читатель пропустит по wire type, новый увидит нулевое значение
Переименовать поледа на проводеимени на проводе нет; но сломается JSON-маппинг и код
Удалить поледа с reservedиначе номер переиспользуют и данные молча испортятся
Добавить значение в enumда осторожностарый клиент получит неизвестное число; в proto3 оно сохраняется как есть
int32int64booluint32даодинаковый wire type; но при сужении значение обрежется
stringbytesдаоба LEN; строка должна быть валидным UTF-8
Одиночное поле → repeatedда для сообщенийстарый читатель сольёт элементы в одно сообщение (последнее значение берут только у скаляров, string и bytes)
Сменить номер полянетэто другое поле; старые данные станут «неизвестными»
int32string, int32fixed32нетразные wire types — мусор при разборе
Переиспользовать номер удалённого поляникогдатихая порча данных, самая дорогая из ошибок
Менять required (proto2)нетровно поэтому в proto3 required вырезали совсем
message Order {
  int64  id       = 1;
  string customer = 2;
  int64  amount   = 3;

  reserved 4, 7 to 9;          // номера, которые больше нельзя занимать
  reserved "discount", "promo"; // и имена, чтобы не сломать JSON-представление

  optional string coupon = 10;  // explicit presence: отличаем "" от «не задано»
}
Как переиспользованный номер тихо портит данные

Было int64 user_id = 3, поле удалили, через полгода добавили bool is_test = 3. Оба VARINT, так что парсер разницы не заметит. Сервис, который забыли передеплоить, пришлёт user_id = 7712, а новый прочитает is_test = true (любое ненулевое число даёт true) и уведёт заказ в тестовый контур. Ни ошибки, ни лога, ни алерта. От этого и защищает reserved, а в CI ещё и buf breaking, который валит PR при такой попытке. Второй эшелон защиты: версия в имени пакета (order.v1, order.v2), и ломающие изменения выносят в новый пакет, а не правят старый.

Ещё две тонкости proto3, которые любят спрашивать. Первая: нет различия «ноль» и «не задано» у скаляров, нулевые значения на провод вообще не пишутся. Отсюда проблема с частичным обновлением («сбросить скидку в 0» неотличимо от «не трогать»), её решают через optional, обёртки (google.protobuf.Int32Value) или FieldMask. Вторая: protobuf не детерминирован. Порядок полей и кодирование map не гарантированы между реализациями и версиями, поэтому байты protobuf не годятся ни в ключ кэша, ни во вход хэша, ни в основу цифровой подписи.

Где применяется, кроме gRPC

  • События в Kafka/Pulsar вместе со Schema Registry: компактно и с контролем совместимости схем (как альтернативу берут Avro, у которого схема эволюционирует по именам, а не по номерам).
  • В Prometheus remote write метрики едут в protobuf поверх HTTP, сжатые snappy.
  • Envoy xDS — вся динамическая конфигурация service mesh описана в protobuf.
  • Кэши и хранилища, где важен размер значения; в Redis это заметно на десятках миллионов ключей.
  • Мобильные и IoT-протоколы, где трафик стоит денег и батарейки.
  • Форматы файлов: например, OSM PBF для карт, tfrecord-подобные форматы в ML.
Суть: четыре формы одного и того же HTTP/2-потока, отличаются только тем, сколько сообщений едет в каждую сторону. Unary — 1:1, обычный вызов. Server streaming — 1:N, выгрузка и подписка. Client streaming — N:1, загрузка и агрегация. Bidirectional — N:M независимо, диалог. Стриминг не требует ни отдельного соединения, ни WebSocket.

Как объявляются и когда какой

ВидВ .protoКогда братьЧем опасен
Unaryrpc Get(Req) returns (Resp)по умолчанию всёничем; ретраи и дедлайны тривиальны
Server streamingrpc List(Req) returns (stream Resp)большая выгрузка, подписка на события, прогресс долгой операцииретрай означает «начать сначала»; нужен курсор для возобновления
Client streamingrpc Upload(stream Req) returns (Resp)загрузка файла кусками, батч метрик, агрегация на серверерезультат только в конце; при обрыве непонятно, что успело записаться
Bidirectionalrpc Chat(stream Req) returns (stream Resp)чат, стакан котировок, синхронизация, длинный диалог с состояниемсостояние на сервере ломает stateless и балансировку; сложнее всего отлаживать

Server streaming в Go

// сервер: шлём заказы потоком, пока клиент не отменил
func (s *server) ListOrders(req *orderv1.ListOrdersRequest, stream orderv1.OrderService_ListOrdersServer) error {
    rows, err := s.repo.Cursor(stream.Context(), req.GetFilter())
    if err != nil { return status.Error(codes.Internal, "query failed") }
    defer rows.Close()

    for rows.Next() {
        select {
        case <-stream.Context().Done(): // клиент ушёл или сработал дедлайн
            return status.FromContextError(stream.Context().Err()).Err()
        default:
        }
        if err := stream.Send(toProto(rows.Order())); err != nil {
            return err // соединение оборвалось, отдаём наверх без обёртки
        }
    }
    return rows.Err()
}

// клиент: поток заканчивается io.EOF, и это не ошибка
st, err := cli.ListOrders(ctx, &orderv1.ListOrdersRequest{})
if err != nil { return err }
for {
    o, err := st.Recv()
    if errors.Is(err, io.EOF) { break }   // нормальное завершение
    if err != nil { return err }          // всё остальное — ошибка
    handle(o)
}

Client streaming и bidirectional

// client streaming: клиент шлёт N, получает 1 итог
up, _ := cli.ImportOrders(ctx)
for _, o := range batch {
    if err := up.Send(o); err != nil { break } // при ошибке Send настоящую причину даст CloseAndRecv
}
summary, err := up.CloseAndRecv()

// bidirectional: запись и чтение в разных горутинах, не вперемешку
stream, _ := cli.Watch(ctx)
go func() {
    for ev := range outbox {
        if err := stream.Send(ev); err != nil { return }
    }
    _ = stream.CloseSend() // «я больше не пишу», но продолжаю читать
}()
for {
    ev, err := stream.Recv()
    if errors.Is(err, io.EOF) { break }
    if err != nil { return err }
    apply(ev)
}
Правила, на которых спотыкаются
  • Send и Recv потокобезопасны между собой, но не в паре одинаковых. Один писатель и один читатель на поток; параллельные Send из двух горутин дают гонку и повреждённый поток.
  • io.EOF из Recv означает успех, а не ошибку: вызов завершился со статусом OK.
  • При ошибке Send нельзя доверять её тексту: реальную причину сервер положит в статус, который приедет из Recv/CloseAndRecv.
  • Дедлайн действует на весь поток целиком, а не на отдельное сообщение. Для бесконечной подписки дедлайн не ставят, а обрыв ловят keepalive-пингами и своими heartbeat-сообщениями.
  • Ретраить стриминг нечем. Встроенная политика retry в gRPC работает, только пока сервер не отправил ни одного сообщения, а дальше остаётся своя логика возобновления по курсору или по «последнему полученному id».
  • Flow control есть. Медленный читатель тормозит писателя окном HTTP/2. Это хорошо (память не растёт без предела), но Send из-за этого может заблокироваться, так что вызывай его под контекстом.
Практическое правило

Начинай с unary, он проще во всём: ретраи, дедлайны, балансировка, метрики, отладка. Переходи на server streaming, когда ответ не помещается в память или нужна подписка. Client streaming бери только для настоящей заливки данных. Bidirectional оставь на крайний случай: он делает соединение stateful, а stateful-соединение конфликтует и с балансировкой, и с деплоем.

Суть: gRPC передаёт дедлайн в заголовке grpc-timeout остатком времени, а каждый узел пересчитывает его в свой абсолютный момент, и он распространяется по всей цепочке вызовов: каждый следующий сервис получает остаток. Отмена клиента доезжает до сервера как закрытие контекста. Метаданные — это заголовки и трейлеры HTTP/2. Ошибки — фиксированный набор из 17 кодов, не совпадающий с HTTP один в один. Интерцепторы — middleware, отдельные для unary и для stream.

Дедлайн, а не таймаут — и почему это важно

Клиент передаёт не «дай мне 300 мс», а «истекает через 300 мс от сейчас». Сервер получает grpc-timeout: 300m, кладёт дедлайн в ctx, а вызывая следующий сервис, передаёт тот же ctx, то есть оставшееся время. Если A дал 300 мс, B потратил 250 на базу, то C получит 50 мс и, скорее всего, сразу вернёт DeadlineExceeded, не тратя ресурсы впустую. Так цепочка обрывается вся разом, а не каскадом наложенных друг на друга таймаутов.

// дедлайн задаёт вызывающий, а не сервер
ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
resp, err := cli.GetOrder(ctx, req)
if status.Code(err) == codes.DeadlineExceeded { /* сюда попадём ровно через 300 мс */ }

// сервер проверяет остаток и передаёт контекст дальше
func (s *server) GetOrder(ctx context.Context, req *orderv1.GetOrderRequest) (*orderv1.Order, error) {
    if dl, ok := ctx.Deadline(); ok && time.Until(dl) < 20*time.Millisecond {
        return nil, status.Error(codes.DeadlineExceeded, "not enough time left") // fail fast
    }
    o, err := s.repo.Get(ctx, req.GetId()) // ctx уходит в database/sql, запрос отменится сам
    ...
}
Три классические ошибки
  • Нет дедлайна вообще. Без него вызов ждёт обрыва соединения, а если сервер завис при живом соединении — бесконечно. Один зависший сервис затыкает пул горутин у всех вызывающих. Дедлайн нужен на каждом исходящем вызове, дефолтный удобно навесить интерцептором.
  • context.Background() внутри хендлера. Цепочка рвётся: отмена и дедлайн клиента дальше не доедут. Оправдано это только для фоновой работы, которая обязана пережить запрос, и ей нужен новый контекст со своим таймаутом (context.WithoutCancel в Go 1.21+ сохраняет значения, но снимает отмену).
  • Дедлайн больше, чем у вызывающего. Бессмысленно: клиент уже уйдёт, а сервер продолжит жечь ресурсы. Вниз по цепочке дедлайн только уменьшается.

Отмена работает симметрично: если клиент вызвал cancel() или ушёл, gRPC шлёт RST_STREAM, а сервер видит ctx.Done() и context.Canceled. Отсюда разница в кодах: Canceled — отменил клиент, DeadlineExceeded — вышло время. Первое обычно не считают ошибкой сервиса и не алертят по нему, а второе считают.

Метаданные

// клиент кладёт метаданные в исходящий контекст
ctx = metadata.AppendToOutgoingContext(ctx,
    "authorization", "Bearer "+token,
    "x-request-id", reqID,
    "x-trace-bin", string(traceBytes)) // суффикс -bin => значение бинарное, поедет в base64

// сервер читает входящие
md, ok := metadata.FromIncomingContext(ctx)
vals := md.Get("x-request-id")

// заголовки (до тела) и трейлеры (после тела)
var header, trailer metadata.MD
resp, err := cli.GetOrder(ctx, req, grpc.Header(&header), grpc.Trailer(&trailer))

Имена ключей приводятся к нижнему регистру, ключи с суффиксом -bin несут бинарь и кодируются base64, а префикс grpc- зарезервирован. Через метаданные передают аутентификацию, трассировку, идемпотентность, версию клиента и локаль, то есть всё сквозное, чему не место в теле сообщения.

Коды ошибок и их маппинг на HTTP

КодСмыслHTTP-аналогРетраить?
OK (0)успех200
CANCELLED (1)отменил клиент499нет
UNKNOWN (2)необёрнутая ошибка (паника без recovery роняет процесс)500нет
INVALID_ARGUMENT (3)запрос неверен сам по себе400нет
DEADLINE_EXCEEDED (4)вышло время504осторожно
NOT_FOUND (5)нет сущности404нет
ALREADY_EXISTS (6)конфликт создания409нет
PERMISSION_DENIED (7)аутентифицирован, но нельзя403нет
RESOURCE_EXHAUSTED (8)квота, лимит, слишком большое сообщение429да, с backoff
FAILED_PRECONDITION (9)состояние системы не позволяет400/409нет
ABORTED (10)конфликт транзакции, гонка409да, на верхнем уровне
OUT_OF_RANGE (11)вышли за границу диапазона400нет
UNIMPLEMENTED (12)метода нет501нет
INTERNAL (13)сломались мы500нет
UNAVAILABLE (14)сервис недоступен сейчас503да, основной ретраибельный
DATA_LOSS (15)потеря или порча данных500нет
UNAUTHENTICATED (16)нет или невалиден токен401нет
Тонкости с кодами ошибок
  • Маппинг не биективен. HTTP-кодов ~60, gRPC-кодов 17. Через grpc-gateway FailedPrecondition и InvalidArgument оба уезжают в 400, а обратно из 400 однозначно не восстановить.
  • Три разных «нельзя». InvalidArgument означает, что запрос плох независимо от состояния системы. FailedPrecondition — запрос нормальный, но система не в том состоянии (удалить непустую папку). OutOfRange — частный случай: запрос может стать валидным позже (чтение за концом файла).
  • Богатые ошибки. Кроме кода и текста есть google.rpc.Status.details, куда кладут типизированные детали: BadRequest.FieldViolation с полем и причиной, RetryInfo с рекомендованной задержкой, QuotaFailure. В Go это status.New(code, msg).WithDetails(…).
  • Не светить внутренности. err.Error() из базы, отданный наружу как Internal, выдаёт имена таблиц и запросы. Клиенту отдают общий текст, а полную ошибку с request-id пишут в лог.

Интерцепторы

Это middleware gRPC, и их два независимых семейства: unary и stream. Одну и ту же функцию на оба не навесишь. Об этом регулярно забывают, и стриминговые методы остаются без логов, метрик и recovery.

// unary-интерцептор: логи + метрики + recovery
func unaryObs(l *slog.Logger) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
                handler grpc.UnaryHandler) (resp any, err error) {
        start := time.Now()
        defer func() {
            if r := recover(); r != nil {          // паника не должна ронять весь сервер
                l.Error("panic", "method", info.FullMethod, "panic", r)
                err = status.Error(codes.Internal, "internal error")
            }
            code := status.Code(err)
            rpcDuration.WithLabelValues(info.FullMethod, code.String()).Observe(time.Since(start).Seconds())
            l.Info("rpc", "method", info.FullMethod, "code", code, "dur", time.Since(start))
        }()
        return handler(ctx, req)
    }
}

// stream-интерцептор: чтобы подменить ctx, оборачивают ServerStream
type wrappedStream struct {
    grpc.ServerStream
    ctx context.Context
}
func (w *wrappedStream) Context() context.Context { return w.ctx }

func streamAuth(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, h grpc.StreamHandler) error {
    ctx, err := authorize(ss.Context())
    if err != nil { return err }
    return h(srv, &wrappedStream{ServerStream: ss, ctx: ctx})
}

s := grpc.NewServer(
    grpc.ChainUnaryInterceptor(unaryObs(log), auth, validate, ratelimit),  // порядок = порядок выполнения
    grpc.ChainStreamInterceptor(streamObs(log), streamAuth),
)

Типовой набор в проде: recovery (самый внешний), логирование и трассировка, метрики, аутентификация и авторизация, валидация запроса (protoc-gen-validate), rate limiting, проброс request-id. На клиенте интерцепторами вешают дефолтный дедлайн, ретраи, circuit breaker и подстановку токена. ChainUnaryInterceptor вызывает их по порядку, снаружи внутрь, поэтому recovery и логирование идут первыми, а бизнес-валидация последней.

Суть: граница проходит по потребителю, а не по производительности. Свои сервисы, которые ты деплоишь и обновляешь сам, — gRPC: контракт, кодогенерация, стриминг, дедлайны. Чужие клиенты, которых ты не контролируешь (браузер, партнёры, публичное API), — REST: открываемость, кэш, любой инструмент, нулевой порог входа. Часто верно и то и другое одновременно: gRPC внутри, REST на границе через шлюз.
КритерийgRPCREST
Кто потребительсвои сервисы, мобильные клиенты с вашим SDKбраузер, партнёры, публичное API
Контрактобязателен и не может протухнутьопциональный OpenAPI
Открываемостьнужен grpcurl/Postman и рефлексияcurl и браузер
Кэш на посредникахнетда, бесплатно
Стримингвстроен, четыре видаSSE или WebSocket отдельно
Балансировка через L4ломается, нужен L7 или client-sideработает
Отладка «глазами»бинарь, нужен инструментчитается в логах и в devtools
Порог входа для новой командыprotoc/buf, генерация, версия пакетанулевой
Дедлайны и отмена сквозь цепочкуиз коробкируками через контекст и заголовки
Через корпоративные прокси и WAFиногда проблемы с HTTP/2 и трейлерамипроходит везде

Гибрид, который встречается чаще всего

Внутри ходит gRPC, а на границе стоит шлюз, который выставляет наружу REST/JSON: grpc-gateway генерирует обратный прокси прямо из .proto с аннотациями google.api.http, так что и REST-контракт, и OpenAPI-спека берутся из того же источника истины. Браузеру отдают gRPC-Web через Envoy. Схема одна, интерфейсов два, и руками их синхронизировать не надо.

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order) {
    option (google.api.http) = { get: "/v1/orders/{id}" };   // и REST, и gRPC из одного описания
  }
  rpc CreateOrder(CreateOrderRequest) returns (Order) {
    option (google.api.http) = { post: "/v1/orders", body: "*" };
  }
}
Аргументы, которые стоит назвать вслух
  • За gRPC: контракт не расходится с кодом; buf breaking ловит ломающие изменения в CI; клиенты генерируются для всех языков; дедлайны и отмена проходят сквозь всю цепочку; стриминг без костылей; трафик в разы меньше.
  • Против gRPC: из браузера напрямую не вызвать; ломается о L4-балансировщики; бинарь не почитать в логах и не подебажить curl-ом; нужна инфраструктура генерации; в мониторинге всё выглядит как HTTP 200; для команды без опыта это заметный организационный оверхед.
  • За REST: нулевой порог входа, кэш и CDN бесплатно, его понимает любой инструмент, ретраи и идемпотентность заданы протоколом, отлично живёт за WAF и корпоративным прокси.
  • Против REST: контракт опционален и протухает; «правильные» коды и формы ошибок держатся на дисциплине; JSON дороже и по CPU, и по трафику; стриминг требует отдельной технологии.
Готовая формулировка

«Наружу — REST, потому что клиентов мы не контролируем, а ошибка сломает чужой код. Внутрь — gRPC, потому что там мы контролируем обе стороны и выигрываем от контракта, генерации и дедлайнов. Если внутри всего три сервиса и один язык, я бы gRPC не тащил: JSON по HTTP плюс общий пакет с типами дешевле в поддержке, а разницу в скорости мы всё равно не заметим.» Последняя фраза важнее первых двух: по ней видно, что ты выбираешь по контексту, а не по моде.

Суть: WebSocket — отдельный протокол (RFC 6455) поверх того же TCP, в который переходят из HTTP одним рукопожатием Upgrade и ответом 101 Switching Protocols. После этого HTTP закончился: идут двусторонние кадры с оверхедом 2–14 байт, без заголовков, без кэша, без ретраев. SSE — поток только сервер→клиент по обычному HTTP. Long polling — эмуляция push циклом запросов.

Рукопожатие

GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==     <- случайные 16 байт в base64
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat.v1                 <- согласование субпротокола
Origin: https://app.example.com                 <- проверять обязательно!

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat.v1

Sec-WebSocket-Accept вычисляется как base64(SHA1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")). Это не безопасность, а защита от дурака: так клиент проверяет, что на том конце WebSocket-сервер, а не кэширующий прокси, который решил, что понял запрос.

Кадры

  • Заголовок кадра: FIN-бит (последний фрагмент), 4 бита opcode (0x1 текст, 0x2 бинарь, 0x8 close, 0x9 ping, 0xA pong), MASK-бит, длина (7, 7+16 или 7+64 бита). Итого 2–14 байт вместо сотен байт HTTP-заголовков.
  • Клиент обязан маскировать данные 4-байтовым ключом (сервер — нет). Это тоже не про шифрование: маскирование ломает атаки на промежуточные прокси, которые могли бы принять содержимое кадра за начало нового HTTP-запроса (cache poisoning).
  • Ping/pong — служебные кадры для keepalive, и они обязательны: без трафика NAT и балансировщики рвут «мёртвые» соединения через 30–120 секунд, а клиент узнаёт об этом только при попытке записи. Пингуют обычно раз в 20–30 секунд.
  • Сообщение можно фрагментировать на несколько кадров: так стримят большие полезные нагрузки.

Чем отличается от HTTP

HTTPWebSocket
Модельзапрос-ответ, инициатор всегда клиентполный дуплекс, инициировать может любой
Состояниеstatelessstateful: соединение и есть сессия
Оверхед сообщениясотни байт заголовков2–14 байт
Кэш, ретраи, кодыиз протоколаничего, пишешь сам
Авторизациязаголовок на каждый запростолько при рукопожатии; дальше — свой протокол
Балансировкапо запросампо соединениям, со всеми последствиями
Масштабированиелюбой инстанс обслужит любой запроснужен общий слой (Redis Pub/Sub, NATS) для рассылки между инстансами

Когда что брать

  • WebSocket нужен для обмена в обе стороны с низкой задержкой: чат, совместное редактирование, игры, торговый терминал, интерактивные дашборды с командами.
  • Для потока только вниз хватает SSE: лента событий, уведомления, прогресс длинной задачи, потоковая выдача текста LLM, живые метрики. Он дешевле, работает через любой прокси, а переподключение и Last-Event-ID встроены в браузерный EventSource. Историческое ограничение в 6 соединений на домен в HTTP/1.1 снимает мультиплексирование HTTP/2.
  • Long polling остался ради совместимости с древними клиентами и сетями, где ничего другого не проходит.
  • gRPC-стриминг — то же самое между сервисами, но с контрактом, дедлайнами, кодами ошибок и flow control.
  • Обычный polling раз в 5–30 секунд подойдёт, если событий мало и задержка терпима. Дешевле в эксплуатации ничего нет, и вспомнить о нём — признак опыта.
// SSE в Go делается обычным долгим обработчиком, без библиотек
func stream(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("X-Accel-Buffering", "no") // иначе nginx буферизует и «реалтайма» не будет

    rc := http.NewResponseController(w) // Go 1.20+: доступ к Flush и дедлайнам записи
    events := subscribe(r.Context(), r.Header.Get("Last-Event-ID"))
    heartbeat := time.NewTicker(20 * time.Second)
    defer heartbeat.Stop()

    for {
        select {
        case <-r.Context().Done(): // клиент отвалился
            return
        case <-heartbeat.C:
            fmt.Fprint(w, ": ping\n\n") // комментарий-пульс, чтобы прокси не закрыл соединение
            _ = rc.Flush()
        case ev := <-events:
            fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, ev.JSON)
            _ = rc.Flush() // без Flush данные осядут в буфере и никуда не поедут
        }
    }
}
Что ломается в проде с WebSocket
  • Нет пингов. Соединения молча умирают в NAT и на балансировщике, а клиент «висит» и не переподключается.
  • Нет реконнекта с backoff — при рестарте сервера все клиенты ломятся обратно одновременно и кладут его снова. Нужен экспоненциальный backoff с джиттером.
  • Авторизация только на рукопожатии. Токен истёк через час, а соединение живёт дальше. Проверку надо повторять своим служебным сообщением или ограничивать время жизни соединения. Вдобавок браузерный WebSocket не умеет ставить заголовок Authorization, поэтому токен тащат в query (он утечёт в логи), в куку или шлют первым сообщением после соединения. Последнее безопаснее.
  • Нет проверки Origin. Получаем Cross-Site WebSocket Hijacking: на WebSocket не действует Same-Origin Policy, и куки уйдут автоматически.
  • Масштабирование. Пользователь на поде A, а событие для него сгенерировал под B. Нужна общая шина: Redis Pub/Sub, NATS, Kafka. Сюда же липкость соединений и деплой, который рвёт все соединения разом.
  • Память. На соединение уходят две горутины и два буфера; 100 тысяч соединений уже требуют отдельного расчёта и, возможно, gobwas/ws с epoll вместо gorilla/websocket с горутиной на соединение.
Суть: выбор формата — это выбор между читаемостью и ценой (размер, CPU) при наличии или отсутствии схемы. JSON — универсальный компромисс для границы системы; protobuf — когда важны размер, скорость и контракт; gob — только Go↔Go; XML — легаси и строгие интеграции; YAML — конфиги и ничего больше. В Go главные грабли — omitempty, который не работает так, как ожидают, для time.Time, структур и «осмысленных нулей».

Сравнение по осям

ФорматСхемаРазмерСкоростьЧеловекочитаемКросс-язычностьНиша
JSONвнешняя, опциональнаясредняядаполнаяпубличные API, логи, конфиги
Protobufобязательная0.2–0.4×высокаянетполнаямежсервисное, события, мобилки
MessagePackнет0.6–0.8×высокаянетполнаякэш, Redis, «JSON, но меньше»
gobсамоописывающаяся0.5–0.8×высокаянеттолько GoGo↔Go, снапшоты, net/rpc
XMLXSD, строгая1.3–2×низкаяусловнополнаяSOAP, госинтеграции, подписанные документы
YAMLвнешняянизкаялучшаяполнаятолько конфиги
Avroобязательная, отдельно0.2–0.4×высокаянетполнаяKafka + Schema Registry, аналитика

Голосом стоит добавить, что у XML есть то, чего нет у JSON: атрибуты, пространства имён, XSD-валидация, XSLT и, главное, XML Signature, подпись части документа. Поэтому он и жив в банках и госсистемах, а вовсе не «потому что не переписали». У YAML же есть «норвежская проблема» (country: NOfalse в YAML 1.1) и якоря, из которых собирают YAML-бомбу. Это ещё один довод не брать его форматом обмена.

encoding/json: теги и правила

type Order struct {
    ID        int64      `json:"id"`
    Customer  string     `json:"customer,omitempty"`
    Amount    int64      `json:"amount"`                 // копейки: float для денег не годится
    Note      *string    `json:"note,omitempty"`         // указатель: отличаем "" от отсутствия
    CreatedAt time.Time  `json:"created_at"`
    DeletedAt *time.Time `json:"deleted_at,omitempty"`   // только указатель даёт настоящий omitempty
    Internal  string     `json:"-"`                      // никогда не сериализуется
    Weird     string     `json:"-,"`                     // а это поле с именем "-"
    BigID     int64      `json:"big_id,string"`          // число как строка: спасает JS от потери точности
    Tags      []string   `json:"tags,omitempty"`         // nil и пустой срез оба исчезнут
    Meta      map[string]any `json:"meta,omitempty"`
}
  • Сериализуются только экспортируемые поля. Поле с маленькой буквы молча исчезнет, отсюда классическое «почему у меня в JSON пусто».
  • При разборе имена сопоставляются без учёта регистра, если точного совпадения нет: {"ID":1} попадёт в поле с тегом json:"id".
  • Встроенная (embedded) структура «разворачивается» в родителя, если у неё нет своего имени в теге.
  • []byte кодируется в base64. time.Time — в RFC 3339. Ключи map сортируются, поэтому вывод детерминирован (в отличие от protobuf).
  • json.Marshal экранирует <, >, & в \u003c, \u003e, \u0026. Отключается только через Encoder.SetEscapeHTML(false).
Четыре ловушки omitempty
  • «Пусто» означает конкретный список: false, 0, "", nil-указатель/интерфейс и пустые массив, срез, map, строка. И всё.
  • time.Time — структура, а структура никогда не «пустая». CreatedAt time.Time `json:",omitempty"` при нулевом значении честно выведет "0001-01-01T00:00:00Z". Помогает только *time.Time или, с Go 1.24, omitzero.
  • bool и осмысленный ноль. IsActive bool с omitempty исчезнет при false, хотя false обычно и есть значимая информация. То же с Discount int: «скидка 0» и «скидки не было» на приёмной стороне неразличимы. omitempty уместен для необязательных полей, а поле со значимым нулём оставляют без тега или делают указателем: omitzero выкинет false и 0 точно так же.
  • Пустая структура не убирается: Address struct{...} с omitempty выведет объект с нулевыми полями, например {"city":""}. Убирает его указатель или omitzero.
// Go 1.24: omitzero, каким omitempty должен был быть с самого начала
type Event struct {
    At      time.Time `json:"at,omitzero"`     // нулевое время исчезнет, этого ждали годами
    Retries int       `json:"retries,omitzero"`// а вот значимый 0 придётся оставить без тега
    Addr    Address   `json:"addr,omitzero"`   // пустая структура тоже исчезнет
}
// omitzero проверяет «нулевое значение типа» (и уважает метод IsZero() bool),
// omitempty проверяет «пустое» по своему списку. Их можно комбинировать в одном теге.

json.RawMessage и отложенный разбор

// когда часть документа надо разобрать позже или пробросить как есть
type Envelope struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"` // не разбираем, пока не знаем тип
}

var e Envelope
if err := json.Unmarshal(data, &e); err != nil { return err }
switch e.Type {
case "order.created":
    var o OrderCreated
    if err := json.Unmarshal(e.Payload, &o); err != nil { return err }
    handle(o)
}

Тот же приём используют, чтобы не потерять неизвестные поля при проксировании: разобрал что нужно, остальное протащил байтами. json.RawMessage всего лишь []byte с методами MarshalJSON/UnmarshalJSON. При разборе байты копируются как есть, а при Marshal содержимое уплотняется и экранируется (< станет \u003c), так что байт в байт он данные не протащит.

Кастомные MarshalJSON и UnmarshalJSON

// Задача: наружу отдавать длительность как "1500ms", а не как 1500000000 наносекунд
type Duration struct {
    time.Duration
}

func (d Duration) MarshalJSON() ([]byte, error) {          // приёмник-значение
    return json.Marshal(d.String())
}

func (d *Duration) UnmarshalJSON(b []byte) error {          // обязательно указатель
    var s string
    if err := json.Unmarshal(b, &s); err != nil { return err }
    v, err := time.ParseDuration(s)
    if err != nil { return fmt.Errorf("duration %q: %w", s, err) }
    d.Duration = v
    return nil
}

// Приём «alias»: добавить поле без бесконечной рекурсии
type Order struct {
    ID     int64     `json:"id"`
    Status Status    `json:"status"`
}

func (o Order) MarshalJSON() ([]byte, error) {
    type alias Order          // новый тип без методов: рекурсии не будет
    return json.Marshal(struct {
        alias
        StatusText string `json:"status_text"`
    }{alias(o), o.Status.String()})
}
Грабли кастомных методов
  • Рекурсия. json.Marshal(o) внутри o.MarshalJSON() уходит в бесконечный цикл и переполняет стек. Спасает новый тип на той же основе (type alias Order — это не алиас, а отдельный тип): методов он не наследует.
  • Приёмник. MarshalJSON на значении работает и для значений, и для указателей; UnmarshalJSON обязан быть на указателе, иначе он вызовется на копии, и поле молча останется нулевым.
  • Есть варианты дешевле. Строкоподобным типам достаточно encoding.TextMarshaler/TextUnmarshaler: их уважают и JSON, и YAML, и ключи map. Для «того же, но с одним лишним полем» часто хватает встраивания и тегов.

Decoder против Unmarshal и неизвестные поля

// Unmarshal: весь документ уже в памяти
var o Order
err := json.Unmarshal(body, &o)

// Decoder читает из io.Reader: тело целиком не держим, умеем поток
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) // лимит от «бомбы» на 2 ГБ
dec.DisallowUnknownFields()                            // строгий разбор: лишнее поле = ошибка
dec.UseNumber()                                        // числа как json.Number, а не float64

if err := dec.Decode(&o); err != nil {
    var syn *json.SyntaxError
    var typ *json.UnmarshalTypeError
    switch {
    case errors.As(err, &syn):  // битый JSON
    case errors.As(err, &typ):  // "amount": "abc" при int64 — можно назвать поле в ответе
    }
    return err
}
// поток JSON-объектов подряд (ndjson): Decode в цикле до io.EOF
for dec.More() { ... }
Что спрашивают дальше
  • Неизвестные поля по умолчанию игнорируются, на этом и держится обратная совместимость JSON-API. DisallowUnknownFields включают на конфигах (лучше упасть на опечатке в имени параметра), а на публичном API обычно нет, иначе новое необязательное поле у клиента сломает тебе приём.
  • Числа по умолчанию становятся float64 при разборе в interface{}. Int64 больше 253 теряет точность: id 9007199254740993 приезжает изменённым. Лечится UseNumber() или тегом ,string.
  • Decoder не требует, чтобы документ кончился. Decode прочитает первое значение и остановится: {"a":1}{"b":2} не будет ошибкой. Для строгости после разбора проверь, что следующий dec.Token() вернул io.EOF: More() хвостовые ] и } не замечает.
  • Unmarshal не обнуляет приёмник. Разбор в уже заполненную структуру дополняет её: поля, которых нет в JSON, сохранят старые значения. Для срезов и map поведение своё и неинтуитивное, поэтому разбирают в свежую переменную.
  • Имена полей сопоставляются без учёта регистра, если точного совпадения нет. Так ведёт себя старый пакет, в encoding/json/v2 это изменилось (см. ниже).
  • Производительность. Старый encoding/json ходит по структуре рефлексией; на горячем пути её заменяли кодогенерацией (easyjson, ffjson) или быстрыми библиотеками (jsoniter, goccy/go-json, sonic) и выигрывали в разы. С Go 1.27 заметную часть разрыва закрыла сама стандартная библиотека, см. следующий блок про encoding/json/v2.

encoding/json/v2 — с Go 1.27 это уже не эксперимент

В Go 1.25 encoding/json/v2 был экспериментом и включался переменной окружения GOEXPERIMENT=jsonv2. В Go 1.27 он включён по умолчанию безо всяких настроек, а выключает его обратная переменная GOEXPERIMENT=nojsonv2. Пакетов теперь два, и граница между ними чёткая:

  • encoding/json/v2 отвечает за семантику: как Go-значение соответствует JSON. Теги, omitzero, кастомные маршалеры, опции разбора.
  • encoding/json/jsontextсинтаксис: как JSON выглядит текстом. Токены (Token), «сырое» значение (Value), потоковые Encoder/Decoder, отступы. Раньше всё это было слеплено в одном пакете, и половину настроек нельзя было выразить.

Паниковать не надо: старый encoding/json никуда не делся и поведения не поменял. Его переписали поверх v2, но наблюдаемую семантику намеренно сохранили, иначе сломался бы весь существующий код в мире. Строгость v2 приезжает ровно тогда, когда ты сам меняешь импорт. Зато Unmarshal стал заметно быстрее, а API — по-настоящему потоковым: MarshalWrite/UnmarshalRead работают с io.Writer/io.Reader, MarshalEncode/UnmarshalDecode — с jsontext.Encoder/Decoder.

import (
    "encoding/json/v2"        // пакет называется json, импортируется без переименования
    "encoding/json/jsontext"
)

// синтаксическую опцию (отступ) и семантическую передают одинаково, списком Options
b, err := json.Marshal(v, jsontext.WithIndent("  "))

// строгий разбор конфига: неизвестное поле = ошибка (замена DisallowUnknownFields)
err = json.Unmarshal(data, &cfg, json.RejectUnknownMembers(true))

// поток без промежуточного буфера
err = json.MarshalWrite(w, v)
err = json.UnmarshalRead(io.LimitReader(r.Body, 1<<20), &req)
Что ломается при переходе на v2 (проверено на go1.27)

v2 строже, причём ощутимо: он отвергает документы, которые старый пакет годами молча глотал. На этом миграции и спотыкаются.

// 1. Дублирующиеся ключи объекта
json.Unmarshal([]byte(`{"a":1,"a":2}`), &v)
// v2 -> ошибка: jsontext: duplicate object member name "a"
// v1 -> nil, молча побеждало последнее значение

// 2. Невалидный UTF-8 (и на чтении, и на записи)
json.Unmarshal([]byte("{\"b\":\"\xff\xfe\"}"), &v)
// v2 -> ошибка: jsontext: invalid UTF-8 within "/b" after offset 6
// v1 -> nil, битые байты тихо заменялись на U+FFFD

// 3. Регистр имён: v2 по умолчанию строгий
json.Unmarshal([]byte(`{"ID":7}`), &v)   // при теге `json:"id"`
// v2 -> поле осталось нулевым, ошибки нет
// v1 -> поле заполнено: при промахе он пробовал сопоставить без учёта регистра
  • Дубли ключей прилетают оттуда, где JSON собирают конкатенацией, шаблоном или склейкой двух объектов. С encoding/json это работает «как повезёт» (побеждает последнее значение), а после перехода на v2 клиент получит 400.
  • Битый UTF-8 появляется, когда в строковое поле попадает кусок бинарных данных, обрезанная по байтам строка или текст в чужой кодировке.
  • С регистром ломается тише всего: ошибки нет, поле просто остаётся нулевым. Старое поведение возвращает опция json.MatchCaseInsensitiveNames(true) или тег `json:"id,case:ignore"`.
  • omitempty в v2 значит другое. В v1 он определён через Go-значения (0, false, "", nil, пустые срез/map). В v2 — через JSON: поле опускается, если записалось бы как null, пустая строка, пустой объект или пустой массив. То есть 0 и false в v2 остаются в выводе, а в v1 исчезали. Вывод меняется без всякой ошибки, и такое ловится хуже всего.
  • Опции тега подчистили: format и unknown из ранних черновиков v2 убраны, inline переименован в embed.
Как отвечать про это на собесе

«С Go 1.27 encoding/json/v2 в стандартной библиотеке по умолчанию, а encoding/json — совместимая обёртка над ним, так что само обновление Go ничего не ломает. Ломает переезд на v2: он отвергает дубли ключей и невалидный UTF-8, по умолчанию сравнивает имена с учётом регистра, а omitempty у него определён через JSON, а не через Go: 0 и false больше не исчезают. Поэтому в новом коде я сразу пишу omitzero, а миграцию делаю сервис за сервисом с прогоном реального трафика».