Real-Time Backend

Проблема масштабирования

Discord держит 19 млн одновременных пользователей. Ни один сервер в мире не вместит столько WebSocket-соединений. Как они это делают - и почему это сложнее, чем просто «добавить серверов»?

  • Slack в 2015 году упирался в лимит одного сервера при росте до 500 000 пользователей - пришлось полностью переписать gateway-слой с учётом горизонтального масштабирования
  • Discord переходил с одного monolith WebSocket-сервера на шардированную архитектуру в 2017 году - каждый шард держит фиксированное число guild'ов, это позволяет добавлять серверы без глобального rebalancing
  • Twitch во время крупных стримов (пик - 6.5 млн зрителей одновременно на одном стриме) использует многоуровневый fan-out: edge-серверы принимают соединения, а внутренняя шина распределяет события между ними
  • Команды, добавляющие второй WebSocket-сервер без shared state, обнаруживают, что пользователи на сервере A не видят сообщения от пользователей на сервере B - классическая «потеря сообщений» при наивном scale-out

Single Server Limit

Один WebSocket-сервер держит соединения в оперативной памяти - объект сокета, пользовательский контекст, буфер сообщений. При 10 000 одновременных соединений это легко 2-4 GB RAM только на состояние. Когда память кончается или CPU упирается в 100%, новые соединения начинают отбрасываться.

Node.js с uWebSockets.js держит ~60 000 соединений на одном ядре при минимальной логике. Slack в 2019 году сообщал о пике в 11 млн одновременных соединений - это физически невозможно на одной машине даже с 128 GB RAM.

  • Состояние соединения (сокет) хранится в RAM конкретного процесса
  • Вертикальное масштабирование (больше RAM/CPU) имеет физический потолок
  • Один сервер - единая точка отказа: упал процесс - все клиенты отключились

Почему нельзя бесконечно масштабировать WebSocket-сервер вертикально (добавляя RAM и CPU)?

Sticky Sessions

Первое инстинктивное решение при добавлении второго сервера - sticky sessions (липкие сессии). Load balancer запоминает, к какому серверу подключился конкретный клиент, и всегда направляет его туда. Обычно через cookie (например, `SERVERID=backend-2`) или IP hash.

Sticky sessions решают одну проблему: клиент всегда попадает на сервер, где живёт его соединение. Но создают три новых: неравномерная нагрузка (один сервер перегружен, другой пустой), отказ сервера роняет всех его клиентов без возможности переключиться, мобильные клиенты меняют IP и "теряют" привязку.

  • IP hash не работает с NAT: весь офис из 500 человек видится как один IP -> всё на один сервер
  • Если backend-2 упал, nginx переключает его клиентов на другие серверы - но соединений там нет, клиенты реконнектятся
  • Деплой без downtime становится кошмаром: нельзя просто перезапустить сервер с активными соединениями

Команда настроила ip_hash в nginx для WebSocket-кластера. Все 300 сотрудников офиса жалуются, что попадают на один и тот же сервер. В чём причина?

Connection Routing

Более продвинутый подход - детерминированная маршрутизация: клиент сам знает, к какому серверу подключаться, или специализированный маршрутизатор держит таблицу `userId -> serverId`. Discord использует эту схему: при подключении gateway-сервер регистрирует `userId` в распределённой таблице, и все последующие события для этого пользователя идут через правильный сервер.

Маршрутизация через Redis решает проблему NAT и неравномерности. Но добавляет latency: каждое сообщение теперь требует минимум один запрос к Redis (lookup) плюс один pub/sub hop. При 100 000 сообщений/сек это 200 000 операций Redis/сек - Redis справится, но задержка растёт с ~1 ms до ~3-5 ms на сообщение.

  1. Клиент подключается к любому WS-серверу
  2. Сервер регистрирует userId -> serverId в Redis
  3. При отправке сообщения другому пользователю: lookup в Redis, затем pub/sub на нужный сервер
  4. Нужный сервер получает сообщение и пушит в открытый сокет

Система использует Redis для маршрутизации: userId -> serverId. Сервер ws-server-2 упал. Что произойдёт с записями в Redis для его клиентов?

Horizontal Problem

Горизонтальное масштабирование WebSocket вскрывает фундаментальную проблему: сообщение от пользователя A (на сервере 1) нужно доставить пользователю B (на сервере 3). Эта операция называется cross-server fan-out. В групповом чате на 1000 участников, распределённых по 10 серверам, одно сообщение порождает до 999 доставок через внутреннюю шину.

Slack в 2022 году описывал архитектуру Channel Fanout Service - отдельный слой, оптимизированный только для fan-out. Без него при росте числа серверов трафик через шину растёт квадратично: N серверов -> N*(N-1) потенциальных маршрутов.

  • Shared state (кто в каком канале) нужен всем серверам - это либо Redis, либо gossip protocol
  • Fan-out через Redis pub/sub работает до ~100 серверов, дальше нужен специализированный broker
  • Presence (онлайн/офлайн) становится отдельной задачей: каждый сервер знает только о своих клиентах

Горизонтальное масштабирование WebSocket - это просто добавить серверов за load balancer, и всё заработает само

Добавление серверов создаёт проблему распределённого состояния: соединения разбросаны по серверам, и для любого cross-server взаимодействия нужна явная координация через shared bus или routing layer

WebSocket - stateful протокол. В отличие от HTTP (где каждый запрос самодостаточен), WebSocket-соединение привязано к конкретному процессу в памяти. Горизонтальный scale-out создаёт «острова» с неполной картиной мира, и без явной архитектуры fan-out и state sharing система начинает терять сообщения или доставлять их только части аудитории.

Система масштабируется с 5 до 50 WebSocket-серверов. Как изменится нагрузка на Redis pub/sub при fan-out в комнате на 500 человек?

Итоги

  • Один WebSocket-сервер имеет физический потолок по RAM и CPU - vertical scaling упирается в железо и создаёт единую точку отказа
  • Sticky sessions (ip_hash) - полумера: ломается на NAT, создаёт неравномерную нагрузку и усложняет деплой без downtime
  • Горизонтальное масштабирование требует решения двух задач: маршрутизации (к какому серверу идёт клиент) и fan-out (как доставить сообщение клиентам на других серверах)

Связанные темы

Масштабирование WebSocket опирается на несколько смежных концепций:

  • Redis Pub/Sub — Основной механизм cross-server fan-out в большинстве WebSocket-кластеров
  • Load Balancing — L4 vs L7 балансировка напрямую влияет на возможности sticky sessions и маршрутизации WebSocket
  • Stateful vs Stateless — WebSocket нарушает stateless-принцип HTTP, именно это делает горизонтальный scale-out нетривиальным

Вопросы для размышления

  • Если бы нужно было спроектировать систему для 1 млн одновременных WebSocket-соединений с нуля, какую схему маршрутизации выбрать и почему?
  • В чём принципиальная разница между fan-out в чате (сообщение -> все участники комнаты) и presence (онлайн-статус -> все друзья пользователя) с точки зрения масштабирования?
  • Sticky sessions упрощают первый шаг горизонтального масштабирования. Когда именно стоит от них отказаться и перейти к централизованной маршрутизации?

Связанные уроки

  • net-53-distributed-intro
Проблема масштабирования

0

1

Войти