Realtime: WebSockets, SSE и long-polling

WebSocket, SSE, long и short polling на одном стенде с серверами на Go и Java за Angie и шиной Redis или NATS: сколько байт стоит событие, что прокси делает с длинным соединением, доходит ли событие до клиента на другом инстансе, что теряется при разрыве и почему буферизация прокси прячет от сервера медленного клиента. Всё — счётом исходов, без замеров задержки

«Нужен realtime» почти всегда значит разное. Одному хватит обновления счётчика раз в несколько секунд, другому нужен двусторонний канал, по которому события идут без паузы. Выбор между WebSocket, Server-Sent Events и опросом обычно обсуждают как вопрос вкуса. Здесь он решается счётом: сколько стоит событие на проводе, что с соединением делает прокси, что происходит, когда инстансов больше одного, что теряется при разрыве и что сервер делает с клиентом, который читает медленнее, чем идут события.

Всё это проверено на стенде с двумя серверами — на Go и на Java — с одним и тем же протоколом. Совпадение двух реализаций повышает уверенность, что результат не специфичен для одной из них; доказательством того, что он про транспорт, оно не служит — у реализаций общие предпосылки и похожая прикладная логика. Расхождение — тоже находка, и таких в статье несколько.

Задержек в статье нет ни одной. На ноутбуке с Docker Desktop они меряют ноутбук; вместо них — «дошло или нет», «сколько байт», «сколько потеряно».

Ретрофутуристский инженерный лист: станция пневмопочты в разрезе. Слева два одинаковых диспетчерских зала «инстанс A» и «инстанс B», над ними подвесной рельс «шина» везёт латунные капсулы из зала в зал; в залах операторы, лотки пусты, стрелки циферблатов на стенах — внизу шкалы. Прозрачные трубы идут к будке «прокси» посередине; за ней высокая башня-склад до потолка забита капсулами — единственное тесное место на картинке. Справа два окна получателей: в верхнее, «клиент», капсулы приходят ровным потоком; в нижнее, «медленный клиент», — по одной, человек неторопливо читает капсулу рядом с песочными часами, а всё, что ему предназначено, застряло в складе за прокси

digital-cookbook/architecture/realtime

В статье

  • четыре способа доставки и сколько каждый платит за событие на проводе;
  • когда realtime не нужен вовсе и опрос — законный выбор;
  • что делает прокси с длинным соединением и где буферизация на самом деле мешает;
  • что меняется, когда инстансов больше одного: шина и привязка клиента;
  • что теряется при разрыве и как это догоняют;
  • медленный клиент и очередь на сервере;
  • сколько памяти стоит простаивающее соединение у Go и Java;
  • чего эти числа не значат.

Стенд: два сервера, один протокол

Серверы отвечают на одни и те же маршруты: /sse — поток text/event-stream, /ws — WebSocket, /poll?after=N — короткий опрос, /longpoll — длинный, /publish — опубликовать событие. Go-сервер написан на net/http и coder/websocket, Java-сервер — на Spring Boot 4.1.1 с WebFlux поверх Netty. Событие — JSON с номером, темой и полезной нагрузкой; номер задаёт издатель, поэтому пропуски и дубли у клиента видны по номерам.

У номера есть обязательный контракт, без которого догон из раздела про разрывы не работает: номера возрастают в порядке доставки внутри темы. Серверы отбрасывают событие с номером не больше уже отданного, а Redis Streams с явными номерами записей отклоняет запись с меньшим номером (XADD). Уникальности мало: если два издателя независимо выдали 101 и 102, а клиент увидел сначала 102, то 101 потеряется. На стенде издатель один и публикует последовательно. При нескольких издателях курсор должен быть порядком записи в журнал — номером записи в потоке, — а не бизнес-идентификатором, назначенным снаружи.

Что протокол действительно один, проверяет отдельный опыт: одинаковый набор запросов к обоим серверам обязан дать одинаковые наблюдения — коды ответов, заголовки, порядок событий. Он даёт; расхождения, о которых пойдёт речь ниже, появляются только там, где поведение задаёт библиотека.

Между клиентом и сервером — Angie 1.12.2 с отдельным конфигом на каждый вариант. Шина между инстансами — Redis 8.8 или NATS 2.14. Клиент-проба на Go реализует используемый стендом поднабор SSE: чтение событий с числовыми идентификаторами и передачу Last-Event-ID при повторном подключении, — и умеет считать байты через собственный TCP-прокси. Это не браузерный EventSource: поле retry проба не обрабатывает, переподключение в опытах она делает по команде сценария, а не сама.

Четыре способа и цена события

Short polling — клиент спрашивает «что нового» по таймеру. Каждый опрос — полноценный HTTP-запрос и ответ, даже если нового нет.

Long polling — запрос висит на сервере, пока не появится событие или не истечёт срок; получив ответ, клиент сразу задаёт следующий. Это realtime поверх обычного HTTP, но запрос и ответ со всеми заголовками приходятся на каждое событие или пачку событий.

Server-Sent Events — один HTTP-ответ, который не заканчивается: сервер дописывает в него события в текстовом формате id: … / data: …. Канал односторонний, от сервера к клиенту, а переподключение с номером последнего события браузер делает сам (спецификация).

WebSocket — после HTTP-рукопожатия с Upgrade: websocket соединение становится двусторонним каналом кадров (RFC 6455). Заголовок кадра от сервера — от двух байт.

Постановка. Клиент подключается к серверу через считающий TCP-прокси, издатель публикует мимо прокси, поэтому в счёт идут только байты транспорта клиента. «Байты на проводе» здесь — байты прикладного TCP-потока, HTTP/1.1 без TLS: заголовки TCP/IP, подтверждения и повторные передачи пакетов счётчик не видит. Событие раз в 100 мс, полезная нагрузка 64 байта. Цена события — разностью двух прогонов, на 20 и на 120 событий: рукопожатие и заголовки потока одинаковы в обоих и в разности сокращаются.

способ байт от сервера на событие, Go / Java байт от клиента на событие, Go / Java запросов на событие, Go / Java
SSE 132 / 130 0 / 0 0 / 0
WebSocket 113 / 113 0 / 0 0 / 0
long polling 247 / 211 120 / 120 1 / 1
опрос раз в 50 мс 388 / 320 226 / 232 2,05 / 2,11
опрос раз в 500 мс 141 / 133 23 / 23 0,21 / 0,21

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

Само событие в JSON — около 110 байт. WebSocket добавляет к нему только заголовок кадра, SSE — строки id: и data: и разделители. Разница между ними — около двадцати байт на событие, и ради неё выбирать транспорт не стоит.

Long polling платит запросом на каждое событие: 120 байт заголовков от клиента и ответ со своими заголовками от сервера. Здесь серверы расходятся: Java отдаёт ответ короче на 36 байт — меньше заголовков. На порядок это не влияет.

Short polling платит не за событие, а за опрос. При опросе раз в 50 мс и событии раз в 100 мс на каждое событие приходится больше двух запросов и около одного пустого ответа. При опросе раз в 500 мс картина обратная: за один ответ приходит пачка из пяти событий, и цена события почти как у SSE. Платят за это задержкой: событие ждёт следующего опроса, до полусекунды. Числа опроса слегка гуляют от прогона к прогону — сколько опросов придётся на прогон, зависит от того, как легли таймеры, — поэтому в сравнение сессий они не входят.

Сжатие зависит от реализации больше, чем от транспорта

Оба транспорта умеют сжимать: SSE — обычным gzip ответа, WebSocket — расширением permessage-deflate (RFC 7692). Gzip потока сжимает его с общим словарём всегда; у permessage-deflate общий словарь между сообщениями — согласуемый параметр (context takeover), и любая сторона может от него отказаться, тогда каждое сообщение сжимается с нуля. На стенде общий словарь согласован в обоих серверах, и повторяющиеся куски JSON у соседних событий должны сжиматься почти в ничто.

вариант байт на событие, Go Java
SSE без сжатия 132,4 130,4
SSE + gzip 79,5 25,4
SSE + gzip уровня 9 25,0 25,4
WebSocket без сжатия 113,2 113,2
WebSocket + deflate 113,2 13,5
WebSocket + deflate, порог 1 байт 71,9 13,5

У Java сжатие работает, как ожидалось: 25 байт на событие у SSE и 13,5 у WebSocket. У Go — две разные причины плохого результата.

Первая — порог. coder/websocket по умолчанию не сжимает сообщения короче 128 байт при общем словаре — так написано в документации к CompressionThreshold. Событие стенда короче, и «WebSocket + deflate» у Go ничем не отличается от варианта без сжатия: расширение согласовано, но не применяется.

$ python3 scripts/run-wire.py go ws-deflate
{"variant": "go/ws-deflate", "outcome": "delivered", "down_per_event": 113.2, "up_per_event": 0.0, "requests_per_event": 0.0, "empty_per_event": 0.0}
$ python3 scripts/run-wire.py java ws-deflate
{"variant": "java/ws-deflate", "outcome": "delivered", "down_per_event": 13.5, "up_per_event": 0.0, "requests_per_event": 0.0, "empty_per_event": 0.0}

Вторая причина видна, если порог снять: с порогом в 1 байт Go сжимает событие до 72 байт, а не до 14. Та же картина у gzip потока SSE — 80 байт против 25. Это свойство compress/flate стандартной библиотеки Go, и стенд проверяет его отдельной пробой: то же сообщение, сброс сжатия после каждого, три уровня.

$ python3 scripts/run-flate.py
{"kind": "flate", "level": 1, "bytes_per_message": 76.1, "raw": 111, "cmd": "python3 scripts/run-flate.py"}
{"kind": "flate", "level": -1, "bytes_per_message": 76.1, "raw": 111, "cmd": "python3 scripts/run-flate.py"}
{"kind": "flate", "level": 9, "bytes_per_message": 15.2, "raw": 111, "cmd": "python3 scripts/run-flate.py"}

На уровне по умолчанию (-1) и на быстром (1) сжатие при частых сбросах почти не находит повторов с прошлыми сообщениями — 76 байт из 111. На уровне 9 находит — 15 байт. Go-сервер с gzip уровня 9 даёт ровно то же, что Java: 25 байт на событие. Почему уровни по умолчанию так ведут себя при сбросе после каждого короткого сообщения — объяснение по коду библиотеки, отдельным замером оно не проверено; проверено следствие.

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

Когда realtime не нужен

Если событие нужно показать «скоро», а не «сразу», опрос — законный выбор. Опрос раз в полминуты стоит один запрос в полминуты на клиента, не держит соединение и проходит через любой прокси и кэш. Насколько он прост дальше, зависит от того, что опрашивают.

Опрос текущего состояния позволяет пропускать промежуточные изменения: клиенту нужно последнее значение, а не история. Ему не нужны ни шина, ни heartbeat, ни догон после разрыва. Так устроена плашка «Доступна новая версия сайта» в истории про VOG: вкладке нужно знать текущую версию, а не каждую выкладку.

Опрос журнала событий — как /poll?after=N на стенде — сохраняет все требования длинного соединения к истории и восстановлению пропусков. Если каждое событие нужно доставить, серверу нужен журнал, клиенту — курсор, а инстансам — общий источник событий, и частота опроса этого не меняет.

Длинное соединение окупается, когда событий много, задержка опроса заметна пользователю или опрос раз в N секунд по тысячам клиентов уже нагружает сервер сильнее, чем открытые соединения. Всё, что дальше, — цена этого выбора.

Прокси между клиентом и сервером

Постановка. Три опыта через Angie, каждый против Go и Java, с контролем без прокси:

  • пять событий SSE; сколько клиент получил за 2 секунды, пока поток открыт — счёт до закрытия, иначе прокси отдал бы буфер при закрытии и «доставлено» было бы ложью;
  • рукопожатие WebSocket;
  • соединение простаивает 6 секунд при proxy_read_timeout 3s (по умолчанию — 60 секунд, укорочено, чтобы опыт уложился в прогон); затем одно событие.
опыт вариант конфига Go Java
SSE: событий в окне без прокси (контроль) все 5 в окне все 5 в окне
буферизация по умолчанию все 5 в окне все 5 в окне
буферизация и gzip все 5 в окне все 5 в окне
proxy_buffering off все 5 в окне все 5 в окне
рукопожатие WS без прокси (контроль) прошло прошло
Upgrade и Connection проксируются прошло прошло
не проксируются отказ, 426 отказ, 400
простой WS без прокси (контроль) живо живо
таймаут 3 с, без heartbeat закрыто закрыто
таймаут 3 с, heartbeat раз в 1 с живо живо
простой SSE без прокси (контроль) живо живо
таймаут 3 с, без heartbeat закрыто закрыто
таймаут 3 с, heartbeat раз в 1 с живо живо

Гипотеза о буферизации не подтвердилась. Записанное до прогона ожидание было привычным: буферизация ответа в прокси держит события SSE, пока буфер не наполнится, и proxy_buffering off или заголовок X-Accel-Buffering: no — обязательная часть настройки. На стенде Angie с буферизацией по умолчанию доставил все пять событий в окне при открытом потоке — с gzip и без; пробой при разборе оси то же проверено на nginx 1.27.5. В этом опыте буферизация не помешала доставить события в окне. Задержку опыт не мерил: он показывает, что прокси не удержал события до закрытия ответа, но не то, что он отдал их без добавочной задержки, — и документация nginx одинаковой немедленной доставки с буферизацией и без неё не обещает. Отдельно обнаружилось другое последствие буферизации — в разделе про медленного клиента: прокси скрывает его от сервера.

WebSocket без проксирования заголовков не проходит рукопожатие. Upgrade и Connection — заголовки «на один пролёт», и прокси их не передаёт, пока не сказано явно:

location /ws {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;  # map: "" → close, иначе upgrade
}

Без двух последних строк сервер получает обычный GET и отказывает. Серверы отказывают по-разному: Go — статусом 426 Upgrade Required, Java — 400. Если в логах клиента одно, а в логах другого сервиса другое, причина одна и та же.

$ python3 scripts/run-proxy.py go handshake no-upgrade
{"variant": "go/handshake/no-upgrade", "outcome": "refused", "detail": "рукопожатие: статус 426"}

Простой дольше таймаута прокси рвёт соединение — и WS, и SSE. Angie закрывает соединение, по которому сервер ничего не писал дольше proxy_read_timeout. Для WebSocket это закрытие без кадра закрытия: клиент видит обрыв TCP, а не код 1000 или 1001, и отличить его от падения сервера по коду не может.

$ python3 scripts/run-proxy.py go idle ws short-idle
{"variant": "go/idle/ws/short-idle", "outcome": "closed", "detail": "закрыто во время простоя: eof"}
$ python3 scripts/run-proxy.py go idle ws short-idle-hb
{"variant": "go/idle/ws/short-idle-hb", "outcome": "alive", "detail": "после простоя событие дошло"}

Heartbeat чаще таймаута лечит оба транспорта: у WebSocket это кадр ping, у SSE — строка-комментарий : heartbeat, которую клиент не видит. Интервал heartbeat выбирают по самому короткому таймауту простоя на пути — прокси, балансировщика облака, корпоративного прокси клиента, — а не по своему.

Больше одного инстанса

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

Постановка. Два инстанса одного сервера за Angie, клиенты подключаются через него и расходятся по инстансам; каждый знает свой инстанс по заголовку X-Instance. Двадцать клиентов SSE или WebSocket, десять событий. Событие публикуется в один инстанс — как событие, пришедшее обработчику на нём; дальше его разносит шина или никто.

шина Go, SSE Go, WS Java, SSE Java, WS
нет только клиенты инстанса A только клиенты инстанса A только клиенты инстанса A только клиенты инстанса A
Redis pub/sub все 20 все 20 все 20 все 20
NATS все 20 все 20 все 20 все 20

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

Без шины событие получают ровно клиенты инстанса, в который его опубликовали, а клиенты второго — ни одного. Сколько клиентов окажется на каждом инстансе, зависит от рабочих процессов Angie — у каждого свой счётчик очереди, — поэтому доля от прогона к прогону разная, а сам исход нет. Шина делает из «события на инстансе» событие для всех: каждый инстанс подписан на неё и раздаёт событие своим клиентам. Шина здесь — именно шина: брокер с гарантиями доставки не нужен, нужна рассылка всем подписчикам. Чем различаются брокеры в остальном, разобрано в сравнении RabbitMQ, Kafka, NATS и Redis.

Привязка клиента к инстансу нужна не всем

Sticky sessions часто называют обязательной частью realtime за балансировщиком. Для WebSocket и SSE это не так: соединение одно и уже живёт на одном инстансе, балансировщик его не перекидывает. Привязка нужна, когда состояние клиента лежит в памяти инстанса между запросами — как курсор long polling.

Постановка. Десять клиентов long polling, двадцать событий; курсор — номер последнего отданного события — сервер хранит в памяти по cookie sid. Шина Redis, чтобы события были на обоих инстансах и пропуски или дубли шли только от курсора.

$ python3 scripts/run-scale.py go longpoll scale
{"variant": "go/longpoll/scale", "outcome": "partial", "detail": "пропусков 0, дублей 175; чистых клиентов 0 из 10"}
$ python3 scripts/run-scale.py go longpoll sticky
{"variant": "go/longpoll/sticky", "outcome": "all", "detail": "у всех 10 клиентов ни пропусков, ни дублей"}

Без привязки запросы клиента чередуются между инстансами, и каждый раз клиент попадает на курсор, отставший на пару событий: дубли у всех десяти клиентов. С привязкой по hash $cookie_sid consistent — ни одного. У Java то же. Лечит это и другое решение: курсор присылает сам клиент, как after=N у short polling, и тогда инстансу помнить нечего.

Разрыв и догон

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

Разрыв подписки инстанса на шину

Постановка. Между инстансом A и шиной стоит рвущий TCP-прокси пробы; инстанс B публикует в шину напрямую. Пять событий до разрыва, пять во время, пять после восстановления. События разрыва публикуются, только когда A сам сообщил, что подписка порвана, и он сообщает это и после публикации — иначе «потерь нет» могло бы значить «разрыва не было».

шина потеряно из 5 событий разрыва, Go Java
Redis pub/sub 5 5
Redis Streams 0 0
NATS core 5 5
NATS JetStream 0 0
$ python3 scripts/run-reconnect.py go gap redis
{"variant": "go/gap/redis", "outcome": "lost", "detail": "потеряно 5, из них в разрыв 5 из 5"}
$ python3 scripts/run-reconnect.py go gap jetstream
{"variant": "go/gap/jetstream", "outcome": "no-loss", "detail": "все 15 дошли, в том числе 5 из разрыва"}

Pub/sub Redis и core NATS доставляют «не более одного раза»: что опубликовано, пока подписчика нет, ему не достанется никогда. Клиентские библиотеки переподключаются и переподписываются сами, но догнать прошлое им неоткуда. Redis Streams и JetStream — журналы: подписчик помнит последний прочитанный номер и после восстановления дочитывает с него. Как JetStream устроен в остальном, разобрано в статье про JetStream.

Потеря на шине теряет событие сразу для всех клиентов инстанса — тихо: ни клиент, ни сервер ошибки не видят.

Граница результата: проверено восстановление после разрыва подписки при сохранной истории. Перезапуск брокера опыт не проверяет — и на стенде JetStream держит поток в памяти (MemoryStorage), такой журнал при перезапуске сервера NATS пропадает (документация NATS); для этого нужен поток в файловом хранилище. Поведение при истёкшей истории опыт тоже не проверяет.

Клиент возвращается с Last-Event-ID

SSE-клиент после обрыва переподключается сам и присылает номер последнего события в заголовке Last-Event-ID. Это не гарантия догона, а просьба: выполнить её сервер может, только если ему есть откуда взять пропущенное.

Постановка. Клиент получает пять событий, рвётся, пока публикуются ещё пять, возвращается с Last-Event-ID: 5 и ждёт последние пять. Ещё три варианта проверяют то, что ломается чаще всего.

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

история на сервере потеряно из 10 событий после разрыва дублей
нет 5 0
Redis Streams 0 0
JetStream 0 0
Redis Streams, публикация на барьере 0 0
JetStream, публикация на барьере 0 0
контроль: подписка после истории 5 0
Redis Streams, чтение истории падает отказ: 503 0

Без истории заголовок приходит, но сервер ничего не может с ним сделать. С историей — дочитывает из журнала и только потом переходит к живым событиям. На стыке ни пропусков, ни дублей: подписка на живые события встаёт раньше чтения истории, и всё, что пришло за время чтения и ожидания, копится в очереди соединения; повторы, которые есть и в истории, и в очереди, сервер отсекает по номеру — потому номерам и нужен порядок. Контроль с подпиской после истории теряет все пять событий барьера: окно между чтением журнала и подпиской — ровно то место, где теряется событие, если порядок перепутан.

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

Отказ истории — случай, на котором первая версия стенда ошибалась. Go-сервер игнорировал ошибку чтения журнала и продолжал живой поток: клиент, вернувшийся с Last-Event-ID: 5, получил бы событие 11 и сдвинул курсор за пропуск, и следующее переподключение пропуск уже не вернуло бы. Java при той же ошибке обрывала поток. Ошибку нашла рецензия по коду; теперь оба сервера читают историю до начала ответа и при ошибке отвечают 503 — клиент знает, что догона не было, и может повторить позже или запросить полное состояние:

$ python3 scripts/run-reconnect.py go resume redis-streams-history-error
{"variant": "go/resume/redis-streams-history-error", "outcome": "refused", "detail": "догон отказан (503); событий после разрыва дошло: 0"}

Живой поток после неудачного чтения истории нельзя выдавать за успешный догон — ни при истёкшей истории, ни при временно недоступной. У WebSocket встроенного Last-Event-ID нет — тот же протокол догона пишут сами.

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

Шторм переподключений

Когда инстанс перезапускается, все его клиенты теряют соединение разом — и разом пытаются вернуться.

Постановка. Двести клиентов WebSocket на одном инстансе, инстанс перезапускается. Пауза перед переподключением — 200 мс плюс случайная добавка до джиттера: 0 или 2000 мс. Счёт — по корзинам в 100 мс от первого разрыва.

$ python3 scripts/run-reconnect.py go storm 0
{"variant": "go/storm/jitter-0ms", "outcome": "burst", "detail": "в худшей корзине 100 мс — 200 переподключений из 200"}
$ python3 scripts/run-reconnect.py go storm 2000
{"variant": "go/storm/jitter-2000ms", "outcome": "spread", "detail": "в худшей корзине 100 мс — 18 из 200, корзин с переподключениями 21"}

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

Медленный клиент

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

Постановка. Рядом два клиента одной темы: быстрый и медленный (приёмный буфер 4 КБ, пауза 5 мс после каждого события). Издатель отправляет 3000 событий по 1 КБ подряд — заведомо больше, чем помещается в буферы сокетов, иначе очередь сервера «не росла» бы из-за ядра. Политики очереди при длине 256: без границы, вытеснение старых, отключение медленного. Счёт снимается со стороны сервера.

транспорт, политика напрямую Angie с буферизацией Angie без буферизации
SSE, без границы очередь росла не заметил очередь росла
SSE, вытеснение выбрасывал не заметил выбрасывал
SSE, отключение отключил не заметил отключил
WS, без границы очередь росла очередь росла очередь росла
WS, вытеснение выбрасывал выбрасывал выбрасывал
WS, отключение отключил отключил отключил

Строки одинаковы у Go и Java. Контроль сошёлся: быстрый клиент в каждом варианте получил все события.

Напрямую каждая политика делает своё: очередь без границы растёт до тысяч событий на одно соединение, вытеснение держит её на 256 и выбрасывает старое, отключение рвёт медленного, как только очередь заполнена. Быстрый сосед ничего из этого не замечает.

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

Главная находка оси — SSE через Angie с буферизацией по умолчанию. Сервер медленного клиента не замечает вовсе: очередь не выше трёх событий, и ни одна политика не срабатывает.

$ python3 scripts/run-slow.py go sse disconnect buffered
{"variant": "go/sse/disconnect/buffered", "outcome": "unnoticed", "detail": "сервер не заметил медленного: очередь не выше 2"}
$ python3 scripts/run-slow.py go sse disconnect unbuffered
{"variant": "go/sse/disconnect/unbuffered", "outcome": "disconnected", "detail": "медленный отключён сервером (очередь до 256)"}

С буферизацией прокси читает ответ сервера так быстро, как сервер пишет, и копит то, что клиент не успел забрать, — в памяти, а сверх буферов во временном файле; по документации до гигабайта на ответ по умолчанию. Для сервера медленного клиента больше нет: все его политики про медленных клиентов молча выключены, а память и диск расходует прокси. Это проверенная стендом причина ставить для SSE proxy_buffering off или X-Accel-Buffering: no: чтобы обратное давление доходило до сервера.

WebSocket через тот же Angie ведёт себя как напрямую: после рукопожатия прокси пересылает кадры как туннель, и буферизация ответа к нему не применяется.

Сколько стоит простаивающее соединение

Единственная величина в статье, а не исход, — и с оговорками.

Постановка. Сервер без шины; базовый замер памяти — дважды, после принудительной сборки мусора; затем проба открывает 5000 простаивающих соединений и держит их; замер — снова после сборки. Память на соединение — прирост RSS процесса и прирост кучи, делённые на число соединений. Куча у двух рантаймов меряется разными метриками: у Go — HeapInuse из runtime.MemStats (память занятых спанов кучи, включая свободное место внутри них), у Java — MemoryUsage.getUsed() кучи JVM. Это разные величины, и строку «куча» между Go и Java напрямую не сравнивают. JVM — с фиксированной кучей 256 МБ.

Go, WS Go, SSE Java, WS Java, SSE
RSS на соединение, КБ 32,7 34,3 12,5 27,3
куча на соединение, КБ (Go — HeapInuse, Java — heap used) 17,4 20,7 10,1 18,6

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

Числа — одной сессии. Между сессиями у Go они держатся в пределах нескольких процентов, а у Java RSS на WebSocket-соединение гулял заметнее — от 12 до 17 КБ на соединение; поэтому сравнивать здесь стоит порядок, а не десятые доли.

По приросту RSS в этом опыте WebSocket у Java в два с лишним раза дешевле, чем у Go, а SSE у Java примерно вдвое дороже её же WebSocket. Почему — гипотеза по устройству серверов, опыт вклад каждого механизма не выделяет: у Go соединение держат горутины со своими стеками и буферы чтения и записи, Java на Netty держит его без отдельного потока, но с буферами вне кучи; SSE в WebFlux — HTTP-ответ, который не кончается, со своей обвязкой. RSS Java после сборки мусора не возвращает уже занятые страницы кучи, поэтому её прирост RSS — оценка сверху.

Память измерена без gzip и без permessage-deflate. Экономия трафика из раздела про сжатие с этими числами автоматически не складывается: общий словарь сжатия хранится на каждое соединение, и coder/websocket прямо предупреждает, что context takeover добавляет память на соединение.

Линейная иллюстрация, а не прогноз: сто тысяч таких соединений при тех же приростах заняли бы от 1,2 до 3,4 ГБ — порядок, при котором память на соединение начинает влиять на число инстансов. Под нагрузкой картина другая: очереди медленных клиентов из предыдущего раздела легко стоят больше, чем само соединение. Как устроены пулы исходящих соединений у тех же двух рантаймов, разобрано для Go и JVM.

В браузере

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

  • Авторизация. Нативный EventSource не умеет добавлять свои заголовки, и браузерный WebSocket API тоже: остаются cookie сессии, токен в адресе (попадает в логи прокси) или авторизация первым сообщением после подключения у WebSocket.
  • Несколько вкладок. Каждая вкладка открывает своё соединение. По HTTP/1.1 браузер держит ограниченное число соединений на домен, и несколько вкладок с SSE могут исчерпать его для всего сайта (MDN); по HTTP/2 потоки делят одно соединение. Одно соединение на все вкладки дают SharedWorker или выбор ведущей вкладки через BroadcastChannel.
  • Переподключение EventSource. Пока объект в состоянии CONNECTING, переподключением занимается браузер: выдерживает паузу из поля retry и сам присылает Last-Event-ID. Событие error при этом тоже приходит — оно сопровождает и штатное переподключение. Финальный ответ после обработки редиректов со статусом, отличным от 200, или с другим типом содержимого браузер считает окончательной ошибкой и переводит объект в CLOSED; ответ 204 — штатный способ сервера остановить переподключения (спецификация). Пересоздавать EventSource после CLOSED или нет — политика приложения, и новый объект начинает с пустым номером: накопленный курсор не переносится, а задать Last-Event-ID нативный конструктор не позволяет. Если нужен догон, приложение само хранит последний номер и передаёт его новым запросом способом, который понимает сервер, — например, параметром адреса. Проба стенда так себя не ведёт: её переподключения задаёт сценарий опыта.

Как выбирать

нужно выбор что настроить сразу
показать текущее состояние «скоро» short polling состояния интервал по допустимой задержке; ни истории, ни шины
доставить каждое событие опросом short polling журнала after=N от клиента; журнал, общий для инстансов
поток событий от сервера SSE heartbeat чаще таймаута прокси; proxy_buffering off; история для Last-Event-ID
двусторонний обмен WebSocket проксирование Upgrade/Connection; ping; свой протокол догона
совместимость с чем угодно long polling курсор от клиента или sticky
больше одного инстанса любой длинный шина; журнал вместо pub/sub, если потеря при разрыве недопустима
много клиентов любой длинный политика медленного клиента; джиттер переподключения

Для мобильных клиентов к этому добавляются фоновый режим и push-уведомления — отдельная тема серии про бэкенд для мобильныхСкоро.

Чего эти числа не значат

  • Ничего о задержке. Окна ожидания — постановка опыта, а не величина.
  • Одна машина: macOS arm64, Docker Desktop, сеть — мост Docker без потерь пакетов и без расстояния между узлами. Мобильная сеть рвёт соединения чаще и иначе.
  • Версии: Go 1.27.1, coder/websocket 1.8.15, Spring Boot 4.1.1 на Java 21, Angie 1.12.2, Redis 8.8, NATS 2.14. Порог сжатия и уровни compress/flate — свойства этих версий.
  • Байт на событие — для события в 64 байта полезной нагрузки. У крупных событий доля заголовков меньше, и разница между транспортами тает.
  • Медленный клиент проверен одним темпом чтения и одним размером события; где прокси перестанет копить и начнёт держать сервер, зависит от его буферов и лимита временных файлов.
  • Память на соединение — у простаивающего соединения после сборки мусора, у JVM с фиксированной кучей; под нагрузкой и с другими настройками GC картина другая.
  • Буферизация не держала события в окне на этих версиях Angie и nginx — это про доставку в окне, задержку опыт не мерил. Другие прокси, CDN и балансировщики облаков могут вести себя иначе — это проверяют своим опытом.

Выводы

  • Транспорт стоит выбирать по направлению потока и инфраструктуре, а не по байтам: SSE и WebSocket различаются на два десятка байт на событие.
  • Сжатие realtime-потока проверяют замером: у Go оно по умолчанию то не включается для коротких сообщений, то почти не сжимает.
  • Heartbeat чаще самого короткого таймаута простоя на пути — обязательная часть любого длинного соединения.
  • Буферизация прокси в опыте не помешала доставке SSE в окне, но спрятала от сервера медленного клиента — и с ним все политики обратного давления.
  • Больше одного инстанса — нужна шина; pub/sub теряет всё, что пришло во время разрыва подписки, журнал догоняет.
  • Last-Event-ID работает, только если серверу есть откуда взять историю, номера событий упорядочены, подписка встаёт раньше чтения журнала, а ошибка чтения журнала оборачивается отказом, а не продолжением потока.
  • Переподключение без случайной добавки к паузе возвращает всех клиентов в одну десятую секунды.

Ссылки

Обсуждение в Telegram

Присоединиться →

Комментарии