Backend-Systemdesign durch Engpässe: Vom URL-Kürzer bis zum E-Commerce
Ein auf Anforderungen ausgerichteter Ansatz zur Gestaltung von Node.js-Backends: Wann Load Balancer, Redis, Replikas, Queues und Rate Limits hinzugefügt werden sollten – und was each davon kostet.
Quellen: System Design for Backend Engineers: A Practical Guide With Real Examples
Viele Ingenieure gehen beim Systemdesign vor, indem sie ein Diagramm erstellen: einen Load Balancer, Redis, Kafka, eine API-Gateway sowie Dutzende von Microservices. Das Ergebnis sieht beeindruckend aus, erklärt aber nichts. Egal ob im Vorstellungsgespräch oder bei einem echten Produkt – Design bedeutet, die richtigen Komponenten für ein spezifisches Problem auszuwählen und jede davon begründen zu können. Diese Anleitung führt Sie mit zwei konkreten Systemen, einem URL-Shortener und einem kleinen E-Commerce-Backend, durch dieses Denkverfahren, damit Sie eine Architektur Schritt für Schritt und begründet ausbauen sowie die Kompromisse aufzeigen können, die jeder Schritt mit sich bringt.
Beginnen Sie bei den Anforderungen, nicht bei der Technologie
Der häufigste frühe Fehler besteht darin, mit Tools zu beginnen. Jemand beschreibt ein Problem und die ersten Gedanken lauten „Redis oder nicht? Kafka? Microservices?“ Diese Reihenfolge ist falsch. Solange man nicht weiß, was das System tun muss und in welchem Umfang, hat man keine Grundlage, irgendetwas auszuwählen.
Nehmen wir einen URL-Verkürzer. Bevor man auch nur ein einziges Diagramm zeichnet, benötigt man Antworten auf Fragen wie diese:
- Wie viele Nutzer wird es geben und wie viele URLs werden sie erstellen?
- Welche Anfragenrate muss es im Spitzenfall bewältigen?
- Wie lange bleiben Links gültig und können sie ablaufen?
- Brauchen die Nutzer Klickanalysen?
- Wie viel Ausfallzeit ist akzeptabel?
- Wie schnell muss eine Umleitung erfolgen?
Jede Antwort prägt das Design. Ein Service, der so viel Traffic verarbeiten muss:
1,000 requests/day
hat fast nichts gemeinsam mit einem, der nur diesen Traffic bewältigen muss:
10 million requests/secon
Die erste Variante passt problemlos auf einen kleinen Server. Die zweite erzwingt bereits ab dem ersten Tag Entscheidungen bezüglich Caching, Replikation und Geografie.
Fangen Sie mit der einfachsten funktionierenden Architektur an
Für einen kleinen URL-Shortener kann die erste Version nahezu trivial einfach sein:
Client
↓
Node.js API
↓
PostgreSQL
Die API erzeugt einen kurzen Code und gibt einen Link wie folgt zurück:
https://example.com/abc123
Dann speichert sie eine einzige Zuordnung in PostgreSQL:
short_code → original_url
Wenn ein Browser einen Pfad wie den unten angegebenen anfragt, sucht die API nach der ursprünglichen URL und antwortet mit einer Umleitung:
/abc123
Für eine moderate Arbeitslast handelt es sich dabei um ein vollständiges, korrektes System. Nichts davon erfordert die üblichen Komponenten:
Kafka
Redis
Kubernetes
Microservices
Diese Elemente hinzuzufügen, nur weil sie verfügbar sind, führt lediglich zu höheren Betriebskosten, mehr Ausfallmöglichkeiten sowie mehr Lernaufwand für jedes neue Teammitglied. Liefern Sie zunächst etwas, das funktioniert, messen Sie es und skalieren Sie anschließend die Komponenten, die sich als Engpässe erweisen.
Skalieren, wenn ein Server nicht ausreicht
Nehmen wir an, der URL-Kürzer wird beliebt und sein einziger API-Prozess wird überlastet:
1 API server
Man könnte weiterhin größere Maschinen kaufen, doch das vertikale Skalieren hat seine Grenzen und eine einzelne Maschine stellt einen einzigen Ausfallpunkt dar. Häufiger wird stattdessen versucht, mehrere identische Instanzen hinter einem Load Balancer zu betreiben:
Load Balancer
↓
┌───────────┼───────────┐
↓ ↓ ↓
Node API Node API Node API
↓ ↓ ↓
└───────────┼───────────┘
↓
PostgreSQL
Die Anfragen werden nun auf mehrere Anwendungsinstanzen verteilt. Das ist horizontales Skalieren: anstelle darauf zu vertrauen
1 huge server
verlassen Sie sich auf
multiple smaller servers
Die horizontale Skalierung funktioniert nur dann, wenn die Instanzen austauschbar sind – was neue Fragen aufwirft, die zuvor nicht bestanden:
- Wo befindet sich der Sitzungszustand, wenn die nächste Anfrage eines Benutzers auf eine andere Instanz gerichtet sein könnte?
- Ist jede Instanz tatsächlich zustandslos?
- Wohin gelangen hochgeladene Dateien, da der lokale Speicher nicht mehr geteilt wird?
- Wie erreicht jede Instanz die gemeinsam genutzte Infrastruktur wie die Datenbank?
Die Skalierung eines Komponenten führt in der Regel dazu, dass an anderer Stelle neue Anforderungen entstehen; hier muss der Zustand in gemeinsam genutzte Speicher übertragen werden.
Fügen Sie einen Cache hinzu, wenn Sie tatsächlich ein Caching-Problem haben
Beliebte Kurzlinks werden immer wieder abgerufen:
/abc123
/abc123
/abc123
/abc123
/abc123
Es gibt keinen Grund, warum jede dieser Anfragen PostgreSQL abfragen sollte. Ein Cache vor der Datenbank liefert häufig genutzte Schlüssel aus dem Speicher, greift bei Fehlschlag auf die Datenbank zurück und speichert das Ergebnis für den nächsten Einsatz:
Client
↓
Load Balancer
↓
Node.js API
↓
Redis
│
├── Cache Hit → Return URL
│
└── Cache Miss
↓
PostgreSQL
↓
Redis
Dieser Cache-aside-Ansatz kann den größten Teil der Lesebelastung bei einer unausgeglichenen Arbeitslast wie Link-Redirects von der Datenbank nehmen. Er wirft jedoch eine neue Frage auf: Wenn das Ziel eines Kurzlinks geändert oder gelöscht wird, wie erfährt die im Cache gespeicherte Kopie davon? Hierfür ist eine Invalidierungsstrategie erforderlich – sei es das Löschen des Schlüssels bei jeder Schreiboperation, die Festlegung einer Gültigkeitsdauer oder beides. Für eine ausführlichere Betrachtung dieser Optionen siehe unseren Leitfaden zu Redis-Caching-Mustern, Fallstricke und Interviewfragen.
Jede Verbesserung in diesem Leitfaden hat einen solchen Preis. Die Fähigkeit, diesen Preis zu benennen, ist es, was ein Design von einem Diagramm unterscheidet.
Bessern Sie die Datenbank, bevor Sie weitere hinzufügen
Zu einem bestimmten Zeitpunkt kann PostgreSQL wie das Engpassproblem erscheinen. Bevor Sie neue Infrastruktur hinzufügen, finden Sie heraus, warum sie langsam ist. Häufige Ursachen sind:
- Fehlende Indizes auf Spalten, die für Abfragen verwendet werden
- Ineffiziente Abfragen oder Vernetzungen
- N+1-Abfragemuster in der Anwendung
- Die Rückgabe weitaus mehr Zeilen oder Spalten, als benötigt
- Schlechte Paginierung, beispielsweise große Offset-Werte
- Eine falsch konfigurierte oder erschöpfte Verbindungs-Pool
Oftmals reicht bereits eine einzige Anweisung zur Lösung aus:
CREATE INDEX ...
Das ist günstiger und sicherer als ein neues Komponente. Die Regel, die man im Hinterkopf behalten sollte: Beheben Sie erst den gemessenen Engpass, bevor Sie um ihn herum eine Architektur aufbauen.
Lese-Replikas für stark lesende Arbeitslasten
Viele Anwendungen lesen weitaus mehr Daten, als sie schreiben – beispielsweise:
90% reads
10% writes
In einer solchen Situation kann die Primärdatenbank unter dem Lesestrom zusammenbrechen. Eine Lösung besteht darin, Lese-Replikas hinzuzufügen: Die Primärdatenbank nimmt alle Schreibvorgänge entgegen, während die Replikas einen Teil der Leseanfragen übernehmen.
Node.js API
↓
┌────────┴────────┐
↓ ↓
Primary DB Read Replica
↓ ↑
Writes Reads
Der Kostenfaktor des Replikationsverzugs
In den meisten Konfigurationen werden Replikas asynchron aktualisiert, sodass ein Schreibvorgang zuerst bei der Primärdatenbank ankommt, bevor er eine Replika erreicht:
Write → Primary
↓
Replication
↓
Read Replica
Falls eine Anfrage etwas schreibt und es anschließend sofort von einer Replika zurückliest, kann sie für kurze Zeit veraltete Daten sehen. Daher muss die Anwendung wissen, welche Lesevorgänge den neuesten Zustand benötigen. Ein gängiger Ansatz besteht darin, „eigene Schreibvorgänge lesen“-Anfragen, wie zum Beispiel das Laden eines Profils unmittelbar nach dessen Speichern, an die Primärdatenbank zu senden und alles Weitere an die Replikas weiterzuleiten.
Langsame Aufgaben in Hintergrundaufträge verschieben
Viele Operationen müssen nicht abgeschlossen sein, bevor der Benutzer eine Antwort erhält:
- E-Mails versenden
- Rechnungen erstellen
- Benachrichtigungen liefern
- Berichte erstellen
- Schwere externe Dienste aufrufen
Anstatt den Benutzer warten zu lassen, protokolliert die API, was erledigt werden muss, stellt einen Auftrag in die Warteschlange und antwortet umgehend:
Client
↓
Node.js API
↓
Create Record
↓
Queue Job
↓
Response
Ein separater Worker-Prozess übernimmt den Auftrag und führt die Arbeit aus:
Queue
↓
Worker
↓
Process Job
In der Praxis kann eine einzige Warteschlange Worker versorgen, die mehrere Auftragstypen verarbeiten:
API
↓
Redis / Queue
↓
Worker
├── Email
├── Invoice
└── Notification
So bleibt die Anfragedauer gering, und es entsteht ein kontrollierter Rahmen für Wiederholungsversuche sowie Durchsatzbegrenzungen. Gleichzeitig erfordert dies eigene Designüberlegungen:
- Ein Auftrag kann mehrmals ausgeführt werden, daher müssen die Verarbeitungsfunktionen Duplikate tolerieren.
Skalierbarkeit kostet immer etwas. Wenn Sie dies in Node.js entwickeln, behandelt unser Leitfaden zu zuverlässigen Hintergrundaufgabensystemen mit BullMQ und Redis diese Probleme anhand von Codebeispielen.
Designen für Fehler, bevor sie eintreten
Eine interessante Gestaltungsarbeit beginnt, wenn man aufhört, nur zu fragen, was passiert, wenn alles funktioniert, und stattdessen darüber nachdenkt, was geschieht, wenn ein Bestandteil versagt.
Funktioniert weiterhin einwandfrei, wenn eine Abhängigkeit ausfällt
Nehmen wir an, Redis wird nicht mehr verfügbar. Muss dann die gesamte API stillstehen? Wenn Redis ausschließlich als Cache verwendet wird, sollte das wahrscheinlich nicht der Fall sein. Die API kann auf den Cache verzichten und direkt von PostgreSQL lesen – zwar langsamer, aber dennoch korrekt:
Request
↓
Redis
↓
FAIL
↓
PostgreSQL
↓
Response
Falls Redis außerdem Ihre Job-Queue oder Rate-Limiter unterstützt, ist ein Ausfall weitaus gravierender, da für diese Funktionen kein einfacher Ersatz vorhanden ist. Die Architektur sollte die Rolle jedes Komponenten klar definieren, denn diese Rolle bestimmt, wie sich das System verhält, wenn die Komponente versagt.
Setzen Sie eine Zeitbegrenzung für jeden externen Aufruf
Stellen Sie sich vor, die API ruft einen externen Zahlungsdienst auf, ohne eine Zeitbegrenzung zu konfigurieren:
API
↓
Payment Service
↓
... waiting ...
↓
... waiting ...
↓
... waiting ...
Während sie wartet, behält der Server Sockets, Speicher und Anfragen für eine Abhängigkeit frei, die möglicherweise niemals antwortet. Unter Last können diese blockierten Anfragen den gesamten Prozess erschöpfen. Ein gut konzipiertes System setzt für jeden Aufruf eine Frist:
API
↓
Payment Service
↓
5 second timeout
↓
Failure
Sobald der Fristablauf erreicht ist, muss die Anwendung entscheiden, wie weiter vorgegangen werden soll – die richtige Wahl hängt von der Operation ab:
- Den Aufruf erneut versuchen
- Einen Fehler an den Client zurückgeben
- Die Operation für später in die Warteschlange einordnen
- Einen Ersatzweg verwenden
- Die Operation als ausstehend markieren und später abgleichen
Erneute Versuche sind nur sicher, wenn Operationen idempotent sind
Wenn ein Zahlungsaufruf abläuft, scheint es selbstverständlich zu sein, ihn erneut zu versuchen. Doch ein Timeout sagt nicht aus, ob die Zahlung fehlgeschlagen ist – es könnte sein, dass die Abbuchung erfolgreich war und nur die Antwort verloren ging. Ein blindes Erneutversuchen könnte dem Kunden zweimal in Rechnung gestellt werden.
Die Lösung liegt in der Idempotenz: Man fügt einer Operation einen stabilen Identifikator hinzu, damit die Empfängerseite eine Wiederholung erkennen kann.
payment_id = order_123
Der Zahlungsdienstleister verwendet diesen Schlüssel, um festzustellen, dass die Anfrage bereits bearbeitet wurde, und gibt stattdessen das ursprüngliche Ergebnis zurück, anstatt erneut Gebühren zu erheben:
Request
↓
Idempotency Key
↓
Payment Provider
↓
Already processed?
├── Yes → Return previous result
└── No → Process payment
Das Hinzufügen von Wiederholungsversuchen erfordert nur eine Zeile Code. Um sie sicher zu gestalten, muss der gesamte Ablauf zusammenarbeiten. Unser Artikel über Idempotenzschlüssel in Node.js POST-Endpunkten zeigt, wie man die Empfangsseite selbst implementieren kann.
Schützen Sie das System mit Rate Limiting
Nehmen wir nun an, ein einzelner Client beginnt, so viel Traffic zu senden:
100,000 requests/minute
Wenn man all diesen Traffic akzeptiert, könnte ein Aufrufer den Service für alle anderen verschlechtern. Ein Rate Limiter steht vor der API und lehnt übermäßige Anfragen mit HTTP 429 ab:
Client
↓
Rate Limiter
↓
Allowed?
├── Yes → API
└── No → 429
Mit mehreren API-Instanzen müssen die Zähler geteilt werden, weshalb Redis ein beliebter Hintergrundspeicher für verteilte Rate-Limiting-Strategien ist: Jede Instanz erhöht die gleichen Schlüssel.
Die Limits sollten das Verhalten jedes Endpunkts widerspiegeln. Ein Anmeldungs-Endpunkt ist ein Ziel für Brute-Force-Angriffe, daher ist ein strenges Limit wie dieses angemessen:
Login:
10 requests/minute
Im Gegensatz dazu kann eine öffentliche Produktliste viel mehr vertragen:
GET /products:
1000 requests/minute
Legen Sie die Limits anhand der Kosten einer Operation sowie ihres Bedrohungsmodells fest, anstatt ein globales Limit anzuwenden.
Systemdesign ist größtenteils ein Abwägen von Vor- und Nachteilen
Oft gibt es keine einzige perfekte Architektur. Die meisten Entscheidungen sind Wahlmöglichkeiten zwischen Optionen, die jeweils in unterschiedlichen Bereichen gut sind.
SQL gegenüber NoSQL
Eine relationale Datenbank bietet starke relationale Modellierung, Transaktionen sowie leistungsstarke Abfragen. Ein NoSQL-Speicher kann unterschiedliche Skalierungseigenschaften, flexible Schemata sowie Vorteile für bestimmte Arbeitslasten bieten. Die Struktur Ihrer Daten und Ihre Zugriffsmuster entscheiden darüber, welche Lösung am besten geeignet ist.
Cache versus Datenbank
Ein Cache ist schnell, aber vorübergehend, und er führt zu Komplexitäten bei der Invalidation. Die Datenbank hingegen ist dauerhaft und bleibt die Quelle der Wahrheit, auch wenn sie bei ständig gelesenen Daten langsamer sein kann.
Monolith versus Microservices
Ein Monolith ist einfacher, lässt sich leichter bereitstellen und in den Anfangsphasen auch leichter debuggen. Microservices ermöglichen unabhängige Bereitstellung und Skalierung sowie klarere Grenzen zwischen Teams – auf Kosten einer deutlich höheren Komplexität bei verteilten Systemen.
Im Allgemeinen ist keine Option per se richtig. Die Anforderungen entscheiden darüber.
Vorzug vor modularem Monolithen gegenüber frühen Microservices
Diese Wahl verdient einen eigenen Abschnitt, denn genau hier ist übermäßige Ingenieurskunst am teuersten. Ein neues Produkt muss nicht mit einer Reihe solcher Dienste beginnen:
Auth Service
User Service
Product Service
Order Service
Payment Service
Notification Service
Eine bessere Standardlösung ist eine einzige bereitstellbare Anwendung:
Node.js Application
die intern in Module mit klaren Grenzen organisiert ist:
├── Auth
├── Users
├── Products
├── Orders
├── Payments
└── Notifications
Es gibt eine einzige Codebasis, eine einzige Bereitstellung sowie eine klare Trennung der Domänen. Falls ein Modul später eigenständig skaliert oder bereitgestellt werden muss, ermöglichen die klaren Grenzen eine einfache Extraktion.
Mikroservices lösen tatsächlich reale organisatorische und Skalierungsprobleme. Gleichzeitig bringen sie Netzwerkfehler zwischen den Services, verteiltes Tracing, Service-Discovery-Funktionen, aufwändigere Deployment-Pipelines, Herausforderungen bei der Datenkonsistenz über Servicegrenzen hinweg sowie umfangreichere Überwachungs- und Infrastrukturanforderungen mit sich. Nehmen Sie diese Kosten nur in Anspruch, wenn Sie die Vorteile benötigen.
Lösen Sie jedes Engpassproblem auf dem kleinstmöglichen effektiven Niveau
Betrachten Sie ein einfaches dreistufiges System mit einer langsamen Datenbank:
Client
↓
API
↓
Database
Es gibt eine Reihe möglicher Lösungen, von der günstigsten bis zur störendsten. Wenn eine bestimmte Abfrage langsam ist, optimieren Sie sie:
Slow query
↓
Optimize query
Falls Suchvorgänge die gesamte Tabelle durchscannen, fügen Sie einen Index hinzu:
Missing index
↓
Add index
Falls dieselben Daten wiederholt abgerufen werden, fügen Sie einen Cache hinzu:
Repeated reads
↓
Add cache
Falls Lesevorgänge im Vordergrund stehen, fügen Sie Replikas hinzu:
Read-heavy workload
↓
Read replicas
Nur bei wirklich großer Skala greift man zu strukturellen Änderungen:
Massive scale
↓
Partitioning / sharding / architectural changes
Die Reihenfolge ist entscheidend. Jeder Schritt wird nur unternommen, wenn der vorherige nicht mehr ausreicht, und jeder fügt mehr Komplexität hinzu als der vorherige.
Ein wiederholbarer Prozess für Design-Interviews
Dieselbe Logik gilt auch für Systemdesign-Interviews. Eine solide Abfolge ist:
- Die funktionalen und nicht-funktionalen Anforderungen klären.
- Den Datenverkehr und die Speicheranforderungen schätzen.
- Die einfachste Architektur vorschlagen, die diesen Anforderungen entspricht.
- Erkennen, wo sie zuerst versagen wird.
- Diese Engpässe gezielt skalieren.
- Fehlerscenarien durchgehen.
- Die getroffenen Kompromisse besprechen.
- Jedes hinzugefügte Komponente begründen.
Der Ansatz ist weitaus überzeugender als das sofortige Zeichnen von zwanzig Kästchen. Ein poliertes Diagramm ohne dahinterliegende Begründung ist kein gutes Design.
Zusammenfassung: ein kleiner E-Commerce-Backend
Durch die Anwendung dieser Ideen auf einen Online-Shop erhält man eine sinnvolle Ausgangsarchitektur wie folgt:
Client
↓
Load Balancer
↓
┌────────┴────────┐
↓ ↓
Node API Node API
│ │
└────────┬────────┘
↓
┌────────┼────────┐
↓ ↓ ↓
Redis PostgreSQL Queue
│ ↓
│ Worker
│ ↓
│ Email / Invoice
│
↓
External APIs
Payment / Shipp
Was diese Architektur vertretbar macht, ist, dass jedes Komponente eine Frage beantwortet:
- Redis kümmert sich um Caching, kurzlebige Daten, Rate-Limit-Zähler sowie die Queue-Infrastruktur.
- PostgreSQL speichert dauerhafte Geschäftsdaten und bietet relationale Transaktionen für Bestellungen und Zahlungen.
- Die Queue übernimmt langsame, asynchrone Aufgaben.
- Mehrere API-Instanzen sorgen für horizontales Skalieren und Redundanz.
- Der Load Balancer verteilt den Traffic auf diese Instanzen.
Bemerken Sie, dass Redis hier mehrere Rollen übernimmt. Das ist effizient, bedeutet aber auch, dass ein Ausfall von Redis gleichzeitig die Caching-Funktionen, das Rate-Limiting sowie die Verarbeitung von Aufgaben beeinträchtigt – genau diese Abhängigkeiten sollte die oben beschriebene Fehleranalyse aufzeigen.
Sechs Fragen, die man stellen sollte, bevor ein Design festgelegt wird
- Welches Problem wird damit gelöst? Wenn Sie dies nicht klar formulieren können, ist die Architektur noch nicht bereit.
- Welche Skalierung ist erforderlich? Anzahl der Benutzer, Anfragenrate, Datenvolumen sowie Ziele hinsichtlich Speicherung und Latenz.
Angemessene Komplexität ist besser als beeindruckende Komplexität
Die größte Falle beim Erlernen des Systemdesigns ist der Glaube, dass eine komplexere Architektur automatisch besser sei. Das ist nicht der Fall – sie ist lediglich komplizierter. Gutes Design strebt nach angemessener Komplexität.
Falls eine Node.js-Anwendung zusammen mit einer PostgreSQL-Datenbank den Anforderungen entspricht, handelt es sich um eine durchaus gute Architektur. Wenn der Verkehr zunimmt, fügen Sie weitere Instanzen hinzu. Wenn Lesevorgänge zum Engpass werden, optimieren Sie diese. Wenn Caching hilft, fügen Sie Redis hinzu. Wenn Arbeiten später erledigt werden können, fügen Sie eine Warteschlange hinzu. Wenn ein Modul tatsächlich eine unabhängige Skalierung oder Bereitstellung benötigt, überlegen Sie, es herauszulösen. Architekturen entwickeln sich gemeinsam mit dem Produkt.
Kernpunkte
- Zuerst werden die Anforderungen sowie grobe Kapazitätsabschätzungen festgelegt; daraus ergeben sich anschließend die Technologieentscheidungen.
- Begonnen wird mit dem einfachsten funktionsfähigen System, und nur dort wird skaliert, wo Messungen einen Engpass aufzeigen.
- Jede Erweiterung hat ihren Preis: gemeinsamer Zustand für horizontale Skalierung, Invalidation für Caches, Verzögerungen bei Replicas sowie Duplikate in Warteschlangen.
- Planen Sie ausdrücklich Fehlertoleranz mit Alternativen, Zeitlimits, idempotenten Wiederholungsversuchen und Bandbreitenbeschränkungen.