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` если восстановление прошло успешно.
- Клиент сохраняет `lastEventId` локально (localStorage, IndexedDB)
- При reconnect передаёт `lastEventId` в auth или query params
- Сервер проверяет: событие есть в буфере - шлёт дельту; буфер протух - шлёт snapshot
- Клиент применяет дельту идемпотентно (дубликаты должны игнорироваться)
Клиент переподключился после 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 когда клиент жёстко привязан к конкретному узлу через балансировщик?