Компьютерные сети Реальное время: WebSocket, SSE, long polling, WebRTC
0%

Реальное время: WebSocket, SSE, long polling, WebRTC

Реальное время: WebSocket, SSE, long polling, WebRTC

HTTP построен на одном допущении: разговор начинает клиент. Он спрашивает — сервер отвечает и замолкает. Это допущение сделало веб масштабируемым (сервер не помнит клиентов, любой запрос можно отдать любой машине), и оно же оказалось главным препятствием, как только у сервера появились новости, которых клиент не ждал: пришло сообщение в чат, поехала цена, соседний редактор подвинул курсор, датчик прислал телеметрию.

Задача всей этой статьи формулируется одной фразой: как передать событие, инициатором которого является сервер, поверх протокола, где инициатор — клиент. Ответов исторически четыре, и они не заменяют друг друга — это лестница компромиссов между задержкой, стоимостью соединения и совместимостью с инфраструктурой, которая стоит между вами и пользователем.

Предполагается, что вы прошли TCP (соединение — это состояние в ядре, и оно стоит памяти), HTTP (заголовки, keep-alive, версии) и UDP с QUIC (когда порядок и надёжность не нужны).

Что мы вообще оптимизируем

Прежде чем выбирать протокол, зафиксируйте четыре числа для вашей задачи — они и определяют ответ:

Параметр Вопрос Почему решает
Допустимая задержка 10 мс, 500 мс или 30 с? 30 с — это обычный polling, 10 мс — это уже WebRTC поверх UDP
Направление сервер → клиент, или в обе стороны? односторонний поток закрывается SSE, а он на порядок проще
Частота и объём 1 событие в минуту или 60 кадров в секунду? редкие события не окупают постоянное соединение
Число клиентов 1 000 или 5 000 000? соединение стоит памяти в ядре и в приложении, и это умножается

И пятое, неочевидное: кто стоит между вами и клиентом. Корпоративный прокси, мобильный оператор с CG-NAT, антивирус с TLS-инспекцией, балансировщик с idle-таймаутом 60 секунд — каждый из них имеет мнение о долгоживущих соединениях. Половина «загадочных» багов реального времени живёт здесь, и разбирается по NAT, фаерволам и VPN.

Эволюция: как индустрия шла к push

Обратите внимание на логику: каждый следующий шаг снимал конкретную боль предыдущего, и почти каждый добавлял новую. Long polling убрал задержку опроса, но добавил дырку между запросами. SSE убрал ручной реконнект, но остался односторонним. WebSocket дал дуплекс, но потерял всё, что HTTP умеет бесплатно (кэширование, сжатие, промежуточное проксирование, коды ответов). WebRTC дал минимальную задержку, но принёс с собой ICE, TURN и три новых протокола в стеке.

Уровень 0: честный короткий polling

// Самое скучное решение. Оно работает везде, и его недооценивают.
async function poll(url, onData, intervalMs = 5000) {
  let etag = null;
  for (;;) {
    const res = await fetch(url, { headers: etag ? { "If-None-Match": etag } : {} });
    if (res.status === 200) {          // 304 = не менялось, тело пустое, трафика ~150 Б
      etag = res.headers.get("ETag");
      onData(await res.json());
    }
    await new Promise((r) => setTimeout(r, intervalMs));
  }
}

Арифметика, которую стоит проделать до того, как тянуть WebSocket:

  • Средняя задержка доставки — половина интервала. Опрос раз в 5 с даёт 2.5 с в среднем и 5 с в худшем случае.
  • Нагрузка — число клиентов, делённое на интервал. 50 000 клиентов с интервалом 5 с — это 10 000 RPS постоянно, независимо от того, есть ли новости.
  • Полезность запроса. Если данные меняются раз в минуту, 92% этих запросов вернут 304 Not Modified. Это не «зря» — с ETag ответ занимает ~150 байт, а CDN может отдать его вместо вас, не доходя до бэкенда (см. CDN и edge).

Polling выигрывает ровно там, где эти числа сходятся: редкие обновления, толерантность к задержке, огромное число клиентов, полная stateless-природа (любой запрос — на любой под, деплой не рвёт ничего). На мобильном он ещё и экономит батарею если интервал большой: каждый выход в сеть поднимает радиомодуль на несколько секунд, поэтому один опрос в минуту дешевле, чем постоянный keep-alive с пингом раз в 20 секунд. Подробности энергетики — в мобильном треке.

Проигрывает polling там, где вам нужно и низкая задержка, и много клиентов. Опрос раз в 200 мс на 50 000 клиентов — это 250 000 RPS ради нескольких событий в секунду.

Уровень 1: long polling

Идея в одну строку: сервер не отвечает, пока ему нечего сказать. Клиент шлёт обычный GET, сервер откладывает ответ, пока не появится событие или не истечёт таймаут, отвечает — и клиент немедленно шлёт следующий запрос.

Три вещи, которые делают long polling работоспособным, и которые почти всегда забывают:

  1. Курсор обязателен. Между ответом и следующим запросом проходят десятки миллисекунд, и всё, что произошло в это окно, должно быть доставлено по номеру последнего виденного события, а не «подписаться заново с текущего момента». Это ровно та же семантика, что и Last-Event-ID в SSE и offset в Kafka — см. доставку сообщений.
  2. Плановый таймаут короче, чем у всех промежуточных узлов. proxy_read_timeout в nginx по умолчанию 60 с, idle timeout у AWS ALB — 60 с, у мобильных операторов бывает 30 с. Отвечайте 204 через 25 секунд сами: контролируемый разрыв всегда лучше внезапного.
  3. Асинхронный сервер. Каждый висящий запрос — это соединение. На модели «поток на запрос» 10 000 клиентов — это 10 000 потоков и смерть. Нужны корутины, async/await, event loop или процессы BEAM (Elixir держит сотни тысяч одновременных запросов на этой модели без единой настройки).

Long polling остаётся лучшим ответом в двух ситуациях: когда инфраструктура категорически не пропускает Upgrade (жёсткие корпоративные прокси), и когда вы работаете на serverless-платформе, где долгое соединение стоит дороже, чем несколько HTTP-запросов.

Уровень 2: Server-Sent Events

SSE — это ответ на вопрос «а что если просто не закрывать ответ?». Сервер отдаёт Content-Type: text/event-stream и пишет в тело бесконечно. Браузер разбирает поток построчно и сам переподключается при обрыве. Это самый недооценённый транспорт в списке: он покрывает 80% задач «реального времени» ценой примерно нуля сложности.

Как это выглядит на проводе

# -N отключает буферизацию вывода curl — без него вы будете смотреть в пустой экран
curl -N -H 'Accept: text/event-stream' https://api.example.com/v1/prices/stream
HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-cache, no-transform
x-accel-buffering: no

retry: 3000

: keep-alive

event: price
id: 1042
data: {"sym":"AAPL","p":193.42}

data: многострочный текст
data: склеивается через перевод строки

: keep-alive

Весь формат — четыре поля и два правила:

  • data: — полезная нагрузка. Несколько подряд склеиваются через \n: так передаётся многострочный текст.
  • event: — имя события, к которому клиент подписывается через addEventListener. Без него срабатывает onmessage.
  • id: — курсор. Браузер запоминает последний и при переподключении сам шлёт его в заголовке Last-Event-ID. Это встроенная в стандарт докачка, и она бесплатна.
  • retry: — сколько миллисекунд ждать перед реконнектом. По умолчанию браузеры используют порядка 3 секунд.
  • Строка, начинающаяся с :, — комментарий. Используется как keep-alive: не даёт прокси и NAT считать соединение мёртвым.
  • Сообщение заканчивается пустой строкой. Забыли второй \n — клиент не получит ничего и будет молча ждать.

Сервер

import json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse

app = FastAPI()

def sse(data: str, event: str | None = None, ev_id: str | None = None) -> bytes:
    """Одно сообщение SSE: каждое поле — своя строка, в конце ОБЯЗАТЕЛЬНО пустая строка."""
    lines = ([f"id: {ev_id}"] if ev_id else []) + ([f"event: {event}"] if event else [])
    lines += [f"data: {ln}" for ln in data.split("\n")]  # многострочность — несколько data
    return ("\n".join(lines) + "\n\n").encode()

@app.get("/v1/prices/stream")
async def stream(request: Request):
    # Браузер САМ прислал курсор при переподключении — вся семантика докачки здесь
    cursor = int(request.headers.get("last-event-id") or 0)

    async def gen():
        nonlocal cursor
        yield b"retry: 3000\n\n"
        while not await request.is_disconnected():
            batch = await store.events_after(cursor, timeout=15)
            if not batch:
                yield b": keep-alive\n\n"   # держим живыми прокси, NAT и сам TCP
                continue
            for ev in batch:
                cursor = ev.id
                yield sse(json.dumps(ev.payload), event="price", ev_id=str(ev.id))

    return StreamingResponse(gen(), media_type="text/event-stream", headers={
        "Cache-Control": "no-cache, no-transform",  # запрещает прокси сжимать и резать
        "X-Accel-Buffering": "no",                  # лично для nginx: не копить ответ
    })

Строчка X-Accel-Buffering: no — это первое, что стоит проверять, когда «SSE не работает, а на localhost работает». nginx по умолчанию буферизует ответ апстрима (proxy_buffering on) и отдаёт его клиенту порциями по мере заполнения буфера. Ваши события копятся, пока не наберётся 4 КБ или не закроется соединение. Лечится либо этим заголовком, либо proxy_buffering off; в location. Ровно та же проблема с gzip — сжатие тоже буферизует, поэтому no-transform.

Ограничения, о которые спотыкаются

  • Только UTF-8 текст. Бинарные данные — только через base64 (+33% объёма) или отдельным HTTP-запросом.
  • Только сервер → клиент. Обратный канал — обычный fetch. На практике это часто плюс: у вас остаются нормальные HTTP-коды, ретраи и идемпотентность на записи.
  • Шесть соединений на origin в HTTP/1.1. Открыли SSE в шести вкладках — седьмая зависнет, и вместе с ней все остальные запросы к этому домену. По HTTP/2 лимит снимается (один TCP, много потоков), поэтому SSE в проде — только по HTTP/2. Обходной путь для HTTP/1.1 — один EventSource на все вкладки через SharedWorker и BroadcastChannel.
  • EventSource не умеет кастомные заголовки. Ни Authorization, ни ничего. Варианты: cookie (и тогда обязательно SameSite — см. XSS и CSRF), одноразовый тикет в query-параметре, либо самописный SSE-клиент поверх fetch с ReadableStream, который заодно даёт контроль над реконнектом.

Уровень 3: WebSocket

WebSocket (RFC 6455) решает единственную задачу, недоступную SSE: симметричный двусторонний канал с низкими накладными расходами на сообщение. Ключевой трюк — он начинается как обычный HTTP-запрос и потому проходит через ту же инфраструктуру, что и веб: те же 443/TCP, тот же TLS, те же прокси, та же аутентификация по cookie.

Рукопожатие

# Смотрим апгрейд глазами. --http1.1 обязателен: по HTTP/2 схема другая.
curl -v --http1.1 --include --no-buffer \
  -H 'Connection: Upgrade' \
  -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' \
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  -H 'Origin: https://app.example.com' \
  https://api.example.com/ws
> GET /ws HTTP/1.1
> Connection: Upgrade
> Upgrade: websocket
> Sec-WebSocket-Version: 13
> Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
>
< HTTP/1.1 101 Switching Protocols
< Upgrade: websocket
< Connection: Upgrade
< Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
< Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover

Sec-WebSocket-Accept считается детерминированно — это не безопасность, а защита от «случайного» апгрейда через кэширующий прокси, который не понял, что происходит:

# accept = base64( sha1( key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" ) )
printf '%s258EAFA5-E914-47DA-95CA-C5AB0DC85B11' 'dGhlIHNhbXBsZSBub25jZQ==' \
  | openssl dgst -sha1 -binary | base64
# → s3pPLMBiTxaQ9kYGzzhZRbK+xOo=  — ровно то, что прислал сервер

Совпало — значит на том конце действительно реализация WebSocket, а не прокси, отдавший что-то из кэша. Сам Sec-WebSocket-Key — 16 случайных байт в base64, генерируется браузером, никакой энтропии на защиту не несёт.

После 101 HTTP заканчивается. То же TCP-соединение переходит в собственный бинарный протокол, и всё, что HTTP умел, вы теряете: нет кодов ответа, нет кэширования, нет повторов, нет Content-Length. Всё это придётся строить заново поверх сообщений.

Кадр по битам

Раскладка кадра WebSocket по битам

Три детали, которые определяют поведение в проде:

Накладные расходы — 2 байта. Заголовок кадра сервер→клиент для короткого сообщения занимает ровно 2 байта против 500–800 байт HTTP-заголовков. Для потока мелких сообщений это разница в два порядка. Именно это, а не «дуплекс», обычно оказывается решающим аргументом.

Маскирование клиентских кадров обязательно. Каждый байт от клиента XOR-ится со случайным 32-битным ключом, который лежит прямо в кадре. Это не шифрование — ключ едет рядом. Смысл в другом: без маскирования злоумышленник со страницы мог сформировать полезную нагрузку, которую промежуточный прокси прочитал бы как валидный HTTP-запрос и закэшировал бы подложный ответ для чужого домена. Атака описана в работе Talking to Yourself for Fun and Profit (Huang et al., 2011) — именно она заставила комитет добавить маску. Цена: лишний проход по буферу на клиенте и невозможность zero-copy.

Сообщение и кадр — не одно и то же. Большое сообщение может быть нарезано на кадры: первый с реальным opcode и FIN=0, промежуточные с opcode=0x0, последний с FIN=1. Между фрагментами разрешено вклинивать управляющие кадры — поэтому ping доходит даже во время передачи гигабайтного файла. Прикладной код почти всегда видит уже собранное сообщение, но лимит на размер ставить обязан сам: без SetReadLimit клиент анонсирует 8 ЭБ и выест вашу память.

Ping, pong и обнаружение трупов

Почему heartbeat обязателен, хотя TCP уже «надёжный»: TCP keepalive в Linux по умолчанию включается через net.ipv4.tcp_keepalive_time = 7200 — два часа. Всё это время сервер будет держать сокет к ноутбуку, который закрыли крышкой ещё утром. Плюс NAT и балансировщики выбрасывают запись из таблицы через 1–5 минут тишины, и вы даже не узнаете: обе стороны считают соединение живым, а пакеты уходят в никуда. Прикладной ping решает обе проблемы. Плата — трафик и радио на мобильных: пинг раз в 20 секунд заметно ест батарею, поэтому в мобильных приложениях интервал повышают и опираются на push-уведомления, когда приложение в фоне.

Жизненный цикл и реконнект

Коды закрытия, которые вы реально увидите: 1000 — норма; 1001 going away (сервер уходит на деплой, вкладка закрывается); 1006аварийный разрыв без close-кадра; 1009 — сообщение больше лимита; 1011 — внутренняя ошибка; 4000–4999 — ваши собственные. 1006 никогда не передаётся по проводу: это значение, которое подставляет клиентская библиотека, когда TCP умер молча. Увидели поток 1006 в метриках — идите смотреть idle-таймауты балансировщика, а не свой код.

function connect(url, onMessage) {
  let attempt = 0, ws, timer;

  const open = () => {
    ws = new WebSocket(url);
    ws.onopen = () => { attempt = 0; };            // сбрасываем ТОЛЬКО после успеха
    ws.onmessage = (e) => onMessage(JSON.parse(e.data));
    ws.onclose = (e) => {
      if (e.code === 1000) return;                 // закрылись сами — не переподключаемся
      const cap = Math.min(30_000, 500 * 2 ** attempt++);
      // full jitter: без него после падения сервера все клиенты вернутся
      // ОДНОЙ волной ровно через 500 мс и уронят его повторно
      timer = setTimeout(open, cap / 2 + Math.random() * (cap / 2));
    };
  };

  // Не ждать таймера, если сеть вернулась: событие приходит раньше любого backoff
  addEventListener("online", () => { clearTimeout(timer); attempt = 0; open(); });
  open();
  return () => ws?.close(1000, "bye");
}

Переподключение — это не «открыть сокет заново». Это восстановление состояния: сервер обязан отдать либо снапшот, либо события с курсора, который клиент запомнил. Протокол, где после реконнекта клиент молча теряет пропущенное, работает идеально на localhost и разваливается в метро.

Сервер: heartbeat, лимиты и backpressure

const (
    pongWait   = 60 * time.Second      // нет pong дольше — считаем клиента мёртвым
    pingPeriod = pongWait * 9 / 10     // пингуем чуть чаще, чем истекает ожидание
    writeWait  = 10 * time.Second      // дедлайн на ОДНУ запись
    maxMessage = 1 << 20               // 1 МБ, иначе клиент выест память сервера
)

func pump(conn *websocket.Conn, out <-chan []byte) {
    defer conn.Close()
    conn.SetReadLimit(maxMessage)
    _ = conn.SetReadDeadline(time.Now().Add(pongWait))
    // Каждый pong отодвигает дедлайн чтения — это и есть детектор живости
    conn.SetPongHandler(func(string) error {
        return conn.SetReadDeadline(time.Now().Add(pongWait))
    })

    ticker := time.NewTicker(pingPeriod)
    defer ticker.Stop()
    write := func(t int, p []byte) error {
        _ = conn.SetWriteDeadline(time.Now().Add(writeWait))
        return conn.WriteMessage(t, p) // дедлайн истёк = клиент не читает, выходим
    }

    for {
        select {
        case msg, ok := <-out:
            if !ok { // хаб закрыл канал — прощаемся вежливо, чтобы клиент понял причину
                _ = write(websocket.CloseMessage,
                    websocket.FormatCloseMessage(websocket.CloseGoingAway, "shutdown"))
                return
            }
            if write(websocket.TextMessage, msg) != nil {
                return
            }
        case <-ticker.C:
            if write(websocket.PingMessage, nil) != nil {
                return
            }
        }
    }
}

// В хабе — ОГРАНИЧЕННАЯ очередь на клиента и явная политика переполнения
func (h *Hub) broadcast(msg []byte) {
    for c := range h.clients {
        select {
        case c.out <- msg:
        default:
            // Клиент не успевает читать. Буферизовать дальше = OOM на сервере.
            // Рвём: он переподключится и заберёт снапшот с курсора.
            close(c.out)
            delete(h.clients, c)
        }
    }
}

Это и есть backpressure — самая частая причина, по которой WebSocket-сервер падает по памяти. Медленный клиент (телефон в лифте) не читает, TCP-окно закрывается, Write блокируется, ваши сообщения копятся в очереди. Без ограниченного буфера и дедлайна записи сервер честно копит гигабайты. Правило: очередь ограничена, переполнение — это решение, а не случайность. Варианты решения — разорвать соединение, схлопнуть очередь в последний снапшот (для котировок это правильнее, чем доставлять устаревшие тики) или дропать события низкого приоритета.

Ресурсы: сколько стоит соединение

Статья расходов Порядок величины Комментарий
Сокет в ядре 4–16 КБ net.ipv4.tcp_rmem / tcp_wmem, можно ужимать
Файловый дескриптор ~1 КБ лимит: ulimit -n, fs.file-max
Состояние в приложении 2–50 КБ буфер, очередь, метаданные сессии
permessage-deflate с context takeover ~300 КБ zlib: ~256 КБ на deflate + ~32 КБ на inflate
TLS-сессия 20–60 КБ буферы записи и чтения OpenSSL

Строка про сжатие — не опечатка. Расширение permessage-deflate (RFC 7692) с сохранением контекста между сообщениями держит скользящее окно zlib на каждое направление каждого соединения. 100 000 соединений — это 30 ГБ только на компрессоры. Поэтому в проде либо отключайте сжатие, либо согласуйте client_no_context_takeover; server_no_context_takeover и уменьшите server_max_window_bits до 10–12: степень сжатия просядет, память упадёт на порядок.

ss -tan state established '( sport = :8080 )' | wc -l   # сколько соединений висит сейчас
ls /proc/$(pgrep -f ws-server)/fd | wc -l               # не упёрлись ли в ulimit -n
ss -tanm state established '( sport = :8080 )' | head   # кто не читает: растущий Send-Q
State  Recv-Q  Send-Q      Local Address:Port      Peer Address:Port
ESTAB  0       0            10.0.0.12:8080          10.0.4.51:52310
ESTAB  0       1244160      10.0.0.12:8080          10.0.7.88:41022   <-- клиент не читает
ESTAB  0       0            10.0.0.12:8080          10.0.4.52:33914

Ненулевой и растущий Send-Q — прямая улика: данные ушли в сокет и застряли, потому что окно получателя закрыто. Про rwnd и что с ним делать — в статье про TCP.

WebSocket через прокси, балансировщики и CDN

# Обязательный map: если клиент прислал обычный keep-alive, хардкод Connection: upgrade
# сломает запрос. Пустое значение заставит nginx не ставить заголовок вовсе.
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      '';
}

location /ws {
    proxy_pass http://ws_backend;
    proxy_http_version 1.1;                    # обязательно: по 1.0 апгрейда не бывает
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    proxy_read_timeout  3600s;                 # иначе nginx убьёт соединение через 60 с
    proxy_send_timeout  3600s;
    proxy_buffering     off;                   # не копить, отдавать сразу
}

Что ещё ломается на этом пути:

  • Idle timeout балансировщика. 60 секунд по умолчанию у большинства облачных LB. Ваш ping должен быть заметно чаще.
  • Sticky sessions. Для WebSocket они не нужны: после 101 соединение прибито к конкретному бэкенду самим TCP. А вот для long polling и SSE-с-состоянием-в-памяти — нужны, иначе каждый следующий запрос попадёт на другой под. Подробнее — в прокси и балансировке.
  • Исчерпание эфемерных портов между LB и бэкендом. 4-кортеж уникален, диапазон net.ipv4.ip_local_port_range даёт ~28 000 соединений на пару адресов. Лечится несколькими портами или адресами на бэкенде.
  • HTTP/2 и HTTP/3. Обычный Upgrade: в HTTP/2 запрещён. Вместо него — Extended CONNECT (RFC 8441) с псевдозаголовком :protocol и настройкой SETTINGS_ENABLE_CONNECT_PROTOCOL; для HTTP/3 то же самое описано в RFC 9220. Браузеры это умеют, но прокси и балансировщики — далеко не все, поэтому проверяйте свою цепочку, а не полагайтесь на «сейчас же 2026 год».
  • CDN. WebSocket проходит насквозь и не кэшируется — с точки зрения CDN это просто туннель, за который вы платите по времени, а не по трафику.

Безопасность

Проверяйте Origin на сервере — руками. Рукопожатие WebSocket выполняется браузером без CORS-преполёта, и cookie отправляются как при обычном запросе. Это значит, что страница evil.com может открыть wss://ваш-api/ws, и он подключится под сессией жертвы. Атака называется Cross-Site WebSocket Hijacking, и единственная защита — белый список Origin плюс CSRF-токен в первом сообщении. Библиотеки по умолчанию либо не проверяют, либо проверяют слишком мягко.

Аутентификация. new WebSocket(url) не принимает заголовки, поэтому Authorization: Bearer недоступен. Рабочие варианты: cookie с Secure; HttpOnly; SameSite=Lax; одноразовый короткоживущий тикет, полученный обычным HTTP-запросом и переданный в query (он попадёт в логи прокси — потому и одноразовый, на 30 секунд); либо аутентификация первым сообщением после установки соединения, с жёстким таймаутом «не представился за 5 секунд — закрываем 4001».

Только wss://. Не ради шифрования (хотя и ради него — см. TLS), а потому что открытый ws:// регулярно ломают промежуточные узлы, которые «оптимизируют» непонятный им трафик. TLS-туннель они не трогают.

Что видно в Wireshark

Снимаем дамп (sudo tcpdump -i any -s 0 -w ws.pcap 'tcp port 8080'; для wss:// понадобится SSLKEYLOGFILE, иначе увидите шифротекст) и открываем в Wireshark. Полезные фильтры:

  • http.response.code == 101 — момент апгрейда. Если его нет, Wireshark не поймёт последующие байты: используйте Decode As... → WebSocket.
  • websocket — все кадры. В дереве видно FIN, Opcode, Mask, Payload length и уже размаскированные данные в поле «Payload».
  • websocket.opcode == 9 || websocket.opcode == 10 — ping и pong. Идеальный способ проверить, что heartbeat реально ходит, а не «включён в конфиге».
  • websocket.opcode == 8 — close-кадры. Их отсутствие перед разрывом — это и есть тот самый 1006.
  • Как выглядит потеря пакета. WebSocket живёт на TCP, поэтому потеря видна на уровне TCP: [TCP Previous segment not captured], серия [TCP Dup ACK], затем [TCP Retransmission]. Прикладной поток при этом замирает целиком — сообщение, чьи байты пришли позже потерянного, не будет отдано приложению, даже если оно уже лежит в буфере ядра. Это head-of-line blocking, и это фундаментальная причина, по которой для медиа и игр используют не WebSocket, а UDP-транспорт.
  • Меню Statistics → Conversations → TCP покажет длительность соединения и объём — быстрый способ найти клиента, которому вы шлёте в 50 раз больше, чем остальным.

Уровень 4: WebRTC

WebRTC появился ради того, чего не может ни один из предыдущих вариантов: медиа в реальном времени между двумя клиентами напрямую, с задержкой в десятки миллисекунд, поверх UDP, без прохода через ваш сервер. Это не «ещё один транспорт», а связка из ICE, DTLS, SRTP, SCTP и SDP, где сложность спрятана в обход NAT.

Три пути между пирами WebRTC и роль STUN и TURN

Сигналинг вы пишете сами

Стандарт сознательно не описывает, как два браузера узнают друг о друге. Вам нужен собственный канал, чтобы обменяться SDP-описаниями и ICE-кандидатами, — и обычно это WebSocket из предыдущего раздела. Забавная деталь: WebRTC почти никогда не существует без WebSocket рядом.

SDP (RFC 8866) — это текстовое описание «что я умею»: кодеки, разрешения, ключи, отпечаток сертификата DTLS. Модель offer/answer формализована в RFC 8829 (JSEP).

ICE: как находится путь

Ключевые моменты, которые определяют качество сервиса:

  • STUN отвечает на один вопрос — «с какого адреса ты ко мне пришёл». Дёшево, stateless, один пакет. Этого достаточно, чтобы пробить симметричные дырки в большинстве домашних NAT.
  • TURN — ретранслятор, и это ваш счёт за трафик. Он нужен там, где hole punching невозможен: symmetric NAT с обеих сторон, корпоративные фаерволы, некоторые мобильные операторы с CG-NAT. По разным публичным отчётам через TURN идёт 8–20% сеансов. Без TURN сервис просто не работает у этой доли пользователей, поэтому это обязательный компонент, а не оптимизация.
  • TURN на 443/TCP и TURNS/TLS — обязательны. В сети, где открыт только 443, turns: неотличим от обычного HTTPS и проходит. Цена — вы вернули себе TCP со всеми его задержками восстановления.
  • Trickle ICE (RFC 8838) — отправлять кандидаты по мере нахождения, а не ждать окончания сбора. Экономит секунду-две на установке звонка.
  • DTLS-SRTP (RFC 5764): медиа шифруется всегда, ключи выводятся из DTLS-рукопожатия, а отпечаток сертификата приезжает в SDP по вашему сигнальному каналу. Это значит, что безопасность WebRTC ровно настолько хороша, насколько защищён ваш сигналинг.

DataChannel: UDP, доступный из браузера

Самая недооценённая часть WebRTC — не видео, а канал данных. Это SCTP поверх DTLS поверх UDP (RFC 8831), и он даёт то, чего нет больше нигде в браузере: настраиваемую надёжность.

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:stun.example.com:3478" },
    { urls: "turns:turn.example.com:443?transport=tcp",
      username: ticket.user, credential: ticket.pass },  // креды короткоживущие, HMAC
  ],
  iceTransportPolicy: "all",   // "relay" — форсировать TURN: так тестируют худший путь
});

// Поведение UDP: свежий кадр важнее полного. Порядок и доставка не гарантируются.
// maxRetransmits и maxPacketLifeTime взаимоисключающие — задавать можно только одно.
const state = pc.createDataChannel("state", { ordered: false, maxRetransmits: 0 });
const chat = pc.createDataChannel("chat");   // поведение TCP: надёжно и по порядку

// Диагностика: без этого вы не поймёте, почему звонок не поднялся
pc.oniceconnectionstatechange = () => console.log(pc.iceConnectionState);
setInterval(async () => {
  for (const s of (await pc.getStats()).values())
    if (s.type === "candidate-pair" && s.state === "succeeded")
      console.log("RTT", s.currentRoundTripTime, "потери", s.packetsLost);
}, 2000);

ordered: false, maxRetransmits: 0 — это ровно та семантика, ради которой игры и мультиплеер существуют на UDP: позиция игрока, устаревшая на 200 мс, не нужна никому, а ожидание её ретрансмиссии тормозит все последующие. См. UDP и QUIC — идея та же, что и с потоками QUIC.

Групповые звонки: почему mesh не работает

Полносвязная схема требует от каждого участника N−1 исходящих потоков. На четверых это уже 3 отправки видео с ноутбука — и он греется. Реальные сервисы используют SFU (Selective Forwarding Unit): каждый шлёт один поток на сервер, сервер раздаёт остальным без перекодирования. Дороже по трафику сервера, но дешёво по CPU и позволяет simulcast — клиент шлёт три качества сразу, SFU выбирает подходящее каждому получателю. MCU (микширование в один поток) экономит трафик клиента, но требует декодировать и кодировать всё на сервере — дорого и добавляет задержку.

Отладка

sudo tcpdump -ni any -vv 'udp port 3478 or tcp port 3478'  # ходит ли STUN/TURN вообще
turnutils_uclient -T -u user -w secret turn.example.com    # тест coturn его же утилитой
stunclient stun.l.google.com 19302                         # публичный STUN за один пакет

Главный инструмент — chrome://webrtc-internals (в Firefox about:webrtc): там видны все кандидаты, выбранная пара, график RTT, потерь и битрейта, полный SDP обеих сторон. 90% инцидентов «звонок не устанавливается» решаются взглядом на список кандидатов: если там только host, значит STUN недоступен; если ICE-состояние застряло в checking, значит проверки связности не проходят и нужен TURN.

Сравнение и выбор

Polling Long polling SSE WebSocket WebRTC DataChannel
Направление клиент→сервер клиент→сервер сервер→клиент дуплекс дуплекс, P2P
Транспорт HTTP HTTP HTTP TCP после 101 UDP + DTLS + SCTP
Типичная задержка интервал/2 10–100 мс 10–100 мс 5–50 мс 1–30 мс
Накладные на сообщение 500–800 Б 500–800 Б ~20 Б 2–14 Б ~30 Б
Бинарные данные да да нет (base64) да да
Авто-реконнект тривиально вручную встроен вручную ICE restart
Докачка после обрыва курсор курсор Last-Event-ID вручную вручную
Проходит корп. прокси всегда всегда почти всегда обычно (wss) часто нужен TURN/443
Работает через CDN да, кэшируется да да туннель нет
Сложность эксплуатации никакой низкая низкая средняя высокая

Последний блок — не украшение. Архитектура «push присылает только {type: 'order.updated', id: 991}, а тело клиент дочитывает обычным GET /orders/991» решает разом идемпотентность, кэширование, авторизацию и восстановление после обрыва, потому что вся сложность остаётся в HTTP, который вы и так умеете готовить. Это же снимает вопрос «что делать с пропущенными событиями»: достаточно перезапросить ресурс. Смежная тема — модели доставки в сообщениях и очередях и загрузка данных на клиенте во фронтенд-треке.

Диагностика: симптом → причина

Симптом Первая гипотеза Чем проверить
SSE не приходит, на localhost работает буферизация прокси curl -N напрямую к бэкенду, потом через прокси; proxy_buffering off
WebSocket закрывается ровно через 60 с idle timeout LB или proxy_read_timeout посчитать интервал между onclose; посмотреть код — будет 1006
Массовый 1006 после деплоя нет graceful close сервер должен слать 1001 до закрытия слушателя
Соединение «живо», данные не идут NAT выбросил запись, обе стороны не знают tcpdump на обоих концах: пакеты уходят, ACK не приходит
Память сервера растёт линейно с клиентами нет лимита очереди или включён deflate ss -tanm смотреть Send-Q; профиль кучи
101 не приходит, вместо него 200 прокси не пробрасывает Upgrade curl -v --http1.1 с заголовками апгрейда
WebRTC: ICE застрял в checking нет TURN или он недоступен chrome://webrtc-internals, tcpdump udp port 3478
Всё хорошо, но у 10% клиентов — нет symmetric NAT, корпоративный фаервол iceTransportPolicy: "relay" для теста; TURN на 443
Реконнекты идут волнами, сервер падает нет джиттера в backoff смотреть распределение времени подключений

Общая методика («сузить участок, потом сузить уровень») разобрана в диагностике сети.

Типичные ошибки

  • Взять WebSocket, потому что «реалтайм». Если поток односторонний, SSE даёт то же самое, плюс встроенный реконнект, плюс докачку по Last-Event-ID, плюс совместимость со всей HTTP-инфраструктурой. Начинайте с него.
  • Считать, что TCP заметит обрыв. Не заметит два часа. Прикладной heartbeat обязателен на обеих сторонах.
  • Реконнект без джиттера. После перезапуска сервера все клиенты вернутся синхронно и уронят его снова. Full jitter — три строки кода.
  • Неограниченная очередь на клиента. Один телефон в лифте — и сервер уходит в OOM. Очередь ограничена, политика переполнения выбрана явно.
  • Отсутствие курсора. Любой обрыв тихо теряет события. Клиент обязан знать, где остановился, а сервер — уметь доиграть с этой точки.
  • permessage-deflate по умолчанию на десятках тысяч соединений. 300 КБ на соединение — и вы объясняете, куда делись 30 ГБ.
  • Не проверять Origin. CSWSH — это реальная дыра, а не теория: браузер отправит cookie за вас.
  • Держать состояние сессии в памяти пода. Любой деплой стирает его. Состояние — во внешнем хранилище, в памяти — только сокет.
  • Тестировать только на Wi-Fi в офисе. Тестовый набор обязан включать мобильную сеть, VPN, корпоративный прокси и переключение сети на ходу.
  • Пинговать раз в 5 секунд на мобильном. Батарея кончится к обеду, а пользователь удалит приложение.

Мини-итог

  • Задача одна: доставить событие, инициатор которого — сервер. Решения образуют лестницу, а не набор альтернатив.
  • Polling честен и дёшев при редких обновлениях; средняя задержка — половина интервала, нагрузка — клиенты делить на интервал.
  • Long polling убирает задержку опроса, но требует курсора и планового таймаута короче, чем у всех промежуточных узлов.
  • SSE — стандартный однонаправленный поток с бесплатным реконнектом и докачкой по Last-Event-ID. Обязательно по HTTP/2 и с отключённой буферизацией прокси.
  • WebSocket даёт дуплекс и 2 байта накладных на сообщение, но забирает всё, что HTTP умел бесплатно. Маска на клиентских кадрах — защита от отравления кэша прокси, а не шифрование.
  • В проде WebSocket живёт на четырёх вещах: heartbeat, лимит размера сообщения, ограниченная очередь с явной политикой и реконнект с джиттером и курсором.
  • WebRTC — единственный способ получить UDP-семантику и P2P из браузера. Основная работа там не в медиа, а в ICE; TURN обязателен, и он стоит трафика.
  • Лучший приём почти для любой архитектуры: по реалтайм-каналу гнать сигнал, а данные забирать обычным HTTP.

Источники

Что дальше

Реальное время решает вопрос «как доставить», но не отвечает на вопрос «как договориться о формате». Как только по WebSocket или DataChannel начинает ходить больше трёх типов сообщений, появляются схемы, версионирование, кодогенерация и стриминг — то есть RPC.

RPC и gRPC: протобуф, стриминг, сравнение с REST — про то, как вызов функции превращается в байты на проводе, чем protobuf платит за компактность и почему двунаправленный стриминг в gRPC устроен принципиально иначе, чем в WebSocket.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов