Strona główna / Artykuły / Projektowanie backendów do czatów w czasie rzeczywistym: pomieszczenia, trwałość danych i skalowalność

Projektowanie backendów do czatów w czasie rzeczywistym: pomieszczenia, trwałość danych i skalowalność

Dowiedz się, jak zaprojektować backend do czatu w czasie rzeczywistym przy użyciu Socket.IO, PostgreSQL i Redis, omawiając pomieszczenia, kolejność przechowywania wiadomości, obecność użytkowników oraz skalowanie na kilka serwerów.

2380 słów

Komunikacja w czasie rzeczywistym zmienia sposób, w jaki serwer i klient ze sobą współpracują, przechodząc poza prosty model żądanie-odpowiedź w świat WebSockets, pomieszczeń, trwałego przechowywania wiadomości, śledzenia obecności oraz skalowania opartego na Redis.

Większość API podąża za przewidywalnym wzorcem:

Client
   ↓
HTTP Request
   ↓
Server
   ↓
HTTP Response

Klient wysyła żądanie. Serwer odsyła odpowiedź. To cała interakcja.

Ale teraz pomyśl o tym, co byłoby potrzebne do stworzenia czegoś takiego jak:

  • WhatsApp
  • Slack
  • Discord
  • Łącza z powiadomieniami na żywo
  • Wskaźniki obecności online
  • Wskaźniki pisania
  • Paneli sterowania na żywo
  • Funkcjonalność wieloosobowa

w tych przypadkach nie chcesz, aby klient wielokrotnie sprawdzał:

">Czy coś się zmieniło?"

Zamiast tego chcesz, aby sam serwer mógł ogłaszać:

"Coś się zmieniło. Oto aktualizacja."

To właśnie jest problem, który rozwiązuje komunikacja w czasie rzeczywistym.

1. HTTP vs komunikacja w czasie rzeczywistym

Klasyczne żądania typu polling w HTTP wyglądają tak:

Client → "Any new messages?"
Server → "No"
Client → "Any new messages?"
Server → "No"Client → "Any new messages?"
Server → "Yes, here's one."

Funkcjonuje to, ale marnuje zapytania i przepustowość na sprawdzanie aktualizacji, które zazwyczaj nie istnieją.

Trwałe połączenie w czasie rzeczywistym działa inaczej:

Client ←────────────→ Server
       connection
Server → New message
Server → User online
Server → Typing...
Server → Message read

Gdy połączenie jest już otwarte, serwer może przesyłać wydarzenia bezpośrednio do klienta w momencie wystąpienia jakiejś zmiany.

WebSockets to powszechnie używana technologia umożliwiająca taki rodzaj połączenia. W ekosystemie Node.js Socket.IO to popularna biblioteka stworzona specjalnie do komunikacji w czasie rzeczywistym.

2. Prosta architektura

A minimalna aplikacja do czatowania może być zorganizowana w ten sposób:

                 ┌──────────────┐
                 │ Web / Mobile │
                 └──────┬───────┘
                        │
                    WebSocket
                        │
                        ▼
                 ┌──────────────┐
                 │   Node.js    │
                 │ Socket.IO    │
                 └──────┬───────┘
                        │
             ┌──────────┴──────────┐
             ▼                     ▼
       ┌───────────┐         ┌───────────┐
       │ PostgreSQL│         │   Redis   │
       │ Messages  │         │ Pub/Sub   │
       └───────────┘         └───────────┘

Każda część pełni odrębną rolę.

Node.js + Socket.IO

Zarządza połączeniami w czasie rzeczywistym oraz wydarzeniami przepływającymi przez nie.

PostgreSQL

Zachowuje wiadomości oraz historię rozmów.

Redis

Staje się niezbędny, gdy uruchamiasz więcej niż jedną instancję aplikacji i potrzebujesz, aby te instancje były zsynchronizowane w celu obsługi wydarzeń w czasie rzeczywistym.

3. Konfiguracja Socket.IO

Bazowa konfiguracja serwera może wyglądać w ten sposób:

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);
  });
});

Za każdym razem, gdy klient się łączy, Socket.IO tworzy dedykowany sokiet dla tego połączenia, dzięki czemu każdy podłączony klient ma swoją własną instancję sokietu.

4. Wydarzenia to podstawowa koncepcja

Zamiast podejścia opartego na żądaniach:

"Call this endpoint"

systemy w czasie rzeczywistym są zazwyczaj zorganizowane wokół nazwanych wydarzeń:

"user-connected"
"send-message"
"message-created"
"user-typing"
"message-read"
"user-offline"

Na przykład serwer może wyemitować:

socket.emit("message-created", {
  id: message.id,
  text: message.text,
});

A klient słucha tego samego zdarzenia:

socket.on("message-created", (message) => {
  console.log("New message:", message);
});

To przechodzenie na model oparty na zdarzeniach jest jednym z podstawowych sposobów, w jaki aplikacje w czasie rzeczywistym różnią się od konwencjonalnych API opartych na REST.

5. Pomieszczenia znacznie upraszczają systemy czatowe

Wyobraź sobie rozmowę jeden na jednego:

User A
User B

Nie chcesz transmitować każdej wiadomości do wszystkich połączonych użytkowników, tylko do tych, którzy faktycznie biorą udział w tej rozmowie.

Socket.IO umożliwia grupowanie soketów w pomieszczeniach:

socket.join(`conversation:${conversationId}`);

Następnie, gdy powstaje nowa wiadomość, jest ona wysyłana do tego konkretnego pomieszczenia:

io.to(`conversation:${conversationId}`)
  .emit("message-created", message);

Tylko sokety, które dołączyły do tego pomieszczenia, otrzymają to zdarzenie.

To samo podejście można zastosować do grupowania rozmów. Pomieszczenie takie jak:

conversation:123

może zawierać kilku uczestników:

User A
User B
User C
User D

Jedno wysłane wiadomość dociera natychmiast do wszystkich w tym pomieszczeniu.

6. Nie przechowuj wiadomości po jej rozsyłce

To wybór projektowy, nad którym warto się dokładnie zastanowić.

Ryzykowna sekwencja wyglądałaby tak:

Receive message
      ↓
Broadcast message
      ↓
Save to database

Problem: co się stanie, jeśli zapis do bazy danych nie powiedzie się po tym, jak wiadomość została już wysłana? Użytkownicy zobaczą wiadomość, która tak naprawdę nigdy nie została zaprotokołowana, co powoduje niespójność pomiędzy tym, co widzą, a tym, co jest przechowywane.

Bardziej niezawodnym podejściem jest najpierw zaprotokołowanie, a dopiero potem rozsyłka:

Client
  ↓
send-message
  ↓
Validate
  ↓
Save to PostgreSQL
  ↓
Database succeeds
  ↓
Broadcast event

W praktyce wygląda to mniej więcej tak:

socket.on("send-message", async (data) => {
  const message = await saveMessage(data);

io.to(`conversation:${data.conversationId}`)
    .emit("message-created", message);
});

Dokładne gwarancje trwałości, których potrzebujesz, różnią się w zależności od aplikacji, ale podstawowa zasada pozostaje ta sama: sposób przechowywania i dostarczania wiadomości powinien być świadomą decyzją, a nie czymś dodanym później.

7. Przechowywanie historii rozmów w PostgreSQL

Budowa systemu opartego na wydarzeniach w czasie rzeczywistym nie oznacza, że wszystko musi istnieć tylko w pamięci.

Ludzie oczekują, że gdy następnego dnia otworzą rozmowę, ich wcześniejsze wiadomości nadal będą tam dostępne.

Społeczna struktura może wyglądać w ten sposób:

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()
);

Następnie, za każdym razem gdy użytkownik otworzy rozmowę, należy wykonać coś w rodzaju:

SELECT *
FROM messages
WHERE conversation_id = $1
ORDER BY created_at DESC
LIMIT 50;

W tym momencie podzieliliśmy zadania na dwie odrębne responsybilności:

Socket.IO
→ Real-time delivery
PostgreSQL
→ Durable message history

Rozdzielenie tych zadań ma duże znaczenie.

8. Dodanie indeksu do historii rozmów

Jeśli twoja aplikacja regularnie wykonywa zapytania w takim kształcie:

WHERE conversation_id = ?
ORDER BY created_at DESC

to struktura bazy danych powinna być zaprojektowana z myślą o tym wzorcu.

Naprzимер:

CREATE INDEX idx_messages_conversation_created
ON messages(conversation_id, created_at DESC);

Cel nie polega na umieszczaniu indeksów wszędzie bez zastanowienia.

Indeks jest przydatny tylko wtedy, gdy odpowiada zapytaniom faktycznie wykonywanym przez aplikację.

I, powtarzając to, co omówiono wcześniej:

Zawsze mierz wydajność przed i po wprowadzeniu zmiany.

9. Obecność online różni się od przechowywania wiadomości

Załóżmy, że chcesz wyświetlić coś takiego:

Mit
● Online

Nie ma potrzeby przechowywać na stałe czegoś takiego:

user.is_online = true

w PostgreSQL za każdym razem, gdy użytkownik się łączy.

Dlaczego nie?

Ponieważ stan obecności ciągle się zmienia.

Dla większości systemów lepszym rozwiązaniem jest przechowywanie takich krótkotrwałych danych o obecności w Redis.

Naprzимер:

online:user:123
TTL → 60 seconds

Klient może wysyłać okresowe sygnały aktywności, aby pokazać, że nadal jest aktywny.

Gdy te sygnały serca przestaną przychodzić, klucz obecności po prostu wygasa sam z siebie.

Dzięki temu tymczasowy stan połączenia nie jest mylony z trwałymi danymi nadającymi się do przechowywania w bazie danych.

10. Wskaźniki pisania są jeszcze bardziej tymczasowe

Weźmy coś takiego:

Mit is typing...

Czy to wymaga wiersza w PostgreSQL?

Zdecydowanie nie.

Jest to czysto tymczasowe.

wystarczy zdarzenie sokietu:

socket.to(roomId).emit("user-typing", {
  userId,
});

A gdy użytkownik przestanie pisać:

socket.to(roomId).emit("user-stopped-typing", {
  userId,
});

To wskazuje na szerszą zasadę projektowania:

Nie wszystko, co śledzi twoja aplikacja, musi znajdować się w bazie danych.

Dobrym testem jest to, czy informacje muszą przetrwać restart serwera.

Jeśli nie, prawdopodobnie lepszym rozwiązaniem będzie jakiś tymczasowy magazyn danych.

11. Problem z wieloma serwerami Node.js

Tutaj sytuacja zaczyna się komplikować.

Wyobraź sobie konfigurację z tylko jednym serwerem Node.js:

Client
   ↓
Node.js

W takim skali wszystko działa sprawnie.

Ale potem ruch sieciowy wzrasta.

Teraz konfiguracja wygląda mniej więcej tak:

              Load Balancer
                /       \
               ↓         ↓
          Node.js A   Node.js B

Użytkownik A łączy się z Node.js A.

Użytkownik B łączy się z Node.js B.

Teraz Użytkownik A wysyła wiadomość.

Jak Node.js B ma dowiedzieć się, że musi dostarczyć tę wiadomość do Użytkownika B?

To właśnie ten typ problemu, który wymaga wspólnego warstwy komunikacji pomiędzy instancjami serwera.

12. Redis może łączyć kilka instancji

Jednym z powszechnych rozwiązań jest Redis w połączeniu z adapterem Socket.IO dla Redis.

Koncepcyjnie wygląda to tak:

                Load Balancer
                 /         \
                ↓           ↓
          Node.js A     Node.js B
                \           /
                 \         /
                   Redis

Dzięki temu zdarzenie utworzone na jednej instancji serwera może być przekazywane do pozostałych.

To właśnie umożliwia skalowanie warstwy w czasie rzeczywistym poza pojedynczy proces Node.js.

Należy jasno zaznaczyć, że Redis nie jest tutaj zamiennikiem PostgreSQL.

Oba te narzędzia służą różnym celom.

PostgreSQL
→ Durable application data
Redis
→ Fast temporary/shared state + coordination

13. Autoryzacja wciąż ma znaczenie

Nawiązanie połączenia WebSocket nie oznacza automatycznie, że to połączenie można ufać.

Nadal wymagana jest autoryzacja.

Zwykłym podejściem jest połączenie klienta przy przedstawieniu jakiegoś tokena.

Zanim udostępniony zostanie dostęp do prywatnych rozmów, serwer musi zweryfikować ten token.

Koncepcyjnie proces wygląda następująco:

Client
  ↓
Connection
  ↓
Authenticate
  ↓
Validate user
  ↓
Allow socket connection

Następnie, gdy klient próbuje dołączyć do pokoju, na przykład:

conversation:123

serwer musi potwierdzić, że autoryzowany użytkownik faktycznie jest uczestnikiem tej rozmowy.

Nigdy nie akceptuj po prostu:

socket.join(conversationId);

przy założeniu, że ID dostarczone przez klienta można ufać bez dodatkowej weryfikacji.

14. Obsługa rozłączeń

Niespodziewane przerywanie połączeń jest stałą rzeczywistością w systemach w czasie rzeczywistym.

Użytkownik może:

  • Zamknąć swój przeglądarkę
  • Stracić połączenie Wi-Fi
  • Przełączyć się między sieciami
  • Pozwolić telefonowi wejść w tryb uśpienia
  • Stracić sygnał komórkowy
  • Zamknąć aplikację siłą

Z tego powodu twój serwer potrzebuje logiki takiej jak:

socket.on("disconnect", (reason) => {
  console.log("Disconnected:", reason);
});

Należy również wziąć pod uwagę logikę ponownego łączenia.

Krótkie przerwanie połączenia trwające pięć sekund nie powinno oznaczać, że użytkownik traci na zawsze dostęp do funkcji w czasie rzeczywistym.

To jest jednym z powodów, dla których systemy w czasie rzeczywistym wymagają zazwyczaj bardziej starannego zarządzania stanem niż typowy REST API.

15. Systemy w czasie rzeczywistym nie wymagają, aby wszystko działało przez WebSockets

Oto kolejna ważna informacja.

Nie istnieje zasada mówiąca, że cała aplikacja musi być przeprojektowana wokół połączeń WebSocket.

Można łączyć różne podejścia:

REST API
+
WebSockets
+
PostgreSQL
+
Redis

Na przykład:

REST

Należy używać REST przy obsłudze:

Login
Get conversation history
Create conversation
Upload files
Search messages

WebSocket

Należy używać zdarzeń WebSocket przy obsłudze:

New message
Typing indicator
Online status
Read receipts
Live notifications

Taka hybrydowa konfiguracja zazwyczaj upraszcza sprawy znacznie bardziej niż próba przepuszczania wszystkiego przez gniazda sieciowe.

16. Kwestie do uwzględnienia w produkcji

Przejście na działanie w czasie rzeczywistym wprowadza nowy poziom wyzwań.

Będziesz musiał wziąć pod uwagę:

Authentication
Authorization
Connection limits
Reconnection
Message ordering
Duplicate messages
Offline users
Presence
Rate limiting
Horizontal scaling
Redis
Monitoring
Database performance

A jeśli budujesz coś bardziej przypominającego pełny produkt do komunikacji, dodaj do tego:

Message delivery guarantees
Idempotency
Unread counts
Read receipts
File attachments
Push notifications
Message pagination

Zakres problemu szybko się rozszerza.

Dlatego właśnie pierwsza wersja nie powinna próbować rozwiązać wszystkiego naraz.

17. Rozsądny punkt wyjścia

Dla mniejszej aplikacji rozsądna architektura wyjściowa wygląda tak:

             Client
                │
                ▼
        ┌───────────────┐
        │    Node.js    │
        │  REST + WS    │
        └───────┬───────┘
                │
         ┌──────┴──────┐
         ▼             ▼
    PostgreSQL       Redis
    Messages         Cache /
    Users            Presence

Można ją rozwijać dalej, gdy faktycznie będzie to konieczne:

                 Load Balancer
                 /           \
                ▼             ▼
          Node.js A       Node.js B
                \             /
                 \           /
                    Redis
                      │
                      ▼
                 PostgreSQL

Zachowaj prostotę początkowej wersji aplikacji. Sprawdź jej wydajność w rzeczywistych warunkach użycia. Dopiero wtedy skaluj te elementy, które okazują się prawdziwymi wąskimi gardłami.

18. Lista kontrolna dla backendów w czasie rzeczywistym

Zanim uznasz backend do czatowania za gotowy do użycia w produkcji, upewnij się co do:

[ ] 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

Ostatnia myśl

Z zewnątrz budowa funkcji czatowania wydaje się prosta.

Piszesz:

"Hello"

A ktoś inny otrzymuje:

"Hello"

Lecz za tymi dwoma słowami kryje się cały zestaw wyzwań inżynieryjnych:

Connection management
Authentication
Authorization
Event delivery
Persistence
Ordering
Presence
Reconnection
Scaling

To ukryte złożoność jest właśnie tym, co sprawia, że systemy w czasie rzeczywistym warto dobrze zrozumieć.

Główna lekcja, którą warto zapamiętać, brzmi następująco:

Opanuj chęć stworzenia masowo rozproszonego systemu od pierwszego dnia.

Zacznij od:

Node.js
+
Socket.IO
+
PostgreSQL

Zobacz, jak ta kombinacja zachowuje się w rzeczywistych warunkach.

Wdroż Redis oraz dodatkowe instancje tylko wtedy, gdy rzeczywiste wymagania tego wymagają.

Zachowaj pierwszą wersję w minimalnym formacie. Poświęć czas na zrozumienie, jak współpracują kluczowe elementy. Obserwuj, jak system radzi sobie pod rzeczywistym obciążeniem. Rozszerz tylko te części konfiguracji, które naprawdę potrzebują większej przepustowości.

Dzięki takiemu podejściu prosta funkcja czatu przekształca się w niezawodny backend w czasie rzeczywistym.

Literatura pokrewna

  • Budowanie niezawodnych systemów zadań w tle za pomocą BullMQ i Redis — Dowiedz się, jak projektować odporne łańcuchy przetwarzania zadań w tle w Node.js przy użyciu BullMQ i Redis, omawiając ponawianie prób, równoczesność, idempotencję oraz monitorowanie.
  • Wybór transportu i architektury dla aplikacji JavaScript w czasie rzeczywistym — Jak WebSockets, Socket.IO, WebRTC, brokerzy wiadomości, regiony brzegowe oraz systemy monitoringu współpracują przy tworzeniu aplikacji do czatowania, transmisji na żywo lub gier wieloosobowych w JavaScript.