В офисном Wi-Fi обновления в приложении появляются мгновенно, уведомления приходят сами собой, все довольны. Вечером те же пользователи едут в метро, переключаются между мобильной сетью и Wi-Fi, уносят ноутбук в сон, и живой на первый взгляд канал превращается в пустышку: клиент считает себя подключённым, а сервер давно вычеркнул этот сокет из списка. Разработчик, который проверял WebSocket только в браузере на рабочем столе, узнаёт о проблеме по скриншотам с текстом "не обновляется" и по тикетам с пояснением, что помогает перезагрузка. Ниже разбор того, что реально происходит с длинными соединениями в продакшене, как собрать переподключение, которое не создаёт шторм, и почему часть сообщений без протокольной работы пропадает навсегда.
Что переживает WebSocket соединение за сутки в реальной сети
WebSocket поверх протокола стандарта RFC 6455 из 2011 года живёт поверх долгоживущего TCP соединения, и сама по себе такая пара способна существовать неделями. Проблема в том, что окружающая инфраструктура длинные соединения не любит. Таблицы трансляции адресов на домашних роутерах и в мобильных сетях чистятся по таймаутам простоя: запись о соединении, по которому не ходят пакеты, удаляется молча. Балансировщики поступают так же, у типового облачного балансировщика таймаут простоя соединения по умолчанию равен 60 секундам, у популярного обратного шлюза время ожидания ответа от сервера позади него по умолчанию тоже 60 секунд. Соединение без трафика выключается вежливо, то есть никак.
Сценарии со сменой сети ещё жёстче. Переход ноутбука с Wi-Fi на мобильную сеть меняет адрес и маршрут, старая привязка в таблице трансляции становится недействительной, пакеты уходят в пустоту, и клиент узнаёт об этом в лучшем случае через несколько минут. Сон устройства рвёт соединение гарантированно, а после пробуждения часть стека ведёт себя так, будто ничего не произошло. Сокет находится в полузакрытом состоянии: с одной стороны он выглядит рабочим, с другой стороны за ним ничего нет.
Отсюда простой вывод, который стоит принять до написания кода. В реальной сети соединение живёт минуты или часы, но не месяцы, и обрыв это нормальное событие, а не исключение. Наивная реализация из обработчиков onopen и onmessage не рассчитана на сутки такой жизни, потому что в ней нет ни одного места, где обрыв замечают.
Heartbeat через ping pong кадры и почему браузерный клиент сам не пингует
Стандарт WebSocket описывает служебные кадры ping и pong, которыми стороны проверяют друг друга. Серверная библиотека ws предлагает классическую схему из документации: раз в 30 секунд обойти всех клиентов, пометить каждого как подозреваемого, отправить ping и удалить тех, кто не ответил pong за прошлый интервал:
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 8080 });
function heartbeat() {
this.isAlive = true;
}
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", heartbeat);
});
const timer = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on("close", () => clearInterval(timer));
Метод terminate здесь не случайно соседствует с привычным close. Закрытие через close запускает вежливое рукопожатие и ждёт ответа противоположной стороны, для потерянного сокета это ожидание бессмысленно. Метод terminate рвёт соединение немедленно и освобождает место в памяти и в списках сервера, поэтому в уборке зависших клиентов используют его.
Диагностику обрывов делают читаемой через коды закрытия. Нормальное завершение приходит с кодом 1000, сервер, который уходит по своей инициативе, закрывает соединение кодом 1001 Going Away, а код 1006 обозначает аномальный обрыв без служебного кадра: TCP соединение пропало, и рукопожатия закрытия не было. Распределение кодов в логах быстро показывает, где именно проблема: гора кодов 1006 это сеть, коды 1001 по расписанию это выкаты.
С браузерным клиентом история другая, и это стабильно сбивает с толку. Стандартный объект WebSocket в браузере не даёт приложению отправить ping: браузер отвечает pong на серверные пинги автоматически, но инициировать проверку со своей стороны код страницы не может. Поэтому клиентский пинг строят на уровне сообщений приложения: раз в 20-30 секунд клиент отправляет короткое сообщение вроде ping, сервер отвечает pong, и если два интервала подряд ответа нет, соединение считается потерянным, клиент закрывает сокет и запускает переподключение.
Переподключение с экспоненциальной задержкой и защитой от синхронного шторма
Первая интуиция при обрыве звучит просто: переподключаться сразу и часто. В кластере такая интуиция стреляет в ногу. Рестарт одной ноды отнимает у неё тысячи клиентов одновременно, и если все ринутся пересоединяться через фиксированную секунду, живые ноды получат всплеск рукопожатий, который выглядит как атака.
Рабочая схема это экспоненциальная задержка со случайным дрожанием. Пауза удваивается от попытки к попытке, от стартовой секунды до разумного потолка, и к каждой паузе добавляется случайный множитель, расшатывающий строй повторных подключений. В крупной клиентской библиотеке для реального времени значения по умолчанию именно такие: стартовая пауза 1000 миллисекунд, удвоение с потолком 5000 миллисекунд, коэффициент дрожания 0.5 и бесконечное число попыток. Для собственного клиента каркас умещается в один экран:
let attempt = 0;
let seq = 0;
function backoff() {
const base = Math.min(1000 * 2 ** attempt, 30000);
attempt += 1;
return base * (0.5 + Math.random());
}
function connect() {
const ws = new WebSocket("wss://example.com/rt");
ws.onopen = () => {
attempt = 0;
ws.send(JSON.stringify({ type: "resume", lastSeq: seq }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.seq <= seq) return;
seq = msg.seq;
handle(msg);
};
ws.onclose = () => setTimeout(connect, backoff());
}
connect();
Отдельная ловушка спрятана в отправке с клиента. Вызов send на потерянный сокет завершается без ошибок: браузер складывает байты в буфер и честно показывает их в свойстве bufferedAmount, а в сеть они не попадают. Ответственность за подтверждения лежит на приложении: важные клиентские сообщения либо дублируются до получения ответа, либо ждут в локальной очереди установления соединения.
Число попыток тоже предмет выбора. Для фоновых панелей и уведомлений переподключение может быть бесконечным. Для интерактивного приложения после потолка попыток честнее показать состояние офлайн с кнопкой повтора, иначе клиент продолжит тратить батарею на попытки в тоннеле без сети.
Где именно теряются сообщения между обрывом и повторным соединением
Потери складываются из трёх окон, и каждое требует своего лекарства. Первое окно, полузакрытое состояние: сервер ещё считает сокет живым и публикует в него события, клиента на другом конце давно нет. Второе окно, отправка в пустоту: клиентские сообщения уходят в буфер потерянного сокета и не достигают адресата. Третье окно, разрыв подписки: пока клиента не было, сервер опубликовал новые события, но не доставил их никому, и после переподключения они не повторяются, потому что никто не помнит, до какого места клиент всё видел.
Третье окно самое коварное, потому что в нём всё выглядит рабочим. Соединение восстановлено, уведомления снова приходят, но события, опубликованные в пропавший интервал, исчезли без следа. Лечение протокольное: каждому сообщению присваивается порядковый номер, сервер хранит кольцевую историю последних событий, при переподключении клиент присылает последний увиденный номер и сервер повторяет пропущенный хвост:
const history = [];
function publish(event) {
seq += 1;
const item = { seq, event };
history.push(item);
if (history.length > 1000) history.shift();
for (const client of wss.clients) {
client.send(JSON.stringify(item));
}
}
wss.on("connection", (ws) => {
ws.on("message", (raw) => {
const msg = JSON.parse(raw);
if (msg.type !== "resume") return;
for (const item of history) {
if (item.seq > msg.lastSeq) ws.send(JSON.stringify(item));
}
});
});
Повтор возможен и по второму разу, поэтому обработчики делают идемпотентными, а клиент отбрасывает дубликаты по номеру.
Размер окна истории подбирают по худшему сценарию: сколько событий публикуется за время самой долгой потери связи. Если клиент отсутствовал дольше окна, честный ответ это не молчаливая дыра, а команда полной пересинхронизации состояния. В командных системах буфер держат в отдельном хранилище с ограничением по времени жизни, небольшому сервису хватает кольцевого буфера в памяти. Полезно сравнить с соседним подходом: у серверных событий через EventSource заголовок Last-Event-ID встроен в протокол, браузер отправляет его автоматически при каждом пересоединении. В WebSocket такой механики нет, её собирают руками, и именно разница между "библиотека умеет из коробки" и "собери протокол сам" отделяет рабочий продакшен от демонстрации.
Масштабирование WebSocket сервера на несколько нод со sticky сессиями
Одна нода уверенно держит десятки тысяч открытых соединений, но следующая проблема появляется раньше ресурсов. Событие рождается на ноде, где сработал обработчик, а сокет конкретного клиента висит на соседней ноде, и без общего канала доставки событие до клиента не дойдёт. Классическое решение это общий брокер: публикация в общий канал, например Redis, и подписка каждой ноды на него.
Повторные подключения после обрыва тоже маршрутизируются. Балансировщик закрепляет клиента за нодой через sticky сессии, иначе повторное рукопожатие попадёт на другой сервер и потеряет локальное состояние. Для популярной библиотеки реального времени sticky сессии обязательны из-за фолбэка на long-polling, чистые WebSocket клиенты обходятся без закрепления, если всё состояние живёт в общем хранилище.
Падение ноды и её развёртывание проверяют всю конструкцию на прочность. При выкате метод server.close у HTTP сервера не трогает уже установленные ws соединения, поэтому сервер сначала рассылает клиентам close с кодом 1001 Going Away, даёт секунды на переподключение к соседним нодам и только потом завершает работу. При падении всё клиентское поголовье ноды переезжает разом, и без дрожания в паузах живые ноды почувствуют этот переезд.
Метрики здесь не украшение. Счётчик живых соединений по нодам, распределение кодов закрытия и число переподключений в секунду дают картину здоровья реального времени: шторм после выката виден сразу, и медленная утечка сокетов на конкретной ноде тоже.
Чек-лист доведения WebSocket клиента до продакшн уровня надёжности
Минимальный набор, после которого соединения перестают быть источником тикетов, выглядит так:
- Настроить проверку живости на обеих сторонах и убирать зависшие сокеты жёстким terminate;
- Реализовать переподключение с экспоненциальной задержкой и случайным дрожанием без бесконечного немедленного цикла;
- Присвоить каждому сообщению порядковый номер и хранить на сервере окно истории для повтора;
- При каждом переподключении синхронизировать последний номер клиента и повторять пропущенный хвост;
- Сделать обработчики идемпотентными и убирать дубликаты по номеру на стороне клиента;
- Держать метрики живых соединений, причин закрытия и всплесков переподключений с порогами для алертов.
Когда упрощённая реализация WebSocket не приносит вреда проекту
Не всякому проекту нужен полный протокол с номерами и историей. Внутренняя панель с кнопкой обновления, инструмент внутри одной локальной сети, демо для показа и прототип на неделю спокойно живут с onopen и onmessage: обрыв там случается редко, а стоимость восстановления равна нажатию F5. Если состояние приложения целиком восстанавливается при загрузке страницы, длинные соединения вообще не несут дополнительной ценности, и усложнение выглядит как работа ради работы.
Продукт с живыми пользователями выходит за эту границу в первый же день. Сеть не становится вежливее от того, что разработчик о ней не думал, мобильный клиент не перестаёт переключать сети от хорошего кода, а балансировщик не продлевает соединения из жалости. Heartbeat, задержка с дрожанием и порядковые номера это не украшение кода, а честная себестоимость слова реальное время в описании проекта. Написать их стоит до первого релиза, потому что после релиза за каждое пропавшее сообщение платят уже не часы разработки, а доверие пользователей.