awesome-everything EN
↑ Обратно к восхождению

Сети и протоколы

Реконнект: jittered backoff, thundering herd, восстановление сообщений

Суть Как переподключаться без краша собственного сервера — jittered exponential backoff, message ID и гарантия доставки at-least-once с дедупликацией.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 12 min

Сервер перезапускается для деплоя. Все 10 миллионов подключённых клиентов мгновенно получают close-код 1006. Все повторяют попытку ровно через 1 секунду. Десять миллионов SYN-пакетов одновременно бьют в load balancer. Он рушится. Сервер так и не восстанавливается. WebSocket не имеет встроенного реконнекта — а значит, один наивный цикл retry отделяет вас от уничтожения собственного сервиса во время maintenance-окна.

Что происходит при разрыве

Когда WebSocket-соединение обрывается — сетевой глюк, перезапуск сервера, idle timeout прокси — клиент получает событие close с кодом 1006 (аномальное закрытие — close-фрейм не получен). Приложение должно решить: переподключаться ли, и если да — когда и как.

Сам WebSocket не предоставляет никакой логики реконнекта. Это полностью зона ответственности приложения.

Проблема thundering herd

Если каждый клиент повторяет попытку с одинаковым фиксированным интервалом после разрыва, retry приходят на сервер синхронизированными волнами:

  • T=0: сервис падает, 10 миллионов клиентов отключаются.
  • T=1с: все 10 миллионов клиентов повторяют попытку одновременно.
  • Сервер получает 10 миллионов SYN-пакетов за ~1 секунду.
  • SYN backlog заполняется. Новые соединения дропаются.
  • T=2с: все 10 миллионов снова повторяют попытку с тем же интервалом.
  • Сервер не получает тихого момента для восстановления.

Даже exponential backoff без jitter не помогает: синхронизированное удвоение всё равно создаёт синхронизированные волны.

Jittered exponential backoff

Решение — добавить случайность (jitter) к таймингу retry каждого клиента, чтобы 10 миллионов попыток размазались по окну в несколько секунд вместо одновременного прихода.

Алгоритм:

attempt = 0
base_ms = random(100, 500)      // разное для каждого клиента
max_ms  = 60_000

while not connected:
  delay = min(base_ms * 2^attempt, max_ms)
  delay = delay * random(0.5, 1.5)   // ±50% jitter
  sleep(delay)
  attempt += 1
  try_connect()

Пример разброса для перезапуска сервера с 100k клиентов:

  • Клиент A выбирает base=150 мс, jitter → retry через 186 мс
  • Клиент B выбирает base=340 мс, jitter → retry через 412 мс
  • Клиент C выбирает base=210 мс, jitter → retry через 288 мс

100k retry размазаны по 10+ секундам. Сервер обрабатывает их небольшими пакетами и восстанавливается за 1–2 retry-окна, а не рушится.

Параметры стратегии реконнекта
Рекомендуемый диапазон базовой задержки
100–500 мс (случайно для каждого клиента)
Коэффициент jitter
±50% от вычисленной задержки
Потолок максимальной задержки
30–60 секунд
Разброс retry для 10M клиентов с jitter
10+ секунд
Разброс retry без jitter (фиксированный 1с интервал)
< 1 секунды (thundering herd)
Библиотеки с jittered backoff по умолчанию
grpc-go, Phoenix, @tensorflow/tfjs

Восстановление сообщений и гарантия at-least-once

Реконнект восстанавливает соединение. Он не восстанавливает потерянные сообщения. Любые сообщения, отправленные сервером пока клиент был отключён — или отправленные клиентом, но не подтверждённые сервером — исчезают, если приложение их не отслеживает.

Паттерн:

  1. Каждое сообщение получает ID, сгенерированный клиентом. Отправитель держит сообщение в очереди retry до получения ACK для этого ID.
  2. Сервер ACK-ирует каждый message ID. Отправитель убирает подтверждённые сообщения из очереди retry.
  3. При реконнекте клиент переотправляет неподтверждённые сообщения. Сервер проверяет Redis stream или БД: если message ID уже существует — отправляет ACK клиенту, но не публикует повторно (предотвращает дубликаты для других подписчиков).

Это реализует at-least-once доставку с идемпотентной дедупликацией — ни одно сообщение не потеряно, ни одно не доставлено другим клиентам более одного раза.

Для дедупликации серверу нужно долговечное состояние, переживающее перезапуски. Типичное хранилище:

  • Redis stream (быстрый, постоянный, упорядоченный, с настраиваемым retention).
  • PostgreSQL (долговечный, с поиском, подходит для истории сообщений с требованиями аудита).

Поток реконнекта клиента:

1. Переподключиться с backoff.
2. После установки соединения отправить { type: "resume", lastSeenId: "msg-4291" }.
3. Сервер читает stream с msg-4292 и доставляет пропущенные сообщения.
4. Сервер ACK-ирует доставку.
5. Клиент подтверждает получение и обновляет lastSeenId.
Почему это работает

Почему не session-layer resumption на транспортном уровне? Некоторые протоколы (QUIC, MPTCP) обрабатывают миграцию соединения на транспортном уровне — приложение не знает о реконнектах. WebSocket сидит на TCP, у которого нет миграции. Собственный session layer (токен + Redis resume state) добавляет 50–100 мс задержки при реконнекте, но работает на любом TCP/TLS WebSocket-деплое. Выбор: простота + универсальность кастомного session layer vs. миграция на транспортном уровне от WebTransport/QUIC (более узкая поддержка браузеров и серверов по состоянию на 2026).

Проследи
1/5

Трассируйте каскадный отказ на WebSocket-сервере после кратковременного сетевого раздела — с jitter и без.

1
Step 1 of 5
1 Гбит/с канал глючит 5 секунд. Все 100 000 WebSocket-клиентов получают close-код 1006. Что видит сервер?
2
Locked
Без jitter: все 100k клиентов повторяют попытку с фиксированным интервалом 1 секунда. Что бьёт по серверу?
3
Locked
Сервер ещё запускается, когда приходит вторая синхронизированная волна (T=2с). Что происходит?
4
Locked
С jitter: клиенты выбирают base=100–500 мс, добавляют ±50% jitter, удваивают при каждой попытке. Опишите первые 10 секунд трафика реконнектов.
5
Locked
При реконнекте клиент отправляет resume-токен с lastSeenId=msg-4291. Redis stream сервера имеет msg-4292 по msg-4300. Что делает сервер?
Викторина

Вы проектируете load balancer перед WebSocket-кластером. Клиент отправляет сообщение серверу A, затем сеть разделяется и клиент переподключается к серверу B. Почему sticky session routing (клиент всегда идёт к одному серверу) безопаснее балансировки по запросу, даже если у серверов есть pub/sub?

Викторина

Мобильный WebSocket-клиент теряет связь, переподключается через 45 секунд и отправляет сообщение из очереди с message ID, на который ещё не получил ACK. Сервер имеет Redis stream с retention 24 часа. Каково правильное действие сервера?

Вспомните перед уходом
  1. 01
    Объясните, почему exponential backoff без jitter всё равно создаёт проблему thundering herd.
  2. 02
    Какое долговечное хранилище требуется на сервере для восстановления сообщений и почему оно должно переживать перезапуски?
  3. 03
    Опишите полный поток реконнекта с восстановлением сообщений: что отправляет клиент, что возвращает сервер и какая дедуп-проверка предотвращает дублирующую доставку?
Итог

WebSocket не имеет встроенного механизма реконнекта — close-код 1006 срабатывает и приложение должно решать что делать. Без jitter массовый разрыв вызывает thundering herd: все клиенты повторяют попытку с синхронизированными интервалами, создавая SYN-волны, не дающие серверу восстановиться. Jittered exponential backoff (base 100–500 мс на клиента, ±50% случайный jitter, удвоение при каждой попытке, потолок 30–60 с) размазывает 10 миллионов retry по секундам — стандартный подход во всех крупных WebSocket-библиотеках. Восстановление сообщений совмещает Redis stream (или БД) с client-generated message ID: отправитель держит неподтверждённые сообщения в локальной очереди, переотправляет при реконнекте, и сервер дедуплицирует проверкой stream перед публикацией, достигая at-least-once доставки без дубликатов. Sticky session routing помогает сохранить in-flight состояние на сервере, к которому клиент был подключён ранее, хотя полная долговечность требует вынесенного состояния в Redis или БД.

Связанные уроки
встречается в258
Продолжить восхождение ↑WebSocket в масштабе: HTTP/2 мультиплексирование, permessage-deflate, C10M
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.