← назад к разделу

HTTP устроен как «вопрос — ответ»: клиент спрашивает, сервер отвечает. Но у многих задач направление обратное: пришло сообщение в чат, изменился статус заказа, обновился курс — сервер должен сообщить клиенту. Обычный HTTP так не умеет, и за годы выработалось три рабочих способа: long polling, Server-Sent Events и WebSocket.

Проще всего увидеть разницу на одной шкале времени: три события на сервере — и три способа доставить их клиенту.

одни и те же три события на сервере: 40, 120 и 140 мс 0 40 80 120 160 200 240 мс сервер long polling запрос → ответ → снова SSE одно соединение WebSocket плюс обратный канал висит event-stream 1 2 3 событие 3 ждёт 20 мс в щели; каждый цикл — заголовки заново все три события уходят сразу — соединение одно клиент шлёт своё сообщение

Событие, попавшее в щель между циклами long polling, ждёт следующего запроса; SSE и WebSocket отдают его сразу. На этой шкале SSE и WebSocket различает только обратный канал — остальная разница между ними (свой протокол после Upgrade, переподключение руками) во время доставки не видна.

Правило выбора: нужен пуш от сервера — начните с SSE; нужен диалог (клиент часто пишет: чат, курсор в совместном документе, игры) — WebSocket; ничего не проходит — long polling.

Обязательно

Long polling: растянутый вопрос

Простейшая идея: клиент спрашивает «есть новости?», а сервер не отвечает, пока новости не появятся (или не истечёт таймаут, скажем 30 секунд). Получив ответ, клиент немедленно спрашивает снова.

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

На сервере запрос не блокируют, а откладывают ответ, пока не появится событие или не истечёт таймаут:

@GetMapping("/orders/{id}/updates")
DeferredResult<ResponseEntity<OrderStatus>> updates(@PathVariable long id) {
    // второй аргумент — что вернуть по таймауту, это тело ответа, а не код статуса
    var result = new DeferredResult<ResponseEntity<OrderStatus>>(
            30_000L, ResponseEntity.noContent().build());
    waiters.add(id, result);
    result.onCompletion(() -> waiters.remove(id, result));
    return result;                        // поток свободен, клиент ждёт
}
@app.get("/orders/{id}/updates")
async def updates(id: int):
    try:
        return await asyncio.wait_for(waiters.next(id), timeout=30)  # ждём событие
    except asyncio.TimeoutError:
        return Response(status_code=204)
func updates(w http.ResponseWriter, r *http.Request) {
    select {
    case ev := <-waiters.Sub(orderID(r)):     // пришло событие
        json.NewEncoder(w).Encode(ev)
    case <-time.After(30 * time.Second):      // таймаут
        w.WriteHeader(http.StatusNoContent)
    }
}
app.get("/orders/:id/updates", (req, res) => {
  const timer = setTimeout(() => res.sendStatus(204), 30_000);   // таймаут
  waiters.add(req.params.id, (ev) => { clearTimeout(timer); res.json(ev); });
});

Клиент, получив ответ, сразу спрашивает снова:

async function poll(id) {
  const r = await fetch(`/orders/${id}/updates`);   // висит, пока нет события
  if (r.status === 200) render(await r.json());
  poll(id);                                          // без паузы — новый вопрос
}

Server-Sent Events: односторонний поток

SSE — часть веб-стандартов: клиент открывает обычное HTTP-соединение, сервер держит его и пишет события текстовым потоком text/event-stream:

data: {"orderId": 42, "status": "SHIPPED"}

data: {"orderId": 43, "status": "PAID"}

Ключевые свойства:

  • однонаправленность — события идут только от сервера; запросы клиент шлёт обычным HTTP рядом;
  • автопереподключение встроено: браузерный EventSource сам восстанавливает соединение и передаёт Last-Event-ID, чтобы дочитать пропущенное;
  • это по-прежнему HTTP: та же авторизация, те же прокси, тот же инструментарий отладки. Но именно посредники SSE и ломают чаще всего, причём двумя способами. Первый — буферизация: прокси копит ответ, чтобы отдать его одним куском, и поток событий встаёт намертво, пока буфер не наберётся (в nginx лечится proxy_buffering off или заголовком X-Accel-Buffering: no от самого сервиса). Второй — таймаут простоя: если событий долго нет, посредник решит, что соединение брошено, и закроет его; поэтому в поток раз в несколько десятков секунд пишут пустой комментарий :keep-alive, просто чтобы по нему что-то ехало;
  • с авторизацией одна оговорка: браузерный EventSource не умеет отправлять заголовок Authorization, поэтому подписку авторизуют cookie либо берут вместо него потоковый fetch.

На сервере держим соединение и пишем в него события по мере появления:

@GetMapping("/orders/{id}/events")
SseEmitter events(@PathVariable long id) {
    SseEmitter emitter = new SseEmitter(1_800_000L);       // 30 минут
    subscribers.add(id, emitter);
    emitter.onCompletion(() -> subscribers.remove(id, emitter));
    return emitter;
}
// при событии: emitter.send(SseEmitter.event().id(seq).data(status));
@app.get("/orders/{id}/events")
async def events(id: int):
    async def stream():
        async for s in subscribe(id):
            yield f"id: {s.seq}\ndata: {s.json()}\n\n"
    return StreamingResponse(stream(), media_type="text/event-stream")
func events(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    flusher := w.(http.Flusher)
    for s := range subscribe(orderID(r)) {
        fmt.Fprintf(w, "id: %d\ndata: %s\n\n", s.Seq, s.JSON())
        flusher.Flush()
    }
}
app.get("/orders/:id/events", (req, res) => {
  res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
  const send = (s) => res.write(`id: ${s.seq}\ndata: ${JSON.stringify(s)}\n\n`);
  subscribe(req.params.id, send);
  req.on("close", () => unsubscribe(req.params.id, send));
});

Клиенту достаточно EventSource — переподключение и Last-Event-ID он берёт на себя:

const es = new EventSource(`/orders/${id}/events`);
es.onmessage = (e) => render(JSON.parse(e.data));
es.onerror = () => {/* браузер переподключится сам, дошлём с Last-Event-ID */};

SSE — недооценённый вариант: для «сервер уведомляет клиента» (статусы, ленты, прогресс операций, курсы) он закрывает задачу заметно дешевле WebSocket.

WebSocket: полный дуплекс

WebSocket начинается как HTTP-запрос с заголовком Upgrade: websocket, после рукопожатия соединение превращается в постоянный двунаправленный канал: обе стороны шлют сообщения когда угодно, без HTTP-заголовков на каждое.

запрос GET заголовок Upgrade ответ 101 протокол сменён кадры в обе стороны, без HTTP

Смотрите на ответ 101: до него это обычный HTTP-запрос, после него то же соединение несёт кадры.

Это единственный из трёх способов с настоящей связью в обе стороны и минимальными накладными расходами на сообщение — цена соответствующая:

  • протокол другой: нужна поддержка на всём пути (прокси, балансировщики, таймауты простоя);
  • переподключение, восстановление подписок и доставка пропущенного — ваша забота (потому поверх WebSocket обычно живёт протокол уровнем выше: STOMP, socket.io и подобные);
  • заголовки задать нельзя: браузерный WebSocket принимает только адрес, и никакого Authorization в него не положить — ровно та же беда, что у EventSource. Авторизуются cookie, одноразовым билетом в строке адреса или полем подпротокола (Sec-WebSocket-Protocol), куда кладут токен;
  • тысячи постоянных соединений — отдельная статья нагрузки на сервер.

Минимальный обработчик: принять соединение, а входящее сообщение разослать остальным:

class ChatHandler extends TextWebSocketHandler {
    private final Set<WebSocketSession> sessions = ConcurrentHashMap.newKeySet();
    public void afterConnectionEstablished(WebSocketSession s) { sessions.add(s); }
    public void afterConnectionClosed(WebSocketSession s, CloseStatus st) { sessions.remove(s); }
    protected void handleTextMessage(WebSocketSession from, TextMessage msg) throws IOException {
        for (WebSocketSession s : sessions)              // разослать остальным
            if (s.isOpen() && s != from) s.sendMessage(msg);
        // ВАЖНО: WebSocketSession не рассчитана на отправку из нескольких потоков.
        // Здесь сообщения от разных клиентов приходят параллельно и могут
        // «перемешаться» в одном соединении. В рабочем коде каждую сессию
        // оборачивают в ConcurrentWebSocketSessionDecorator.
    }
}
@app.websocket("/ws/chat")
async def chat(ws: WebSocket):
    await ws.accept(); clients.add(ws)
    try:
        while True:
            msg = await ws.receive_text()
            for c in clients:
                if c is not ws: await c.send_text(msg)   # разослать остальным
    except WebSocketDisconnect:
        clients.discard(ws)
func chat(w http.ResponseWriter, r *http.Request) {
    conn, _ := upgrader.Upgrade(w, r, nil)
    clients.Add(conn); defer clients.Remove(conn)
    for {
        _, msg, err := conn.ReadMessage()
        if err != nil { return }
        clients.Broadcast(conn, msg)                     // разослать остальным
    }
}
const wss = new WebSocketServer({ port: 8080, path: "/ws/chat" });
wss.on("connection", (ws) => {
  ws.on("message", (msg) => {
    for (const c of wss.clients)                          // разослать остальным
      if (c !== ws && c.readyState === 1) c.send(msg.toString());
  });
});

Клиент шлёт и принимает в обе стороны; переподключение пишем сами:

let ws;
let delay = 500;                                  // стартовая пауза перед повтором
function connect() {
  ws = new WebSocket(`wss://${location.host}/ws/chat`);
  ws.onopen = () => { delay = 500; };             // связь есть — сбрасываем паузу
  ws.onmessage = (e) => addMessage(JSON.parse(e.data));
  // переподключение — на вас; пауза растёт и слегка разбрасывается,
  // иначе после перезапуска сервера все клиенты придут одновременно
  ws.onclose = () => {
    delay = Math.min(30_000, delay * 2);
    setTimeout(connect, delay * (0.5 + Math.random()));
  };
}
connect();
sendBtn.onclick = () => ws.send(JSON.stringify({ text: input.value }));

Где спотыкаются начинающие

  • Считают, что веб-сокет — это «быстрее». Он не быстрее, он двусторонний: выигрыш в том, что серверу не надо ждать запроса. Для односторонних обновлений поток событий проще и дешевле.
  • Не ставят сердцебиение и удивляются, что соединение живёт ровно минуту: его закрывает посредник, а не код.
  • Держат состояние в памяти экземпляра. Клиент переподключился и попал на другой узел — подписки нет, данных нет. Состояние подписки хранят там, где его увидит любой экземпляр.
  • Забывают про переподключение на клиенте. Поток событий переподключается сам, веб-сокет — нет: его надо переподключать самому, с нарастающей паузой и разбросом, иначе после сбоя все клиенты вернутся одновременно.
  • Рассылают всем, а фильтруют на клиенте. Отправить каждому клиенту все события и позволить ему выбрать нужные — это трафик и память сервера впустую; фильтр всегда на сервере.
  • Открывают соединение на каждую вкладку. Одно соединение на страницу, а не на компонент: несколько потоков событий с одной страницы — прямой путь в предел шести соединений.
  • Проверяют работу только на быстрой сети. Медленный клиент — это не «редкий случай», а половина мобильных пользователей; без обратного давления он валит сервер.

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

Long pollingSSEWebSocket
Направлениесервер → клиентсервер → клиентоба
Транспортобычный HTTPобычный HTTPсвой протокол после Upgrade
Переподключениесамо по природевстроено (EventSource)руками / библиотекой
Накладные расходывысокиенизкиеминимальные
Когда братьвраждебная средауведомления, ленты, прогрессчат, игры, совместное редактирование

Масштабирование: общая грабля

С постоянными соединениями ломается привычная модель «любой запрос — на любой экземпляр»: соединение живёт на конкретном сервере, а событие для клиента может родиться на другом. Стандартное решение — общая шина: экземпляры публикуют события в брокер или Redis Pub/Sub, и каждый доставляет их своим подключённым клиентам. Плюс придётся подружить балансировщик с длинными соединениями: таймауты простоя, при необходимости — привязка клиента к экземпляру.

событие на узле 1 клиента здесь нет публикует шина событий publish и subscribe рассылает узлам узел 2 здесь висит соединение шлёт в соединение клиент получил событие

Событие родилось на узле 1, а соединение клиента висит на узле 2: смотрите, где путь проходит через общую шину.

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

Дополнительно: при первом чтении можно пропустить

Глубже: ограничения, о которые спотыкаютсярасширенное

Шесть соединений на источник в HTTP/1.1

Самая частая жалоба на поток событий: открыл вторую вкладку — работает, третью — работает, а на четвёртой всё встало. Причина не в сервере: браузер держит не больше шести одновременных соединений к одному источнику по HTTP/1.1, и каждый поток событий занимает одно из них навсегда. Две вкладки с потоком — это два занятых соединения; после шести у страницы не остаётся соединений даже на обычные запросы, и они встают в очередь.

Лечится это переходом на HTTP/2, где все запросы и потоки идут в одном соединении и предел браузера перестаёт действовать: сотня потоков событий укладывается в одно соединение. Это и есть главная практическая причина включать HTTP/2 на входе, даже если ускорения он не даёт (подробнее — в статье про версии HTTP). У веб-сокетов этого ограничения нет — у них свой предел, заметно выше.

Сердцебиение и таймауты простоя

Долгое соединение, по которому ничего не идёт, выглядит для посредников мёртвым. Обратный прокси, балансировщик, преобразователь адресов — у каждого свой таймаут простоя (часто 60 секунд), и по его истечении соединение закрывается. Клиент при этом может не узнать об этом сразу: он ждёт данных, которых уже никто не пришлёт.

Поэтому в оба протокола встроено сердцебиение, и его надо включать явно.

В потоке событий это комментарий — строка, начинающаяся с двоеточия: клиент её игнорирует, а посредники видят трафик.

: ping

data: {"type":"order.updated","id":"ord-42"}

В веб-сокетах есть служебные кадры ping и pong на уровне протокола: сервер посылает ping, клиент автоматически отвечает pong, и по отсутствию ответа сервер закрывает соединение. В большинстве библиотек это настройка вида «интервал ping» и «таймаут ожидания pong».

Интервал выбирают меньше самого короткого таймаута простоя в цепочке: посредник закрывает через 60 секунд — сердцебиение каждые 20–30. И помните, что сердцебиение решает две задачи: держит соединение живым и обнаруживает, что другая сторона умерла молча.

Обратное давление: клиент читает медленнее, чем сервер пишет

Сценарий, который валит сервер незаметно: сервер отправляет по сто сообщений в секунду, а клиент на слабом мобильном соединении успевает принимать десять. Разница копится в буфере отправки на сервере, и буфер растёт. Сотня таких клиентов — и память сервиса уходит в буферы.

Признак: растёт память, растёт число байт в очередях отправки, а процессор спокоен. Смотрят это в метриках сервера (Send-Q в ss для сокетов, размер буфера в библиотеке) — и до аварии, а не после.

Что с этим делают, в порядке применения. Считать буфер и рвать соединение при превышении предела: клиент, который не успевает, получает отключение и переподключается — это честнее, чем съесть память сервиса. Отбрасывать промежуточные сообщения: для обновлений состояния (цена, позиция, счётчик) важно последнее значение, а не все; такие сообщения складывают в «последнее известное» и отправляют одно вместо сотни. Снижать частоту: отправлять раз в секунду сводку вместо потока событий. И предел на клиента: сколько сообщений в секунду и сколько байт в буфере разрешено.

Для потока событий есть отдельная полезная особенность: браузер сам переподключается и присылает идентификатор последнего полученного сообщения — значит, сервер может продолжить с нужного места, и разрыв «медленного» клиента не означает потери данных.

Сколько соединений держит один узел

Цифры в обсуждениях («двести тысяч соединений») выглядят абстрактно, пока не разложены по ресурсам.

Дескрипторы. Каждое соединение — один файловый дескриптор, и предел процесса по умолчанию невелик (тысячи). Двести тысяч соединений требуют предела в районе 250 тысяч и настройки как в процессе, так и в системе. Это первое, что упирается, и ошибка выглядит как «слишком много открытых файлов».

Память. На соединение уходит память в ядре (буферы приёма и отправки — единицы-десятки килобайт) плюс память приложения на состояние (кто подписан, на что, последний идентификатор). Порядок: сто тысяч соединений при 20 КБ на каждое — это два гигабайта только на буферы, а с состоянием приложения больше. Отсюда и главный рычаг экономии — уменьшение буферов и хранение минимума состояния.

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

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

Коротко

  • Long polling — запрос, который ждёт события; работает везде, но дорог и с щелями между циклами — запасной вариант.
  • SSE — односторонний поток событий поверх обычного HTTP со встроенным переподключением; лучший старт для «сервер уведомляет клиента».
  • WebSocket — двунаправленный канал с минимальными накладными расходами; берут для диалоговых сценариев, платят своей обвязкой переподключений и доставки.
  • Масштабирование постоянных соединений — через общую шину событий между экземплярами; разрывы и дочитывание — часть дизайна, не аварийный случай.
  • Браузер держит шесть соединений к одному источнику по HTTP/1.1, и каждый поток событий занимает одно навсегда: лечится переходом на HTTP/2.
  • Сердцебиение обязательно (комментарий в потоке событий, кадры ping и pong в веб-сокете) с интервалом меньше самого короткого таймаута простоя в цепочке.
  • Медленный клиент копит буфер отправки на сервере: буфер считают и рвут соединение при превышении, промежуточные обновления сворачивают до последнего значения.
  • Цена соединения — дескриптор, память ядра и состояние приложения: десятки тысяч на узел держатся спокойно, сотни тысяч требуют настройки пределов и неблокирующей модели.

Что почитать дальше