Проектирование бэкендов для реального времени чатов: комнаты, сохранение данных и масштабирование
Узнайте, как спроектировать бэкенд для реального времени чата с использованием Socket.IO, PostgreSQL и Redis, в том числе рассмотрим такие аспекты, как комнаты, порядок сохранения сообщений, статус присутствия и масштабирование на несколько серверов.
Общение в реальном времени меняет способ взаимодействия сервера и клиента, выходя за рамки простой модели запрос-ответ и переходя к использованию WebSockets, комнат, сохранения сообщений, отслеживания онлайн-статуса и масштабирования на основе Redis.
Большинство API следуют предсказуемой схеме:
Client
↓
HTTP Request
↓
Server
↓
HTTP Response
Клиент отправляет запрос. Сервер возвращает ответ. В этом и заключается вся интеракция.
Но теперь подумайте, что потребуется для создания чего-то вроде:
- Slack
- Discord
- Ленты живых уведомлений
- Индикаторов онлайн-статуса
- Индикаторов ввода текста
- Живых панелей управления
- Функций для нескольких игроков
В таких случаях не нужно, чтобы клиент постоянно проверял:
"Что-нибудь изменилось?"
Вместо этого необходимо, чтобы сам сервер мог объявлять:
«Что-то изменилось. Вот обновление.
Именно эту проблему решает общение в реальном времени.
1. HTTP против общения в реальном времени
Традиционный метод HTTP-запросов выглядит так:
Client → "Any new messages?"
Server → "No"
Client → "Any new messages?"
Server → "No"Client → "Any new messages?"
Server → "Yes, here's one."
Этот метод работает, но тратит запросы и пропускную способность на проверку обновлений, которых обычно нет.
Постоянное соединение в реальном времени работает иначе:
Client ←────────────→ Server
connection
Server → New message
Server → User online
Server → Typing...
Server → Message read
Как только соединение установлено, сервер может напрямую отправлять события клиенту каждый раз, когда что-то происходит.
WebSockets — это широко используемая технология для создания таких соединений. В экосистеме Node.js Socket.IO — популярная библиотека, разработанная специально для общения в реальном времени.
2. Простая архитектура
Минимальное приложение для чатов может быть структурировано следующим образом:
┌──────────────┐
│ Web / Mobile │
└──────┬───────┘
│
WebSocket
│
▼
┌──────────────┐
│ Node.js │
│ Socket.IO │
└──────┬───────┘
│
┌──────────┴──────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ PostgreSQL│ │ Redis │
│ Messages │ │ Pub/Sub │
└───────────┘ └───────────┘
Каждый компонент выполняет свою уникальную функцию.
Node.js + Socket.IO
Управляет активными соединениями и событиями, проходящими через них.
PostgreSQL
Хранит сообщения и историю разговоров.
Redis
Пригодится, когда вы запускаете несколько экземпляров приложения и нуждаетесь в синхронизации этих экземпляров для обработки реальных временных событий.
3. Настройка Socket.IO
Базовая настройка сервера может выглядеть так:
import { Server } from "socket.io";
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL,
credentials: true,
},
});
io.on("connection", (socket) => {
console.log("User connected:", socket.id);
socket.on("disconnect", () => {
console.log("User disconnected:", socket.id);
});
});
Каждый раз, когда клиент подключается, Socket.IO создает отдельный сокет для этого соединения, так что каждый подключенный клиент получает собственную инстанцию сокета.
4. События — основная концепция
В отличие от подхода, основанного на запросах:
"Call this endpoint"
системы реального времени обычно организованы вокруг именованных событий:
"user-connected"
"send-message"
"message-created"
"user-typing"
"message-read"
"user-offline"
Например, сервер может отправить:
socket.emit("message-created", {
id: message.id,
text: message.text,
});
А клиент прослушивает именно это событие:
socket.on("message-created", (message) => {
console.log("New message:", message);
});
Такой переход к модели, основанной на событиях, является одним из ключевых факторов, отличающих приложения в реальном времени от традиционных API на основе REST.
5. Комнаты значительно упрощают системы чата
Представьте одному-на-один диалог:
User A
User B
Вы не хотите транслировать каждое сообщение всем подключенным пользователям, а только тем, кто действительно участвует в этом разговоре.
Socket.IO позволяет группировать сокеты в комнаты:
socket.join(`conversation:${conversationId}`);
Затем, когда создается новое сообщение, оно отправляется именно в эту конкретную комнату:
io.to(`conversation:${conversationId}`)
.emit("message-created", message);
Только сокеты, присоединенные к этой комнате, получат событие.
Та же идея применима к групповым разговорам. Комната может включать:
conversation:123
нескольких участников:
User A
User B
User C
User D
Одно отправленное сообщение сразу достигает всех в той комнате.
6. Не сохраняйте сообщение после трансляции
Это выбор проектировщика, о котором стоит тщательно подумать.
Рискованная последовательность действий будет выглядеть так:
Receive message
↓
Broadcast message
↓
Save to database
Проблема: что произойдет, если запись в базу данных завершится с ошибкой после того, как сообщение уже было отправлено? Пользователи увидят сообщение, которое на самом деле так и не было сохранено, что приведет к несоответствию между тем, что видят люди, и тем, что хранится.
Более надежным подходом является сначала сохранение данных, а затем трансляция:
Client
↓
send-message
↓
Validate
↓
Save to PostgreSQL
↓
Database succeeds
↓
Broadcast event
На практике это выглядит примерно так:
socket.on("send-message", async (data) => {
const message = await saveMessage(data);
io.to(`conversation:${data.conversationId}`)
.emit("message-created", message);
});
Конкретные гарантии надежности, необходимые вам, будут различаться в зависимости от приложения, но основной принцип остается неизменным: способ сохранения и доставки сообщений должен быть осознанным решением, а не последующей идеей.
7. Хранение истории чатов в PostgreSQL
Создание системы на основе реальных временных событий не означает, что всё должно храниться только в памяти.
Пользователи ожидают, что когда они возобновят разговор на следующий день, их предыдущие сообщения всё ещё будут там.
Упрощённая схема может выглядеть так:
CREATE TABLE messages (
id UUID PRIMARY KEY,
conversation_id UUID NOT NULL,
sender_id UUID NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
Затем, каждый раз когда пользователь открывает разговор, необходимо выполнить что-то вроде:
SELECT *
FROM messages
WHERE conversation_id = $1
ORDER BY created_at DESC
LIMIT 50;
На этом этапе мы разделили задачи на две отдельные обязанности:
Socket.IO
→ Real-time delivery
PostgreSQL
→ Durable message history
Разделение этих аспектов значительно упрощает работу.
8. Добавление индекса для истории разговоров
Если ваше приложение регулярно выполняет запросы в таком формате:
WHERE conversation_id = ?
ORDER BY created_at DESC
то схема базы данных должна быть создана с учётом этого шаблона.
Например:
CREATE INDEX idx_messages_conversation_created
ON messages(conversation_id, created_at DESC);
Смысл не в том, чтобы бездумно размещать индексы повсюду.
Индекс полезен только тогда, когда он соответствует запросам, которые фактически выполняет ваше приложение.
И, повторяя то, что уже говорилось ранее:
Всегда измеряйте производительность до и после внесения изменений.
9. Онлайн-статус отличается от хранения сообщений
Предположим, вы хотите отобразить что-то вроде:
Mit
● Online
Нет необходимости сохранять что-то вроде:
user.is_online = true
внутри PostgreSQL каждый раз, когда подключается пользователь.
Почему нет?
Потому что статус присутствия постоянно меняется.
Для большинства систем более подходящим решением будет хранение таких краткосрочных данных о присутствии в Redis.
Например:
online:user:123
TTL → 60 seconds
Клиент может отправлять периодические сигналы активности, чтобы показать, что он всё ещё работает.
Как только сердцебиения прекращаются, ключ присутствия автоматически истекает срока действия.
Это предотвращает ошибочное восприятие временного состояния соединения как постоянных данных, пригодных для хранения в базе данных.
10. Индикаторы ввода являются ещё более временными
Возьмем, к примеру:
Mit is typing...
Требуется ли для этого строка в PostgreSQL?
Определённо нет.
Это совершенно временное явление.
Для этого достаточно события сокета:
socket.to(roomId).emit("user-typing", {
userId,
});
А как только пользователь прекращает ввод:
socket.to(roomId).emit("user-stopped-typing", {
userId,
});
Это указывает на более общий принцип проектирования:
Не всё, что отслеживает ваше приложение, должно храниться в базе данных.
Хорошим критерием является вопрос о том, нужно ли информация сохраняться после перезагрузки сервера.
Если нет, вероятно, более подходящим решением будет какой-то временный способ хранения.
11. Проблема с несколькими серверами Node.js
Именно здесь всё начинает становиться более сложным.
Представьте себе конфигурацию с одним сервером Node.js:
Client
↓
Node.js
При таком масштабе всё работает без проблем.
Но затем объём трафика увеличивается.
Теперь конфигурация выглядит примерно так:
Load Balancer
/ \
↓ ↓
Node.js A Node.js B
Пользователь A подключается к Node.js A.
Пользователь B подключается к Node.js B.
Теперь пользователь A отправляет сообщение.
Как Node.js B должен узнать, что ему нужно доставить это событие пользователю B?
Именно такие проблемы требуют наличия общего слоя обмена сообщениями между экземплярами сервера.
12. Redis может подключать несколько экземпляров
Одним из распространённых решений является Redis в сочетании с адаптером Socket.IO для Redis.
Концептуально это выглядит так:
Load Balancer
/ \
↓ ↓
Node.js A Node.js B
\ /
\ /
Redis
Благодаря этому событие, созданное на одном экземпляре сервера, может быть распространено среди остальных экземпляров.
Именно это позволяет слою в реальном времени масштабироваться за пределы одного процесса Node.js.
Следует четко понимать, что Redis здесь не является заменой PostgreSQL.
Эти два инструмента предназначены для разных целей.
PostgreSQL
→ Durable application data
Redis
→ Fast temporary/shared state + coordination
13. Аутентификация по-прежнему важна
Установление соединения WebSocket не означает автоматически, что это соединение можно считать надежным.
Требуется аутентификация.
Типичный подход заключается в том, что клиент подключается, предоставляя какой-либо токен.
Прежде чем предоставить доступ к приватным разговорам, сервер должен проверить этот токен.
Концептуально процесс выглядит следующим образом:
Client
↓
Connection
↓
Authenticate
↓
Validate user
↓
Allow socket connection
Затем, когда клиент пытается присоединиться к комнате, например:
conversation:123
сервер должен подтвердить, что аутентифицированный пользователь действительно является участником этого разговора.
Никогда не стоит просто соглашаться:
socket.join(conversationId);
на предположении, что ID, предоставленное клиентом, можно доверять без проверки.
14. Обработка разрывов соединения
Неожиданное прерывание соединений — это постоянная реальность в системах в реальном времени.
Пользователь может:
- Закрыть свой браузер
- Потерять подключение к Wi-Fi
- Переключиться между сетями
- Включить режим сна телефона
- Потерять мобильный сигнал
- Принудительно закрыть приложение
Поэтому вашему серверу необходима логика вроде:
socket.on("disconnect", (reason) => {
console.log("Disconnected:", reason);
});
Также необходимо учитывать логику повторного подключения.
Кратковременное прерывание соединения на пять секунд не должно означать, что пользователь навсегда теряет доступ к функциям в реальном времени.
Именно поэтому системы в реальном времени обычно требуют более тщательного управления состоянием, чем типичный REST API.
15. Для работы в реальном времени не обязательно использовать WebSockets
Вот ещё один важный вывод.
Не существует правила, требующего полной переработки всего приложения с использованием соединений WebSocket.
Можно комбинировать разные подходы:
REST API
+
WebSockets
+
PostgreSQL
+
Redis
Например:
REST
Используйте REST при обработке:
Login
Get conversation history
Create conversation
Upload files
Search messages
WebSocket
Используйте события WebSocket при обработке:
New message
Typing indicator
Online status
Read receipts
Live notifications
Такая гибридная схема обычно делает всё гораздо проще, чем попытка прокачивать всю информацию через сокеты.
16. Что нужно учитывать при разработке для производства
Переход к режиму реального времени добавляет новый уровень сложностей.
Вам потребуется учитывать:
Authentication
Authorization
Connection limits
Reconnection
Message ordering
Duplicate messages
Offline users
Presence
Rate limiting
Horizontal scaling
Redis
Monitoring
Database performance
А если вы разрабатываете что-то более похожее на полноценный мессенджер, добавьте к этому ещё:
Message delivery guarantees
Idempotency
Unread counts
Read receipts
File attachments
Push notifications
Message pagination
Масштаб проблемы быстро растёт.
Именно поэтому первая версия не должна пытаться решить всё сразу.
17. Разумная отправная точка
Для небольшого приложения разумная начальная архитектура выглядит так:
Client
│
▼
┌───────────────┐
│ Node.js │
│ REST + WS │
└───────┬───────┘
│
┌──────┴──────┐
▼ ▼
PostgreSQL Redis
Messages Cache /
Users Presence
Оттуда можно развиваться, когда это действительно понадобится:
Load Balancer
/ \
▼ ▼
Node.js A Node.js B
\ /
\ /
Redis
│
▼
PostgreSQL
Сохраняйте первоначальную структуру простой. Оцените её производительность в реальных условиях использования. Только затем масштабируйте те компоненты, которые действительно становятся узкими местами.
18. Чек-лист для фоновых сервисов в реальном времени
Прежде чем считать фоновый сервис чата готовым к использованию в производстве, убедитесь в следующем:
[ ] Authentication implemented
[ ] Authorization for conversations
[ ] WebSocket connection handling
[ ] Room management
[ ] Message persistence
[ ] Message pagination
[ ] Reconnection handling
[ ] Duplicate message handling
[ ] Online/offline presence
[ ] Typing indicators
[ ] Rate limiting
[ ] Redis for multi-instance coordination
[ ] Logging
[ ] Monitoring
[ ] Database indexes
[ ] Load testing
[ ] Failure scenarios tested
Заключение
Снаружи создание функции чата кажется простым.
Вы вводите:
"Hello"
А кто-то другой получает:
"Hello"
Но за этими двумя словами скрывается целый набор инженерных задач:
Connection management
Authentication
Authorization
Event delivery
Persistence
Ordering
Presence
Reconnection
Scaling
Именно эта скрытая сложность делает системы в реальном времени достойными тщательного изучения.
Главный урок, который стоит запомнить, заключается в следующем:
Сопротивляйтесь желанию сразу создавать крайне распределенную систему.
Начните с:
Node.js
+
Socket.IO
+
PostgreSQL
Оцените, как ведет себя эта комбинация в реальных условиях.
Внедряйте Redis и дополнительные инстансы только тогда, когда реальные требования сделают это необходимым.
Сохраняйте первую версию минимальной. Уделите время пониманию того, как вместе работают основные компоненты. Посмотрите, как система справляется с реальной нагрузкой. Расширяйте только те части конфигурации, которым действительно нужна дополнительная производительность.
Именно так базовая функция чата превращается в надежный реально-временной бэкенд.
Связанная литература
- Основы кэширования в Redis: шаблоны, подводные камни и вопросы для собеседований — Узнайте, как работает кэширование в Redis в приложениях на Node.js, начиная с механизмов кэширования в стороне и использования TTL, заканчивая защитой от перегрузки, политиками удаления данных и распространёнными вопросами на собеседованиях.
- Выявление настоящего узкого места в медленном эндпоинте Node.js — Ознакомьтесь с систематическим методом отслеживания задержек на стороне бэкенда вдоль пути обработки запроса — от кода на Node.js до запросов к базе данных — с использованием функций измерения времени и команд EXPLAIN ANALYZE.