Сети
Секция, где 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.
«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/8 | loopback | 127.0.0.1, весь /8 — тоже loopback |
169.254.0.0/16 | link-local (APIPA) | DHCP не ответил; 169.254.169.254 — метадата облака |
100.64.0.0/10 | CGNAT (RFC 6598) | операторский NAT, Tailscale |
224.0.0.0/4 | multicast | OSPF, 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, корпоративному). Серию итеративных запросов делает уже резолвер.
| Тип | Что возвращает | Зачем на практике |
|---|---|---|
| A | IPv4-адрес | основной тип; несколько A-записей = примитивная балансировка round-robin |
| AAAA | IPv6-адрес | 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, антиспам-проверки |
У 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,
удалённый порт}. Уникальна именно пятёрка, а не порт.
- У сервера кончаются не порты, а 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
Вопрос-каркас: по нему интервьюер смотрит, насколько глубоко ты можешь зайти и где остановишься. Отвечай крупными блоками, называй, что дорого по времени, а потом по просьбе углубляйся в любой из них.
- Разбор ввода. Браузер решает, это URL или поисковый запрос. Нормализует ввод и сверяется со списком HSTS preload: если домен там есть, схема принудительно становится https ещё до какого-либо трафика.
- Проверка кэшей. HTTP-кэш браузера (может отдать страницу вообще без сети),
Service Worker, кэш DNS браузера, кэш DNS ОС,
/etc/hosts. - DNS. Рекурсивный запрос к резолверу, дальше корень → TLD → авторитативный (см. схему выше). Обычно по UDP/53, а большой ответ приходит усечённым с флагом TC, и запрос повторяют по TCP. В ответе набор A/AAAA; клиент по Happy Eyeballs пробует v6, а через ~250 мс и v4 (в Go — через 300 мс).
- Маршрутизация и ARP. Хост по маске понимает, что адрес чужой, берёт шлюз по умолчанию, узнаёт его MAC по ARP, кладёт IP-пакет в Ethernet-кадр.
- TCP handshake. SYN → SYN-ACK → ACK, один RTT. Handshake (по-русски рукопожатие) называют короткий обмен служебными сообщениями перед полезными данными: стороны договариваются о параметрах связи и убеждаются, что собеседник жив и слышит. Здесь же согласуются MSS, window scale, SACK. По дороге домашний роутер делает NAT.
- TLS handshake. ClientHello с SNI (Server Name Indication — имя запрашиваемого
сайта; оно едет в открытую, и по нему сервер понимает, чей сертификат отдать, когда на
одном IP висят сотни доменов) и ALPN (Application-Layer Protocol Negotiation — список
прикладных протоколов, которые понимает клиент,
h2иhttp/1.1; сервер выбирает один прямо в рукопожатии, без лишнего круга), ответ сервера, проверка цепочки сертификатов, вывод общего секрета через ECDHE. В TLS 1.3 это один RTT, при возобновлении с early data — ноль. - HTTP-запрос.
GET / HTTP/1.1плюс заголовки (или HEADERS-фрейм в HTTP/2). На пути стоят CDN, балансировщик, реверс-прокси, и каждый может ответить из кэша. - Ответ и рендер. Браузер строит 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Уровни и что на них живёт
- 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.
Маска и подсеть
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 случается сплошь и рядом.
Потому что NAT оказался достаточно хорошим костылём и снял остроту дефицита. Заплатили за это сквозной адресуемостью (end-to-end principle), а вокруг обхода NAT вырос целый пласт технологий. В IPv6 адресов хватает всем, NAT не нужен, а роль ARP играет NDP поверх ICMPv6.
Порядок разрешения имени
- Кэш браузера (десятки секунд), затем кэш ОС (
systemd-resolved,nscd), затем/etc/hosts— до сети дело может и не дойти. - Stub resolver шлёт вопрос с флагом RD=1 («сделай за меня всю работу») на резолвер из
/etc/resolv.conf. Ходит он по UDP/53; если ответ не влез, приходит флаг TC (truncated), и клиент повторяет запрос по TCP/53. С EDNS0 UDP-ответ может быть больше 512 байт. - Резолвер идёт по иерархии: корневой сервер отдаёт NS для
com, TLD-сервер отвечает NS дляgoogle.com, а авторитативный возвращает саму A-запись с флагом AA=1. - Ответ кладётся в кэши на всём обратном пути на время 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 не кэширует DNS сам: кэш только у ОС и резолвера. А соединение из пула
http.Transport о смене DNS не узнаёт вовсе: адрес спрашивают только при dial, и
клиент может месяцами держать соединение к IP, который давно выведен из ротации.
IdleConnTimeout закрывает только простаивающие соединения; возраст занятых
ограничивают обёрткой в DialContext или на сервере. И ещё: у Go два резолвера, чистый Go (сам читает
/etc/resolv.conf) и cgo (getaddrinfo, умеет NSS/mDNS), а переключают их
через GODEBUG=netdns=go|cgo.
Решает он вот что: в провод кладут не 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.
Отсюда вывод: сервер обслуживает тысячи клиентов на одном порту. Порт 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: на одной машине он ощутимо быстрее,
а доступ к нему разграничивают правами файловой системы. Это и есть ответ на «как ускорить
локальный вызов, если оба процесса на одном хосте».
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 на собесе.
Трёхстороннее рукопожатие
На сервере полуоткрытые соединения живут в 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 по ретрансмиссиям не учитываются, иначе оценка поедет. - Три дублирующих ACK → fast retransmit. Приёмник, получая сегменты «с дыркой», на каждый шлёт один и тот же ack (номер начала дырки). Три одинаковых ack подряд означают «один сегмент потерялся, остальные идут», и отправитель повторяет его сразу, не дожидаясь таймера.
SACK (selective acknowledgment, RFC 2018) чинит главную слабость кумулятивного ACK: без него
приёмник не может сказать «мне не хватает только байтов 5000–6460, а 6460–20000 уже есть».
С SACK он перечисляет полученные диапазоны в опциях, и отправитель повторяет ровно дырки, а не всё
начиная с потерянного места. Без SACK на канале с потерями и большим окном пропускная способность
падает в разы. Договариваются о нём в рукопожатии (SACK permitted).
Алгоритм Нейгла копит мелкие записи, чтобы не слать пакет на каждый байт: пока есть
неподтверждённые данные, новый маленький сегмент придерживается. 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, пока окно не откроется.
Отсюда формула: максимальная пропускная способность одного TCP-соединения ограничена
window / RTT. При RTT 100 мс и окне 64 КБ это ~5 Мбит/с, сколько бы гигабит ни было в канале.
Это и есть bandwidth-delay product, поэтому на длинных «толстых» линках включают window scaling и поднимают
net.ipv4.tcp_rmem/tcp_wmem.
Управление перегрузкой
Окно получателя ничего не знает о том, что творится между хостами. Перегрузку сети TCP
оценивает сам, по косвенным признакам (потери, а в современных алгоритмах ещё и рост задержки),
и держит второе окно, cwnd. Реально в полёте может быть min(rwnd, cwnd) байт.
| Фаза | Что делает | Когда включается |
|---|---|---|
| Slow start | cwnd += 1 MSS на каждый подтверждённый сегмент, то есть удвоение за RTT | старт соединения; после таймаута RTO; после долгого простоя |
| Congestion avoidance | cwnd += 1 MSS за RTT — аддитивный рост | когда cwnd дорос до ssthresh |
| Fast retransmit | повтор потерянного сегмента сразу, по трём dup ACK | потеря одиночного сегмента |
| Fast recovery | ssthresh = cwnd/2, cwnd = ssthresh, продолжаем без возврата в slow start | сразу после fast retransmit |
| Таймаут RTO | ssthresh = cwnd/2, cwnd = 1, полный slow start | ACK не пришёл вообще — худший сценарий |
Классическую схему (Reno/NewReno) называют AIMD: «аддитивный рост, мультипликативное падение». CUBIC, дефолт в Linux с 2006 года, заменяет линейный рост кубической функцией от времени с последней потери: быстро возвращается к прежнему окну и осторожно щупает выше, и на «толстых» каналах с большим RTT это заметно быстрее. BBR от Google меняет саму модель: он не считает потерю сигналом перегрузки (на Wi-Fi и мобильных потери бывают от помех), а постоянно оценивает доступную полосу и минимальный RTT и держит ровно столько данных в полёте, сколько нужно, чтобы не наполнять буферы промежуточных роутеров. Так он лечит bufferbloat — ситуацию, когда толстые очереди на роутерах дают гигантские задержки при формально нулевых потерях.
Их постоянно путают, а разница простая. Flow control защищает приёмник: сколько он готов
принять прямо сейчас, знает только он сам, поэтому он и объявляет rwnd явно, в каждом ACK.
Congestion control защищает сеть между вами: сколько она выдержит, никто не сообщает —
отправитель угадывает сам по потерям и задержкам и держит свою оценку cwnd. Нужны оба:
быстрый приёмник за узким каналом требует congestion control, широкий канал с медленным
приёмником требует flow control. В полёте разрешено min(rwnd, cwnd).
Закрытие соединения и TIME_WAIT
Закрытие четырёхстороннее, потому что TCP-соединение полнодуплексное: FIN
говорит «я больше ничего не пришлю», но встречный поток остаётся живым. Это и есть
half-close, в Go его делает conn.CloseWrite() на *net.TCPConn. Если приложение на
пассивной стороне закрывает сокет сразу, ядро склеивает ACK и FIN, и пакетов получается три.
TIME_WAIT возникает только у того, кто закрыл первым. Это единственное состояние, которое
живёт после того, как приложение уже забыло про сокет: ядро держит пятёрку занятой 2 × MSL
(в Linux это константа 60 секунд, TCP_TIMEWAIT_LEN, меняется только пересборкой ядра).
Опасен он на стороне, которая закрывает первой. Если это клиент (или твой сервис в роли
клиента к чужому 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.
| Свойство | TCP | UDP |
|---|---|---|
| Соединение | есть, рукопожатие 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-alive | HTTP 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Рукопожатие
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 означает только «скопировано в буфер отправки ядра». Соединение может
умереть после этого, и приложение узнает об этом в лучшем случае на следующей операции.
Сквозную гарантию даёт только прикладной уровень: подтверждение от самого приложения-получателя.
Последовательность
FIN от активной стороны (FIN_WAIT_1) → ACK от пассивной
(та переходит в CLOSE_WAIT, активная — в FIN_WAIT_2) → пассивная
сторона, когда её приложение тоже закроется, шлёт свой FIN
(LAST_ACK) → активная подтверждает и уходит в TIME_WAIT на 2 × MSL.
Между вторым и третьим пакетом соединение полузакрыто: пассивная сторона всё ещё может
слать данные. Если она закрывается сразу, ACK и FIN склеиваются и пакетов получается три.
Зачем TIME_WAIT
- Дать последнему ACK шанс дойти. Если он потеряется, пассивная сторона по таймауту повторит FIN, и кто-то должен на него ответить. Иначе она получит RST и решит, что соединение оборвалось аварийно.
- Дать умереть заблудившимся сегментам. Сегмент старого соединения, застрявший в сети, не должен быть принят за данные нового соединения с той же пятёркой. 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 —
утечка. На собесе разницу между этими двумя ответами видно сразу.
Чего нет
- Доставки: датаграмма может пропасть молча, отправитель не узнает.
- Порядка: вторая может обогнать первую.
- Дедупликации: сеть может размножить пакет, приложение получит два.
- 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 выбирают не потому, что не нужна надёжность, а потому, что нужна своя надёжность».
| Сценарий | Что беру | Почему |
|---|---|---|
| REST/gRPC API между сервисами | TCP | нужен каждый байт и порядок; соединения долгоживущие, рукопожатие амортизируется |
| Работа с БД, очередями, S3 | TCP | то же плюс пул соединений; потеря байта означает битые данные |
| Передача файлов, бэкапы | TCP | важна целостность, не важна задержка; congestion control сам займёт свободную полосу |
| DNS-запрос | UDP (fallback TCP) | вопрос и ответ помещаются в одну датаграмму; рукопожатие ради 60 байт дороже самой работы. При TC=1 повтор по TCP; зонные трансферы AXFR — всегда TCP |
| NTP, DHCP, метрики StatsD | UDP | fire-and-forget: потерять один тик метрики дешевле, чем платить за соединение |
| VoIP, видеозвонок (WebRTC) | UDP | кадр, опоздавший на 300 мс, уже не нужен; лучше артефакт, чем пауза. Надёжность частичная, через FEC и джиттер-буфер |
| Быстрые игры (шутеры) | UDP | шлём состояние мира тиками; устаревшая позиция игрока бесполезна, ретрансмиссия только добавит лаг |
| Видеостриминг YouTube/Netflix | TCP (HLS/DASH поверх HTTP) | вопрос-ловушка: это не realtime, а загрузка сегментов в буфер на 10–30 секунд — целостность важнее задержки. Realtime-стрим (WebRTC, SRT) — уже UDP |
| Многопользовательская рассылка в LAN, service discovery (mDNS) | UDP | multicast в TCP невозможен |
| HTTP/3 | UDP | QUIC строит свою надёжность поверх UDP, чтобы обойти TCP-HOL и ускорить рукопожатие |
Сильный ответ строится на одном критерии: полезны ли данные после задержки. Если да — TCP, повтор имеет смысл. Если нет — UDP, повтор только вредит, увеличивая задержку и занимая канал. Второй критерий касается размера обмена относительно накладных расходов: три пакета рукопожатия ради одного 60-байтного вопроса не окупаются. И сразу назови ловушку со стримингом: интервьюер часто ждёт «стриминг = UDP», а правильный ответ различает буферизованное видео (TCP) и realtime-связь (UDP).
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.Dialerkeep-alive включён по умолчанию (15 секунд) — в отличие от голого сокета в C.
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, и порядок доставки у каждого потока свой — номера пакетов и подтверждения общие на соединение, но потерянные байты задерживают только свой поток. Потеря пакета тормозит только тот поток, чьи байты потерялись; остальные едут дальше.
Так ведут себя любые упорядоченные очереди. В партиции Kafka одно «ядовитое» сообщение, которое не удаётся обработать, блокирует всю партицию; спасают dead letter queue и параллелизация по ключу. В пуле воркеров с одной общей очередью долгая задача держит короткие, и помогают отдельные очереди по классам задач. Коммутатор с input queuing: кадр, чей выходной порт занят, блокирует следующие — лечится virtual output queues. Если интервьюер уводит туда, общий рецепт один: разделить поток на независимые очереди либо разрешить обработку не по порядку.
9.3HTTP и TLS
Протокол, на котором ты пишешь каждый день, и криптография, в которую он завёрнут. Номера кодов наизусть не спрашивают, спрашивают семантику: почему именно этот код и этот метод, что TLS реально даёт, а чего нет.
Как HTTP выглядит на проводе
HTTP/1.1 гоняет поверх TCP обычный текст. Сообщение состоит из стартовой строки, набора
заголовков вида Name: value, пустой строки и тела. Строки
всегда разделяет CRLF (\r\n), а два CRLF подряд означают «заголовки кончились».
$ 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
(длина известна заранее), 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 означает «обработай это по своим правилам», и какой идентификатор создать, обычно решает
сервер. Повтор создаёт новый ресурс — отсюда два заказа при двойном клике или ретрае после
таймаута. Это не теория: любая сетевая ошибка неоднозначна — ты не знаешь, то ли запрос не дошёл,
то ли дошёл, а потерялся ответ. Лечится ключом идемпотентности: клиент генерирует 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 403 | 401 — «я не знаю, кто ты»: токена нет, он протух или подпись невалидна. Обязателен заголовок WWW-Authenticate. Смысл: перелогинься и приходи снова. 403 — «я знаю, кто ты, и тебе нельзя»: аутентификация прошла, не хватает прав. Повтор с тем же токеном бесполезен. |
| 400 vs 422 | 400 — сообщение вообще не разобрать: битый JSON, нет обязательного поля, не тот тип. 422 Unprocessable Entity — синтаксис корректен, но семантика невалидна: дата окончания раньше даты начала, отрицательная сумма, несуществующий статус. Многие API живут только на 400 — это допустимо, но 422 точнее. |
| 404 vs 403 vs 410 | 404 — нет ресурса (или мы не хотим раскрывать, что он есть — часто отдают вместо 403 для приватных объектов). 410 Gone — был и удалён навсегда, поисковикам сигнал выкинуть из индекса. |
| 409 Conflict | Состояние ресурса не позволяет выполнить операцию: дубликат уникального поля, конфликт версий при оптимистичной блокировке (If-Match не совпал — тогда точнее 412 Precondition Failed), попытка отменить уже отгруженный заказ. |
| 429 Too Many Requests | Rate limit. Обязательно отдавай Retry-After (секунды или дата) и по возможности X-RateLimit-Remaining/Reset — иначе клиент будет долбиться в цикле. |
| 502 vs 503 vs 504 | 502 Bad Gateway — прокси сходил на апстрим и получил мусор или разрыв (апстрим упал, отдал битый ответ). 503 Service Unavailable — сервис сам говорит «я сейчас не могу»: перегрузка, деплой, circuit breaker открыт; сюда же Retry-After. 504 Gateway Timeout — апстрим не ответил за отведённое время. Разница важна при разборе инцидента: 502 — «упал бэкенд», 504 — «бэкенд тормозит». |
| 301 vs 302 vs 307/308 | 301/308 — навсегда (301 кэшируется браузером агрессивно, откатить тяжело), 302/307 — временно. Ключевое отличие: 301 и 302 исторически позволяли клиенту сменить POST на GET, а 307 и 308 обязаны сохранить метод и тело. Для API всегда бери 307/308. |
Клиентская библиотека должна ретраить только идемпотентные запросы и только на 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 |
Authorization | Bearer <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
Текстовый, в соединении один запрос за раз. Версия дала 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 и как
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
Директива 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)
}
Кэш ключуется по URL. Если ответ зависит ещё от чего-то (сжатия, языка, пользователя),
перечисли эти заголовки в Vary, иначе общий кэш отдаст одному пользователю
ответ, сгенерированный для другого. Самая опасная версия бага: персональный ответ без
Cache-Control: private оседает на CDN и раздаётся всем. Правило: любой ответ, который
зависит от Authorization или куки, помечай private (а лучше
no-store) и всегда ставь Vary.
Вопросы
8CRLF 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 | да | да | нет | редко |
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.
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))
})
}
Годами 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 — срок жизни, без них кука сессионная и живёт до закрытия браузера.
Куку браузер прикрепляет автоматически — отсюда CSRF и нужда в SameSite и
CSRF-токенах. Заголовок Authorization ставят руками, поэтому CSRF ему не
грозит, зато токен приходится где-то хранить, и localStorage для этого плохое
место: любой XSS его прочитает. В SPA обычно держат refresh-токен в HttpOnly-куке,
а access-токен в памяти JS.
Что было в 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 (оказался неудачным и де-факто мёртв).
HTTP/2 убрал HOL-блокировку на прикладном уровне, но добавил зависимость от одного TCP-соединения. TCP обязан отдавать байты приложению строго по порядку: если потерялся один сегмент, ядро держит в буфере всё, что пришло после него, и встают все стримы, даже те, чьи данные уже пришли. В HTTP/1.1 с шестью соединениями потеря била только по одному из них. Поэтому на плохой сети (мобильный интернет, потери 2–5%) HTTP/2 иногда проигрывает HTTP/1.1. Это и чинит QUIC: он уносит нумерацию и восстановление на уровень потоков.
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Формат | текст | бинарные фреймы | бинарные фреймы |
| Транспорт | TCP | TCP | QUIC поверх UDP |
| Параллельность | несколько TCP-соединений | стримы в одном соединении | стримы в одном соединении |
| Заголовки | текст целиком | HPACK | QPACK |
| HOL прикладной | есть | нет | нет |
| HOL транспортный | есть (в рамках соединения) | есть, общий на все стримы | нет |
| Рукопожатие | TCP + TLS = 2–3 RTT | TCP + TLS = 2–3 RTT | 1 RTT, 0 RTT по билету |
| Смена сети | рвётся | рвётся | переживает (connection ID) |
В Go клиент включает HTTP/2 сам, если ты пользуешься дефолтным http.Transport и
не трогал TLSClientConfig. При ручной настройке TLS автоапгрейд отключается,
и надо либо ставить ForceAttemptHTTP2: true, либо звать
http2.ConfigureTransport. О версии договариваются в
ALPN внутри TLS-рукопожатия, так что лишнего раунда нет. HTTP/2 возможен и без TLS
(h2c), но браузеры так не умеют: это вариант для межсервисного трафика, в том числе gRPC.
Четыре вещи, которые 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, поэтому туда кладут только
идемпотентные запросы.
Рукопожатие TLS 1.3 по шагам
- ClientHello: версии, список шифронаборов, поддерживаемые группы, SNI (какой
домен нужен), ALPN (h2/http1.1) и сразу
key_share, долю ключа для той группы, которую клиент угадал. - ServerHello: выбранный шифронабор и свой
key_share. С этого момента обе стороны уже могут вычислить общий секрет, и всё дальнейшее шифруется. - Сервер шифрованно шлёт Certificate, CertificateVerify (подписывает хэш всего рукопожатия приватным ключом и так доказывает владение) и Finished.
- Клиент проверяет цепочку и подпись, шлёт 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 без тела.Как кэш принимает решение
- Есть ли запись по ключу (URL + заголовки из
Vary)? Нет — идём в сеть. - Свежа ли она (возраст меньше
max-age/ доExpires)? Если да, отдаём мгновенно, в сеть не ходим вообще. - Протухшую не выбрасываем, а делаем условный запрос с
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, а не затирает чужие изменения.
Кэш ключуется по 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 сводится к набору ограничений, и масштабируемость следует из них сама.
Шесть ограничений
- Клиент-сервер. Разделение ответственности: интерфейс отдельно, хранение отдельно, и стороны развиваются независимо.
- Stateless. Каждый запрос самодостаточен: между запросами сервер ничего не помнит о клиенте, всё нужное едет в самом запросе (токен, параметры, версия). Отсюда горизонтальное масштабирование: любой запрос обработает любой инстанс, а падение узла не рвёт «сессию».
- Кэшируемость. Ответ обязан явно говорить, кэшируемый он или нет. Поэтому кэши и CDN можно вставить между клиентом и сервером, не меняя ни того, ни другого.
- Единообразный интерфейс считается центральным ограничением и распадается на четыре:
идентификация ресурсов через URI; манипуляция ресурсами через представления;
самоописывающиеся сообщения (метод,
Content-Type, кэш-заголовки); HATEOAS (гипермедиа как двигатель состояния приложения). - Слоистая система. Клиент не знает, разговаривает он с сервером напрямую или через три прокси, CDN и балансировщик. Именно поэтому промежуточные узлы вообще возможны.
- 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, этого названия не заслуживает.
Строго говоря, 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 мыслит глаголами: есть удалённая процедура с именем и аргументами, а сколько таких процедур и как они называются, решает автор сервиса.
| REST | RPC (в т.ч. 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, не ломая компиляцию существующих серверов.
Четыре вида вызовов
Protobuf на проводе
Главная идея формата: имён полей на проводе нет. Есть номера. Сообщение устроено как последовательность пар «ключ, значение», где ключ кодирует номер поля и wire type (как читать значение). Парсер, встретив незнакомый номер, знает по wire type, сколько байт пропустить. Отсюда почти бесплатная прямая и обратная совместимость.
Дальше без перевода пойдут два жаргонных слова. Wire type («тип на проводе») не
совпадает с типом из .proto: он отвечает на единственный вопрос «как найти конец
значения». Вариантов всего шесть, поэтому wire type влезает в три бита ключа. Varint
(variable-length integer, «целое переменной длины») записывает число так, чтобы мелкие
числа занимали мало места: в каждом байте 7 полезных бит, а старший, восьмой, бит работает
флагом «дальше есть ещё байт». Число 1 умещается в один байт, 300 в два, миллиард в пять.
| Wire type | Название | Как читается | Типы |
|---|---|---|---|
0 | VARINT | байты, пока старший бит = 1 | int32/64, uint32/64, sint32/64, bool, enum |
1 | I64 | ровно 8 байт | fixed64, sfixed64, double |
2 | LEN | varint длины, потом столько байт | string, bytes, вложенные сообщения, packed-массивы |
3/4 | SGROUP/EGROUP | группы: устарели, но встречаются в proto2 и в editions (DELIMITED) | — |
5 | I32 | ровно 4 байта | fixed32, sfixed32, float |
AC 02». Умение довести до битов отличает
«читал про protobuf» от «понимаю формат».Эволюция схемы
Из «на проводе только номера» следуют все правила совместимости. Безопасно: добавить новое
поле с новым номером (старый читатель его проигнорирует, новый увидит нулевое значение);
переименовать поле (имя на провод не попадает, но сломаются JSON-представление и код);
удалить поле, если номер уйдёт в reserved; добавить значение в enum (при условии, что
клиент корректно обрабатывает неизвестное); превратить одиночное поле в repeated
того же типа для string, bytes и сообщений. Нельзя: менять номер существующего поля;
менять тип на несовместимый (int32 ↔ string); переиспользовать номер
удалённого поля.
Допустим, было 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 совпадение исчезло.
Четыре способа починить
- 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 | SSE | WebSocket | |
|---|---|---|---|
| Направление | сервер→клиент (эмуляция) | только сервер→клиент | обе стороны |
| Протокол | обычный HTTP | обычный HTTP, text/event-stream | ws:///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 внутри одной кодовой базы, снапшоты на диск |
| XML | XSD, очень строгая | ×1.3–2 от JSON | низкая | условно | SOAP, госинтеграции, банки, документооборот с подписью |
| YAML | внешняя | — | низкая | лучшая | только конфиги; для трафика не используют |
| Avro | обязательная, схема едет отдельно | ×0.2–0.4 | высокая | нет | Kafka + Schema Registry, аналитические пайплайны, Hadoop |
- Только Go. Другие языки его не читают, так что для любого внешнего API он отпадает сразу.
- Самоописывающийся. В поток едет описание типов, поэтому первое сообщение выходит большим, а следующие однотипные стоят копейки. Значит, gob хорош для потока и плох для одиночных мелких значений в кэше.
- Экспортируемые поля и нулевые значения. Кодируются только публичные поля; поля с
нулевым значением по умолчанию не передаются вовсе. Для интерфейсных полей нужен
gob.Register, иначе и кодирование, и декодирование вернут ошибку «type not registered».
Про YAML надо предупредить отдельно: мало того что он медленный, он ещё и опасный.
Классическая «норвежская проблема»: country: NO в YAML 1.1 разбирается как булев
false. А из якорей и ссылок (&anchor/*ref)
собирают «YAML-бомбу», которая раскрывается экспоненциально. Так что YAML годится для конфигов,
которые пишет человек, и никогда для трафика.
Вопросы
11Шесть ограничений и что каждое даёт
| Ограничение | Что запрещает | Что за это получаешь |
|---|---|---|
| Клиент-сервер | смешивать UI и хранение | независимую эволюцию сторон |
| Stateless | помнить клиента между запросами | горизонтальное масштабирование, простые ретраи |
| Кэшируемость | молчать про кэшируемость ответа | CDN и прокси между клиентом и сервером |
| Единообразный интерфейс | свои глаголы и свои коды | любой посредник понимает семантику |
| Слоистость | клиенту знать, кто на том конце | прокси, шлюзы, балансировщики, mesh |
| Code on demand (опц.) | — | исполняемый код от сервера (JS в браузере) |
Stateless — что это значит на практике
Это не значит «состояния нет вообще»: данные в базе никуда не деваются. Речь про состояние сессии клиента на конкретном инстансе. Всё нужное запрос обязан нести сам: токен вместо «сервер помнит, что ты залогинен», курсор пагинации вместо «сервер помнит, где ты остановился». Взамен любой инстанс обрабатывает любой запрос, sticky session не нужен, падение узла не рвёт работу пользователя, ретрай безопасен, а автоскейл честный.
Строго говоря, да. Серверная сессия по 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: состояние ресурса
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 половина его преимуществ исчезает.
GET + URI + кэш-заголовки понятны всем посредникам.
RPC — это всегда POST с непрозрачным телом, поэтому кэш строишь сам.
С балансировкой ровно наоборот: REST на HTTP/1.1 раскидывается любым L4, а gRPC на одном
долгоживущем HTTP/2-соединении «приклеивается» к одному поду, потому что L4 выбирает бэкенд
один раз — при установке TCP.Кэш: почему у REST он бесплатный
- Ключом кэша служит URI плюс заголовки из
Vary. У RPC ключа нет: у всех вызовов один путь и разные тела, а тело в ключ кэша не входит. GETобъявлен безопасным и идемпотентным, поэтому кэшировать его можно по умолчанию.POSTкэшируем только в теории и практически никем.- Условные запросы (
ETag+If-None-Match→304) дают почти бесплатную ревалидацию: 150 байт вместо мегабайта. - Уровни кэша складываются: браузер → Service Worker → CDN → реверс-прокси → кэш приложения. Каждый снимает часть нагрузки, и ни один не знает про твой код.
В RPC/gRPC всё это делают вручную: кэш в Redis по ключу из имени метода и полей запроса,
своя логика инвалидации, TTL своими руками. Исключения есть: кэшируемые методы можно
объявить через google.api.http и выставить наружу как GET через grpc-gateway.
Но это уже трансляция в REST, а не кэш gRPC.
Балансировка: где именно ломается
- Клиент открывает одно TCP-соединение. L4-балансировщик выбирает бэкенд по 5-кортежу (src IP, src port, dst IP, dst port, протокол) и делает это один раз.
- gRPC мультиплексирует в этом соединении сотни потоков. Все они, естественно, идут на выбранный бэкенд.
- Соединение живёт часами: gRPC его не рвёт, а keepalive-пинги не дают закрыться.
- В итоге один под получает весь трафик клиента, остальные простаивают. Отскейлились с 3 подов до 30, а нагрузка не перераспределилась: никто не переподключался.
- CPU на одном поде 90%, на остальных 5%, а HPA считает среднее и поэтому не скейлит.
- После деплоя новые поды стоят пустые, пока старые не убьют.
- Задержка не падает от добавления реплик.
- В Kubernetes через обычный ClusterIP так будет гарантированно: kube-proxy работает на L4 (iptables, nftables или IPVS) и балансирует соединения, а не запросы.
Решения и их цена
| Способ | Как работает | Цена |
|---|---|---|
| L7-прокси | Envoy/nginx/ALB терминируют HTTP/2 и раскидывают каждый вызов | лишний хоп, ресурсы, ещё одна точка отказа |
| Client-side LB | headless service + resolver отдаёт все IP, клиент держит по соединению к каждому и делает round-robin по вызовам | логика в каждом клиенте, соединений «клиенты × поды» |
| Service mesh | sidecar делает то же самое, но вне кода приложения | сложность платформы, +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-прокси.
Терминология, на которой путаются
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-first | Code-first | |
|---|---|---|
| Источник истины | openapi.yaml в репозитории | код и аннотации над хендлерами |
| Порядок работы | согласовали спеку → сгенерировали типы и роутинг → написали логику | написали хендлеры → сгенерировали спеку → отдали потребителям |
| Параллельная работа | фронт стартует сразу с моков | фронт ждёт готовый бэкенд |
| Расхождение кода и спеки | невозможно: код из спеки | обычное дело: забыли обновить аннотацию |
| Обратная совместимость | проверяется diff-ом спеки в CI | ловится глазами на ревью |
| Скорость на старте | медленнее, спека — отдельный артефакт для ревью | быстрее, спека «сама появляется» |
| Инструменты в Go | oapi-codegen, ogen | swaggo/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 вопроса «contract-first или code-first» нет: единственный источник истины там
.proto, и code-first в Go не встречается (в .NET есть protobuf-net.Grpc, но это экзотика). Это и есть главный аргумент за
gRPC во внутренних API: контракт не может разойтись с реальностью, а
buf breaking проверяет совместимость из коробки. Верно и обратное:
contract-first в REST пытается повторить дисциплину, которую gRPC даёт по построению.
.proto,
сериализация protobuf, транспорт HTTP/2, кодогенерация клиента и сервера. Быстрее за счёт
четырёх вещей: компактный бинарный формат без имён полей, мультиплексирование в одном
долгоживущем соединении, сжатие заголовков HPACK, отсутствие рефлексии в сгенерированном коде.
Плюс встроенные дедлайны, отмена, метаданные, стриминг и интерцепторы.Что происходит при вызове
- Сгенерированный клиент сериализует запрос в protobuf.
- Открывается новый поток в уже существующем HTTP/2-соединении. Летят HEADERS:
:method: POST,:path: /order.v1.OrderService/GetOrder,content-type: application/grpc,te: trailers, дедлайн вgrpc-timeout, метаданные пользователя. - DATA-фреймы несут «length-prefixed message»: 1 байт флага сжатия + 4 байта длины big-endian + сами байты сообщения. Если сообщений несколько, это и есть стриминг.
- Ответ приходит так же, а результат вызова лежит в трейлерах после тела:
grpc-status: 0, при ошибке ещёgrpc-messageиgrpc-status-details-bin.
Ошибка 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 = 1 | int32/64, uint32/64, sint32/64, bool, enum | отрицательные int32 — всегда 10 байт |
| 1 I64 | 8 байт | fixed64, sfixed64, double | выгоден, когда числа обычно большие |
| 2 LEN | varint длины + данные | string, bytes, вложенные сообщения, packed-массивы | вложенность = просто байты внутри байтов |
| 5 I32 | 4 байта | fixed32, sfixed32, float | для хэшей и id фиксированной ширины |
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 оно сохраняется как есть |
int32 ↔ int64 ↔ bool ↔ uint32 | да | одинаковый wire type; но при сужении значение обрежется |
string ↔ bytes | да | оба LEN; строка должна быть валидным UTF-8 |
Одиночное поле → repeated | да для сообщений | старый читатель сольёт элементы в одно сообщение (последнее значение берут только у скаляров, string и bytes) |
| Сменить номер поля | нет | это другое поле; старые данные станут «неизвестными» |
int32 ↔ string, int32 ↔ fixed32 | нет | разные 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.
Как объявляются и когда какой
| Вид | В .proto | Когда брать | Чем опасен |
|---|---|---|---|
| Unary | rpc Get(Req) returns (Resp) | по умолчанию всё | ничем; ретраи и дедлайны тривиальны |
| Server streaming | rpc List(Req) returns (stream Resp) | большая выгрузка, подписка на события, прогресс долгой операции | ретрай означает «начать сначала»; нужен курсор для возобновления |
| Client streaming | rpc Upload(stream Req) returns (Resp) | загрузка файла кусками, батч метрик, агрегация на сервере | результат только в конце; при обрыве непонятно, что успело записаться |
| Bidirectional | rpc 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-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 | REST |
|---|---|---|
| Кто потребитель | свои сервисы, мобильные клиенты с вашим 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 плюс общий пакет с типами дешевле в поддержке, а разницу в скорости мы всё равно не заметим.» Последняя фраза важнее первых двух: по ней видно, что ты выбираешь по контексту, а не по моде.
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бинарь,0x8close,0x9ping,0xApong), MASK-бит, длина (7, 7+16 или 7+64 бита). Итого 2–14 байт вместо сотен байт HTTP-заголовков. - Клиент обязан маскировать данные 4-байтовым ключом (сервер — нет). Это тоже не про шифрование: маскирование ломает атаки на промежуточные прокси, которые могли бы принять содержимое кадра за начало нового HTTP-запроса (cache poisoning).
- Ping/pong — служебные кадры для keepalive, и они обязательны: без трафика NAT и балансировщики рвут «мёртвые» соединения через 30–120 секунд, а клиент узнаёт об этом только при попытке записи. Пингуют обычно раз в 20–30 секунд.
- Сообщение можно фрагментировать на несколько кадров: так стримят большие полезные нагрузки.
Чем отличается от HTTP
| HTTP | WebSocket | |
|---|---|---|
| Модель | запрос-ответ, инициатор всегда клиент | полный дуплекс, инициировать может любой |
| Состояние | stateless | stateful: соединение и есть сессия |
| Оверхед сообщения | сотни байт заголовков | 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 данные осядут в буфере и никуда не поедут
}
}
}
- Нет пингов. Соединения молча умирают в 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с горутиной на соединение.
omitempty, который не работает так, как ожидают,
для time.Time, структур и «осмысленных нулей».Сравнение по осям
| Формат | Схема | Размер | Скорость | Человекочитаем | Кросс-язычность | Ниша |
|---|---|---|---|---|---|---|
| JSON | внешняя, опциональная | 1× | средняя | да | полная | публичные API, логи, конфиги |
| Protobuf | обязательная | 0.2–0.4× | высокая | нет | полная | межсервисное, события, мобилки |
| MessagePack | нет | 0.6–0.8× | высокая | нет | полная | кэш, Redis, «JSON, но меньше» |
| gob | самоописывающаяся | 0.5–0.8× | высокая | нет | только Go | Go↔Go, снапшоты, net/rpc |
| XML | XSD, строгая | 1.3–2× | низкая | условно | полная | SOAP, госинтеграции, подписанные документы |
| YAML | внешняя | — | низкая | лучшая | полная | только конфиги |
| Avro | обязательная, отдельно | 0.2–0.4× | высокая | нет | полная | Kafka + Schema Registry, аналитика |
Голосом стоит добавить, что у XML есть то, чего нет у JSON: атрибуты, пространства имён,
XSD-валидация, XSLT и, главное, XML Signature, подпись части документа. Поэтому
он и жив в банках и госсистемах, а вовсе не «потому что не переписали». У YAML же есть
«норвежская проблема» (country: NO → false в 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).
- «Пусто» означает конкретный список:
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 теряет точность: id9007199254740993приезжает изменённым. Лечится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 строже, причём ощутимо: он отвергает документы, которые старый пакет годами молча глотал. На этом миграции и спотыкаются.
// 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, а миграцию делаю
сервис за сервисом с прогоном реального трафика».