Модуль: Сети и протоколы · Уровень: Middle+/Senior
TL;DR#
- WebSocket (RFC 6455) — полнодуплексный двунаправленный канал поверх одного TCP, устанавливается HTTP Upgrade-рукопожатием, затем переходит на бинарный фрейм-протокол.
- Использует тот же порт (80/443), проходит прокси/файрволлы как HTTP; после upgrade — это уже не HTTP.
- Фреймы: text/binary/ping/pong/close + fragmentation; клиент обязан маскировать payload.
- Выбирать вместо polling/SSE, когда нужна низкая латентность И двунаправленность (чат, игры, коллаборация). SSE достаточно для server→client односторонних потоков.
- Масштабирование — главная боль: stateful соединения, sticky-сессии или pub/sub-бэкплейн (Redis/NATS/Kafka), лимиты на FD/память.
- В Go:
gorilla/websocket(де-факто, но архивный) иcoder/websocket(бывш. nhooyr.io/websocket, контекст-aware, рекомендуемый).
Теория#
Установка: HTTP Upgrade#
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat # опц. субпротоколы
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= # SHA1(key+GUID), base64- После
101соединение перестаёт быть HTTP — дальше бинарные WS-фреймы. Sec-WebSocket-Accept= base64(SHA1(client_key + magic_GUID)) — подтверждает, что сервер понял WS (защита от случайного апгрейда кешами).- Поверх TLS — это
wss://(рекомендуется всегда). - HTTP/2 имеет своё расширение для WS (RFC 8441, CONNECT), но классика — поверх h1.
Структура фрейма#
- FIN бит (последний фрейм сообщения) + opcode: 0x1 text, 0x2 binary, 0x8 close, 0x9 ping, 0xA pong, 0x0 continuation.
- MASK бит + 32-битный masking key: клиент → сервер фреймы ОБЯЗАНЫ быть замаскированы (XOR), сервер → клиент — нет. Это защита от cache-poisoning через прокси.
- Payload length: 7 / 7+16 / 7+64 бит (расширяемая длина).
- Fragmentation: большое сообщение можно слать частями (FIN=0 у промежуточных).
Ping/Pong (keep-alive на уровне WS)#
- Control-фреймы
ping/pongдля проверки живости и удержания соединения (NAT, прокси-таймауты). - На
pingpeer обязан ответитьpong(с тем же payload). - Сервер обычно периодически шлёт ping и закрывает соединение, если нет pong в дедлайн → детект мёртвых клиентов. TCP keep-alive здесь недостаточно надёжен/быстр.
WebSocket vs SSE vs Polling#
| Long Polling | SSE | WebSocket | |
|---|---|---|---|
| Направление | client↔server (через переоткрытие) | server→client | полный дуплекс |
| Транспорт | HTTP | HTTP (text/event-stream) | TCP после upgrade |
| Латентность | высокая | низкая | низкая |
| Авто-reconnect | вручную | встроен (Last-Event-ID) | вручную |
| Бинарные данные | да | нет (только текст/UTF-8) | да |
| Через HTTP/2 мультиплекс | да | да | сложнее |
| Сложность/инфра | низкая | низкая | высокая (stateful) |
Правило: нужен только server→client (нотификации, прогресс, котировки) — бери SSE (проще, авто-reconnect, обычный HTTP). Нужен дуплекс/низкая латентность в обе стороны (чат, игры, совместное редактирование) — WebSocket.
WebSocket в Go#
// coder/websocket (рекомендуемый, контекст-aware)
func handler(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, nil)
if err != nil { return }
defer c.Close(websocket.StatusInternalError, "")
ctx := r.Context()
for {
typ, data, err := c.Read(ctx)
if err != nil { return } // включая закрытие
if err := c.Write(ctx, typ, data); err != nil { return }
}
}// gorilla/websocket — паттерн с отдельными read/write горутинами
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024, WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true }, // проверяйте Origin!
}
conn, _ := upgrader.Upgrade(w, r, nil)
// gorilla: writes НЕ потокобезопасны — один writer-горутина обязательнаМасштабирование#
- Каждое соединение = живая горутина(ы) + FD + буферы. 100k соединений = тюнинг ulimit/FD, память на буферы.
- Stateful: соединение привязано к конкретному инстансу. Сообщение для пользователя надо доставить на тот инстанс, где он висит.
- Sticky sessions (балансировщик держит клиента на одном инстансе) ИЛИ pub/sub backplane: инстансы публикуют/подписываются на сообщения через Redis Pub/Sub, NATS, Kafka → любой инстанс может доставить любому клиенту.
- Fan-out (broadcast): hub-паттерн (реестр клиентов + канал рассылки), но не блокируйте hub медленным клиентом — per-client буфер + дроп/отключение при переполнении.
- Graceful shutdown: слать close-фрейм с кодом, давать клиентам переподключиться к другому инстансу.
Подводные камни / gotchas#
CheckOriginпо умолчанию у gorilla отклоняет cross-origin; разработчики ставятreturn true— это открывает CSWSH (Cross-Site WebSocket Hijacking). Проверяйте Origin/используйте токены.- Writes не потокобезопасны (gorilla): одновременная запись из двух горутин = коррупция. Один writer-горутина или мьютекс.
- Медленный клиент (slow consumer) забивает буфер → рост памяти/блокировка hub. Нужен bounded буфер и политика дропа/дисконнекта.
- Забытый ping/pong → мёртвые соединения копятся, FD-лик; промежуточные прокси режут “тихие” соединения по таймауту.
- WS не проходит некоторые корпоративные прокси без правильной поддержки Upgrade; нужен fallback (SSE/long-polling) — поэтому socket.io-подобные либы делают fallback.
- TLS обязателен на практике (
wss://): поws://многие прокси ломают/инспектируют апгрейд. - Backpressure: WebSocket не даёт application-level flow control из коробки — реализуйте сами (кредиты/ack), иначе быстрый отправитель утопит получателя.
- Сообщение vs фрейм: одно логическое сообщение может быть фрагментировано; не привязывайте бизнес-логику к границам TCP-чтений.
- HTTP/2 и WS: классический WS не мультиплексируется в h2; за reverse-proxy надо явно поддерживать upgrade (nginx
proxy_set_header Upgrade).
Вопросы на собеседовании#
В: Как устанавливается WebSocket-соединение?
О: Обычным HTTP-запросом с Upgrade: websocket/Connection: Upgrade и Sec-WebSocket-Key. Сервер отвечает 101 Switching Protocols с Sec-WebSocket-Accept = base64(SHA1(key + magic GUID)). После этого соединение переключается на бинарный фрейм-протокол, перестав быть HTTP.
В: Зачем клиент маскирует фреймы, а сервер нет? О: Маскирование (XOR с ключом) защищает от cache-poisoning/инъекций через промежуточные прокси, которые могли бы принять часть payload за HTTP-запрос. Угроза актуальна только для трафика client→server, поэтому маскируется только он.
В: Когда выбрать SSE вместо WebSocket? О: Когда нужен только односторонний поток server→client (нотификации, прогресс, лента). SSE проще: обычный HTTP, встроенный авто-reconnect с Last-Event-ID, легко проходит прокси. WebSocket — когда нужен полный дуплекс и/или бинарные данные с низкой латентностью.
В: Зачем ping/pong, если есть TCP keep-alive? О: TCP keep-alive медленный (десятки минут по умолчанию) и не проверяет живость приложения/прокси. WS ping/pong на уровне приложения быстро детектит мёртвых peer’ов и удерживает соединение через NAT/прокси-таймауты.
В: Главная сложность масштабирования WebSocket? О: Соединения stateful и привязаны к инстансу. Чтобы доставить сообщение пользователю, нужно знать, на каком инстансе он висит. Решения: sticky sessions либо pub/sub backplane (Redis/NATS/Kafka), через который инстансы обмениваются сообщениями.
В: Что такое CSWSH и как защититься?
О: Cross-Site WebSocket Hijacking — вредоносная страница открывает WS к вашему серверу, используя cookie жертвы (WS не подчиняется same-origin/CORS как fetch). Защита: проверять заголовок Origin, использовать CSRF-токены/auth-токены вместо cookie-only.
В: Как обработать медленного клиента при broadcast? О: Per-client bounded буфер (канал); если он переполнен — дропать сообщения или закрывать соединение, но НЕ блокировать общий hub. Иначе один slow consumer затормозит рассылку всем.
В: Почему в gorilla нельзя писать из нескольких горутин? О: Write не потокобезопасен. Стандартный паттерн — одна writer-горутина, читающая из канала, плюс одна reader-горутина; либо мьютекс на запись. Иначе фреймы перемешаются.
На что копают на senior+#
- Архитектура hub/connection registry, backpressure через bounded каналы, политика дропа.
- Конкретный backplane: Redis Pub/Sub (нет персистентности, at-most-once) vs Kafka/NATS (гарантии, переподключение); presence/online-статусы.
- Лимиты ОС: FD ulimit, эфемерные порты, память на горутины/буферы при 100k+ соединениях, epoll vs горутина-на-соединение (gnet и подобные).
- Graceful deploy stateful-сервиса: drain, close-коды (1001 going away), reconnect-стратегии клиента с backoff+jitter.
- Безопасность: Origin-проверка, аутентификация (токен в первом сообщении vs subprotocol vs query), rate limiting на сообщения.
- WS over HTTP/2 (RFC 8441) и почему обычно остаются на h1; поведение за L7-прокси (nginx/Envoy) с upgrade.
- Сравнение coder/websocket (контекст-aware, проще, без отдельного write-горутины) vs gorilla (архивный, больше контроля, legacy-кодовые базы).