lenec ru

← все посты

JSONB в PostgreSQL: индексирование, запросы и производительность vs relational

17K

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, объёму трафика и совместимости с клиентами.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.