25. WebSocket, SSE и долгоживущие соединения
Введение: От запрос-ответ к постоянному каналу
Классическая архитектура Всемирной паутины строилась по принципу коротких дискретных взаимодействий: клиент постучался, задал вопрос, сервер ответил, соединение закрылось. Классическая HTTP-модель request-response требует открытия нового TCP-соединения (или повторного использования через Keep-Alive) для каждой операции. Это создает накладные расходы на рукопожатия, задержки и потребление памяти на стороне сервера.
Представьте себе биржевого трейдера, который каждые 200 миллисекунд отправляет курьера на биржу с запиской: «Изменилась ли цена акций?». В 99% случаев курьер возвращается с ответом «Нет». Этот подход, известный как Short Polling (периодический опрос), сжигает процессорные такты, переполняет таблицы маршрутизации и забивает сеть бесполезными HTTP-заголовками. Позже инженеры придумали костыль Long Polling (когда сервер удерживает запрос открытым, пока не появятся данные), но и он оставался неуклюжей имитацией двусторонней связи.
Для сценариев, где клиенту критично получать обновления в реальном времени (чат, биржевые тикеры, live-логирование, коллаборативные редакторы), сервер должен сам инициировать отправку данных клиенту.
Для этого используются долгоживущие соединения (Persistent Connections). В Go они реализуются двумя основными протоколами: SSE (Server-Sent Events) и WebSocket. Но устроены они по-разному: WebSocket начинается с механизма HTTP Upgrade, а SSE — это обычный HTTP-ответ с Content-Type: text/event-stream без Upgrade и без 101.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Polling ["Классический опрос (Polling)"]
P1["Клиент: Есть данные?"]:::entry --> P2["Сервер: Нет"]
P3["Клиент: Есть данные?"]:::entry --> P4["Сервер: Нет"]
P5["Клиент: Есть данные?"]:::entry --> P6["Сервер: Вот 1 сообщение"]
end
subgraph SSEStream ["Server-Sent Events (SSE)"]
S1["Клиент: Подключился (HTTP GET)"]:::entry --> S2["Сервер: data: 1"]
S2 --> S3["Сервер: data: 2"]
S3 --> S4["Сервер: data: 3 (Односторонний стрим)"]
end
subgraph WSDuplex ["WebSocket (Full-Duplex)"]
W1["Клиент <--> Сервер: Полнодуплексный бинарный канал"]:::entry
W2["Одновременная передача в обе стороны без задержек"]:::process
end
HTTP Upgrade: Как рождается долгоживущее соединение
WebSocket-соединение начинается как обычный HTTP-запрос. Клиент отправляет заголовок Upgrade, а сервер отвечает кодом 101 Switching Protocols. После этого TCP-соединение не закрывается, а протокол на транспортном уровне меняется.
Клиент отправляет стандартный заголовок рукопожатия:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Сервер подтверждает смену протокола:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
В Go это реализуется через интерфейс http.Hijacker. Проверку заголовков и ответ 101 выполняет не net/http, а библиотека WebSocket (например, gorilla/websocket), которая «перекидывает» (hijack) сырой net.Conn из net/http в ваш код. Управление над потоком байтов передается вам, а http.Server перестает парсить заголовки и тело.
sequenceDiagram
autonumber
participant Client as Клиент (Браузер)
participant HTTPServer as net/http Server
participant Handler as Go Handler (Код приложения)
Client->>HTTPServer: HTTP GET /ws (Header Upgrade: websocket)
HTTPServer->>Handler: Вызов ServeHTTP(w, r)
Handler->>HTTPServer: w.(http.Hijacker).Hijack()
HTTPServer-->>Handler: Возврат net.Conn и bufio.ReadWriter
Note over HTTPServer: Сервер net/http отпускает сокет и забывает о нем!
Handler->>Client: HTTP/1.1 101 Switching Protocols
Note over Client,Handler: Сырой полнодуплексный TCP-канал передан приложению
Handler<<->>Client: Двунаправленный обмен фреймами WebSocket
Server-Sent Events (SSE): Простой и надежный push
SSE — это текстовый протокол поверх HTTP/1.1. Он поддерживает только одностороннюю передачу данных (от сервера к клиенту). Клиент сам должен реализовывать логику повторных попыток при обрыве связи.
Спецификация SSE невероятно проста: это непрерывный поток текста в кодировке UTF-8, разделенный двойными переводами строк (\n\n). Каждое сообщение состоит из простых текстовых полей:
id: 42\n
event: trade\n
data: {"symbol": "BTCUSD", "price": 95000}\n\n
Почему SSE?
- Работает через любой HTTP-прокси и NAT (
Upgradeдля SSE не нужен: это обычный HTTP-ответ). - Автоматический reconnect, heartbeat и ID-механизм встроены в браузерный
EventSource. Если соединение прерывается, браузер сам переподключается и передает заголовокLast-Event-ID: 42, позволяя серверу дослать только упущенные события. - Нет накладных расходов на бинарный фрейминг.
Реализация в Go
func sseHandler(w http.ResponseWriter, r *http.Request) {
// Проверяем поддержку SSE клиентом
if !strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
http.Error(w, "SSE not supported", http.StatusBadRequest)
return
}
// SSE требует заголовков
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
// Получаем интерфейс для принудительной отправки буфера
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
// Канал для входящих событий (пример)
events := make(chan string, 10)
go func() {
// Имитация генерации событий
for i := 0; i < 5; i++ {
events <- fmt.Sprintf("data: tick %d\n\n", i)
time.Sleep(1 * time.Second)
}
close(events)
}()
for {
select {
case <-r.Context().Done():
// Клиент отключился. Освобождаем ресурсы.
return
case msg, ok := <-events:
if !ok {
return
}
if _, err := w.Write([]byte(msg)); err != nil {
return
}
flusher.Flush() // Критично! Данные не уйдут в сеть без этого
}
}
}
WebSocket: Полный дуплекс и фрейминг
WebSocket (RFC 6455) предоставляет полнодуплексный (full-duplex) бинарный или текстовый канал поверх одного TCP-соединения. Клиент и сервер могут отправлять данные одновременно без ожидания ответа.
Внутреннее устройство протокола
WebSocket не отправляет «сырые» байты. Он оборачивает их в фреймы:
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 ... :
+---------------------------------------------------------------+
- Masking: Клиент обязан маскировать payload (для безопасности против CVE-2011-7008). Сервер может не маскировать исходящие пакеты. Маскирование выполняется через 4-байтовый ключ операцией XOR.
- Opcode:
0x0(continuation),0x1(text),0x2(binary),0x8(close),0x9(ping),0xA(pong). - Payload length: Поддерживает 16-bit, 32-bit и 64-bit длины (если длина < 126 — 7 бит, 126 — следующие 2 байта, 127 — следующие 8 байт).
- Heartbeat:
ping/pongфреймы используются для проверки живости соединения и обхода таймаутов NAT/прокси.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph WSFrame ["Анатомия WebSocket фрейма"]
FIN["FIN (1 бит): Финал"]:::process --> Opcode["Opcode (4 бита): Text / Binary / Ping"]
Opcode --> MaskBit["Mask (1 бит): Обязателен от клиента"]
MaskBit --> Len["Payload Len: 7 / 16 / 64 бит"]
Len --> MaskKey["Masking Key (4 байта XOR)"]
MaskKey --> PayloadData["Payload Data"]
end
Реализация в Go
Стандартная библиотека не содержит WebSocket. Индустриальный стандарт — github.com/gorilla/websocket или nhooyr.io/websocket. Ниже пример с корректной обработкой ошибок и контекстом:
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
// Нельзя возвращать true безусловно: это отключает стандартную проверку
// same-origin. Сверяйте Origin со списком разрешённых источников.
return r.Header.Get("Origin") == "https://example.com"
},
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close() // Всегда закрываем соединение
// Ограничиваем размер входящего сообщения (иначе клиент может прислать гигантский кадр)
conn.SetReadLimit(1 << 20)
// Чтение в отдельной горутине для асинхронной обработки
done := make(chan struct{})
go func() {
defer close(done)
for {
_, msg, err := conn.ReadMessage()
if err != nil {
// CloseSent или net.Error — нормальное завершение
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("Read error: %v", err)
}
break
}
// Обработка сообщения...
}
}()
// Запись в основном цикле (или через sync.Mutex, если пишет один writer)
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
// Клиент отключился (цикл чтения завершился). Контекст r.Context() после Hijack
// не отменяется, поэтому на него рассчитывать нельзя.
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return
case <-ticker.C:
// Отправляем ping для поддержания NAT-таблиц
err := conn.WriteMessage(websocket.PingMessage, nil)
if err != nil {
return
}
}
}
}
Под капотом: Go, netpoller и управление памятью
1. Netpoller и CPU Cache
Долгоживущие соединения идеально ложатся на netpoller (epoll в Linux, kqueue в BSD/macOS). Файловые дескрипторы остаются в epoll-инстансе. Когда приходит пакет, ядро будит один из рабочих тредов Go (runtime.netpoll), который пробуждает соответствующую горутину.
- Механический симпатизм: Если вы обрабатываете сообщения в одной горутине, вы пользуетесь CPU cache locality. Регистры и кэш L1/L2 не инвалидируются при переключении контекста. Это критично для high-throughput систем.
2. Горутинный стек и GC
Каждая активная WebSocket/SSE-соединение держит живую горутину.
- Стек: Изначально 2 КБ. Если данные уходят в кучу (escape analysis), стек перераспределяется. Долгоживущие горутины могут накапливать «хвосты» аллокаций, если не освобождать срезы и мапы.
-
GC Pressure: Частое создание
[]byteдля payload вызывает нагрузку на GC. Используйтеsync.Poolдля буферов, если протокол позволяет:Go var msgPool = sync.Pool{ New: func() interface{} { b := make([]byte, 0, 4096) return &b }, } // ... buf := msgPool.Get().(*[]byte) defer msgPool.Put(buf)
3. TCP и Kernel Buffers
Долгоживущее соединение упирается в ограничения ядра:
net.core.rmem_max/wmem_max: Буферы сокета. Если клиент читает медленнее, чем сервер пишет,TCP Windowзакрывается. Горутина блокируется наWrite, ожидая освобождения пространства в ядре.- Backpressure: В Go это проявляется как блокировка
conn.Write. Не используйте неблокирующие каналы без контроля глубины — это приведет к OOM.
Масштабирование и паттерны
| Аспект | SSE | WebSocket |
|---|---|---|
| Направление | Server → Client | Client ↔ Server (Full-duplex) |
| Прокси/NAT | Пропускается легко (HTTP/1.1) | Требует Upgrade (nginx/HAProxy настроены по умолчанию) |
| Масштабирование | Sticky sessions или Pub/Sub (Redis/NATS) | Sticky sessions или Pub/Sub (Redis/NATS) |
| Сложность | Низкая (стандартный http.ResponseWriter) |
Высокая (фрейминг, маскирование, reconnect-логика) |
Горизонтальное масштабирование
Одна инстанция Go не может доставить сообщение всем клиентам, если они распределены по разным нодам. Решение:
- Sticky Sessions: Load balancer отправляет все пакеты от клиента на один сервер.
- External Pub/Sub (Рекомендуется): Серверы подписываются на канал (Redis Pub/Sub, NATS, Kafka). При получении сообщения от клиента, сервер транслирует его в Pub/Sub, и все узлы рассылают данные своим клиентам.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Клиент 1"]:::entry -->|"WebSocket"| B["Сервер A"]
C["Клиент 2"]:::entry -->|"WebSocket"| D["Сервер B"]
E["Клиент 3"]:::entry -->|"WebSocket"| F["Сервер C"]
B -->|"Pub/Sub"| G["Redis / NATS"]
D -->|"Pub/Sub"| G
F -->|"Pub/Sub"| G
G -->|"Broadcast"| B
G -->|"Broadcast"| D
G -->|"Broadcast"| F
Сравнение с другими языками
- PHP (FPM): Убивает процесс после каждого запроса. Для долгоживущих соединений нужны Swoole, ReactPHP или Hyperf. В Go это не требуется, так как
net/httpспроектирован с нуля под асинхронность. - Java: Historically Tomcat использовал блокирующий I/O (BIO). Сейчас используется Netty (NIO) с event-loop моделью. Go делает то же самое, но с меньшим количеством boilerplate.
- C#: SignalR абстрагирует WebSocket/SSE/Long Polling. В Go вы чаще работаете с сырыми сокетами или легковесными обертками, что дает больше контроля, но требует понимания сетевых нюансов.
Итог
Долгоживущие соединения в Go — это не магия, а аккуратное управление файловыми дескрипторами через netpoller, контроль жизненного цикла горутин и понимание ограничений TCP-стека. Выбор между SSE и WebSocket определяется архитектурой: SSE для уведомлений и легковесных сценариев, WebSocket для интерактивных приложений с полным дуплексом. При масштабировании обязательно закладывайте Pub/Sub слой или sticky sessions.
Мы разобрали, как поддерживать активные каналы связи. Следующим шагом логично изучить бинарные RPC-протоколы, которые часто используются внутри микросервисов для высокопроизводительного обмена данными: 26. gRPC, Protocol Buffers и сетевые особенности RPC.