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-соединение не закрывается, а протокол на транспортном уровне меняется.

Клиент отправляет стандартный заголовок рукопожатия:

HTTP
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Сервер подтверждает смену протокола:

HTTP
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). Каждое сообщение состоит из простых текстовых полей:

TEXT
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

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 не отправляет «сырые» байты. Он оборачивает их в фреймы:

TEXT
 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 ...                :
+---------------------------------------------------------------+
  1. Masking: Клиент обязан маскировать payload (для безопасности против CVE-2011-7008). Сервер может не маскировать исходящие пакеты. Маскирование выполняется через 4-байтовый ключ операцией XOR.
  2. Opcode: 0x0 (continuation), 0x1 (text), 0x2 (binary), 0x8 (close), 0x9 (ping), 0xA (pong).
  3. Payload length: Поддерживает 16-bit, 32-bit и 64-bit длины (если длина < 126 — 7 бит, 126 — следующие 2 байта, 127 — следующие 8 байт).
  4. 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. Ниже пример с корректной обработкой ошибок и контекстом:

Go
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 не может доставить сообщение всем клиентам, если они распределены по разным нодам. Решение:

  1. Sticky Sessions: Load balancer отправляет все пакеты от клиента на один сервер.
  2. 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.