Real-Time Backend

Connection Lifecycle

Discord обслуживает 19 миллионов одновременных пользователей. Когда узел перезагружается - миллионы клиентов должны переподключиться за секунды, не за часы. Это возможно только если lifecycle соединения спроектирован правильно с самого начала.

  • Socket.io использует exponential backoff с randomizationFactor 0.5 по умолчанию - именно поэтому переподключение после сбоя выглядит плавным а не волной
  • Discord Gateway посылает heartbeat каждые 41 250 мс + случайный jitter - без этого миллионы клиентов синхронизировались бы и одновременно нагружали сервер
  • Kubernetes rolling deploy посылает SIGTERM подам за 30 секунд до удаления - именно этот grace period используют WebSocket-серверы для DRAIN pattern
  • Slack хранит буфер пропущенных событий в Redis: если клиент переподключился в течение 2 минут - он получает дельту, иначе полный snapshot канала

Reconnection

WebSocket-соединение живёт от нескольких секунд до нескольких дней - и всё это время оно может оборваться. Мобильный клиент теряет Wi-Fi, балансировщик перезагружается, сервер падает. Без автоматического переподключения пользователь просто видит «заглушку» и уходит. Переподключение - это не nice-to-have, это базовый контракт realtime-приложения.

WebSocket-хендшейк - обычный HTTP Upgrade: браузер шлёт `GET /ws` с заголовками `Upgrade: websocket` и `Sec-WebSocket-Key`, сервер отвечает `101 Switching Protocols`. После этого TCP-соединение переключается в двунаправленный режим. Разрыв происходит когда TCP-стек перестаёт получать ACK от другой стороны - это может занять от секунды (активный RST) до нескольких минут (таймаут keepalive).

Причина разрыва важна: `io server disconnect` означает что сервер намеренно закрыл соединение (например, невалидный токен). В этом случае автоматический reconnect бессмысленен - нужно показать пользователю ошибку или обновить credentials.

После разрыва WebSocket-соединения клиент получил reason='io server disconnect'. Что должна делать клиентская логика?

Backoff Jitter

Сценарий: сервер упал, 50 000 клиентов получили disconnect одновременно. Без jitter все они попытаются переподключиться через одну и ту же паузу - и накроют сервер волной в момент когда он только поднялся. Это называется thundering herd. Exponential backoff + jitter решают это: каждый клиент ждёт случайное время в диапазоне [0, min(cap, base * 2^attempt)].

Discord использует heartbeat с периодом 41 250 мс плюс случайный jitter до 2 500 мс. Это не произвольные числа - период рассчитан так чтобы шлюз успевал обработать миллионы соединений без пиков нагрузки. Если heartbeat не получил ACK в течение одного периода - соединение считается мёртвым и начинается reconnect.

  • **Full Jitter** - равномерное распределение в [0, cap], минимальная нагрузка на сервер при массовом reconnect
  • **Equal Jitter** - распределение в [cap/2, cap], гарантирует минимальную паузу и лучший P99
  • **Decorrelated Jitter** - следующая пауза = random(base, prev*3), эффективно ломает корреляцию между клиентами

AWS рекомендует Full Jitter для большинства retry-сценариев. Decorrelated Jitter чуть лучше распределяет нагрузку но сложнее в реализации и отладке.

10 000 клиентов одновременно потеряли соединение. Какой подход к reconnect минимизирует thundering herd эффект?

State Recovery

Переподключение восстанавливает TCP-соединение, но не состояние приложения. За время разрыва сервер мог отправить 50 сообщений в чат, обновить счёт игры, изменить позиции курсоров. Клиент не знает что пропустил. Правильная state recovery - это не просто «снова подключился», это «подключился и получил всё что пропустил».

Socket.io с версии 4.6 поддерживает connection state recovery из коробки: сервер хранит буфер events в памяти (или Redis) и при reconnect в течение заданного окна (по умолчанию 2 минуты) автоматически реплеит пропущенное. Клиент получает `socket.recovered = true` если восстановление прошло успешно.

  1. Клиент сохраняет `lastEventId` локально (localStorage, IndexedDB)
  2. При reconnect передаёт `lastEventId` в auth или query params
  3. Сервер проверяет: событие есть в буфере - шлёт дельту; буфер протух - шлёт snapshot
  4. Клиент применяет дельту идемпотентно (дубликаты должны игнорироваться)

Клиент переподключился после 10 минут разрыва. Сервер хранит буфер events за последние 2 минуты. Что должен сделать сервер?

Graceful Disconnect

Есть два способа закрыть WebSocket: корректный (graceful) и аварийный. Корректный - это WebSocket Close Frame с кодом (1000 = нормальное закрытие, 1001 = сервер уходит, 1008 = политика) + TCP FIN. Аварийный - TCP RST без предупреждения, или просто таймаут без каких-либо сигналов. Клиент ведёт себя по-разному в зависимости от того какой сигнал получил.

TCP FIN - вежливое завершение: «я закончил отправку, но могу ещё принимать». TCP RST - аварийное: «немедленно закрыть, все данные потеряны». WebSocket Close Frame летит внутри TCP перед FIN - это уровень приложения поверх транспортного. Если сервер просто убить (`kill -9`), клиент получит RST и должен начать reconnect.

  • **Close code 1000** - нормальное закрытие, reconnect не нужен
  • **Close code 1001** - сервер уходит (deploy/restart), клиент должен переподключиться
  • **Close code 1008** - нарушение политики (невалидный токен), reconnect без обновления credentials бесполезен
  • **Close code 1011** - внутренняя ошибка сервера, можно переподключиться с backoff
  • **Нет Close Frame (RST/timeout)** - аварийный разрыв, немедленный reconnect с backoff

DRAIN pattern - стандарт для zero-downtime деплоев: балансировщик (nginx, HAProxy) посылает SIGTERM воркеру, воркер уведомляет клиентов и ждёт grace period (5-30s) прежде чем закрыть соединения. За это время клиенты успевают переподключиться к другому узлу без заметного разрыва.

Graceful shutdown - это просто `server.close()` которое перестаёт принимать новые соединения

Graceful shutdown для WebSocket включает три фазы: уведомление клиентов, grace period для переподключения, и только потом закрытие оставшихся соединений

`server.close()` останавливает новые соединения но не трогает существующие - они продолжают висеть. Без явного уведомления клиенты не знают что надо переподключиться к другому узлу. DRAIN pattern решает именно это: даёт клиентам время и информацию для корректной миграции.

Сервер получил SIGTERM перед деплоем. Какой порядок действий обеспечит zero-downtime для WebSocket-клиентов?

Итоги

  • **Reconnection не автоматический** - нужно различать server-initiated disconnect (ошибка credentials) и transport-level разрыв (сеть), логика переподключения разная
  • **Thundering herd убивает только что поднявшийся сервер** - exponential backoff с Full Jitter превращает spike из 50 000 одновременных reconnect в равномерную нагрузку на 30 секунд
  • **State recovery = lastEventId + буфер на сервере** - клиент сохраняет позицию, сервер решает: дельта (если в буфере) или snapshot (если буфер протух)
  • **DRAIN pattern для zero-downtime** - SIGTERM, уведомить клиентов, grace period 5-30s, закрыть с Close Frame 1001

Связанные темы

Connection Lifecycle строится поверх нескольких фундаментальных тем:

  • WebSocket Protocol — Handshake и Close Frame - это детали протокола на уровне RFC 6455
  • Масштабирование WebSocket — DRAIN pattern и state recovery критичны при горизонтальном масштабировании с несколькими узлами
  • Heartbeat и Keepalive — Heartbeat обнаруживает мёртвые соединения которые запускают reconnect-логику

Вопросы для размышления

  • Как бы изменилась стратегия reconnect если приложение работает в условиях нестабильного мобильного интернета где разрывы случаются каждые несколько минут?
  • Какие данные стоит включать в state recovery snapshot а какие лучше запрашивать отдельным REST-запросом после reconnect?
  • Как DRAIN pattern совместить с sticky sessions когда клиент жёстко привязан к конкретному узлу через балансировщик?

Связанные уроки

  • net-16-tcp-flow
Connection Lifecycle

0

1

Войти