Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Server-Sent Events (SSE): живые обновления по HTTP

SSE на практике: односторонние realtime-обновления без WebSocket

Когда SSE проще WebSocket: формат text/event-stream, авто-реконнект браузера, реализация на FastAPI и nginx-настройка, ретраи и масштабирование.

Прямой ответ

Server-Sent Events — встроенный в браузер механизм односторонних live-обновлений поверх обычного HTTP: сервер отдаёт ответ Content-Type: text/event-stream и держит соединение, досылая события в простом текстовом формате (data: {...}\n\n; опционально event: имя, id: номер, retry: мс); клиент — new EventSource("/events") — три строки вместо WebSocket-инфраструктуры, и главное преимущество — встроенный авто-реконнект (обрыв — браузер переподключается сам, посылая Last-Event-ID — сервер может продолжить с места обрыва — retry-логика, которую в WebSocket вы пишете руками, здесь в стандарте). Выбор против WebSocket: SSE — когда поток односторонний (уведомления, прогресс задач, биржевые/метрические ленты, live-лог, LLM-стриминг токенов — клиент только читает; клиент→сервер идёт обычными запросами — POST действия — модель «читаем поток, действуем REST-ом» покрывает большинство продуктовых задач); WebSocket — когда двунаправленность реальна (чаты, совместное редактирование, игры — постоянный поток в обе стороны); «взяли WebSocket для уведомлений» — типичное усложнение (реконнекты/heartbeat/прокси-настройки — SSE даёт бесплатно). Серверная реализация (FastAPI): StreamingResponse c async-генератором событий (StreamingResponse — ссылки в шапке); критичные детали: события из источника (очередь/Redis pub/sub — redis-паттерны — мульти-воркер: воркер A принял задачу, соединение у воркера B — шина обязательна при >1 процессе), keep-alive комментарии каждые 15–30 с (: ping\n\n — прокси/LB рвут молчащие соединения — без пингов «поток умирает через минуту» за nginx — прокси-таймауты), Cache-Control: no-store + X-Accel-Buffering: no (nginx не буферизовал поток — иначе события копятся и приходят пачкой), отмена генератора по disconnect (request.is_disconnected() — клиент ушёл — ресурс освобождается; ASGI-сервер (uvicorn) держит соединение — лимиты воркеров учитывать: 1000 SSE-клиентов = 1000 занятых соединений — asyncio справляется, sync-воркеры — нет — асинхронность обязательна для SSE-нагрузки).

Протокол: формат, реконнект, Last-Event-ID

Формат события (текст, разделитель — пустая строка): обязательное data: (многострочные — несколько data: — клиент склеит через \n; JSON — строкой в data — стандарт практики), event: тип (именованные потоки — addEventListener('progress') — мультиплексирование одного соединения под разные события — уведомление+прогресс+курс — один канал), id: N (идентификатор для возобновления — монотонный по потоку), retry: 3000 (пауза реконнекта в мс — клиент уважает; дефолт ~3 с), : комментарий (игнорируется клиентом — используется для пингов); ограничения: только UTF-8 текст (бинарь — base64 в data — «картинки по SSE» — лучше WebSocket/отдельные fetch), ~64 КБ на событие разумный предел (огромные payload — не сюда), браузерный лимит соединений на домен (HTTP/1.1 — 6 на origin — SSE занимает одно навсегда — 5 вкладок + SSE = шестая вкладка висит — HTTP/2 решает (мультиплексирование — лимит ~100 потоков — h2/h3 для SSE-сайтов практически обязателен — «тормозит при нескольких вкладках» — диагноз отсюда). Реконнект-контракт: обрыв (сеть/перезапуск сервера/прокси-таймаут) → браузер ждёт retry → переподключается с заголовком Last-Event-ID: 42 → сервер может доотправить события с id>42 (буфер недавних событий — очередь в памяти/Redis — пропущенные уведомления доезжают — «переподключился и увидел всё»); не обязан (нет буфера — поток с текущего момента — клиент получает «свежее» — для котировок нормально, для уведомлений — потеря — буферизация по важности данных — продуктовое решение); EventSource клиент: onopen/onmessage/onerror (+именованные) — обработка «закрыто сервером» (readyState CLOSED — сервер может закрыть намеренно (204/не-text/event-stream — браузер не реконнектит — «разлогин — закрываем поток» — явное закрытие вместо бесконечных реконнектов в пустоту).

Сервер: FastAPI, источники событий, масштаб

Минимальный эндпоинт: async-генератор, yielding строки формата, в StreamingResponse(media_type="text/event-stream") + заголовки (Cache-Control: no-store, X-Accel-Buffering: no, Connection: keep-alive); источник событий — паттерны: (1) Redis pub/sub (публикатор — любой воркер/сервис — подписчик-генератор на соединение — мультипроцессность решена — redis; pub/sub at-most-once — отключённый клиент пропустил — компенсация Last-Event-ID буфером или полнота не нужна), (2) Redis streams (consumer per client с id-продолжением — at-least-once + буфер из коробки — надёжнее pub/sub для уведомлений — XREAD BLOCK с last-id — реконнект продолжает), (3) очередь процесса asyncio (однопроцессный dev — Queue на клиента — просто, но не масштабируется — процессы); LLM-стриминг (токены модели → data-события — паттерн «chat completion stream» — LLM-приложения — SSE стандарт де-факто стриминговых API); жизненный цикл: проверка disconnect в цикле (иначе генератор живёт на ушедшего клиента — утечка соединений/памяти — на нагрузке заметно — утечки в новом обличье), таймаут жизни соединения (час — закрыть с retry — клиент переподключится — ротация против «вечных» соединений через дрейфующие прокси), авторизация (EventSource не умеет кастомные заголовки (!) — токен в query-параметре (короткоживущий — лог-гигиена — логи — токены в URL логируются прокси — одноразовый/обменный токен решает) или кука (same-origin ок — JWT/сессии); CORS — обычный механизм (CORS) для cross-origin SSE). Масштабирование: соединения — долгоживущие (LB/nginx держат — таймауты чтения длинные/отключены на SSE-локации — proxy_read_timeout 3600 + пинги — nginx); деплой с SSE — graceful (соединения закрываются — клиенты реконнектятся на новый под — реконнект-шторм при деплое — retry-джиттер (рандомизация на клиенте/серверный retry с разбросом) — shutdown-дисциплина); число соединений на инстанс — тысячи (asyncio — fd-лимиты/память на буферы — ulimit + замеры — 10k соединений × 10 КБ буферов — 100 МБ — приемлемо); K8s — HPA по числу соединений (кастомная метрика — CPU при SSE-простое низкий — HPA по CPU соврёт —_connections-метрика честнее); sticky-сессии не нужны (состояние в Redis — реконнект на любой инстанс продолжает — stateless-принцип (12-factor) соблюдается естественно).

Практический сценарий: прогресс долгих операций без поллинга

Исходник: сервис отчётов (экспорт-конвейер) — генерация 30 с–10 мин; UI текущий — поллинг /status каждые 3 с (нагрузка ×20 на статус-эндпоинт, UX — «дёрганый» прогресс, мобильные жалуются на батарею); WebSocket рассматривали — отказались (односторонность задачи — реконнект-инфраструктура WS — оверхед). Реализация SSE (2 дня): (1) источник — Redis streams: воркер Celery пишет прогресс (XADD report:{id} * stage "aggregate" pct 40) на этапах (Celery — существующая шина); (2) эндпоинт GET /reports/{id}/events (FastAPI StreamingResponse): подписка XREAD BLOCK с id=0 (история с начала — подключившийся видит пройденные этапы), yield id:{entry_id}\nevent:progress\ndata:{json}\n\n, пинг : ping каждые 20 с, по завершении — event:done + закрытие (клиент не реконнектит — готово); disconnect-check в цикле; (3) реконнект: Last-Event-ID → XREAD с того id (пропущенные этапы доезжают — буфер = сам stream — отдельной буферизации не понадобилось — стрим-источник дал надёжность бесплатно); (4) nginx-локация /reports/.*/events — proxy_buffering off, read_timeout 3600, HTTP/2 включён (h2 — мульти-вкладки не упираются в лимит 6); авторизация — сессионная кука (same-origin — без query-токенов); (5) клиент: EventSource + слушатели progress/done (полоса прогресса обновляется плавно; done → загрузка файла по presigned-URL); (6) деплой-проверка: rollout во время активной генерации — клиенты реконнектнулись (пинг-интервал+retry 3 с), потоки продолжились с Last-Event-ID — пользователи видели паузу ~5 с, не потерю. Результаты: статус-поллинг удалён (нагрузка на API −35% — статус-эндпоинт был топ-3 по RPS), UX — реалтайм-прогресс (опрос поддержки — жалобы «непонятно, идёт ли» исчезли), батарея/трафик мобильных — события вместо 20 запросов в минуту; нагрузка на сервер — 300 одновременных SSE (asyncio — fd/memory в норме — метрика активных соединений в Grafana (дашборд) + алерт >2000 — емкость); масштаб-план: 5k+ соединений — отдельный пул SSE-инстансов (HPA по connections-метрике готов); находка: прокси корпоративного клиента резал соединения на 60 с молча — пинг-интервал 20 с закрыл и это («у части пользователей отваливалось» — диагностика через Last-Event-ID-логи показала 60-секундный паттерн — сетевая диагностика в клиентском варианте).

FAQ

SSE против WebSocket — короткий критерий? Направление данных: только сервер→клиент (уведомления/прогресс/ленты/стриминг LLM) — SSE (HTTP-нативность, авто-реконнект, Last-Event-ID, работает через большинство прокси с минимальной настройкой); постоянная двунаправленность (чат/коллаборация/игры — клиент шлёт часто, не запросами) — WebSocket; «нужен ли клиенту канал в сервер» — если действия редкие и запросные (POST кнопка) — SSE+REST проще и надёжнее WS; гибрид редок и почти всегда признак невыбранной модели.

Почему SSE-поток «зависает» и оживает пачками? Буферизация на пути: nginx proxy_buffering (X-Accel-Buffering: no или buffering off на локации — чаще всего оно), CDN/прокси-буферы (некоторые CDN буферизуют text/event-stream — passthrough-настройка/обход CDN для SSE-путей), gzip-сжатие потока (компрессор копит данные для эффективности — отключить сжатие на SSE-локации), приложения-буферы (промежуточный прокси-слой в коде — response-обёртки с буфером — ASGI-стриминг должен быть честным); диагностика — curl -N (флаги событий в реальном времени или пачками — слой буферизации локализуется по hop'ам — диагностика в миниатюре).

Как ограничить число SSE-клиентов и не уронить сервер? Слои: учёт соединений (in-memory счётчик/метрика — алерт по ёмкости), отказ при лимите (503 + Retry-After — клиент реконнектится позже — честнее зависания), лимит на пользователя (3 соединения — «вкладки плодятся, сервер платит» — дедупликация по user_id с закрытием старых), K8s-скейл по connections-метрике (HPA — CPU-метрика для SSE бесполезна — соединения почти не жгут CPU — кастомная метрика обязательна), fd-лимиты ОС (ulimit -n — 10k+ соединений — дефолтные 1024 кончатся мгновенно — системные лимиты); graceful-деплой с retry-джиттером (реконнект-шторм 5000 клиентов одновременно — пик нагрузки на старт — разброс retry гасит).

Нужен ли SSE для стриминга ответов LLM? Де-факто стандарт: OpenAI-совместимые API стримят токены SSE (stream: true — data-события на токен — LLM-интеграции) — клиентский код EventSource/fetch-stream универсален; альтернативы — WebSocket (двунаправленность для интерактивных агент-сессий оправдана — прерывание генерации потоком в обе стороны) и fetch+ReadableStream (без EventSource-обёртки — гибче заголовки/авторизация — авторизационное ограничение EventSource обходится именно так); для своего LLM-бэкенда (локальные модели) — SSE совместимость с экосистемным клиентским кодом — аргумент сам по себе; таймауты длинные (генерация минутами — прокси-настройки как выше), heartbeat при паузах модели (долгий «думающий» этап без токенов — пинг держит соединение).

Источники: спецификация WHATWG (server-sent events), MDN EventSource, документация FastAPI — ссылки в шапке.