Echtzeit-Kommunikation für DAOs: Architektur und Skalierung
Echtzeit-Messaging ist kein gelöstes Problem. Zumindest nicht, wenn Ihre Anforderungen über einen einfachen Chat hinausgehen. In einem Projekt für eine DAO-Community-Plattform standen wir vor der Aufgabe, ein Messaging-System zu entwerfen, das dezentrale Governance-Strukturen abbilden kann, dabei Token-basierte Zugriffssteuerung unterstützt und trotzdem bei tausenden gleichzeitigen Verbindungen performant bleibt.
Dieser Beitrag dokumentiert die Architekturentscheidungen, die wir getroffen haben, samt Benchmarks und Codebeispielen.
WebSocket vs. Server-Sent Events
Die erste Entscheidung betrifft das Transportprotokoll. WebSockets bieten bidirektionale Kommunikation über eine persistente TCP-Verbindung. Server-Sent Events (SSE) sind unidirektional (Server-zu-Client) und laufen über HTTP/2.
Unsere Messergebnisse auf einem 4-Core-Server mit 16 GB RAM:
Protokoll | Verbindungen | Latenz p50 | Latenz p99 | RAM/Verbindung
----------------|--------------|------------|------------|---------------
WebSocket | 10.000 | 2,1 ms | 8,4 ms | ~4,2 KB
SSE (HTTP/2) | 10.000 | 3,8 ms | 14,1 ms | ~6,8 KB
WebSocket | 50.000 | 3,4 ms | 22,7 ms | ~4,3 KB
SSE (HTTP/2) | 50.000 | 9,2 ms | 61,3 ms | ~7,1 KB
WebSockets gewinnen bei Latenz und Speicherverbrauch. SSE hat den Vorteil, dass es durch Firewalls und Proxies weniger Probleme bereitet und automatisch reconnected. Für eine Community-Plattform mit bidirektionalem Messaging (Senden und Empfangen) war WebSocket die klare Wahl. SSE setzen wir ergänzend für Benachrichtigungen und Statusupdates ein, wo kein Rückkanal benötigt wird.
Verbindungsmanagement
Der WebSocket-Server läuft auf Node.js mit der ws-Bibliothek. Jede
Verbindung durchläuft nach dem Handshake eine Authentifizierungsphase:
import { WebSocketServer } from "ws";
import { verifyToken } from "./auth";
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", async (req, socket, head) => {
try {
const token = new URL(req.url!, `http://${req.headers.host}`)
.searchParams.get("token");
if (!token) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
const user = await verifyToken(token);
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req, user);
});
} catch {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
}
});
Wichtig: Der Token wird als Query-Parameter übergeben, nicht als Header. WebSocket-Clients können bei der Initiierung keine Custom-Header setzen. Der Token ist kurzlebig (30 Sekunden) und wird über einen separaten REST-Endpunkt angefordert.
Nachrichtenreihenfolge und Konsistenz
In einem verteilten System mit mehreren Server-Instanzen ist die Reihenfolge von Nachrichten nicht trivial. Wir verwenden Hybrid Logical Clocks (HLC), die physische Zeitstempel mit einem logischen Zähler kombinieren:
interface HLC {
ts: number; // Physische Zeit in Millisekunden
counter: number;
nodeId: string;
}
function tick(local: HLC, received?: HLC): HLC {
const now = Date.now();
if (!received) {
return {
ts: Math.max(local.ts, now),
counter: local.ts === Math.max(local.ts, now)
? local.counter + 1 : 0,
nodeId: local.nodeId,
};
}
const maxTs = Math.max(local.ts, received.ts, now);
let counter = 0;
if (maxTs === local.ts && maxTs === received.ts)
counter = Math.max(local.counter, received.counter) + 1;
else if (maxTs === local.ts)
counter = local.counter + 1;
else if (maxTs === received.ts)
counter = received.counter + 1;
return { ts: maxTs, counter, nodeId: local.nodeId };
}
HLCs garantieren kausale Ordnung ohne Abhängigkeit von synchronisierten Uhren. Zwei
Nachrichten vom selben Absender haben immer die korrekte Reihenfolge. Bei gleichzeitigen
Nachrichten verschiedener Absender wird die nodeId als Tiebreaker verwendet.
Präsenzanzeige
Präsenz (online/offline/abwesend) ist überraschend aufwändig. Naive Implementierungen mit Heartbeats erzeugen bei 10.000 Nutzern und 30-Sekunden-Intervallen über 300 Nachrichten pro Sekunde nur für Präsenz. Unser Ansatz:
// Präsenz-Update nur bei Statuswechsel
// Redis Sorted Set: Score = Timestamp, Member = userId
await redis.zadd("presence:channel:42", Date.now(), userId);
// Cleanup: Einträge älter als 90 Sekunden entfernen
await redis.zremrangebyscore(
"presence:channel:42", 0, Date.now() - 90_000
);
// Online-Nutzer eines Channels abfragen
const online = await redis.zrangebyscore(
"presence:channel:42",
Date.now() - 90_000, "+inf"
);
Clients senden alle 60 Sekunden einen stillen Heartbeat. Die Präsenzliste wird nur bei tatsächlichen Wechseln (online → offline) an andere Clients verteilt, nicht bei jedem Heartbeat.
Skalierung mit Redis Pub/Sub
Einzelne Node.js-Instanzen stoßen bei ca. 50.000 gleichzeitigen WebSocket-Verbindungen an ihre Grenzen. Für horizontale Skalierung nutzen wir Redis Pub/Sub als Message-Broker zwischen den Instanzen:
import Redis from "ioredis";
const pub = new Redis(process.env.REDIS_URL);
const sub = new Redis(process.env.REDIS_URL);
// Nachricht an alle Instanzen verteilen
async function broadcastToChannel(
channelId: string,
message: Message
) {
await pub.publish(
`ch:${channelId}`,
JSON.stringify(message)
);
}
// Jede Instanz abonniert die Channels ihrer Clients
sub.on("message", (redisChannel, data) => {
const channelId = redisChannel.replace("ch:", "");
const message = JSON.parse(data);
// An lokale WebSocket-Clients weiterleiten
const locals = connectionsByChannel.get(channelId);
if (locals) {
const payload = JSON.stringify(message);
for (const ws of locals) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
}
}
});
Benchmark mit 3 Instanzen hinter einem Load Balancer:
Instanzen | Verbindungen | Nachrichten/s | Latenz p50 | Latenz p99
----------|--------------|---------------|------------|----------
1 | 50.000 | 12.400 | 3,4 ms | 22,7 ms
3 | 150.000 | 34.800 | 4,1 ms | 28,3 ms
3 | 150.000 | 82.000 | 6,7 ms | 41,2 ms
Die Skalierung ist nahezu linear. Redis Pub/Sub ist der Flaschenhals: Bei über 100.000 Nachrichten pro Sekunde sollten Sie Redis Cluster oder eine dedizierte Lösung wie NATS in Betracht ziehen.
Fazit
Die Architektur lässt sich zusammenfassen: WebSockets für bidirektionale Kommunikation, HLC für kausale Ordnung, Redis Sorted Sets für Präsenz und Redis Pub/Sub für horizontale Skalierung. Diese Kombination trägt uns bis ca. 150.000 gleichzeitige Verbindungen auf drei Instanzen. Für den typischen DAO-Einsatz mit einigen tausend aktiven Nutzern ist das mehr als ausreichend.
Den größten Zeitaufwand haben wir nicht in die eigentliche Messaging-Logik investiert, sondern in Edge Cases: Verbindungsabbrüche während Token-Gating-Prüfungen, Race Conditions bei gleichzeitigem Channel-Join, und die korrekte Zustellung von Nachrichten, die während eines Reconnects eingegangen sind. Darüber mehr in einem zukünftigen Beitrag.