Балансировщик раскидывал вебсокеты по нодам, а состояние было локальным
В конце августа мы почти неделю разбирались, почему часть пользователей в общих чатах не видит сообщений друг друга — при этом ни ошибок в логах, ни просадок по CPU, ни жалоб мониторинга. Соединения по WebSocket устанавливались штатно, пинги ходили, а сообщения до части адресатов просто не доезжали. Причина оказалась не в сети и не в клиенте, а в том, как мы вообще проектировали хранение состояния между нодами за балансировщиком.
Содержание
Что сломалось
Сервис — realtime-уведомления и групповые чаты поверх WebSocket, за nginx как L7-балансировщиком, три бэкенд-ноды на одинаковых VPS. Апстрим настроен без sticky-сессий, простым round-robin: новое соединение уходит на любую живую ноду.
Симптом с точки зрения пользователя выглядел так: человек заходит в комнату, видит историю сообщений (она тянется из базы при подключении), но новые сообщения от других участников той же комнаты у него не появляются, пока он не перезагрузит страницу — и тогда, может быть, снова не появятся. Заявки в поддержку шли волнами, без явной закономерности по времени суток или региону. Часть пользователей вообще не жаловалась — видимо, им везло с распределением по нодам.
Отдельно смущало то, что бэкенд не падал и не переподключался: в консоли браузера соединение висело зелёным, readyState — OPEN, keep-alive пинги от сервера приходили каждые 25 секунд по расписанию. То есть проблема была не «соединение разорвано», а «соединение живо, но нужные данные до него не долетают».
Что показывали логи и метрики
Первым делом посмотрели на очевидное — и оно было чистым:
- Метрики nginx (
upstream_response_time, коды ответов на upgrade-запросы) — без аномалий, все хендшейки101 Switching Protocolsпроходили нормально. - CPU, память, число открытых файловых дескрипторов на всех трёх нодах — в пределах обычных значений, без скачков в моменты жалоб.
- Логи приложения на broadcast-событиях — сообщение действительно рассылалось, лог
message.broadcast room=42 recipients=3появлялся исправно.
Именно последний пункт и стал первой зацепкой. Число recipients в логах broadcast почти всегда было меньше, чем реальное число участников комнаты по данным из базы. Мы стали логировать rows отдельно: сколько участников комнаты числится в БД и сколько получателей реально нашлось у broadcast-функции на конкретной ноде. Разница была систематической и примерно совпадала с тем, сколько участников комнаты «досталось» другим нодам при подключении.
Добавили в access-лог nginx кастомный заголовок с именем апстрим-сервера ($upstream_addr) и стали сверять его с client_id из логов приложения. Картина сложилась быстро: участники одной и той же комнаты чата оказывались на разных нодах — что ожидаемо при round-robin без affinity — а broadcast каждой ноды находил получателей только среди тех, кто подключён именно к ней.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКакие гипотезы отбросили
Прежде чем дойти до реальной причины, проверили и закрыли несколько версий:
- Проблема на стороне клиента. Проверили на нескольких чистых сессиях в разных браузерах и с разных IP — эффект воспроизводился стабильно и не зависел от клиента.
- Протухание токена авторизации. Токен на соединении был живой, повторной авторизации не требовалось, реконнектов из-за 401 в логах не было.
- Задержка или потеря сообщений в очереди между сервисом отправки и WebSocket-слоем. Очереди на тот момент между этими компонентами не было вовсе — broadcast вызывался синхронно в рамках одного процесса, так что «потерять» сообщение в транспорте между сервисами оно не могло.
- Проблемы с сетью между балансировщиком и нодами. Прогнали
mtrи синтетические WebSocket-сессии с внешнего хоста — пакетлосса и скачков RTT не увидели, хендшейки укладывались в обычные миллисекунды. - Флап health-check и перекидывание соединений между нодами на лету. Проверили конфиг
upstream— таймауты health-check не совпадали по времени с жалобами, а сам разрыв соединения (что было бы видно как reconnect в логах клиента) не происходил.
Все эти версии по отдельности были правдоподобны — именно поэтому на них ушло больше половины времени разбора инцидента. Но ни одна не объясняла главный факт: broadcast стабильно находил *меньше* получателей, чем реально было участников комнаты, причём разница коррелировала не со временем и не с нагрузкой, а с тем, кто на какую ноду попал при подключении.
В чём была реальная причина
Комнаты чата и подписки на них хранились в обычной структуре в памяти процесса — что-то вроде:
# room_registry.py — упрощённо, как было в коде до разбора инцидента
rooms: dict[int, set[WebSocketConnection]] = {}
def subscribe(room_id: int, conn: WebSocketConnection) -> None:
rooms.setdefault(room_id, set()).add(conn)
def broadcast(room_id: int, message: dict) -> None:
for conn in rooms.get(room_id, set()):
conn.send_json(message)
Это классическая и вполне рабочая схема — но только для одного процесса на одной машине. Когда участники одной комнаты оказывались на трёх разных нодах, у каждой ноды был *свой* словарь rooms, и broadcast видел только тех, кто физически подключён к этому же процессу. Никакого механизма для того, чтобы нода №1 узнала, что сообщение нужно ещё и подписчикам на нодах №2 и №3, в коде не было — потому что при однонодовой архитектуре в этом просто не было нужды, а когда добавили второй и третий сервер под балансировщик, эту часть никто не пересматривал.
Балансировщик тем временем делал ровно то, для чего он предназначен: равномерно распределял входящие соединения. С точки зрения nginx и инфраструктуры всё было настроено штатно — просто само приложение неявно полагалось на то, что все клиенты одной комнаты окажутся в памяти одного процесса, а это предположение перестало быть верным в тот момент, когда появилась вторая нода.
Отдельно стоит сказать, почему баг был таким «рваным» и не воспроизводился стабильно у всех: при трёх нодах и round-robin вероятность, что два конкретных участника небольшой комнаты (2-3 человека) окажутся на одной ноде, довольно высокая — поэтому часть пользователей вообще не замечала проблему. А в комнатах с 10+ участниками почти гарантированно кто-то оказывался «невидимым» для остальных, и жалобы шли именно оттуда — что мы, кстати, подтвердили постфактум, посчитав размер комнаты у обращавшихся в поддержку: у всех он был заметно выше среднего.
Почему до этого не доходили в тестах
Отдельный урок инцидента — почему проблема не всплыла до продакшена. Локально и на стейджинге сервис почти всегда крутился в единственном экземпляре: и разработчики, и автотесты запускали один процесс, поэтому весь трафик закономерно попадал в один и тот же словарь rooms, и тесты broadcast зелёные проходили. Многонодовая конфигурация появилась только на проде, когда добавили вторую и третью ноду ради отказоустойчивости и запаса по нагрузке — и именно тогда «бесплатная» согласованность в памяти одного процесса перестала работать, а никакого теста, который поднимал бы два экземпляра сервиса и проверял кросс-нодовый broadcast, в наборе не было.
Здесь же стоит отметить смежную и более простую ошибку, которую иногда путают с этой: если бы у нас был не пул равноценных нод, а обычный обратный прокси перед единственным бэкендом, riskов с расхождением состояния бы не было в принципе — почитайте, как в целом работает reverse proxy и путь запроса, это помогает понимать, где на самом деле проходит граница ответственности балансировщика.
Что изменили после инцидента
Быстрый (временный) фикс на время, пока готовили нормальное решение — включили sticky-сессии на уровне nginx по ip_hash, чтобы клиенты с одного IP стабильно попадали на одну ноду. Это снизило частоту жалоб, но не решило проблему концептуально: пользователи за одним NAT/офисным IP действительно чаще попадали на одну ноду, но участники одной комнаты из разных сетей — по-прежнему нет, а сама привязка по IP плохо работает с мобильными сетями и корпоративными прокси, где IP у пользователя может меняться посреди сессии.
Основное решение — вынести состояние комнат из памяти процесса в общий слой, видимый всем нодам. Остановились на Redis Pub/Sub: каждая нода при broadcast публикует сообщение в канал, привязанный к room_id, а каждая нода одновременно подписана на каналы тех комнат, для которых у неё есть локальные подключения:
# после исправления: broadcast идёт через Redis, локальный словарь остаётся,
# но пополняется не только напрямую, а и по событиям из Pub/Sub
import redis.asyncio as redis
r = redis.Redis(host="127.0.0.1", port=6379, db=0)
async def broadcast(room_id: int, message: dict) -> None:
await r.publish(f"room:{room_id}", json.dumps(message))
async def redis_listener() -> None:
pubsub = r.pubsub()
await pubsub.psubscribe("room:*")
async for event in pubsub.listen():
if event["type"] != "pmessage":
continue
room_id = int(event["channel"].decode().split(":")[1])
message = json.loads(event["data"])
for conn in rooms.get(room_id, set()):
await conn.send_json(message)
Локальный словарь rooms при этом никуда не делся — он по-прежнему нужен, чтобы найти *свои* соединения на конкретной ноде, но источником правды о том, кому вообще отправлять сообщение, стал Redis, а не память отдельного процесса. Если интересна более широкая тема — у нас есть отдельный разбор, как установить и настроить Redis на VPS с нуля, и сравнение Redis и Memcached для похожих задач кеша и pub/sub.
Дополнительно пересмотрели саму схему балансировки. Ради простоты и однородности решили не привязываться к IP клиента, а honestly признать: WebSocket-нагрузку правильнее либо балансировать без всякой affinity (раз состояние теперь общее через Redis), либо, если affinity всё же нужна по другим причинам, делать её на уровне явного идентификатора сессии, а не IP-адреса. В нашем случае после перехода на Redis Pub/Sub необходимость в sticky-сессиях вообще отпала — любая нода может обслуживать любую комнату, потому что broadcast больше не зависит от того, где физически лежит TCP-соединение. Если у вас похожий выбор между HAProxy и nginx для балансировки перед подобным сервисом, у нас есть отдельное сравнение — HAProxy или nginx для балансировки — оно не про WebSocket-специфику, но полезно для базового выбора инструмента.
Из мониторинга добавили метрику числа локальных подключений на комнату на каждой ноде и алерт, который сравнивает суммарное число подписчиков по всем нодам с числом активных участников комнаты по данным БД — расхождение больше определённого порога теперь видно сразу, а не через тикеты в поддержку. Ещё завели простой чек-лист для ревью новых фич в realtime-слое: если фича трогает in-memory состояние, разработчик обязан явно ответить на вопрос «что произойдёт, если это состояние понадобится другой ноде» — до этого инцидента такого пункта в чек-листе просто не существовало.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли было обойтись без Redis, например просто хранить состояние в базе?
Технически да, но для broadcast с низкой задержкой опрос базы на каждое сообщение — плохой вариант: добавляет задержку и лишнюю нагрузку на БД на каждое сообщение в каждой комнате. Pub/Sub заточен именно под доставку событий в реальном времени и в нашем случае избавил от необходимости поллинга.
Почему не выбрали sticky-сессии как постоянное решение?
Sticky по IP не решает проблему для участников одной комнаты, находящихся в разных сетях, и плохо ведёт себя при смене IP на мобильных подключениях — клиент может внезапно попасть на другую ноду и снова оказаться «невидимым» для части комнаты до реконнекта. Общее состояние через Redis решает проблему для любой топологии клиентов, а не только для частного случая.
Как проверить, что похожая проблема есть в своём сервисе, не дожидаясь жалоб?
Добавьте метрику или тестовый скрипт, который сравнивает число подписчиков broadcast-функции с реальным числом участников по данным хранилища правды (БД или аналог), и проверьте кросс-нодовый сценарий вручную: поднимите два инстанса локально, подключите к разным нодам двух клиентов в одну комнату и убедитесь, что сообщение долетает до обоих.
Redis Pub/Sub — единственный вариант для такой задачи?
Нет, есть альтернативы вроде Redis Streams (с гарантией доставки и возможностью повторного чтения) или брокеров сообщений вроде NATS — Pub/Sub мы выбрали за простоту внедрения в уже работающий сервис и достаточную скорость доставки для нашего сценария; если нужны гарантии доставки при падении подписчика, стоит смотреть в сторону Streams или полноценной очереди.
Как избежать такой ошибки при проектировании нового realtime-сервиса с нуля?
Заранее закладывайте, что бэкенд будет работать в нескольких экземплярах, даже если на старте нода одна — тестируйте broadcast-логику как минимум на двух локальных инстансах перед первым релизом на прод, и с самого начала выносите разделяемое состояние (комнаты, presence, подписки) в общий слой, а не в память процесса.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →