Simmetric Chat mit Redis und pg-boss auf mehrere Instanzen skalieren
Simmetric Chat skaliert horizontal hinter jedem Load Balancer, ohne Sticky Sessions. Wie der Zustand nach Redis, Postgres und in die pg-boss-Job-Queue wandert.
Eine Instanz ist einfach. Zwei sind eine Designentscheidung.
Eine einzelne Instanz von Simmetric Chat zu betreiben ist bewusst trivial: ein Serverprozess, ein Postgres, und jeder Zustandsteil, der sonst geteilt werden müsste, lebt bequem im Speicher. In dem Moment, in dem Sie eine zweite Instanz hinter einen Load Balancer stellen, wird all dieser In-Process-Zustand zu einem Korrektheitsproblem. Rate-Limit-Zähler driften auseinander, also multipliziert sich das effektive Limit mit der Anzahl der Instanzen. Ein auf Instanz A widerrufenes Token wird auf Instanz B weiterhin akzeptiert. Ein streamender Nutzer auf Instanz B sieht nie die auf Instanz A emittierten Ereignisse. Keiner dieser Fehler kündigt sich an - er taucht später auf, als Meldung „das Limit funktioniert nicht" oder „das Logout meldet mich nicht überall ab".
Simmetric Chat behandelt horizontale Skalierung als erstklassigen Deployment-Modus. Die Multi-Instanz-Arbeit, abgeschlossen über den Milestone des Redis-Layers und in v0.22 enthalten, externalisiert genau diese Zustandsteile, damit jede Instanz jede Anfrage bedienen kann. Dieser Artikel geht die Topologie durch, die fünf Redis-Konsumenten, die pg-boss-Job-Queue, die Schlüssel, die über eine ganze Flotte übereinstimmen müssen, und das Graceful-Degradation-Verhalten, das ein Single-Instance-Deployment voll unterstützt hält.
Die Topologie: identische, stateless Instanzen
Die Form des Deployments ist bewusst unspektakulär. N identische Serverprozesse stehen hinter einem beliebigen HTTP-Load-Balancer; ein geteiltes Postgres hostet die Anwendungsdaten, pgvector und das pgboss-Job-Schema; ein geteiltes Redis hält den Cross-Instance-Koordinationszustand:
Load balancer (ein beliebiger HTTP-LB - keine Sticky Sessions nötig)
│
┌───────┼───────┐
Server 1 Server 2 … Server N identische Prozesse, :3000
└───────┼───────┘
│
Geteiltes Postgres Geteiltes Redis
App-Daten + pgvector Rate Limit, Widerrufsliste,
+ pgboss-Schema SSE-Relay, Locks, CacheJede Instanz führt denselben Code aus und ist aus Sicht des Load Balancers stateless. Es gibt keine serverseitige Session-Map, die einen Client an eine Instanz bindet: Authentifizierung ist ein stateless JWT, HS256-signiert mit einem geteilten JWT_SECRET und bei jeder Anfrage validiert. Alles, was sonst im Prozessspeicher läge, wird an einen deklarierten Ort ausgelagert:
- Rate-Limit-Zähler → Redis (geteiltes Fenster über die Flotte); ohne Redis zählt jede Instanz für sich
- JWT-Widerruf (`jti`-Liste) → Redis; ohne Redis hat der Widerruf keine Wirkung und Tokens bleiben bis zu ihrem Ablauf gültig
- SSE-Fan-out → Relay über Redis-Pub/Sub; ohne Redis erreichen Ereignisse nur Clients der emittierenden Instanz
- Verteilte Locks (Reaping-Scheduler) → redlock auf Redis; ohne Redis tut der Lock nichts
- Auth- und Konfigurations-Cache → Redis; ohne Redis fällt jede Lesung auf eine Datenbankabfrage zurück
- Hintergrundjobs → pg-boss, auf dasselbe geteilte Postgres gestützt
Sticky Sessions sind nicht nötig
Der Load Balancer braucht keine Session-Affinität für REST-Verkehr. Die einzigen langlebigen Verbindungen sind SSE-Streams, und auch die brauchen keine Verankerung: Ein Client hält eine Streaming-Verbindung zu der Instanz, die ihn akzeptiert hat, aber auf jeder anderen Instanz emittierte Ereignisse kommen trotzdem an, weil jede Instanz SSE-Ereignisse auf einen Redis-Kanal publiziert und jede Instanz die Nachrichten dieses Kanals an ihre eigenen verbundenen Clients weiterreicht. Das Relay ist in einer Zwei-Instanz-Simulation end-to-end verifiziert, einschließlich des Origin-Skip-Verhaltens, das eine Instanz daran hindert, ihre eigenen Ereignisse erneut zu empfangen.
Wenn Ihr Balancer Least-Connections-Routing bietet, bevorzugen Sie es für den SSE-Pfad - langlebige Verbindungen sollten nach der Anzahl offener Verbindungen verteilt werden statt per Round-Robin. Es ist eine Effizienzentscheidung, keine Korrektheitsanforderung. Jeder HTTP-Load-Balancer funktioniert.
Die fünf Redis-Konsumenten
REDIS_URL ist bewusst optional, und die Single-Instance-Fallbacks sind voll unterstütztes Verhalten, keine Unfälle. Multi-Instanz verlangt jedoch, dass jede Instanz auf dasselbe Redis zeigt (oder Cluster oder Sentinel), weil fünf Subsysteme sonst still divergieren:
- Rate-Limit-Store. Mit einem geteilten Redis-Store bedeutet ein globales Limit von 200 Anfragen pro Minute 200 über das ganze Deployment. Ohne ihn hält jede Instanz ihren eigenen Zähler und das effektive Limit wird das N-fache des Konfigurierten - ein Unfall, der auf einen Verkehrsspitze wartet.
- JWT-Widerruf. Die Widerrufsprüfung konsultiert eine Blacklist, die global sein muss. Ohne Redis funktioniert ein abgemeldetes oder administrativ widerrufenes Token auf den anderen Instanzen bis zum Ablauf weiter; „überall abmelden" wird still zu „hier abmelden".
- SSE-Fan-out. Das Cross-Instance-Relay lebt auf Redis-Pub/Sub. Ohne es sieht ein Nutzer, dessen Stream auf Instanz B gelandet ist, nie die auf Instanz A emittierten Ereignisse - der Fehler zeigt sich als rätselhaft hängende Live-Updates.
- Verteilte Locks. Reaper-artige Wartungsscheduler erwerben via redlock einen Cross-Instance-Mutex, damit zwei Instanzen nie dieselbe Bereinigung gleichzeitig ausführen. Ohne Redis tut der Lock-Wrapper nichts - und das ist einer der Gründe, warum geplante Jobs zu pg-boss migriert sind, dessen Dedup nicht von einem Lock abhängt.
- Auth- und Konfigurations-Cache. Systemeinstellungen und Rollen-Caches werden über Redis geteilt. Ohne Redis gibt es keinen Cache: Jede Lesung ist eine frische Postgres-Abfrage, was sich als Leseverstärkung genau dann zeigt, wenn der Verkehr wächst.
# Root-.env - identisch auf jeder Instanz
REDIS_URL=redis://redis-host:6379
# TLS-Variante:
# REDIS_URL=rediss://redis-host:6379Die Clients sprechen Standard-Redis-URLs über ioredis. Ein Produktionsstart ohne gesetztes REDIS_URL protokolliert eine laute Warnung; betrachten Sie diese Warnung als das Deployment, das Ihnen sagt, dass es im Single-Instance-Modus läuft, egal was die Instanzen sagen.
pg-boss: die Job-Queue ohne zusätzliche Infrastruktur
Die Hintergrundarbeit von Simmetric Chat - Archiv-Konsistenzprüfungen, Synthesis-Reaping, Vektor-Bereinigung, MCP-Healthchecks, MCP-Reaping, Chat-Nachrichten-Retention und Upload-Draft-Bereinigung - läuft auf pg-boss, einer auf Postgres gestützten Job-Queue. Sieben setInterval-basierte Scheduler wurden zu pg-boss-geplanten Jobs migriert. pg-boss erstellt automatisch sein eigenes pgboss-Schema in der Datenbank, die Sie bereits für die Anwendungsdaten betreiben, sodass horizontale Skalierung keine Infrastrukturkomponente hinzufügt und den ganzen Stack air-gap-kompatibel hält.
Die für Multi-Instanz-Deployments wichtigste Eigenschaft ist das verteilte Job-Dedup: Wenn mehrere Instanzen denselben geplanten Auslösezeitpunkt erreichen, sammelt genau eine Instanz den Job ein und die anderen überspringen ihn wegen Contention. Die Queue, nicht ein Lock-Wrapper, ist der Dedup-Mechanismus - eine Sache weniger, die man falsch konfigurieren kann.
Zwei latenzsensitive Poller sind bewusst nicht migriert. Der Reaper der OCR-Pipeline und der Reaper der Synthesis-Pipeline bleiben auf In-Process-Intervallen von 10 Sekunden, weil die minimale Cron-Granularität von pg-boss eine Minute beträgt und das Abholen der Pipelines sechsmal langsamer machen würde. Ihr Multi-Instanz-Verhalten ist dokumentiert und sicher: Beide sind idempotente Claim-dann-Dispatch-Zyklen, die auf Datenbankebene mit atomaren Claims deduplizieren, sodass konkurrierende Instanzen denselben wartenden Job nicht zweimal beanspruchen können, und der Collector jeden redundanten Dispatch als No-op absorbiert.
Graceful Shutdown ist Teil des Vertrags. In-flight-Jobs werden unter einem pg-boss-Grace-Timeout gedraint, bevor die Datenbankverbindung schließt, und die gesamte Shutdown-Sequenz läuft unter einem äußeren Zeitlimit. Ist Postgres beim Start nicht verfügbar oder fällt zur Laufzeit aus, werden pg-boss-Jobs mit protokolliertem Fehler übersprungen - der Server geht nicht in einen Crash-Loop, und die Queue erholt sich, wenn Postgres zurückkommt. Eine Queue-Unterbrechung darf die Chat-API nie offline nehmen.
Die Schlüssel, die über die Flotte übereinstimmen müssen
Zwei Geheimnisse verdienen explizite Aufmerksamkeit, wenn Sie von einer Instanz zu vielen wechseln:
- `JWT_SECRET` - der symmetrische Signaturschlüssel für stateless Session-Tokens. Derselbe Wert auf jeder Instanz ist genau das, was die Eigenschaft „jede Instanz kann jede Anfrage bedienen" wahr macht.
- `ENCRYPTION_KEY` - der AES-256-GCM-Schlüssel für Verschlüsselung at rest (32 Bytes), in der Produktion erforderlich, wo der Server beim Start laut fehlschlägt, wenn er fehlt. Jede Instanz muss denselben Wert tragen, weil ein von einer Instanz verschlüsselter Blob auf allen entschlüsselbar sein muss.
Die beiden Schlüssel getrennt zu halten ist selbst eine Multi-Instanz-Sicherheitseigenschaft. JWT_SECRET zu rotieren ist billig - es beschränkt sich auf das Neuausstellen von Tokens -, während das Rotieren von ENCRYPTION_KEY ein vollständiges Re-Encryption-Runbook erfordert. Das alte Verhalten, den Verschlüsselungsschlüssel aus dem JWT-Secret abzuleiten, koppelte die beiden Rotationen; in einer Flotte ist diese Kopplung ein Bricket für die ganze Flotte, das auf eine routinemäßige Secret-Rotation wartet.
# einmal generieren, auf jede Instanz verteilen
openssl rand -hex 32 # JWT_SECRET
openssl rand -base64 32 # ENCRYPTION_KEYDer Postgres-Verbindungspool ist der praktische Skalierungsbegrenzer. Jede Instanz öffnet einen begrenzten Anwendungspool plus einen separaten pg-boss-Pool, daher ist die Dimensionierung des Datenbankpools und von Postgres' max_connections relativ zur Instanzanzahl Betreiberverantwortung. Es gibt keine harte Obergrenze für die Instanzanzahl; beginnen Sie mit zwei und skalieren Sie nach Bedarf.
Was bricht, wenn eine Abhängigkeit fehlt
Graceful Degradation ist designtes Verhalten, und es lohnt sich, sie vor dem Capacity Planning zu kennen statt nach einem Vorfall:
- Redis fehlt, eine Instanz. Alles funktioniert. SSE hat einfach keinen Ort zum Relays, Rate Limits sind pro Prozess, Caches sind pro Prozess. Das ist der unterstützte Single-Instance-Standard.
- Redis fehlt, N Instanzen. Die fünf Divergenzen oben erscheinen still - multiplizierte Rate Limits, Widerruf pro Instanz, gebrochenes Cross-Instance-Streaming, inerte Locks und überhitzte Datenbanklesungen. Die Produktionsstart-Warnung ist Ihr Signal.
- Postgres nicht verfügbar. Der Server startet und bedient Anfragen, die die Queue nicht brauchen; die sieben pg-boss-Scheduler überspringen ihre Läufe und versuchen es später erneut; Anfragen, die die Queue brauchen, scheitern anmutig mit protokollierten Fehlern. Eine transiente Unterbrechung beim Start bringt den Prozess nicht in einen Crash-Loop.
Die Designposition ist konsistent: Optionale Abhängigkeiten degradieren zu dokumentierten Verhaltensweisen, und der Ausfall einer Abhängigkeit degradiert eine Fähigkeit statt der Plattform.
Von einer Instanz zu zwei
Das Deployment wachsen zu lassen ist Konfigurationsarbeit, nicht Codearbeit:
- Richten Sie jede Instanz auf dasselbe
DATABASE_URL,JWT_SECRET,COLLECTOR_SECRETundENCRYPTION_KEY. - Setzen Sie
REDIS_URLauf jeder Instanz, zeigend auf ein geteiltes Redis (Einzelinstanz, Cluster oder Sentinel). - Starten Sie neu und bestätigen Sie, dass das Startlog die Single-Instance-Warnung nicht zeigt.
- Stellen Sie die Instanzen hinter Ihren Load Balancer und leiten Sie Health Checks auf
GET /api/health, das den Status von Datenbank, Collector und Festplatte meldet.
Ein letztes Multi-Instanz-Detail, das es wert ist, gekannt zu werden: API-Key-Authentifizierung ist eine gehashte HMAC-Suche in O(1), keine bcrypt-Schleife. Diese Änderung wurde genau deshalb gemacht, damit die API-Key-Verifikation billig genug bleibt, um bei jeder Anfrage über jede Instanz zu laufen, ohne geteilten Cache und ohne CPU-Spitzen pro Instanz. Es ist die Art stiller Entscheidung, die horizontale Skalierung langweilig macht - was genau richtig ist.


