Entwurf von Echtzeit-Chat-Backends: Räume, Persistenz und Skalierung
Erfahren Sie, wie man einen Echtzeit-Chat-Backend mit Socket.IO, PostgreSQL und Redis entwirft – dabei werden Themen wie Räume, die Persistenzreihenfolge von Nachrichten, Präsenz und Skalierung über mehrere Server behandelt.
Die Echtzeitkommunikation verändert die Beziehung zwischen Server und Client, indem sie über einfache Anfrage-Antwort-Verfahren hinausgeht und in eine Welt von WebSockets, Räumen, Nachrichtenpersistenz, Präsenzverfolgung sowie skalierbaren Lösungen auf Basis von Redis übergeht.
Die meisten APIs folgen einem vorhersehbaren Muster:
Client
↓
HTTP Request
↓
Server
↓
HTTP Response
Der Client sendet eine Anfrage. Der Server sendet eine Antwort zurück. Das ist die gesamte Interaktion.
Aber denken Sie nun darüber nach, was nötig wäre, um etwas wie Folgendes zu entwickeln:
- Slack
- Discord
- Echtzeit-Benachrichtigungsfeeds
- Anzeiger für die Online-Präsenz
- Anzeiger für das Tippen
- Echtzeit-Dashboards
- Mehrspielerfunktionen
In solchen Fällen möchte man nicht, dass der Client immer wieder nachfragt:
"Hat sich etwas geändert?"
Sondern man möchte, dass der Server selbst in der Lage ist, mitzuteilen:
„Etwas hat sich geändert. Hier ist die Aktualisierung.“
Genau dieses Problem löst die Echtzeitkommunikation.
1. HTTP gegenüber Echtzeitkommunikation
Die traditionelle HTTP-Polling-Methode sieht so aus:
Client → "Any new messages?"
Server → "No"
Client → "Any new messages?"
Server → "No"Client → "Any new messages?"
Server → "Yes, here's one."
Es funktioniert, verschwendet aber Anfragen und Bandbreite beim Überprüfen von Aktualisierungen, die in der Regel nicht vorhanden sind.
Eine dauerhafte Echtzeitverbindung verhält sich anders:
Client ←────────────→ Server
connection
Server → New message
Server → User online
Server → Typing...
Server → Message read
Sobald die Verbindung geöffnet ist, kann der Server Ereignisse direkt an den Client senden, sobald sich etwas ändert.
WebSockets sind eine weit verbreitete Technologie, um diese Art von Verbindung zu ermöglichen. Im Node.js-Ökosystem ist Socket.IO eine beliebte Bibliothek, die speziell für die Echtzeitkommunikation entwickelt wurde.
2. Eine einfache Architektur
Eine minimale Chat-Anwendung könnte wie folgt strukturiert sein:
┌──────────────┐
│ Web / Mobile │
└──────┬───────┘
│
WebSocket
│
▼
┌──────────────┐
│ Node.js │
│ Socket.IO │
└──────┬───────┘
│
┌──────────┴──────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ PostgreSQL│ │ Redis │
│ Messages │ │ Pub/Sub │
└───────────┘ └───────────┘
Jeder Bestandteil übernimmt eine spezifische Rolle.
Node.js + Socket.IO
Verwaltet Live-Verbindungen sowie die durch sie fließenden Ereignisse.
PostgreSQL
Speichert Nachrichten und das Konversationsverlauf.
Redis
Kommt zum Einsatz, wenn Sie mehr als eine Instanz Ihrer Anwendung betreiben und diese für Echtzeit-Ereignisse synchron bleiben müssen.
3. Einrichten von Socket.IO
Eine grundlegende Servereinrichtung könnte wie folgt aussehen:
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);
});
});
Jedes Mal, wenn sich ein Client verbindet, erstellt Socket.IO einen dedizierten Socket für diese Verbindung, sodass jeder verbundene Client seine eigene Socket-Instanz erhält.
4. Ereignisse sind das zentrale Konzept
Anstelle des auf Anfragen basierenden Denkansatzes:
"Call this endpoint"
werden Echtzeit-Systeme in der Regel um benannte Ereignisse herum organisiert:
"user-connected"
"send-message"
"message-created"
"user-typing"
"message-read"
"user-offline"
Zum Beispiel könnte der Server folgendes aussenden:
socket.emit("message-created", {
id: message.id,
text: message.text,
});
Und der Client lauscht auf dieses gleiche Ereignis:
socket.on("message-created", (message) => {
console.log("New message:", message);
});
Diese Verlagerung hin zu einem ereignisgesteuerten Modell ist eine der grundlegenden Unterschiede zwischen Echtzeitanwendungen und herkömmlichen REST-basierten APIs.
5. Räume machen Chat-Systeme viel einfacher
Stellen Sie sich ein Ein-zu-Eins-Gespräch vor:
User A
User B
Man möchte keine Nachricht an alle verbundenen Benutzer senden, sondern nur an diejenigen, die tatsächlich an diesem Gespräch teilnehmen.
Socket.IO ermöglicht es, Sockets in einen Raum zu gruppieren:
socket.join(`conversation:${conversationId}`);
Dann wird jede neue Nachricht an diesen spezifischen Raum gesendet:
io.to(`conversation:${conversationId}`)
.emit("message-created", message);
Nur die Sockets, die sich diesem Raum angeschlossen haben, erhalten das Ereignis.
Dieselbe Idee lässt sich auf Gruppengespräche übertragen. Ein Raum wie:
conversation:123
kann mehrere Teilnehmer enthalten:
User A
User B
User C
User D
Und eine einzige gesendete Nachricht erreicht alle in diesem Raum gleichzeitig.
6. Speichern Sie die Nachricht nicht nach der Übertragung
Das ist eine Gestaltungsentscheidung, über die man sorgfältig nachdenken sollte.
Eine riskante Abfolge wäre:
Receive message
↓
Broadcast message
↓
Save to database
Das Problem: Was passiert, wenn das Schreiben in die Datenbank nachdem die Nachricht bereits gesendet wurde fehlschlägt? Die Benutzer hätten eine Nachricht gesehen, die eigentlich nie gespeichert wurde, was zu einer Inkonsistenz zwischen dem, was die Leute sehen, und dem, was gespeichert ist, führt.
Ein zuverlässigeres Muster besteht darin, zunächst zu speichern und anschließend zu übertragen:
Client
↓
send-message
↓
Validate
↓
Save to PostgreSQL
↓
Database succeeds
↓
Broadcast event
In der Praxis sieht das ungefähr so aus:
socket.on("send-message", async (data) => {
const message = await saveMessage(data);
io.to(`conversation:${data.conversationId}`)
.emit("message-created", message);
});
Die genauen Zuverlässigkeitsgarantien, die Sie benötigen, variieren je nach Anwendung, doch das zugrundeliegende Prinzip gilt im Allgemeinen: Wie Nachrichten gespeichert und übermittelt werden, sollte eine bewusste Entscheidung sein – nicht etwas, was erst im Nachhinein bedacht wird.
7. Chatsverlauf in PostgreSQL speichern
Das Aufbauen eines Systems um Echtzeit-Ereignisse zu kümmern bedeutet nicht, dass alles nur im Speicher existieren muss.
Menschen erwarten, dass ihre früheren Nachrichten noch vorhanden sind, wenn sie am nächsten Tag eine Konversation öffnen.
Ein vereinfachtes Schema könnte so aussehen:
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()
);
Dann würde man jedes Mal, wenn ein Benutzer eine Konversation öffnet, etwas wie folgt ausführen:
SELECT *
FROM messages
WHERE conversation_id = $1
ORDER BY created_at DESC
LIMIT 50;
An diesem Punkt haben wir die Aufgaben in zwei unterschiedliche Verantwortungsbereiche aufgeteilt:
Socket.IO
→ Real-time delivery
PostgreSQL
→ Durable message history
Es ist sehr wichtig, diese Aufgaben getrennt zu halten.
8. Ein Index für den Chatsverlauf hinzufügen
Falls Ihre Anwendung regelmäßig Abfragen in dieser Form ausführt:
WHERE conversation_id = ?
ORDER BY created_at DESC
dann sollte Ihr Datenbankschema mit diesem Muster berücksichtigt werden.
Zum Beispiel:
CREATE INDEX idx_messages_conversation_created
ON messages(conversation_id, created_at DESC);
Es geht nicht darum, Indexe ohne nachzudenken überall hinzuzufügen.
Ein Index ist nur nützlich, wenn er mit den Abfragen übereinstimmt, die Ihre Anwendung tatsächlich ausführt.
Und um das zu wiederholen, was bereits erwähnt wurde:
Messen Sie immer die Leistung vor und nach der Änderung.
9. Die Online-Präsenz unterscheidet sich von der Nachrichtenspeicherung
Nehmen wir an, Sie möchten etwas wie Folgendes anzeigen:
Mit
● Online
Es gibt keinen Grund, etwas wie Folgendes dauerhaft zu speichern:
user.is_online = true
innerhalb von PostgreSQL jedes Mal, wenn sich ein Benutzer anmeldet.
Warum nicht?
Weil der Präsenzstatus ständig ändert.
Für die meisten Systeme eignet es sich besser, solche kurzlebigen Präsenzdaten stattdessen in Redis zu speichern.
Zum Beispiel:
online:user:123
TTL → 60 seconds
Der Client kann periodische Heartbeat-Nachrichten senden, um anzuzeigen, dass er weiterhin aktiv ist.
Sobald die Herzschlagmeldungen nicht mehr eintreffen, läuft der Präsenzschlüssel einfach von selbst ab.
Dadurch wird verhindert, dass ein vorübergehender Verbindungsstatus fälschlicherweise als dauerhafte, für die Datenbank geeignete Information angesehen wird.
10. Eingabesymbole sind noch vorübergehender
Nehmen wir zum Beispiel Folgendes:
Mit is typing...
Braucht das eine Zeile in PostgreSQL?
Absolut nicht.
Es handelt sich dabei um etwas rein Vorübergehendes.
Ein Socket-Ereignis reicht aus:
socket.to(roomId).emit("user-typing", {
userId,
});
Und sobald der Benutzer aufhört einzutippen:
socket.to(roomId).emit("user-stopped-typing", {
userId,
});
Das weist auf ein allgemeineres Designprinzip hin:
Nicht alles, was Ihre Anwendung verfolgt, muss in einer Datenbank gespeichert werden.
Eine gute Prüfung ist, ob die Informationen auch nach einem Serverneustart erhalten bleiben müssen.
Falls nicht, ist wahrscheinlich eine Art vorübergehender Speicherung besser geeignet.
11. Das Problem mit mehreren Node.js-Servern
Hier wird es zunehmend kompliziert.
Stellen Sie sich eine Konfiguration mit nur einem Node.js-Server vor:
Client
↓
Node.js
Bei dieser Skala funktioniert alles reibungslos.
Aber dann steigt die Verkehrsmenge an.
Nun sieht die Konfiguration eher so aus:
Load Balancer
/ \
↓ ↓
Node.js A Node.js B
Benutzer A ist mit Node.js A verbunden.
Benutzer B ist mit Node.js B verbunden.
Nun sendet Benutzer A eine Nachricht.
Wie soll Node.js B erfahren, dass es diese Nachricht an Benutzer B übermitteln muss?
Genau dieses Problem erfordert eine gemeinsame Nachrichtenschicht zwischen den Serverinstanzen.
12. Redis kann mehrere Instanzen verbinden
Eine gängige Lösung ist Redis in Kombination mit dem Socket.IO Redis-Adapter.
Konzeptionell sieht das so aus:
Load Balancer
/ \
↓ ↓
Node.js A Node.js B
\ /
\ /
Redis
Mit dieser Lösung kann ein auf einer Serverinstanz erstelltes Ereignis an die anderen Instanzen weitergeleitet werden.
Das ist es, was es ermöglicht, dass die Echtzeit-Schicht über einen einzelnen Node.js-Prozess hinaus skaliert.
Es ist wichtig zu betonen, dass Redis hier kein Ersatz für PostgreSQL ist.
Die beiden Tools dienen unterschiedlichen Zwecken.
PostgreSQL
→ Durable application data
Redis
→ Fast temporary/shared state + coordination
13. Authentifizierung bleibt wichtig
Die Herstellung einer WebSocket-Verbindung bedeutet nicht automatisch, dass diese Verbindung vertrauenswürdig ist.
Eine Authentifizierung ist weiterhin erforderlich.
Ein typischer Ansatz besteht darin, dass der Client bei der Verbindung einen bestimmten Token vorlegt.
Bevor Zugang zu privaten Gesprächen gewährt wird, muss der Server diesen Token überprüfen.
Konzeptionell sieht der Ablauf wie folgt aus:
Client
↓
Connection
↓
Authenticate
↓
Validate user
↓
Allow socket connection
Dann, wenn ein Client versucht, sich einer Room anzuschließen, wie zum Beispiel:
conversation:123
muss der Server bestätigen, dass der authentifizierte Benutzer tatsächlich an diesem Gespräch teilnimmt.
Nehmen Sie niemals einfach hin:
socket.join(conversationId);
unter der Annahme, dass die vom Client bereitgestellte ID ohne weiteres vertrauenswürdig ist.
14. Umgang mit Verbindungsabbrüchen
Unerwarteter Verbindungsverlust ist in Echtzeit-Systemen eine ständige Realität.
Ein Benutzer könnte:
- Seinen Browser schließen
- Sieht seine Wi-Fi-Verbindung verloren gehen
- Zwischen Netzwerken wechseln
- Lässt sein Telefon in den Ruhezustand gehen
- Verliert das Mobilfunksignal
- Das Programm gewaltsam beenden
Deshalb benötigt Ihr Server Logik wie:
socket.on("disconnect", (reason) => {
console.log("Disconnected:", reason);
});
Auch eine Wiederherstellungslogik muss berücksichtigt werden.
Ein kurzer fünfsekündiger Netzwerkausfall sollte nicht bedeuten, dass ein Benutzer dauerhaft keinen Zugriff auf Echtzeitfunktionen mehr hat.
Dies ist ein Grund, warum Echtzeit-Systeme im Allgemeinen eine sorgfältigere Zustandsverwaltung erfordern als typische REST-APIs.
15. Für Echtzeitfunktionen sind nicht immer WebSockets notwendig
Hier ist ein weiterer wichtiger Punkt.
Es gibt keine Regel, die vorschreibt, dass Ihre gesamte Anwendung um WebSocket-Verbindungen herum neu aufgebaut werden muss.
Man kann verschiedene Ansätze kombinieren:
REST API
+
WebSockets
+
PostgreSQL
+
Redis
Zum Beispiel:
REST
wenden Sie REST an, wenn Sie folgendes verarbeiten:
Login
Get conversation history
Create conversation
Upload files
Search messages
WebSocket
wenden Sie WebSocket-Events an, wenn Sie folgendes verarbeiten:
New message
Typing indicator
Online status
Read receipts
Live notifications
Diese hybride Konfiguration macht die Sache in der Regel viel einfacher als der Versuch, alles über Sockets abzuwickeln.
16. Dinge, die man für die Produktion im Hinterkopf behalten sollte
Der Übergang zum Echtzeitbetrieb bringt neue Herausforderungen mit sich.
Man muss berücksichtigen:
Authentication
Authorization
Connection limits
Reconnection
Message ordering
Duplicate messages
Offline users
Presence
Rate limiting
Horizontal scaling
Redis
Monitoring
Database performance
Und wenn Sie etwas entwickeln, das eher einem vollwertigen Messaging-Produkt ähnelt, fügen Sie noch Folgendes hinzu:
Message delivery guarantees
Idempotency
Unread counts
Read receipts
File attachments
Push notifications
Message pagination
Der Umfang des Problems erweitert sich schnell.
Deshalb sollte die erste Version nicht versuchen, alles auf einmal zu lösen.
17. Ein vernünftiger Ausgangspunkt
Für eine kleinere Anwendung sieht eine sinnvolle Ausgangsarchitektur so aus:
Client
│
▼
┌───────────────┐
│ Node.js │
│ REST + WS │
└───────┬───────┘
│
┌──────┴──────┐
▼ ▼
PostgreSQL Redis
Messages Cache /
Users Presence
Man kann von dort aus erweitern, sobald es tatsächlich notwendig ist:
Load Balancer
/ \
▼ ▼
Node.js A Node.js B
\ /
\ /
Redis
│
▼
PostgreSQL
Halten Sie die anfängliche Implementierung einfach. Messen Sie deren Leistung unter tatsächlicher Nutzung. Erst dann skalieren Sie diejenigen Komponenten, die sich als echte Engpässe erweisen.
18. Eine Checkliste für Echtzeit-Backends
Bevor man ein Chat-Backend als produktreif betrachtet, sollte man Folgendes überprüfen:
[ ] 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
Letzter Gedanke
Von außen betrachtet scheint die Entwicklung einer Chat-Funktion einfach zu sein.
Man tippt:
"Hello"
Und jemand anderes erhält:
"Hello"
Doch hinter diesen beiden Worten verbirgt sich eine ganze Reihe von ingenieurtechnischen Herausforderungen:
Connection management
Authentication
Authorization
Event delivery
Persistence
Ordering
Presence
Reconnection
Scaling
Genau diese verborgene Komplexität macht Echtzeit-Systeme zu etwas, das es wert ist, gründlich verstanden zu werden.
Die wichtigste Lektion, die man beherzigen sollte, lautet:
Gegen den Drang ankämpfen, bereits am ersten Tag ein massiv verteiltes System zu erstellen.
Fangen Sie mit Folgendem an:
Node.js
+
Socket.IO
+
PostgreSQL
Erfahren Sie, wie sich diese Kombination unter realen Bedingungen verhält.
Fügen Sie Redis sowie zusätzliche Instanzen nur dann hinzu, wenn tatsächliche Anforderungen dies notwendig machen.
Lassen Sie die erste Version minimal bleiben. Nehmen Sie sich Zeit, um zu verstehen, wie die Kernkomponenten zusammenarbeiten. Beobachten Sie, wie das System unter echter Last funktioniert. Erweitern Sie nur die Teile der Einrichtung, die wirklich mehr Kapazität benötigen.
Durch diesen schrittweisen Ansatz entwickelt sich eine einfache Chat-Funktion zu einem zuverlässigen Echtzeit-Backend.
Verwandte Literatur
- Grundlagen des Redis-Cachings: Muster, Fallstricke und Fragen in Vorstellungsgesprächen — Erfahren Sie, wie das Redis-Caching in Node.js-Anwendungen funktioniert, von Cache-Aside und TTL über Schutz vor Überlastung bis hin zu Eviction-Politiken sowie gängigen Fragen in Vorstellungsgesprächen.
- Erkennen des wahren Engpasses in einem langsamen Node.js-Endpunkt — Lernen Sie eine systematische Methode, um die Latenz im Backend entlang des Anfragepfades zu verfolgen – vom Node.js-Code über Datenbankabfragen bis hin zum Einsatz von Timing- und EXPLAIN ANALYZE-Funktionen.