MAATRIX / Блог / Балансировщик раскидывал вебсокеты по нодам, а состояние было локальным

Балансировщик раскидывал вебсокеты по нодам, а состояние было локальным

MAATRIX

В конце августа мы почти неделю разбирались, почему часть пользователей в общих чатах не видит сообщений друг друга — при этом ни ошибок в логах, ни просадок по CPU, ни жалоб мониторинга. Соединения по WebSocket устанавливались штатно, пинги ходили, а сообщения до части адресатов просто не доезжали. Причина оказалась не в сети и не в клиенте, а в том, как мы вообще проектировали хранение состояния между нодами за балансировщиком.

Что сломалось

Сервис — realtime-уведомления и групповые чаты поверх WebSocket, за nginx как L7-балансировщиком, три бэкенд-ноды на одинаковых VPS. Апстрим настроен без sticky-сессий, простым round-robin: новое соединение уходит на любую живую ноду.

Симптом с точки зрения пользователя выглядел так: человек заходит в комнату, видит историю сообщений (она тянется из базы при подключении), но новые сообщения от других участников той же комнаты у него не появляются, пока он не перезагрузит страницу — и тогда, может быть, снова не появятся. Заявки в поддержку шли волнами, без явной закономерности по времени суток или региону. Часть пользователей вообще не жаловалась — видимо, им везло с распределением по нодам.

Отдельно смущало то, что бэкенд не падал и не переподключался: в консоли браузера соединение висело зелёным, readyStateOPEN, 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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →