Головна / Статті / Проєктування бекендів для чату в реальному часі: кімнати, зберігання даних та масштабування

Проєктування бекендів для чату в реальному часі: кімнати, зберігання даних та масштабування

Дізнайтеся, як спроєктувати бекенд для чату в реальному часі за допомогою Socket.IO, PostgreSQL та Redis, з урахуванням кімнат, порядку зберігання повідомлень, статусу присутності та масштабування кількома серверами.

2380 слів

Комунікація в реальному часі змінює спосіб взаємодії сервера та клієнта, виходячи за межі простої моделі запит-відповідь та переходячи до використання WebSockets, кімнат, зберігання повідомлень, відстеження присутності та масштабування на основі Redis.

Більшість API дотримуються передбачуваної схеми:

Client
   ↓
HTTP Request
   ↓
Server
   ↓
HTTP Response

Клієнт надсилає запит. Сервер повертає відповідь. Це і є вся взаємодія.

Але тепер уявіть, що потрібно для створення чогось на кшталт:

  • WhatsApp
  • 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

Ось тут ситуація починає ставати складнішою.

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 та додаткові інстанції лише тоді, коли реальні вимоги зроблять цей крок необхідним.

Зберігайте першу версію мінімальною. Витратьте час на розуміння того, як разом працюють основні компоненти. Спостерігайте, як система справляється під реальним навантаженням. Розширюйте лише ті частини конфігурації, які дійсно потребують більшої пропускної здатності.

Саме так поступово базова функція чату перетворюється на надійний бекенд у реальному часі.

Пов’язана література

  • Створення надійних систем фонових завдань за допомогою BullMQ та Redis — Дізнайтеся, як проектувати стійкі потоки фонових завдань у Node.js з використанням BullMQ та Redis, розглядаючи питання повторних спроб, конкурентності, ідемпотентності та моніторингу.
  • Вибір транспорту та архітектури для реальночасних JavaScript-додатків — Як WebSockets, Socket.IO, WebRTC, брокери повідомлень, краєві регіони та моніторинг взаємодіють під час створення чат-програм, прямих трансляцій чи багатокористувацьких ігор на JavaScript.