Модуль: Сети и протоколы · Уровень: 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, прокси-таймауты).
  • На ping peer обязан ответить pong (с тем же payload).
  • Сервер обычно периодически шлёт ping и закрывает соединение, если нет pong в дедлайн → детект мёртвых клиентов. TCP keep-alive здесь недостаточно надёжен/быстр.

WebSocket vs SSE vs Polling#

Long PollingSSEWebSocket
Направлениеclient↔server (через переоткрытие)server→clientполный дуплекс
ТранспортHTTPHTTP (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-кодовые базы).