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 на сообщение.
- Клиент подключается к любому WS-серверу
- Сервер регистрирует userId -> serverId в Redis
- При отправке сообщения другому пользователю: lookup в Redis, затем pub/sub на нужный сервер
- Нужный сервер получает сообщение и пушит в открытый сокет
Система использует 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 упрощают первый шаг горизонтального масштабирования. Когда именно стоит от них отказаться и перейти к централизованной маршрутизации?