WebSockets Deep Dive
Зачем знать: WebSocket — основа всех realtime-систем (chat, notifications, trading, multiplayer games, collaborative editing). На уровне Middle 2 Go-разработчик должен уметь не просто открыть соединение через
gorilla/websocket, а правильно архитектурировать hub/broadcast, держать 10K+ connections per instance, обрабатывать heartbeats, корректно делать graceful shutdown и понимать протокол RFC 6455 на уровне frames.
Содержание
Заголовок раздела «Содержание»- Базовая концепция
- Глубокое погружение (под капотом)
- Gotchas (12+)
- Production-практики
- Вопросы (25+)
- Practice
- Источники
1. Базовая концепция
Заголовок раздела «1. Базовая концепция»Зачем WebSocket?
Заголовок раздела «Зачем WebSocket?»HTTP request/response: WebSocket (после handshake):Client → Request → Client ↔ ↔ ↔ ↔ ↔ ↔ ↔ Server ← Response ← (full duplex, persistent)Client → Request → ← Response ←WebSocket — full-duplex, persistent connection поверх TCP с собственным framing и control-сообщениями. Открывается через HTTP Upgrade и затем “съезжает” в свой протокол.
Handshake
Заголовок раздела «Handshake»WebSocket-соединение начинается как HTTP/1.1-запрос с заголовком Upgrade:
Client → Server:
GET /chat HTTP/1.1Host: server.example.comUpgrade: websocketConnection: UpgradeSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Sec-WebSocket-Version: 13Sec-WebSocket-Protocol: chat.v1Origin: http://example.comServer → Client:
HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Sec-WebSocket-Protocol: chat.v1Sec-WebSocket-Accept = base64(SHA1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) — магическая константа из RFC 6455.
После 101 Switching Protocols соединение уходит из HTTP-режима и становится WebSocket.
Минимальный пример (coder/websocket)
Заголовок раздела «Минимальный пример (coder/websocket)»package main
import ( "context" "log" "net/http" "time"
"github.com/coder/websocket")
func handler(w http.ResponseWriter, r *http.Request) { c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ Subprotocols: []string{"chat.v1"}, }) if err != nil { log.Println("accept:", err) return } defer c.CloseNow()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel()
for { typ, data, err := c.Read(ctx) if err != nil { log.Println("read:", err) return } // эхо обратно if err := c.Write(ctx, typ, data); err != nil { return } }}
func main() { http.HandleFunc("/ws", handler) log.Fatal(http.ListenAndServe(":8080", nil))}Минимальный пример (gorilla/websocket)
Заголовок раздела «Минимальный пример (gorilla/websocket)»package main
import ( "log" "net/http"
"github.com/gorilla/websocket")
var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return true // ⚠️ в продакшене — валидация Origin },}
func wsHandler(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println("upgrade:", err) return } defer conn.Close()
for { mt, msg, err := conn.ReadMessage() if err != nil { log.Println("read:", err) return } if err := conn.WriteMessage(mt, msg); err != nil { return } }}
func main() { http.HandleFunc("/ws", wsHandler) log.Fatal(http.ListenAndServe(":8080", nil))}⚠️ gorilla/websocket в 2024 году перешёл в read-only / maintenance mode. Активно развивается coder/websocket (бывший nhooyr.io/websocket). Для нового кода — coder/websocket.
Сравнение библиотек
Заголовок раздела «Сравнение библиотек»| Lib | Status | Special features |
|---|---|---|
gorilla/websocket | maintenance | Industry standard, stable API |
coder/websocket (nhooyr.io) | active | Чище API, лучше context-support, json.Marshal helpers |
gobwas/ws | active | Low-alloc, zero-copy, низкоуровневый |
fasthttp/websocket | active | Для fasthttp-серверов |
2. Глубокое погружение (под капотом)
Заголовок раздела «2. Глубокое погружение (под капотом)»Frame format (RFC 6455 §5.2)
Заголовок раздела «Frame format (RFC 6455 §5.2)» 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1+-+-+-+-+-------+-+-------------+-------------------------------+|F|R|R|R| opcode|M| Payload len | Extended payload length ||I|S|S|S| (4) |A| (7) | (16/64) ||N|V|V|V| |S| | (if payload len==126/127) || |1|2|3| |K| | |+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +| Extended payload length continued, if payload len == 127 |+ - - - - - - - - - - - - - - - +-------------------------------+| |Masking-key, if MASK set to 1 |+-------------------------------+-------------------------------+| Masking-key (continued) | Payload Data |+-------------------------------- - - - - - - - - - - - - - - - +: Payload Data continued ... :+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +| Payload Data continued ... |+---------------------------------------------------------------+Поля:
- FIN (1 бит) — последний frame в сообщении (для фрагментации).
- RSV1-3 (3 бита) — зарезервировано для расширений (например, RSV1=1 у
permessage-deflate). - Opcode (4 бита):
0x0— Continuation frame (продолжение фрагментированного сообщения)0x1— Text frame (UTF-8)0x2— Binary frame0x8— Close0x9— Ping0xA— Pong- 0x3-0x7, 0xB-0xF — зарезервированы
- MASK (1 бит) — клиент ОБЯЗАН маскировать payload. Сервер не должен.
- Payload len (7/7+16/7+64 бит):
- 0-125 → длина прямо в этом поле
- 126 → длина в следующих 16 битах
- 127 → длина в следующих 64 битах
- Masking key (32 бита) — только если MASK=1.
- Payload data — байты сообщения.
Маскирование (masking)
Заголовок раздела «Маскирование (masking)»masked[i] = unmasked[i] XOR masking_key[i % 4]Назначение — предотвращение cross-protocol attacks, когда browser-driven payload “случайно” выглядит как валидный HTTP-запрос для middlebox proxy и может его атаковать.
⚠️ Сервер НЕ должен маскировать (RFC 6455 §5.3). Если server-frame маскирован — клиент должен закрыть соединение с code 1002 (protocol error).
Control frames (Ping/Pong/Close)
Заголовок раздела «Control frames (Ping/Pong/Close)»Control frames имеют opcode ≥ 0x8 и не могут быть фрагментированы, payload ≤ 125 байт.
Client Server | --- Ping (0x9) --------> | | <--- Pong (0xA) -------- | ответный pong с тем же payloadВ Go обе библиотеки сами отвечают на ping автоматически:
- coder/websocket: на ping → автоматический pong.
- gorilla/websocket: handler
SetPingHandler(func(string) error { return WriteControl(PongMessage, ...) })стоит по умолчанию.
Close handshake
Заголовок раздела «Close handshake»A → B: Close frame с code+reasonB → A: Close frame (echo close)[обе стороны закрывают TCP]Close codes (RFC 6455 §7.4):
- 1000 — Normal Closure
- 1001 — Going Away (server shutdown)
- 1002 — Protocol error
- 1003 — Unsupported Data (например, бинарь когда ждут текст)
- 1006 — Abnormal closure (no Close frame, just TCP RST) — НЕ может быть отправлен, только используется внутренне
- 1007 — Invalid frame payload data (например, не UTF-8 в text frame)
- 1008 — Policy violation
- 1009 — Message too big
- 1011 — Internal server error
- 1012 — Service restart
- 4000-4999 — Application-specific (можно использовать в своих протоколах)
Subprotocols
Заголовок раздела «Subprotocols»Sec-WebSocket-Protocol — клиент шлёт список предпочитаемых subprotocol, сервер выбирает один (или ни одного). Это позволяет иметь несколько протоколов на одном endpoint (например, chat.v1, chat.v2).
// coder/websocket: разрешённые subprotocolsopts := &websocket.AcceptOptions{ Subprotocols: []string{"chat.v2", "chat.v1"},}c, _ := websocket.Accept(w, r, opts)chosen := c.Subprotocol() // выбранныйCompression: permessage-deflate (RFC 7692)
Заголовок раздела «Compression: permessage-deflate (RFC 7692)»Расширение для сжатия payload (DEFLATE). Negotiate через handshake:
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits⚠️ Compression compute-overhead заметный: для small messages (< 200 байт) compression может быть медленнее, чем raw, плюс CPU нагрузка. Для chat (small payloads) часто отключают.
Архитектура сервера: per-connection goroutines
Заголовок раздела «Архитектура сервера: per-connection goroutines» ┌─────────────────────────────┐ │ HTTP Listener │ └──────────────┬──────────────┘ │ accept + upgrade ▼ ┌─────────────────────────────┐ │ Hub (broadcast) │ │ ┌──────────────────────┐ │ │ │ map[*Client]bool │ │ │ │ register/unregister │ │ │ │ broadcast chan │ │ │ └──────────────────────┘ │ └────┬────────────────────┬───┘ │ │ ┌────────▼────────┐ ┌───────▼────────┐ │ Client A │ │ Client B │ │ ┌───────────┐ │ │ ┌──────────┐ │ │ │ readPump │ │ │ │ readPump │ │ │ └───────────┘ │ │ └──────────┘ │ │ ┌───────────┐ │ │ ┌──────────┐ │ │ │ writePump │ │ │ │writePump │ │ │ └───────────┘ │ │ └──────────┘ │ │ send chan │ │ send chan │ └─────────────────┘ └────────────────┘Read/Write concurrency правила
Заголовок раздела «Read/Write concurrency правила»⚠️ КЛЮЧЕВОЕ ПРАВИЛО RFC 6455: все frames одного сообщения должны идти подряд. Если несколько goroutines пишут параллельно — frames переплетутся, протокол сломается.
- gorilla/websocket: один writer и один reader. Конкурентные
WriteMessageНЕДОПУСТИМЫ. Решение — все писатели через канал в одну writer-goroutine. - coder/websocket: внутренний mutex на write, но они рекомендуют всё равно использовать одну goroutine для writes.
Hub pattern (broadcast)
Заголовок раздела «Hub pattern (broadcast)»type Hub struct { clients map[*Client]bool broadcast chan []byte register chan *Client unregister chan *Client mu sync.Mutex}
type Client struct { hub *Hub conn *websocket.Conn send chan []byte}
func (h *Hub) run() { for { select { case c := <-h.register: h.mu.Lock() h.clients[c] = true h.mu.Unlock() case c := <-h.unregister: h.mu.Lock() if _, ok := h.clients[c]; ok { delete(h.clients, c) close(c.send) } h.mu.Unlock() case msg := <-h.broadcast: h.mu.Lock() for c := range h.clients { select { case c.send <- msg: default: // slow consumer → drop & disconnect close(c.send) delete(h.clients, c) } } h.mu.Unlock() } }}Per-client read/write loops
Заголовок раздела «Per-client read/write loops»func (c *Client) readPump() { defer func() { c.hub.unregister <- c c.conn.Close() }() c.conn.SetReadLimit(maxMessageSize) // защита от huge messages c.conn.SetReadDeadline(time.Now().Add(pongWait)) c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)) return nil }) for { _, msg, err := c.conn.ReadMessage() if err != nil { return } c.hub.broadcast <- msg }}
func (c *Client) writePump() { ticker := time.NewTicker(pingPeriod) defer func() { ticker.Stop() c.conn.Close() }() for { select { case msg, ok := <-c.send: c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if !ok { c.conn.WriteMessage(websocket.CloseMessage, []byte{}) return } if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { return } case <-ticker.C: c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } } }}Heartbeats: ping/pong cadence
Заголовок раздела «Heartbeats: ping/pong cadence»pingPeriod = 30s // отправляем ping каждые 30spongWait = 60s // ждём pong не дольше 60s (включает 30s до следующего ping)writeWait = 10s // макс время на отправку любого frameИдея: если pong не пришёл за pongWait → клиент мёртв → reset read deadline вызывает таймаут → readPump падает → unregister.
⚠️ В browser JS API нельзя контролировать ping cadence клиента — это делает только сервер. Клиент в JS реагирует на ping автоматически.
3. Gotchas (⚠️)
Заголовок раздела «3. Gotchas (⚠️)»-
⚠️ CheckOrigin: false по умолчанию в gorilla. Без CheckOrigin любой сайт может открыть WS к вашему серверу (CSRF). Всегда настраивайте:
upgrader.CheckOrigin = func(r *http.Request) bool {origin := r.Header.Get("Origin")return origin == "https://myapp.com"} -
⚠️ Concurrent writes ломают frame stream. Никогда не вызывайте
WriteMessageиз разных горутин для одного conn — frames смешаются. Используйте один writePump. -
⚠️ Не закрытый Close frame — TCP RST. Если просто
conn.Close()безWriteMessage(CloseMessage, ...)— клиент получит “Abnormal closure (1006)”. -
⚠️ Browser WebSocket API не позволяет custom HTTP headers (кроме Sec-WebSocket-Protocol). Для auth используйте:
?token=...в URL query (но логи!)- cookie (если same-domain)
Sec-WebSocket-Protocol: bearer.token.<jwt>(hack, но работает)- Auth message сразу после connect (первое сообщение)
-
⚠️ NGINX без правильных headers роняет WS. Минимум:
location /ws {proxy_pass http://backend;proxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade";proxy_read_timeout 3600s;proxy_send_timeout 3600s;} -
⚠️ ReadLimit обязателен. Без
SetReadLimitзлой клиент может отправить frame на 2 ГБ → OOM. -
⚠️ TCP_NODELAY и Nagle. WebSocket с маленькими сообщениями страдает от Nagle (TCP буферизует). gorilla и coder уже устанавливают TCP_NODELAY, но если за proxy/LB — проверьте.
-
⚠️ Idle connection обрывает L4 LB. AWS NLB закрывает idle 350с, HAProxy дефолт 60с. Без application-level ping → “тихая смерть” соединения. Cadence ping должна быть меньше LB timeout.
-
⚠️ HTTP/2 не нативно поддерживает WebSocket. RFC 8441 (CONNECT extension) есть, но мало клиентов/прокси поддерживают. Browser открывает WS поверх HTTP/1.1.
-
⚠️ HTTP/3 + WebSocket = RFC 9220. Тоже мало кто поддерживает. На практике WS всё ещё HTTP/1.1.
-
⚠️ Sticky sessions для WS с broadcast. Если у вас 3 instance и broadcast по hub’у — клиенты подключённые к instance A не получат сообщений instance B. Решения: pub/sub layer (Redis, NATS) или sticky LB + global broadcast.
-
⚠️ Sec-WebSocket-Key проверяется ВЫХОДНО. Если ваш middleware подменяет header — handshake ломается.
-
⚠️ Compression bomb.
permessage-deflateбез context_takeover создаёт CPU pressure, плюс atak “decompression bomb” (маленький запрос → огромный распакованный payload). Лимит на uncompressed size обязателен. -
⚠️ Origin header в WS != Origin в CORS. Не передаётся в preflight. Проверяйте вручную в CheckOrigin.
-
⚠️ JSON sending:
c.Write(ctx, websocket.MessageText, data)— это правильно, но если для каждого сообщения делать json.Marshal и аллоцировать bytes — GC pressure. На high-throughput используйте sync.Pool для buffer’ов.
4. Production-практики
Заголовок раздела «4. Production-практики»Полный сервер с broadcast hub (coder/websocket)
Заголовок раздела «Полный сервер с broadcast hub (coder/websocket)»package main
import ( "context" "encoding/json" "log" "net/http" "os" "os/signal" "sync" "syscall" "time"
"github.com/coder/websocket")
type Message struct { From string `json:"from"` Body string `json:"body"`}
type Hub struct { mu sync.RWMutex clients map[*Client]struct{} bcast chan Message register chan *Client leave chan *Client}
type Client struct { id string conn *websocket.Conn send chan Message hub *Hub ctx context.Context}
func NewHub() *Hub { return &Hub{ clients: make(map[*Client]struct{}), bcast: make(chan Message, 256), register: make(chan *Client), leave: make(chan *Client), }}
func (h *Hub) Run(ctx context.Context) { for { select { case <-ctx.Done(): h.mu.Lock() for c := range h.clients { close(c.send) } h.clients = nil h.mu.Unlock() return case c := <-h.register: h.mu.Lock() h.clients[c] = struct{}{} h.mu.Unlock() case c := <-h.leave: h.mu.Lock() if _, ok := h.clients[c]; ok { delete(h.clients, c) close(c.send) } h.mu.Unlock() case msg := <-h.bcast: h.mu.RLock() for c := range h.clients { select { case c.send <- msg: default: log.Printf("slow consumer %s, dropping", c.id) go func(cl *Client) { h.leave <- cl }(c) } } h.mu.RUnlock() } }}
func (c *Client) readLoop() { defer func() { c.hub.leave <- c }() c.conn.SetReadLimit(65536) // 64 KiB max msg for { _, data, err := c.conn.Read(c.ctx) if err != nil { return } var m Message if err := json.Unmarshal(data, &m); err != nil { continue // skip invalid JSON } m.From = c.id c.hub.bcast <- m }}
func (c *Client) writeLoop() { pingT := time.NewTicker(30 * time.Second) defer pingT.Stop() for { select { case <-c.ctx.Done(): c.conn.Close(websocket.StatusNormalClosure, "ctx done") return case m, ok := <-c.send: if !ok { c.conn.Close(websocket.StatusNormalClosure, "") return } wctx, cancel := context.WithTimeout(c.ctx, 10*time.Second) err := wsjsonWrite(wctx, c.conn, m) cancel() if err != nil { return } case <-pingT.C: wctx, cancel := context.WithTimeout(c.ctx, 10*time.Second) err := c.conn.Ping(wctx) cancel() if err != nil { return } } }}
func wsjsonWrite(ctx context.Context, c *websocket.Conn, v any) error { data, err := json.Marshal(v) if err != nil { return err } return c.Write(ctx, websocket.MessageText, data)}
func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel()
hub := NewHub() go hub.Run(ctx)
mux := http.NewServeMux() mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ InsecureSkipVerify: false, OriginPatterns: []string{"app.example.com"}, }) if err != nil { return } cctx, ccancel := context.WithCancel(r.Context()) defer ccancel() client := &Client{ id: r.URL.Query().Get("uid"), conn: conn, send: make(chan Message, 32), hub: hub, ctx: cctx, } hub.register <- client go client.writeLoop() client.readLoop() })
srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, } go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(err) } }() <-ctx.Done() sd, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() srv.Shutdown(sd) log.Println("bye")
_ = os.Stdout}// Соединенияconst ( maxConnections = 50_000 // ulimit -n / 2 минимум maxMessageSize = 64 << 10 // 64 KiB sendChanBuffer = 32 // backpressure writeTimeout = 10 * time.Second pingPeriod = 30 * time.Second pongWait = 60 * time.Second)Mертвые соединения: backpressure
Заголовок раздела «Mертвые соединения: backpressure»case msg := <-h.bcast: for c := range h.clients { select { case c.send <- msg: default: // канал переполнен → клиент медленный // отключаем его, чтобы не блокировать broadcast close(c.send) delete(h.clients, c) } }Метрики (Prometheus)
Заголовок раздела «Метрики (Prometheus)»var ( activeConnections = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "websocket_active_connections", }) messagesIn = prometheus.NewCounter(prometheus.CounterOpts{ Name: "websocket_messages_in_total", }) messagesOut = prometheus.NewCounter(prometheus.CounterOpts{ Name: "websocket_messages_out_total", }) slowConsumers = prometheus.NewCounter(prometheus.CounterOpts{ Name: "websocket_slow_consumers_total", }))Reconnect стратегия (client side)
Заголовок раздела «Reconnect стратегия (client side)»class ReconnectingWS { constructor(url, opts = {}) { this.url = url; this.delay = 500; this.maxDelay = 30000; this.connect(); } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.delay = 500; }; this.ws.onclose = (e) => { // экспоненциальный backoff с jitter const jitter = Math.random() * 0.3 + 0.85; const wait = this.delay * jitter; setTimeout(() => this.connect(), wait); this.delay = Math.min(this.delay * 2, this.maxDelay); }; this.ws.onmessage = (e) => this.onMessage(e); }}⚠️ Без jitter — все клиенты ретраят синхронно после краша сервера → thundering herd.
Load balancing WebSocket
Заголовок раздела «Load balancing WebSocket»Варианты:
- L4 (TCP) — простой, но без проверки health на app-level.
- L7 WS-aware (NGINX, HAProxy, Envoy) — может маршрутизировать по URL, добавлять headers.
- Sticky sessions — клиент всегда попадает на тот же instance (для in-memory state).
- Pub/Sub layer — Redis Streams / NATS / Kafka — instances обмениваются broadcasted events.
┌──────────┐ Client → │ LB │ (sticky session by cookie/IP) └────┬─────┘ │ ┌────────────┼────────────┐ ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌────────┐ │ App 1 │ │ App 2 │ │ App 3 │ └───┬────┘ └───┬────┘ └───┬────┘ │ │ │ └─────► Redis Pub/Sub ◄────┘ (для broadcast между instances)Pub/Sub broadcast через Redis
Заголовок раздела «Pub/Sub broadcast через Redis»import "github.com/redis/go-redis/v9"
func subscribe(rdb *redis.Client, hub *Hub) { pubsub := rdb.Subscribe(context.Background(), "ws:broadcast") ch := pubsub.Channel() for msg := range ch { var m Message json.Unmarshal([]byte(msg.Payload), &m) hub.bcast <- m }}
func publish(rdb *redis.Client, m Message) { data, _ := json.Marshal(m) rdb.Publish(context.Background(), "ws:broadcast", data)}WebSocket vs SSE vs gRPC streaming
Заголовок раздела «WebSocket vs SSE vs gRPC streaming»| Свойство | WebSocket | SSE | gRPC streaming |
|---|---|---|---|
| Направление | Bi-directional | Server → Client | Любое (uni/bi) |
| Protocol | RFC 6455 (TCP) | HTTP/1.1 plain | HTTP/2 |
| Browser support | Native | Native | Через gRPC-Web |
| Binary support | Да | Нет (только text) | Да |
| Reconnect | Manual | Auto (через EventSource) | Manual |
| Proxy/firewall friendliness | Часто проблемы | Хорошо | Зависит от HTTP/2 поддержки |
| Use case | Chat, games | Live feed, notifications | Microservice streaming |
Real cases (2026)
Заголовок раздела «Real cases (2026)»- Twitch chat — миллионы WS соединений, share хабы по каналам, IRC-like протокол поверх WS.
- Slack — WebSocket Gateway, server отправляет события, client — minimal traffic.
- Discord — Gateway WebSocket, server отправляет events, client poll’ит REST для остального.
- Trading — order book updates через WS (~1000 msg/sec на client).
- Multiplayer games — WebRTC predпочтительнее для P2P + UDP, но Slack/Discord используют WS.
Graceful shutdown
Заголовок раздела «Graceful shutdown»func (h *Hub) Shutdown(ctx context.Context) { h.mu.Lock() defer h.mu.Unlock()
for c := range h.clients { wctx, cancel := context.WithTimeout(ctx, 1*time.Second) c.conn.Close(websocket.StatusGoingAway, "server shutting down") cancel() delete(h.clients, c) close(c.send) _ = wctx }}5. Вопросы (25+)
Заголовок раздела «5. Вопросы (25+)»-
Что такое WebSocket и зачем он нужен? Full-duplex persistent connection поверх TCP, открывается через HTTP Upgrade. Нужен для realtime двусторонней коммуникации (chat, notifications) без polling overhead.
-
Опиши WebSocket handshake. Client посылает
GET ... Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: ...Server отвечает101 Switching Protocols Sec-WebSocket-Accept: base64(sha1(key + magic)). После 101 соединение покидает HTTP-режим. -
Что такое Sec-WebSocket-Key и зачем нужен Accept? Случайный 16-byte nonce, base64. Server считает
SHA1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")и возвращает вSec-WebSocket-Accept. Это защита от подмены: middlebox proxy не понимает Upgrade и не сможет сфабриковать accept. -
Какие opcodes есть в WebSocket? 0x0 — continuation, 0x1 — text, 0x2 — binary, 0x8 — close, 0x9 — ping, 0xA — pong. Остальные зарезервированы.
-
Почему клиент должен маскировать payload, а сервер нет? Защита от cross-protocol attacks: middlebox proxy может «увидеть» в unmasked payload что-то похожее на HTTP-запрос и неправильно обработать. Маска предотвращает.
-
Что такое control frames? Frames с opcode ≥ 0x8 (close, ping, pong). Не могут быть фрагментированы, payload ≤ 125 байт, не зависят от data flow.
-
Что такое close handshake? Стороны обмениваются Close frames (opcode 0x8) с code+reason, затем закрывают TCP. Если без Close frame → код 1006 (abnormal closure).
-
Чем concurrency-правила WebSocket отличаются от HTTP? В HTTP каждый запрос/ответ независимый. В WebSocket frames одного сообщения должны идти строго подряд → нельзя писать из нескольких goroutines одновременно.
-
Как сделать broadcast в WebSocket-сервере? Hub-pattern: одна goroutine хранит map[*Client]struct{}, broadcast канал. При сообщении — итерация по клиентам, отправка в их send-канал. Каждый клиент имеет writePump, читающий send.
-
Что такое heartbeat в WebSocket? Периодические ping/pong (control frames). Если pong не приходит за timeout → клиент мёртв. Также защита от idle disconnect на LB.
-
Какую ping-cadence выбрать? Меньше idle timeout вышестоящих LB/proxy. Например, AWS NLB 350s → ping каждые 30s. Default в Go HTTP/2 — 15s.
-
Что такое permessage-deflate? Расширение сжатия payload (DEFLATE). Negotiate через
Sec-WebSocket-Extensions. CPU vs network trade-off. Для small messages часто отключают. -
Почему gorilla/websocket в read-only? Maintenance burden, плюс альтернативы (coder/websocket) сделаны с более чистым API. Для нового кода — coder/websocket.
-
Как сделать authentication для WebSocket? Token в query (
?token=...), cookie (если same-site), Sec-WebSocket-Protocol header (hack), auth message сразу после connect. Browser API не позволяет custom HTTP headers. -
Почему NGINX часто роняет WS? Без
proxy_http_version 1.1,proxy_set_header Upgrade $http_upgrade,proxy_set_header Connection "upgrade"— NGINX считает HTTP/1.0 и закрывает после ответа. -
Что такое sticky sessions и зачем нужны для WS? Если состояние в памяти процесса (например, hub знает только своих клиентов), LB должен направлять клиента на тот же instance. Иначе после reconnect клиент попадёт на другой instance и потеряет state.
-
Как сделать broadcast между несколькими instances? Pub/Sub слой: Redis, NATS, Kafka. Instance публикует событие в shared channel, все instances subscribe → broadcast своим WS-клиентам.
-
Как масштабировать до 100K connections per instance? Достаточно горутин (1 на conn легковесна, ~4 КБ stack), но: ulimit -n должен быть высокий, ядро tuned (somaxconn, file descriptors), sendChanBuffer небольшой, slow consumer disconnect.
-
Что такое slow consumer? Клиент, который не успевает читать сообщения — его send-канал переполняется → блокирует broadcast → tail latency растёт для всех. Решение: drop & disconnect.
-
WS vs SSE: когда выбирать? SSE проще (HTTP/1.1, auto-reconnect, нет специального протокола), но только server→client. WS — bi-directional, binary, sub-protocols. Для server push (live feed) часто SSE достаточно.
-
WS vs gRPC streaming: когда выбирать? gRPC — для service-to-service (HTTP/2, typed messages). WS — для browser клиентов (gRPC-Web имеет ограничения, нет bidi). Для mobile native — оба варианта валидны.
-
Какой размер per-message максимум стоит ставить? Зависит от приложения. Для chat 64 KiB достаточно. Для file transfer — выше, но лучше через chunks. SetReadLimit — обязателен.
-
Что делать с TLS на WebSocket?
wss://= WebSocket over TLS. Поверх стандартного HTTPS handshake. Сертификаты те же. Performance impact — только handshake (~few ms), стабильное соединение overhead negligible. -
Как обнаружить, что клиент отвалился (TCP RST)? Чтение даст error (EOF, connection reset). Write даст error (broken pipe). Heartbeat ping пропустит pong → readDeadline истечёт. Если без ping — соединение может «висеть» часами.
-
Когда WebSocket плохо подходит?
- Когда proxy/firewall между клиентом и сервером не пропускает Upgrade → нужно fallback на long-polling (socket.io делает).
- Когда нужна reliability при network failures на mobile → лучше HTTP/2 (gRPC) или WebRTC.
- Когда payload очень мелкий и не часто → polling может быть дешевле.
-
Как реализовать backpressure на WebSocket?
send chan Message с capacity 32. Если полон —select default→ drop сообщения или disconnect клиента. -
HTTP/2 поддерживает WebSocket? RFC 8441 (Bootstrapping WebSockets with HTTP/2) описывает CONNECT extension, но мало клиентов и proxy поддерживают. В практике WS — это HTTP/1.1 Upgrade.
6. Practice
Заголовок раздела «6. Practice»Задача 1 — Echo-сервер
Заголовок раздела «Задача 1 — Echo-сервер»Напишите WS-сервер, эхо-отражающий любое сообщение. Используйте coder/websocket. Подключитесь через wscat -c ws://localhost:8080/ws.
Задача 2 — Hub broadcast
Заголовок раздела «Задача 2 — Hub broadcast»Реализуйте чат-сервер: каждое сообщение от любого клиента рассылается всем другим. Используйте hub-pattern.
Задача 3 — Heartbeat
Заголовок раздела «Задача 3 — Heartbeat»Добавьте в hub-сервер автоматический ping каждые 30s. Если клиент не отвечает pong за 60s — disconnect. Логируйте active connections.
Задача 4 — Backpressure
Заголовок раздела «Задача 4 — Backpressure»Добавьте, что если клиент не успевает читать (send chan full) — отключаете его. Проверьте: один медленный клиент не блокирует остальных.
Задача 5 — Auth via subprotocol
Заголовок раздела «Задача 5 — Auth via subprotocol»Реализуйте: клиент при handshake шлёт Sec-WebSocket-Protocol: bearer.JWT_TOKEN. Сервер декодирует JWT, проверяет, ассоциирует client с user. Без валидного токена — handshake reject.
Задача 6 — Reconnecting client
Заголовок раздела «Задача 6 — Reconnecting client»Напишите JS-клиента с exponential backoff и jitter. Сервер периодически рестартите, проверьте, что клиент переподключается.
Задача 7 — Multi-instance broadcast
Заголовок раздела «Задача 7 — Multi-instance broadcast»Запустите 2 instances одного сервера, между ними — Redis Pub/Sub. Клиент A подключён к instance 1, клиент B — к instance 2. Сообщение от A должно прийти B.
Задача 8 — Graceful shutdown
Заголовок раздела «Задача 8 — Graceful shutdown»Реализуйте graceful shutdown: при SIGTERM — closes всем клиентам с code 1001 (Going Away), ждёт до 10s, потом hard-close.
Задача 9 — Metrics endpoint
Заголовок раздела «Задача 9 — Metrics endpoint»Добавьте /metrics (Prometheus) с метриками: active_connections, messages_in_total, messages_out_total, slow_consumers_total. Откройте Grafana.
Задача 10 — Load test
Заголовок раздела «Задача 10 — Load test»С помощью wsbench или tsung сделайте 10K connections к серверу, замеряйте: memory (heap), goroutines, file descriptors.
7. Источники
Заголовок раздела «7. Источники»- RFC 6455 — The WebSocket Protocol: https://datatracker.ietf.org/doc/html/rfc6455
- RFC 7692 — Compression Extensions for WebSocket (permessage-deflate)
- RFC 8441 — Bootstrapping WebSockets with HTTP/2
- RFC 9220 — Bootstrapping WebSockets with HTTP/3
- coder/websocket (бывш. nhooyr.io/websocket): https://github.com/coder/websocket
- gorilla/websocket docs: https://pkg.go.dev/github.com/gorilla/websocket
- gobwas/ws (low-alloc): https://github.com/gobwas/ws
- WebSocket Security Issues (Cure53 audit): https://github.com/uknowsec/Security-PPT
- Phoenix Channels architecture (хорошая референс-имплементация): https://hexdocs.pm/phoenix/channels.html
- High-Performance Browser Networking (Ilya Grigorik), глава 17 — WebSocket