WebSocket в Node.js: архитектура, масштабирование и альтернативы (SSE, Long Polling)
WebSocket — это полнодуплексный протокол для real-time коммуникации между клиентом и сервером. В отличие от HTTP, где клиент инициирует каждый запрос, WebSocket позволяет серверу отправлять данные клиенту в любой момент. Это критично для чатов, онлайн-игр, биржевых котировок и коллаборативных редакторов. Но при масштабировании на несколько серверов возникают проблемы: как синхронизировать сообщения между инстансами, как обеспечить sticky sessions, и когда стоит выбрать альтернативы вроде Server-Sent Events. В этой статье разберём архитектуру WebSocket в Node.js, сравним библиотеки, реализуем горизонтальное масштабирование с Redis и проанализируем trade-off'ы относительно SSE и Long Polling.
WebSocket протокол и handshake
WebSocket стартует как обычный HTTP-запрос с заголовком Upgrade: websocket. Сервер отвечает статусом 101 Switching Protocols, и соединение переключается на бинарный протокол поверх TCP. После этого обе стороны могут отправлять frames в любой момент без overhead'а HTTP-заголовков.
Пример handshake:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
После handshake клиент и сервер обмениваются frames. Каждый frame содержит opcode (text, binary, ping, pong, close), payload length и данные. Протокол поддерживает фрагментацию больших сообщений и автоматические ping/pong для keep-alive.
Библиотеки: ws, socket.io, uWebSockets.js
ws — минималистичная библиотека
ws — это низкоуровневая реализация WebSocket для Node.js. Она не добавляет абстракций, работает быстро и подходит для случаев, когда нужен полный контроль.
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
console.log('Клиент подключился');
ws.on('message', (data) => {
console.log('Получено:', data.toString());
ws.send(`Эхо: ${data}`);
});
ws.on('close', () => {
console.log('Клиент отключился');
});
});
console.log('WebSocket сервер на ws://localhost:8080');
Клиентский код:
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => {
ws.send('Привет, сервер!');
};
ws.onmessage = (event) => {
console.log('Ответ:', event.data);
};
Плюсы ws: лёгкая (нет зависимостей), быстрая, совместима с любым HTTP-сервером. Минусы: нет автоматического reconnect, fallback на polling, rooms/namespaces — всё нужно реализовывать вручную.
socket.io — high-level фреймворк
socket.io добавляет поверх WebSocket удобные абстракции: rooms (группы клиентов), namespaces (изолированные каналы), автоматический reconnect и fallback на long polling, если WebSocket недоступен.
const { Server } = require('socket.io');
const io = new Server(3000);
io.on('connection', (socket) => {
console.log('Клиент подключился:', socket.id);
socket.on('chat:message', (msg) => {
io.emit('chat:message', { user: socket.id, text: msg });
});
socket.on('disconnect', () => {
console.log('Клиент отключился:', socket.id);
});
});
console.log('Socket.IO на http://localhost:3000');
Клиент:
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io('http://localhost:3000');
socket.emit('chat:message', 'Привет всем!');
socket.on('chat:message', (data) => {
console.log(data.user, ':', data.text);
});
</script>
Плюсы: удобный API, rooms/namespaces из коробки, fallback. Минусы: больший размер бандла (клиент ~30 КБ gzip), несовместимость с нативным WebSocket (нужен socket.io-клиент).
uWebSockets.js — максимальная производительность
uWebSockets.js — это биндинги к C++ библиотеке µWebSockets, которая в 10-20 раз быстрее ws в бенчмарках. Подходит для high-load сценариев (100k+ одновременных соединений).
const uWS = require('uWebSockets.js');
uWS.App().ws('/*', {
open: (ws) => {
console.log('Клиент подключился');
},
message: (ws, message, isBinary) => {
const text = Buffer.from(message).toString();
ws.send(`Эхо: ${text}`);
},
close: (ws) => {
console.log('Клиент отключился');
},
}).listen(8080, (token) => {
if (token) {
console.log('uWebSockets на ws://localhost:8080');
}
});
Минусы: API отличается от стандартного Node.js, сложнее отлаживать, нет экосистемы middleware как у socket.io.
Масштабирование: проблема и решения
Когда приложение работает на одном сервере, все WebSocket-соединения находятся в памяти этого процесса. Но при горизонтальном масштабировании (несколько инстансов за load balancer) возникает проблема: клиент A подключён к серверу 1, клиент B — к серверу 2. Если A отправляет сообщение, сервер 1 не знает о клиенте B.
Sticky sessions
Первое решение — настроить load balancer так, чтобы все запросы от одного клиента попадали на один и тот же сервер. Это называется sticky sessions (или session affinity).
Пример для nginx:
upstream websocket_backend {
ip_hash;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
server {
location /socket.io/ {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Директива ip_hash привязывает клиента к серверу по IP. Но это не решает проблему broadcast'а: если нужно отправить сообщение всем клиентам, каждый сервер знает только о своих соединениях.
Redis pub/sub для синхронизации
Решение — использовать Redis как message broker. Когда сервер получает сообщение от клиента, он публикует его в Redis-канал. Все серверы подписаны на этот канал и рассылают сообщение своим клиентам.
Архитектура:
Клиент A → Сервер 1 → Redis pub → Сервер 1, 2, 3 → Клиенты A, B, C
Реализация с socket.io и socket.io-redis (устарел, используйте @socket.io/redis-adapter):
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const io = new Server(3000);
const pubClient = createClient({ host: 'localhost', port: 6379 });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
io.adapter(createAdapter(pubClient, subClient));
console.log('Redis adapter подключён');
});
io.on('connection', (socket) => {
socket.on('chat:message', (msg) => {
// Отправляется всем клиентам на всех серверах
io.emit('chat:message', { user: socket.id, text: msg });
});
});
Теперь io.emit() публикует сообщение в Redis, и все инстансы получают его через подписку. Это работает и для rooms:
socket.join('room:123');
io.to('room:123').emit('message', 'Привет комнате 123');
Redis-адаптер автоматически синхронизирует информацию о том, какие клиенты в каких rooms находятся.
Практический пример: чат с горизонтальным масштабированием
Создадим простой чат, который работает на нескольких серверах.
Сервер (server.js)
const express = require('express');
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const app = express();
const server = require('http').createServer(app);
const io = new Server(server);
const PORT = process.env.PORT || 3000;
// Redis adapter
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
io.adapter(createAdapter(pubClient, subClient));
console.log(`Сервер ${PORT}: Redis adapter готов`);
});
app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});
io.on('connection', (socket) => {
console.log(`Сервер ${PORT}: клиент ${socket.id} подключился`);
socket.on('chat:join', (username) => {
socket.username = username;
socket.broadcast.emit('chat:user-joined', username);
});
socket.on('chat:message', (msg) => {
io.emit('chat:message', {
user: socket.username || 'Аноним',
text: msg,
timestamp: Date.now(),
});
});
socket.on('disconnect', () => {
if (socket.username) {
io.emit('chat:user-left', socket.username);
}
console.log(`Сервер ${PORT}: клиент ${socket.id} отключился`);
});
});
server.listen(PORT, () => {
console.log(`Сервер запущен на http://localhost:${PORT}`);
});
Клиент (index.html)
<!DOCTYPE html>
<html>
<head>
<title>Чат</title>
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<div id="messages"></div>
<input id="input" placeholder="Сообщение..." />
<button onclick="send()">Отправить</button>
<script>
const socket = io();
const username = prompt('Ваше имя:');
socket.emit('chat:join', username);
socket.on('chat:message', (data) => {
const div = document.createElement('div');
div.textContent = `${data.user}: ${data.text}`;
document.getElementById('messages').appendChild(div);
});
socket.on('chat:user-joined', (user) => {
console.log(`${user} присоединился`);
});
function send() {
const input = document.getElementById('input');
socket.emit('chat:message', input.value);
input.value = '';
}
</script>
</body>
</html>
Запуск нескольких инстансов
# Терминал 1
PORT=3000 node server.js
# Терминал 2
PORT=3001 node server.js
# Nginx конфиг с ip_hash
upstream chat {
ip_hash;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
Теперь клиенты, подключённые к разным серверам, видят сообщения друг друга благодаря Redis pub/sub.
Альтернативы: SSE и Long Polling
Server-Sent Events (SSE)
SSE — это однонаправленный протокол (сервер → клиент) поверх HTTP. Подходит для уведомлений, live-обновлений, где клиенту не нужно отправлять данные часто.
// Сервер
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const interval = setInterval(() => {
res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
}, 1000);
req.on('close', () => clearInterval(interval));
});
// Клиент
const eventSource = new EventSource('/events');
eventSource.onmessage = (event) => {
console.log(JSON.parse(event.data));
};
Плюсы SSE: работает через обычный HTTP (проще с прокси и файрволами), автоматический reconnect в браузере, меньше overhead. Минусы: только текст (нет бинарных данных), только сервер → клиент, лимит на 6 одновременных соединений в браузере к одному домену.
Long Polling
Long Polling — это техника, где клиент отправляет HTTP-запрос, сервер держит его открытым до появления данных, затем отвечает, и клиент сразу отправляет новый запрос.
// Сервер
let clients = [];
app.get('/poll', (req, res) => {
clients.push(res);
req.on('close', () => {
clients = clients.filter((c) => c !== res);
});
});
app.post('/broadcast', (req, res) => {
clients.forEach((client) => {
client.json({ message: req.body.message });
});
clients = [];
res.sendStatus(200);
});
Плюсы: работает везде (даже через старые прокси). Минусы: высокий overhead (каждый ответ = новый HTTP-запрос), сложнее масштабировать.
Когда использовать что
- WebSocket — для чатов, онлайн-игр, коллаборативных редакторов, где нужна низкая latency и двусторонняя коммуникация.
- SSE — для live-обновлений (новости, биржевые котировки, прогресс-бары), где клиент только читает данные.
- Long Polling — fallback для старых браузеров или сетей, где WebSocket блокируется.
Вывод
WebSocket в Node.js — это мощный инструмент для real-time приложений, но он требует продуманной архитектуры при масштабировании. Библиотека ws даёт контроль, socket.io — удобство, uWebSockets.js — производительность. Для горизонтального масштабирования используйте Redis pub/sub и sticky sessions на load balancer. Если задача не требует двусторонней коммуникации, рассмотрите SSE — он проще в инфраструктуре и не требует специальных библиотек. Long Polling остаётся fallback'ом для legacy-окружений. Выбор зависит от требований к latency, объёму трафика и совместимости с клиентами.