Ich möchte hier anhand eines Beispiels einer einfachen URL Shortener erklären wie man eine moderne Systemarchitektur plant.
Inhaltsverzeichnis
- Überblick: Was soll das System tun?
- Anforderungen
- Problemstellung: Warum brauchen wir diese Architektur?
- System-Architektur
- 4.1 Komponenten-Übersicht
- 4.2 Datenbank-Design
- 4.3 Short-Code-Generierung
- 4.4 API-Endpunkte
- 4.4.1 POST /api/shorten
- 4.4.2 GET /{short_code}
- Redis-Caching-Strategie: Warum und wie?
- Load Balancing: Was macht ein Load-Balancer genau?
- 6.1 Das Problem: Warum brauchen wir einen Load-Balancer?
- 6.2 Was macht ein Load-Balancer genau?
- 6.3 Load-Balancing-Algorithmen
- 6.4 Health Checks: Warum sind sie kritisch?
- 6.5 Session-Persistenz (Sticky Sessions)
- 6.6 SSL/TLS Termination
- 6.7 Rate Limiting und DDoS-Schutz
- 6.8 Traefik-Konfiguration: Vollständiges Beispiel
- 6.9 Monitoring und Metriken
- Skalierbarkeit: Warum horizontale Skalierung?
- 7.1 Das Problem: Vertikale vs. Horizontale Skalierung
- 7.2 Warum horizontale Skalierung für unseren URL-Shortener?
- 7.3 Anforderungen für horizontale Skalierung
- 7.4 Skalierung in der Praxis
- 7.5 Skalierungs-Limits und Bottlenecks
- 7.6 Skalierungsschritte: Praktisches Beispiel
- 7.7 Monitoring der Skalierung
- Erweiterte Optimierungen
- Zusammenfassung
Überblick: Was soll das System tun?
Am Anfang überlegen wir uns wie wir generell die Short-URLs Designen. Dazu beginnen wir was eigentlich gemacht werden soll:
- User gibt eine lange URL ein
- Die URL wird von einem API-Endpunkt entgegen genommen und gibt eine Short-URL zurück und speichert die Short-URL in einer Datenbank
- Der User ruft die Short-URL auf, es wird in einer Datenbank nachgeschaut ob die Short-URL vorhanden ist, holt die Value und schickt den Nutzer mit einem HTTP-301 weiter.
Anforderungen
- Der Short-Code sollte maximal 7 Zeichen lang sein. Mit einer Base64-Konvertierung haben wir kein Problem mit doppelten Indexen.
- Die Server sollten horizontal Skalierbar sein, dazu ist wichtig das ein Load-Balancer (am besten Traefik) die Last verteilt.
- Damit man keine doppelten Short-URLs erstellt sollte man einen Redis nutzen um die Datenbank zu cachen und so anfragen schneller umzusetzen.
Problemstellung: Warum brauchen wir diese Architektur?
Bevor wir in die Details gehen, müssen wir verstehen, welche Probleme wir lösen müssen. Ein naives System mit nur einem Server und einer Datenbank würde bei steigender Last schnell an seine Grenzen stoßen.
Problem 1: Datenbank-Performance als Flaschenhals
Das Problem: Stellen Sie sich vor, Sie haben 10.000 Anfragen pro Sekunde für Redirects. Jede Anfrage muss:
- Eine Datenbankverbindung aufbauen (5-10ms Overhead)
- Eine SQL-Query ausführen (10-50ms je nach Last)
- Das Ergebnis zurückgeben
Bei 10.000 Anfragen/Sekunde bedeutet das:
- Ohne Cache: 10.000 × 50ms = 500 Sekunden Gesamtzeit → System bricht zusammen
- Mit Cache (Redis): 9.500 × 0.1ms (Cache-Hit) + 500 × 50ms (Cache-Miss) = 25 Sekunden → System läuft stabil
Die Lösung: Redis als In-Memory-Cache reduziert Datenbankzugriffe um 95-99%.
Problem 2: Single Point of Failure
Das Problem: Ein einzelner Server kann:
- Ausfallen (Hardware-Fehler, Software-Crash)
- Überlastet werden (CPU/Memory-Limits erreicht)
- Wartungsarbeiten erfordern (Updates, Patches)
Wenn dieser eine Server ausfällt, ist die gesamte Anwendung offline.
Die Lösung: Mehrere Server (horizontale Skalierung) + Load-Balancer sorgen für:
- Hochverfügbarkeit: Wenn ein Server ausfällt, übernehmen die anderen
- Lastverteilung: Kein einzelner Server wird überlastet
- Wartbarkeit: Server können einzeln aktualisiert werden
Problem 3: Skalierbarkeits-Limits
Das Problem: Ein einzelner Server hat physikalische Limits:
- CPU: Maximal 32-64 Cores pro Server
- Memory: Maximal 512GB-1TB RAM
- Netzwerk: Maximal 10-100 Gbps
Wenn Sie wachsen, müssen Sie entweder:
- Vertikal skalieren: Größere, teurere Server kaufen (sehr teuer, schnell an Limits)
- Horizontal skalieren: Mehr günstige Server hinzufügen (kosteneffizient, praktisch unbegrenzt)
Die Lösung: Horizontale Skalierung mit Load-Balancer ermöglicht praktisch unbegrenztes Wachstum.
Problem 4: Latenz und Benutzererfahrung
Das Problem: Benutzer erwarten Antwortzeiten unter 100ms. Bei einer Datenbank-Query:
- Lokale Datenbank: 5-20ms
- Remote-Datenbank: 20-100ms (abhängig von Netzwerk-Latenz)
- Bei hoher Last: 100-1000ms+ (Datenbank wird zum Flaschenhals)
Die Lösung: Redis liefert Antworten in <1ms, was die Benutzererfahrung drastisch verbessert.
System-Architektur
Wichtiger Hinweis: Alle Code-Beispiele in diesem Tutorial sind vereinfachte Meta-Code-Beispiele zur Veranschaulichung der Konzepte und Logik. Sie dienen dem Verständnis der System-Architektur und sind nicht als vollständige, produktionsreife Implementierungen gedacht. In einer produktiven Umgebung wären zusätzlich zu berücksichtigen: umfassende Fehlerbehandlung, Input-Validierung, Security-Best-Practices, Logging, Monitoring, Connection-Pooling, Migrationen, Tests, Dokumentation und viele weitere Aspekte.
Komponenten-Übersicht
Unser System besteht aus folgenden Komponenten:
┌─────────────┐
│ Client │
└──────┬──────┘
▼
┌─────────────────┐
│ Load Balancer │
└──────┬──────────┘
├──────────────┬──────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ API-1 │ │ API-2 │ │ API-3 │ (Horizontale Skalierung)
└────┬─────┘ └────┬─────┘ └────┬─────┘
└──────┬───────┴──────┬───────┘
▼ ▼
┌─────────┐ ┌─────────┐
│ Cache │ │ DB │
└─────────┘ └─────────┘
1. Datenbank-Design
Hinweis: Die folgenden Code-Beispiele sind vereinfachte Meta-Code-Beispiele zur Veranschaulichung der Konzepte. In einer produktiven Umgebung wären zusätzliche Aspekte wie Fehlerbehandlung, Validierung, Migrationen etc. zu berücksichtigen.
Zuerst müssen wir die Datenbank-Struktur definieren. Wir benötigen eine Tabelle für die URL-Mappings:
SQL Schema (Meta-Code Beispiel):
-- Vereinfachtes Schema zur Veranschaulichung
-- In Produktion: Weitere Indizes, Constraints, Partitionierung etc.
CREATE TABLE url_mappings (
id BIGSERIAL PRIMARY KEY, -- Eindeutige ID
short_code VARCHAR(7) UNIQUE NOT NULL, -- 7-stelliger Code
original_url TEXT NOT NULL, -- Original-URL
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
access_count BIGINT DEFAULT 0, -- Optional: Analytics
INDEX idx_short_code (short_code) -- Index für schnelle Lookups
);
Wichtige Überlegungen:
short_codeist eindeutig und indiziert für schnelle Lookupsoriginal_urlspeichert die vollständige URLaccess_countfür Analytics (optional)- In Produktion: Weitere Optimierungen wie Partitionierung, Read-Replicas etc.
2. Short-Code-Generierung
Der Short-Code wird aus einer eindeutigen ID generiert. Hier ist ein vereinfachtes Meta-Code-Beispiel:
Meta-Code Beispiel (Python-ähnlich):
# Vereinfachtes Beispiel zur Veranschaulichung
# In Produktion: Kollisionsprüfung, Retry-Logik etc.
import base64
import hashlib
def generate_short_code(url_id: int) -> str:
"""
Generiert einen 7-stelligen Base64-kodierten Short-Code
aus einer numerischen ID.
Logik:
1. Konvertiere ID zu Bytes
2. Base64-kodiere
3. Kürze auf 7 Zeichen
"""
id_bytes = url_id.to_bytes(8, byteorder='big')
encoded = base64.urlsafe_b64encode(id_bytes).decode('utf-8')
short_code = encoded[:7].rstrip('=')
return short_code
# Alternative: Hash-basiert (für bessere Verteilung)
def generate_short_code_from_url(url: str, salt: str = "") -> str:
"""
Generiert einen Short-Code aus der URL selbst.
Vorteil: Deterministisch (gleiche URL = gleicher Code)
"""
hash_input = (url + salt).encode('utf-8')
hash_bytes = hashlib.sha256(hash_input).digest()[:5]
encoded = base64.urlsafe_b64encode(hash_bytes).decode('utf-8')
return encoded[:7].rstrip('=')
3. API-Endpunkte
Hinweis: Die folgenden Code-Beispiele sind vereinfachte Meta-Code-Beispiele zur Veranschaulichung der Logik. In Produktion wären zusätzlich zu implementieren: Input-Validierung, Rate-Limiting, umfassende Fehlerbehandlung, Logging, Monitoring, Connection-Pooling etc.
POST /api/shorten
API-Spezifikation:
Request:
{
"url": "https://www.example.com/very/long/url/path"
}
Response:
{
"short_code": "aB3dEfG",
"short_url": "https://short.ly/aB3dEfG",
"original_url": "https://www.example.com/very/long/url/path"
}
Meta-Code Implementierung (vereinfacht):
# Meta-Code: Vereinfachtes Beispiel zur Veranschaulichung der Logik
# Framework-agnostisch dargestellt
@app.route('/api/shorten', methods=['POST'])
def shorten_url():
# 1. Request-Daten extrahieren
original_url = request.get_json()['url']
# 2. Cache-Check: Existiert bereits ein Short-Code für diese URL?
cache_key = f"url:{original_url}"
cached_code = redis.get(cache_key)
if cached_code:
# Cache-Hit: Gebe existierenden Code zurück
return {
'short_code': cached_code,
'short_url': f'https://short.ly/{cached_code}',
'original_url': original_url
}
# 3. Cache-Miss: Erstelle neuen Eintrag
# 3a. Generiere Short-Code
url_id = db.get_next_id() # Oder Auto-Increment
short_code = generate_short_code(url_id)
# 3b. Speichere in Datenbank
db.insert('url_mappings', {
'short_code': short_code,
'original_url': original_url
})
# 3c. Cache speichern (beide Richtungen)
redis.setex(f"url:{original_url}", 3600, short_code)
redis.setex(f"code:{short_code}", 86400, original_url)
# 4. Response zurückgeben
return {
'short_code': short_code,
'short_url': f'https://short.ly/{short_code}',
'original_url': original_url
}
Logik-Flow:
- Cache-Check: Prüfe ob URL bereits existiert → verhindert Duplikate
- Code-Generierung: Erstelle neuen eindeutigen Short-Code
- Datenbank-Speicherung: Persistiere Mapping
- Cache-Update: Speichere in beide Richtungen für schnelle Lookups
GET /{short_code}
API-Spezifikation:
Request:
GET /abc123
Response:
HTTP 301 Redirect
Location: https://www.example.com/very/long/url/path
Meta-Code Implementierung (vereinfacht):
# Meta-Code: Vereinfachtes Beispiel zur Veranschaulichung der Logik
@app.route('/<short_code>', methods=['GET'])
def redirect_to_url(short_code):
# 1. Cache-Check: Ist URL im Cache?
cache_key = f"code:{short_code}"
cached_url = redis.get(cache_key)
if cached_url:
# Cache-Hit: Sofortiger Redirect (sehr schnell: <1ms)
return redirect(cached_url, code=301)
# 2. Cache-Miss: Hole aus Datenbank
original_url = db.query(
"SELECT original_url FROM url_mappings WHERE short_code = ?",
short_code
)
if not original_url:
return error(404, 'Short URL not found')
# 3. Cache für zukünftige Anfragen speichern
redis.setex(cache_key, 86400, original_url) # 24 Stunden
# 4. Optional: Access-Count asynchron aktualisieren
# (Nicht blockierend, läuft im Hintergrund)
async_update_access_count(short_code)
# 5. Redirect zurückgeben
return redirect(original_url, code=301)
Logik-Flow:
- Cache-Check: Prüfe ob Short-Code im Cache → 95-99% der Fälle
- Datenbank-Fallback: Falls nicht im Cache → Hole aus DB
- Cache-Update: Speichere für zukünftige Anfragen
- Redirect: HTTP 301 Permanent Redirect zur Original-URL
4. Redis-Caching-Strategie: Warum und wie?
Warum Redis? Das Performance-Problem verstehen
Ohne Redis (nur Datenbank):
Anfrage → API-Server → Datenbank-Query (50ms) → Antwort
Bei 10.000 Anfragen/Sekunde:
- Datenbank muss 10.000 Queries/Sekunde verarbeiten
- Jede Query benötigt 50ms → Datenbank wird überlastet
- System bricht zusammen oder wird extrem langsam
Mit Redis (Cache-Layer):
Anfrage → API-Server → Redis-Cache (0.1ms) → Antwort
↓ (Cache-Miss, 5% der Fälle)
→ Datenbank-Query (50ms) → Cache speichern → Antwort
Bei 10.000 Anfragen/Sekunde:
- 9.500 Anfragen aus Cache (0.1ms) = 0.95 Sekunden
- 500 Anfragen aus Datenbank (50ms) = 25 Sekunden
- Gesamt: ~26 Sekunden statt 500 Sekunden = 95% Performance-Gewinn
Was macht Redis genau?
Redis ist ein In-Memory-Datenbank (alle Daten im RAM), was sie extrem schnell macht:
| Operation | Datenbank (PostgreSQL) | Redis |
|---|---|---|
| Einfacher Read | 5-50ms | 0.1-1ms |
| Einfacher Write | 10-100ms | 0.1-1ms |
| Durchsatz | 1.000-10.000 Ops/s | 100.000-1.000.000 Ops/s |
Warum ist Redis so schnell?
- Keine Festplatten-I/O: Alles im RAM (1000x schneller als SSD)
- Einfache Datenstrukturen: Key-Value-Store ohne komplexe Joins
- Single-threaded: Keine Locks, keine Race-Conditions
- Optimiert für Geschwindigkeit: C-Implementierung, minimaler Overhead
Cache-Strategien im Detail
Redis wird für zwei kritische Zwecke verwendet:
1. URL → Short-Code Cache (Verhindert Duplikate)
Problem ohne Cache:
# User sendet gleiche URL zweimal
POST /api/shorten {"url": "https://example.com"}
→ Datenbank-Query: "SELECT * WHERE url = ..." (50ms)
→ Nicht gefunden → Neuer Eintrag erstellt
POST /api/shorten {"url": "https://example.com"} # Gleiche URL!
→ Datenbank-Query: "SELECT * WHERE url = ..." (50ms)
→ Nicht gefunden → Noch ein Eintrag erstellt (DUPLIKAT!)
Lösung mit Cache:
# Erste Anfrage
POST /api/shorten {"url": "https://example.com"}
→ Redis-Check: "GET url:https://example.com" (0.1ms)
→ Nicht gefunden → Datenbank → Cache speichern
→ Redis: "SETEX url:https://example.com 3600 abc123"
# Zweite Anfrage (gleiche URL)
POST /api/shorten {"url": "https://example.com"}
→ Redis-Check: "GET url:https://example.com" (0.1ms)
→ Gefunden! → Sofort zurückgeben (keine Datenbank-Query)
2. Short-Code → URL Cache (Beschleunigt Redirects)
Das Problem: Redirects sind der häufigste Operationstyp (99% aller Anfragen). Jeder Redirect ohne Cache bedeutet:
- Datenbankverbindung aufbauen
- SQL-Query ausführen
- Ergebnis zurückgeben
Bei 1 Million Redirects/Tag = 11.5 Redirects/Sekunde → Datenbank wird überlastet.
Die Lösung:
# Redirect-Anfrage
GET /abc123
→ Redis: "GET code:abc123" (0.1ms)
→ Gefunden! → Sofortiger Redirect (keine Datenbank-Query)
Cache-Hit-Rate Optimierung:
Eine gute Cache-Hit-Rate liegt bei 95-99%. Das bedeutet:
- 95-99% der Anfragen werden aus Cache bedient (<1ms)
- 1-5% der Anfragen gehen zur Datenbank (50ms)
Cache-Strategie im Code (Meta-Code Beispiel):
# Meta-Code: Vereinfachtes Beispiel zur Veranschaulichung
# Cache-Aside Pattern: Cache wird "neben" der Datenbank verwendet
# Beim Erstellen einer Short-URL
# Cache in beide Richtungen für schnelle Lookups
redis.setex(f"url:{original_url}", 3600, short_code) # URL → Code (1 Stunde)
redis.setex(f"code:{short_code}", 86400, original_url) # Code → URL (24 Stunden)
# Beim Abrufen (Cache-Aside Pattern)
def get_url_from_cache_or_db(short_code):
# 1. Prüfe Cache zuerst (schnell: <1ms)
cached_url = redis.get(f"code:{short_code}")
if cached_url:
return cached_url # Cache-Hit: Sofort zurückgeben
# 2. Cache-Miss: Hole aus Datenbank (langsam: 50ms)
url = db.query("SELECT original_url WHERE short_code = ?", short_code)
# 3. Speichere im Cache für zukünftige Anfragen
if url:
redis.setex(f"code:{short_code}", 86400, url)
return url
Redis-Konfiguration und Memory-Management
Warum Memory-Limits wichtig sind:
Redis speichert alles im RAM. Ohne Limits würde Redis:
- Den gesamten verfügbaren RAM verbrauchen
- Das System zum Absturz bringen
- Andere Anwendungen verdrängen
Redis-Konfiguration (redis.conf):
# Maximal 2GB RAM für Redis
maxmemory 2gb
# Eviction-Policy: Welche Keys werden gelöscht wenn Memory voll ist?
# allkeys-lru: Löscht am wenigsten genutzte Keys (LRU = Least Recently Used)
maxmemory-policy allkeys-lru
# Alternative Policies:
# - noeviction: Keine Keys löschen (Fehler wenn voll)
# - allkeys-lfu: Löscht am wenigsten häufig genutzte Keys
# - volatile-lru: Löscht nur Keys mit TTL
TTL (Time-To-Live) Strategie (Meta-Code Beispiel):
# Meta-Code: Beispiel für unterschiedliche TTL-Strategien
# TTL bestimmt, wie lange Daten im Cache bleiben
# Kurze TTL für selten genutzte/temporäre Daten
redis.setex("temp:session:123", 300, data) # 5 Minuten
# Mittlere TTL für normale Daten
redis.setex("url:https://example.com", 3600, code) # 1 Stunde
# Lange TTL für häufig genutzte, stabile Daten
redis.setex("code:abc123", 86400, url) # 24 Stunden
Warum unterschiedliche TTLs?
- Kurze TTL: Daten ändern sich häufig oder sind temporär
- Lange TTL: Daten ändern sich selten, hohe Cache-Hit-Rate gewünscht
- Strategie: Balance zwischen Cache-Hit-Rate und Datenkonsistenz
Redis-Cluster für hohe Verfügbarkeit
Problem: Single Redis-Instanz
Wenn Redis ausfällt:
- Alle Cache-Daten sind weg
- System fällt auf Datenbank zurück (langsam)
- Bei hoher Last: System kann zusammenbrechen
Lösung: Redis-Cluster oder Redis-Sentinel
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Redis-1 │ │ Redis-2 │ │ Redis-3 │
│ (Master)│◄───┤(Replica)│◄───┤(Replica)│
└─────────┘ └─────────┘ └─────────┘
│ │ │
└──────────────┴──────────────┘
│
┌─────────┐
│ Sentinel│ (Überwacht Master, failover bei Ausfall)
└─────────┘
Vorteile:
- Hochverfügbarkeit: Wenn Master ausfällt, übernimmt Replica
- Read-Skalierung: Replicas können für Reads genutzt werden
- Datenredundanz: Daten sind auf mehreren Servern gespeichert
5. Load Balancing: Was macht ein Load-Balancer genau?
Das Problem: Warum brauchen wir einen Load-Balancer?
Ohne Load-Balancer (ein Server):
Client → API-Server (10.000 Anfragen/Sekunde)
Probleme:
- Single Point of Failure: Wenn Server ausfällt, ist alles offline
- Performance-Limit: Ein Server kann nur begrenzte Anfragen verarbeiten
- Wartungsprobleme: Server muss offline für Updates
- Ressourcen-Limit: CPU/Memory-Limits werden erreicht
Mit Load-Balancer (mehrere Server):
Client → Load-Balancer → API-Server-1 (3.333 Anfragen/Sekunde)
→ API-Server-2 (3.333 Anfragen/Sekunde)
→ API-Server-3 (3.333 Anfragen/Sekunde)
Vorteile:
- Hochverfügbarkeit: Wenn ein Server ausfällt, übernehmen die anderen
- Skalierbarkeit: Mehr Server = mehr Kapazität
- Wartbarkeit: Server können einzeln aktualisiert werden
- Lastverteilung: Kein Server wird überlastet
Was macht ein Load-Balancer genau?
Ein Load-Balancer ist ein Reverse-Proxy, der zwischen Clients und Servern sitzt und Anfragen intelligent verteilt.
Funktionsweise im Detail:
1. Client sendet Anfrage an Load-Balancer
GET https://short.ly/abc123
2. Load-Balancer analysiert Anfrage
- Welche Server sind verfügbar?
- Welcher Server hat am wenigsten Last?
- Welcher Algorithmus soll verwendet werden?
3. Load-Balancer wählt Server aus
→ API-Server-2 (geringste Last)
4. Load-Balancer leitet Anfrage weiter
GET http://api-2:5000/abc123
5. Server verarbeitet Anfrage
→ Redis-Check → Redirect
6. Antwort geht zurück durch Load-Balancer
HTTP 301 → Client
Load-Balancing-Algorithmen
1. Round-Robin (Standard)
Wie es funktioniert:
- Anfragen werden sequenziell an Server verteilt
- Server 1 → Server 2 → Server 3 → Server 1 → …
Beispiel:
Anfrage 1 → Server 1
Anfrage 2 → Server 2
Anfrage 3 → Server 3
Anfrage 4 → Server 1
Anfrage 5 → Server 2
Vorteile:
- Einfach zu implementieren
- Gleichmäßige Verteilung bei ähnlicher Server-Performance
Nachteile:
- Ignoriert aktuelle Server-Last
- Ignoriert Server-Kapazität (starker vs. schwacher Server)
2. Least Connections
Wie es funktioniert:
- Sendet Anfrage an Server mit den wenigsten aktiven Verbindungen
Beispiel:
Server 1: 100 aktive Verbindungen
Server 2: 50 aktive Verbindungen ← Wird gewählt
Server 3: 75 aktive Verbindungen
Vorteile:
- Berücksichtigt aktuelle Server-Last
- Gut für langlebige Verbindungen (WebSockets, Streaming)
3. Weighted Round-Robin
Wie es funktioniert:
- Jeder Server hat ein Gewicht (z.B. Server 1 = 3, Server 2 = 1)
- Stärkere Server bekommen mehr Anfragen
Beispiel:
Server 1 (Gewicht 3): 3 Anfragen
Server 2 (Gewicht 1): 1 Anfrage
Server 1: 3 Anfragen
Server 2: 1 Anfrage
Vorteile:
- Berücksichtigt unterschiedliche Server-Kapazitäten
- Optimal wenn Server unterschiedlich stark sind
4. IP Hash (Sticky Sessions)
Wie es funktioniert:
- Hash der Client-IP bestimmt, welcher Server verwendet wird
- Gleiche IP → immer gleicher Server
Beispiel:
Client 192.168.1.1 → Hash → Server 2 (immer)
Client 192.168.1.2 → Hash → Server 1 (immer)
Vorteile:
- Session-Persistenz (wichtig für Stateful-Anwendungen)
- Cache-Freundlich (gleicher Client → gleicher Server)
Nachteile:
- Ungleichmäßige Verteilung bei wenigen Clients
- Problem wenn Server ausfällt (Sessions gehen verloren)
Health Checks: Warum sind sie kritisch?
Das Problem ohne Health Checks:
Server 2 ist abgestürzt, aber Load-Balancer weiß es nicht
→ Anfragen werden weiterhin an Server 2 gesendet
→ Client erhält Fehler (Timeout, 500 Error)
→ 33% der Anfragen schlagen fehl
Die Lösung: Health Checks
Ein Load-Balancer überprüft regelmäßig, ob Server erreichbar sind:
# Traefik Health Check Konfiguration
healthcheck:
path: /health
interval: 10s # Alle 10 Sekunden prüfen
timeout: 3s # Timeout nach 3 Sekunden
retries: 3 # 3 Fehlversuche = Server als "down" markieren
Health Check Endpoint im API-Server (Meta-Code Beispiel):
# Meta-Code: Vereinfachtes Beispiel zur Veranschaulichung
# In Produktion: Timeouts, detaillierte Checks, Metriken etc.
@app.route('/health', methods=['GET'])
def health_check():
# Prüfe kritische Komponenten
try:
# 1. Redis erreichbar?
redis.ping()
# 2. Datenbank erreichbar?
db.ping() # Oder einfache Query
# 3. Optional: Weitere Checks
# - Disk Space
# - Memory Usage
# - External Services
return {'status': 'healthy'}, 200
except Exception as e:
# Fehler: Server als "unhealthy" markieren
return {'status': 'unhealthy', 'error': str(e)}, 503
Health Check Workflow:
1. Load-Balancer sendet GET /health an Server
2. Server prüft: Redis OK? Datenbank OK?
3. Server antwortet: 200 OK oder 503 Unhealthy
4. Load-Balancer markiert Server als "up" oder "down"
5. Nur "up" Server erhalten Anfragen
Vorteile:
- Automatisches Failover: Abgestürzte Server werden automatisch ausgeschlossen
- Selbstheilung: Wenn Server wieder online ist, wird er automatisch wieder eingebunden
- Keine manuelle Intervention: System läuft weiter ohne Administrator
Session-Persistenz (Sticky Sessions)
Das Problem:
Bei Stateful-Anwendungen (z.B. Shopping-Cart):
Request 1: Client → Load-Balancer → Server 1 (Cart wird erstellt)
Request 2: Client → Load-Balancer → Server 2 (Cart ist leer! Problem!)
Die Lösung: Sticky Sessions
# Traefik Cookie-basierte Session-Persistenz
labels:
- "traefik.http.services.api.loadbalancer.sticky.cookie=true"
- "traefik.http.services.api.loadbalancer.sticky.cookie.name=server_id"
Wie es funktioniert:
- Erste Anfrage → Load-Balancer wählt Server 1
- Load-Balancer setzt Cookie:
server_id=server1 - Weitere Anfragen → Cookie wird gelesen → Immer Server 1
Für unser URL-Shortener:
- Nicht notwendig: Unsere API ist stateless (kein Session-State)
- Aber nützlich: Kann Cache-Hit-Rate verbessern (gleicher Client → gleicher Server)
SSL/TLS Termination
Was ist SSL/TLS Termination?
Der Load-Balancer übernimmt die SSL-Entschlüsselung:
Client → [HTTPS verschlüsselt] → Load-Balancer → [HTTP unverschlüsselt] → API-Server
Vorteile:
- Performance: CPU-intensive SSL-Entschlüsselung nur im Load-Balancer
- Zentrales Zertifikats-Management: Nur Load-Balancer braucht Zertifikate
- Einfacheres Backend: API-Server müssen kein SSL handhaben
Traefik SSL-Konfiguration:
traefik:
command:
- "--certificatesresolvers.letsencrypt.acme.email=admin@short.ly"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
volumes:
- ./letsencrypt:/letsencrypt
Rate Limiting und DDoS-Schutz
Das Problem: DDoS-Angriffe
Angreifer sendet 100.000 Anfragen/Sekunde
→ Ohne Rate Limiting: Alle Server überlastet
→ System bricht zusammen
Die Lösung: Rate Limiting im Load-Balancer
# Traefik Rate Limiting
labels:
- "traefik.http.middlewares.ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.ratelimit.ratelimit.period=1s"
- "traefik.http.routers.api.middlewares=ratelimit"
Wie es funktioniert:
- Maximal 100 Anfragen pro Sekunde pro Client
- Überschreitungen werden abgelehnt (429 Too Many Requests)
- Schützt Backend-Server vor Überlastung
Traefik-Konfiguration: Vollständiges Beispiel
docker-compose.yml für Traefik:
version: '3.8'
services:
traefik:
image: traefik:v2.10
command:
# API Dashboard (nur für Entwicklung)
- "--api.insecure=true"
# Docker Provider (automatische Service-Erkennung)
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
# Entry Points (Ports)
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# SSL/TLS (Let's Encrypt)
- "--certificatesresolvers.letsencrypt.acme.email=admin@short.ly"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
# Logging
- "--accesslog=true"
- "--log.level=INFO"
ports:
- "80:80" # HTTP
- "443:443" # HTTPS
- "8080:8080" # Traefik Dashboard
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./letsencrypt:/letsencrypt
networks:
- app-network
api-1:
build: ./api
labels:
# Traefik aktivieren
- "traefik.enable=true"
# Routing-Regeln
- "traefik.http.routers.api.rule=Host(`short.ly`)"
- "traefik.http.routers.api.entrypoints=web,websecure"
- "traefik.http.routers.api.tls.certresolver=letsencrypt"
# Service-Konfiguration
- "traefik.http.services.api.loadbalancer.server.port=5000"
# Load-Balancing-Algorithmus (Round-Robin ist Standard)
# Für Least Connections:
# - "traefik.http.services.api.loadbalancer.server.weight=1"
# Health Checks
- "traefik.http.services.api.loadbalancer.healthcheck.path=/health"
- "traefik.http.services.api.loadbalancer.healthcheck.interval=10s"
# Rate Limiting (optional)
- "traefik.http.middlewares.ratelimit.ratelimit.average=100"
- "traefik.http.routers.api.middlewares=ratelimit"
networks:
- app-network
environment:
- REDIS_HOST=redis
- DB_HOST=postgres
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 10s
timeout: 3s
retries: 3
api-2:
build: ./api
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`short.ly`)"
- "traefik.http.routers.api.entrypoints=web,websecure"
- "traefik.http.routers.api.tls.certresolver=letsencrypt"
- "traefik.http.services.api.loadbalancer.server.port=5000"
- "traefik.http.services.api.loadbalancer.healthcheck.path=/health"
- "traefik.http.services.api.loadbalancer.healthcheck.interval=10s"
networks:
- app-network
environment:
- REDIS_HOST=redis
- DB_HOST=postgres
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 10s
timeout: 3s
retries: 3
api-3:
build: ./api
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`short.ly`)"
- "traefik.http.routers.api.entrypoints=web,websecure"
- "traefik.http.routers.api.tls.certresolver=letsencrypt"
- "traefik.http.services.api.loadbalancer.server.port=5000"
- "traefik.http.services.api.loadbalancer.healthcheck.path=/health"
- "traefik.http.services.api.loadbalancer.healthcheck.interval=10s"
networks:
- app-network
environment:
- REDIS_HOST=redis
- DB_HOST=postgres
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 10s
timeout: 3s
retries: 3
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
networks:
- app-network
command: redis-server --maxmemory 2gb --maxmemory-policy allkeys-lru
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_DB=urlshortener
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- app-network
volumes:
redis-data:
postgres-data:
networks:
app-network:
driver: bridge
Monitoring und Metriken
Wichtige Load-Balancer-Metriken:
- Request-Rate: Anfragen pro Sekunde
- Response-Time: Durchschnittliche Antwortzeit
- Error-Rate: Prozent der fehlgeschlagenen Anfragen
- Server-Status: Welche Server sind “up” oder “down”?
- Lastverteilung: Wie viele Anfragen pro Server?
Traefik Dashboard:
Zugriff auf http://localhost:8080 zeigt:
- Alle konfigurierten Routen
- Status aller Backend-Server
- Request-Metriken in Echtzeit
- Health-Check-Status
6. Skalierbarkeit: Warum horizontale Skalierung?
Das Problem: Vertikale vs. Horizontale Skalierung
Vertikale Skalierung (Scale-Up):
Ein Server: 4 Cores, 8GB RAM
↓
Größerer Server: 8 Cores, 16GB RAM
↓
Noch größerer Server: 16 Cores, 32GB RAM
Probleme:
- Teuer: Größere Server kosten exponentiell mehr
- Limits: Irgendwann gibt es keine größeren Server mehr
- Downtime: Server muss offline für Hardware-Upgrade
- Single Point of Failure: Ein Server = ein Ausfallpunkt
Horizontale Skalierung (Scale-Out):
1 Server: 4 Cores, 8GB RAM
↓
3 Server: 12 Cores, 24GB RAM (gesamt)
↓
10 Server: 40 Cores, 80GB RAM (gesamt)
Vorteile:
- Kosteneffizient: Mehr kleine Server sind günstiger als ein großer
- Praktisch unbegrenzt: Kann beliebig viele Server hinzufügen
- Kein Downtime: Neue Server können live hinzugefügt werden
- Hochverfügbarkeit: Wenn ein Server ausfällt, übernehmen andere
Warum horizontale Skalierung für unseren URL-Shortener?
Szenario: Wachstum der Anwendung
Start: 100 Anfragen/Sekunde → 1 Server reicht
↓
Nach 6 Monaten: 1.000 Anfragen/Sekunde → 3 Server nötig
↓
Nach 1 Jahr: 10.000 Anfragen/Sekunde → 10 Server nötig
↓
Nach 2 Jahren: 100.000 Anfragen/Sekunde → 50 Server nötig
Mit vertikaler Skalierung:
- Müssten Sie Server ständig ersetzen (teuer, Downtime)
- Irgendwann gibt es keine größeren Server mehr
Mit horizontaler Skalierung:
- Einfach mehr Server hinzufügen (günstig, kein Downtime)
- Praktisch unbegrenzt skalierbar
Anforderungen für horizontale Skalierung
1. Stateless API-Server
Was bedeutet “stateless”?
Ein stateless Server speichert keinen lokalen State (keine Session-Daten, kein lokaler Cache).
Stateless (gut für Skalierung):
# Jede Anfrage ist unabhängig
@app.route('/<short_code>')
def redirect(short_code):
# Keine lokalen Variablen, keine Session-Daten
url = get_url_from_redis_or_db(short_code) # Externe Datenquelle
return redirect(url)
Stateful (schlecht für Skalierung):
# Server speichert State lokal
session_data = {} # Lokaler Speicher
@app.route('/<short_code>')
def redirect(short_code):
# Problem: Wenn Request an anderen Server geht, ist State weg!
if short_code in session_data: # Nur auf diesem Server!
return redirect(session_data[short_code])
Warum ist Stateless wichtig?
Request 1: Client → Load-Balancer → Server 1 (State gespeichert)
Request 2: Client → Load-Balancer → Server 2 (State fehlt! Problem!)
Mit stateless:
- Jeder Server ist identisch
- Jede Anfrage kann an jeden Server gehen
- Keine Abhängigkeit zwischen Anfragen
2. Shared Database
Warum eine gemeinsame Datenbank?
Server 1 erstellt: short.ly/abc123 → https://example.com
Server 2 erstellt: short.ly/xyz789 → https://test.com
Server 3 muss beide URLs kennen können!
Ohne Shared Database (jeder Server eigene DB):
Server 1 DB: abc123 → example.com
Server 2 DB: xyz789 → test.com
Server 3 DB: (leer)
Problem: Server 3 kennt abc123 und xyz789 nicht!
Mit Shared Database:
Alle Server → Gemeinsame Datenbank
Server 1: Erstellt abc123 → Speichert in DB
Server 2: Erstellt xyz789 → Speichert in DB
Server 3: Kann beide URLs abrufen → Liest aus DB
3. Shared Cache (Redis)
Warum ein gemeinsamer Cache?
Server 1: Cache für abc123 → example.com
Server 2: Cache für xyz789 → test.com
Server 3: Kein Cache
Problem: Server 3 muss immer zur Datenbank (langsam)
Mit Shared Cache:
Alle Server → Gemeinsamer Redis
Server 1: Cache für abc123 → Speichert in Redis
Server 2: Cache für xyz789 → Speichert in Redis
Server 3: Kann beide aus Redis lesen (schnell!)
4. Load Balancer
Warum ein Load-Balancer?
Ohne Load-Balancer müssten Clients wissen, welcher Server verfügbar ist:
Client muss wählen:
- Soll ich zu Server 1, 2 oder 3 gehen?
- Welcher Server ist verfügbar?
- Wie verteile ich Last gleichmäßig?
Mit Load-Balancer:
Client → Load-Balancer (wählt automatisch Server) → Server
Skalierung in der Praxis
Schritt 1: Start mit einem Server
services:
api:
build: ./api
# Ein Server, kann ~1.000 Anfragen/Sekunde verarbeiten
Schritt 2: Skalierung bei steigender Last
# Docker Compose Skalierung
docker-compose up -d --scale api=3
# Jetzt: 3 Server, können ~3.000 Anfragen/Sekunde verarbeiten
Schritt 3: Automatische Skalierung (Auto-Scaling)
Bei Cloud-Providern (AWS, Google Cloud, Azure):
# Kubernetes Auto-Scaling Beispiel
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-autoscaler
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api
minReplicas: 3
maxReplicas: 50
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Wie Auto-Scaling funktioniert:
CPU-Auslastung < 50% → Reduziere Server (z.B. 3 → 2)
CPU-Auslastung > 70% → Erhöhe Server (z.B. 3 → 5)
CPU-Auslastung > 90% → Erhöhe Server (z.B. 5 → 10)
Vorteile:
- Kosteneffizient: Nur so viele Server wie nötig
- Automatisch: Keine manuelle Intervention
- Reagiert auf Last: Skaliert bei Traffic-Spitzen hoch
Skalierungs-Limits und Bottlenecks
Wichtige Überlegung: Wo sind die Limits?
1. API-Server (horizontale Skalierung möglich)
1 Server → 3 Server → 10 Server → 50 Server → 100 Server
✅ Praktisch unbegrenzt skalierbar
2. Load-Balancer (kann selbst skaliert werden)
1 Load-Balancer → 2 Load-Balancer (Active-Passive)
✅ Kann auch skaliert werden
3. Redis (kann zu Cluster erweitert werden)
1 Redis → Redis-Cluster (3-6 Nodes)
✅ Kann skaliert werden (Sharding)
4. Datenbank (kritischer Bottleneck!)
1 Datenbank → Read-Replicas (für Reads)
→ Master (für Writes)
⚠️ Writes sind schwerer zu skalieren
Datenbank-Skalierung:
Read-Replicas für bessere Read-Performance:
Master DB (Writes) → Replica 1 (Reads)
→ Replica 2 (Reads)
→ Replica 3 (Reads)
API-Server können Reads auf Replicas verteilen
Sharding für sehr große Datenmengen:
Shard 1: URLs a-m (20% der Daten)
Shard 2: URLs n-z (30% der Daten)
Shard 3: URLs 0-9 (50% der Daten)
Jeder Shard auf separatem Server
Skalierungsschritte: Praktisches Beispiel
Phase 1: MVP (1-100 Anfragen/Sekunde)
# 1 API-Server
docker-compose up -d
Phase 2: Wachstum (100-1.000 Anfragen/Sekunde)
# 3 API-Server
docker-compose up -d --scale api=3
Phase 3: Skalierung (1.000-10.000 Anfragen/Sekunde)
# 10 API-Server
docker-compose up -d --scale api=10
# Redis-Cluster für bessere Cache-Performance
# Datenbank Read-Replicas für bessere DB-Performance
Phase 4: Enterprise (10.000+ Anfragen/Sekunde)
# 50+ API-Server
# Redis-Cluster mit 6 Nodes
# Datenbank mit Sharding
# CDN für statische Inhalte
# Multi-Region Deployment
Monitoring der Skalierung
Wichtige Metriken zum Überwachen:
- Request-Rate: Anfragen pro Sekunde pro Server
- Response-Time: Durchschnittliche Antwortzeit
- CPU-Auslastung: Sollte < 70% sein für Puffer
- Memory-Auslastung: Sollte < 80% sein
- Error-Rate: Sollte < 1% sein
- Cache-Hit-Rate: Sollte > 95% sein
Wann sollte skaliert werden?
CPU > 70% für > 5 Minuten → Skaliere hoch
Response-Time > 200ms → Skaliere hoch
Error-Rate > 1% → Skaliere hoch
Cache-Hit-Rate < 90% → Prüfe Redis-Konfiguration
7. Erweiterte Optimierungen
Datenbank-Optimierung:
-- Partitionierung für große Tabellen
CREATE TABLE url_mappings_2025 PARTITION OF url_mappings
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
-- Read Replicas für bessere Lesepfad-Performance
Caching-Optimierung:
- Hot URLs: Häufig aufgerufene URLs länger cachen
- Bloom Filter: Schnelle Prüfung ob Short-Code existiert (vor DB-Query)
Monitoring:
- Redis Hit-Rate überwachen
- Datenbank-Query-Performance tracken
- API-Response-Zeiten messen
- Load Balancer-Metriken beobachten
Zusammenfassung
Dieses System-Design bietet:
✅ Skalierbarkeit: Horizontale Skalierung durch stateless API-Server
✅ Performance: Redis-Caching für schnelle Antwortzeiten
✅ Zuverlässigkeit: Load Balancing verteilt Last gleichmäßig
✅ Einfachheit: Klare Trennung der Komponenten
Mit dieser Architektur können Sie Millionen von Short-URLs verwalten und tausende von Anfragen pro Sekunde verarbeiten.