Ereignisgesteuerte Architektur: Wie wir einen 50.000 Dollar pro Monat teuren Monolithen ersetzt haben
Ein praktischer Leitfaden zur ereignisgesteuerten Architektur mit konkreten Vorher-Nachher-Code-Beispielen, der zeigt, wie wir ein eng gekoppeltes Auftragsabwicklungssystem in eine robuste, skalierbare Ereignis-Pipeline zerlegt haben.
Das Problem: Ein Monolith, der nicht skalierbar war
Unser Kunde verfügte über eine monolithische Django-Anwendung zur Auftragsabwicklung. Jeder Auftrag löste eine synchrone Abfolge von Schritten aus: Bestandsprüfung → Zahlungsabwicklung → Versand der Bestätigungs-E-Mail → Aktualisierung der Analysedaten → Benachrichtigung des Lagers. Wenn der E-Mail-Dienst langsam war, kam der gesamte Bestellvorgang zum Stillstand. Wenn die Analysedaten nicht verfügbar waren, schlugen die Bestellungen fehl.
Das System verarbeitete während der normalen Geschäftszeiten 200 Bestellungen pro Minute, brach jedoch bei Blitzverkäufen mit 2.000 Bestellungen pro Minute zusammen. Um dies auszugleichen, zahlten sie monatlich 50.000 Dollar für überdimensionierte Server.
Zuvor: Die synchrone Kette des Schmerzes
# The old way: everything synchronous, everything coupled
def process_order(request):
order = Order.objects.create(**request.data)
# If ANY of these fail, the order fails
inventory_service.reserve(order) # 200ms
payment_service.charge(order) # 800ms
email_service.send_confirmation(order) # 500ms
analytics_service.track(order) # 300ms
warehouse_service.notify(order) # 400ms
# Total: 2200ms minimum response time
# If email is down: order fails
# If analytics is slow: checkout is slow
return Response({'order_id': order.id})Die Architektur: Ereignisse + Befehle
Wir haben das System in zwei Konzepte unterteilt:
Befehle (die unbedingt erfolgreich sein MÜSSEN): Bestandsreservierung + Zahlung. Diese bleiben synchron, da die Bestellung davon abhängt.
Ereignisse (Dinge, die passieren SOLLTEN): E-Mail, Analysen, Benachrichtigungen aus dem Data Warehouse. Diese werden als Ereignisse veröffentlicht und asynchron verarbeitet. Wenn sie fehlschlagen, wird ein erneuter Versuch unternommen – der Kunde muss nicht warten.
Danach: Ereignisgesteuert mit Celery + Redis
# The new way: synchronous for critical path, events for the rest
def process_order(request):
order = Order.objects.create(**request.data)
# Critical path: must succeed (synchronous)
inventory_service.reserve(order) # 200ms
payment_service.charge(order) # 800ms
# Publish event: fire-and-forget (async)
publish_event('order.completed', {
'order_id': order.id,
'customer_email': order.customer.email,
'total': str(order.total),
})
# Total: 1000ms response time
# Email down? Order still succeeds. Retried later.
return Response({'order_id': order.id})
# Event handlers (separate workers)
@celery_app.task(bind=True, max_retries=5, default_retry_delay=60)
def handle_order_completed(self, event_data):
"""Each handler is independent and retriable"""
try:
email_service.send_confirmation(event_data)
except Exception as exc:
self.retry(exc=exc, countdown=2 ** self.request.retries * 60)Das Event-Bus-Muster
Wir haben einen schlanken Event-Bus mit Redis Streams entwickelt (nicht nur Pub/Sub – Streams bieten Ihnen Persistenz und Consumer-Gruppen):
import redis
import json
from datetime import datetime
class EventBus:
def __init__(self):
self.redis = redis.Redis(host='redis', port=6379, db=0)
def publish(self, event_type: str, data: dict):
"""Publish event to Redis Stream"""
event = {
'type': event_type,
'data': json.dumps(data),
'timestamp': datetime.utcnow().isoformat(),
}
self.redis.xadd(f'events:{event_type}', event)
def subscribe(self, event_type: str, group: str, consumer: str):
"""Read events with consumer group (each event processed once)"""
try:
self.redis.xgroup_create(
f'events:{event_type}', group, id='0', mkstream=True
)
except redis.ResponseError:
pass # Group already exists
while True:
events = self.redis.xreadgroup(
group, consumer,
{f'events:{event_type}': '>'},
count=10, block=5000
)
for stream, messages in events:
for msg_id, fields in messages:
yield msg_id, json.loads(fields[b'data'])
self.redis.xack(stream, group, msg_id)Ergebnisse nach 3 Monaten
| Metrisch | Zuvor | Danach |
|---|---|---|
| Reaktionszeit an der Kasse | 2.200 ms | 1.000 ms |
| Spitzen-Durchsatz | 200 Bestellungen pro Minute | 5.000 Bestellungen/Minute |
| Monatliche Serverkosten | 50.000 Dollar | 12.000 Dollar |
| Auswirkungen des Ausfalls des E-Mail-Dienstes | Alle Bestellungen schlagen fehl | Bestellungen werden bearbeitet, E-Mails stehen in der Warteschlange |
| Risiko bei der Bereitstellung | Alles oder nichts | Dienste unabhängig voneinander bereitstellen |
Wann man keine ereignisgesteuerte Architektur verwenden sollte
Ereignisgesteuert ist nicht immer die richtige Lösung. Vermeiden Sie diesen Ansatz, wenn:
1. Du benötigst eine strenge Reihenfolge. Ereignisse können in ungeordneter Reihenfolge eintreffen. Wenn Schritt B nach Schritt A erfolgen muss, halte sie synchron.
2. Du benötigst sofortige Konsistenz. Ereignisse sind erst nach einer gewissen Zeit konsistent. Wenn der Benutzer das Ergebnis sofort sehen muss, solltest du keine asynchronen Ereignisse verwenden.
3. Ihr Team ist klein. Ereignisgesteuerte Architektur erhöht die Komplexität des Betriebs. Einem Team aus zwei bis drei Entwicklern ist mit einem gut strukturierten Monolithen besser gedient.
4. Sie haben weniger als 100 Anfragen pro Minute. Ein synchrones Monolith-System kommt damit gut zurecht. Übertreiben Sie es nicht mit der Komplexität.
Fang mit einem Monolithen an. Löse die Systemkomponenten auf, wenn du Probleme bekommst. Die schlechteste Architektur ist die, die für Probleme entwickelt wurde, die du noch gar nicht hast.
— alokknight Engineering
