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
# Die alte Methode: alles synchron, alles miteinander
verknĂĽpftdef process_order(request):
order = Order.objects.create(**request.data)
# Wenn auch nur einer dieser Schritte fehlschlägt, schlägt die Bestellung fehl
inventory_service.reserve(order) # 200 ms
payment_service.charge(order) # 800 ms
email_service.send_confirmation(order) # 500 ms
analytics_service.track(order) # 300 ms
warehouse_service.notify(order) # 400 ms
# Gesamt: Mindestens 2200 ms Reaktionszeit
# Wenn der E-Mail-Dienst ausfällt: Bestellung schlägt fehl
# Wenn die Analyse langsam ist: Checkout ist langsam
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
# Der neue Ansatz: Synchron fĂĽr den kritischen Pfad, Ereignisse fĂĽr den
Rest def process_order(request):
order = Order.objects.create(**request.data)
# Kritischer Pfad: muss erfolgreich sein (synchron)
inventory_service.reserve(order) # 200 ms
payment_service.charge(order) # 800 ms
# Ereignis veröffentlichen: „Fire-and-Forget“ (async)
publish_event('order.completed', {
'order_id': order.id,
'customer_email': order.customer.email,
'total': str(order.total),
})
# Insgesamt: 1000 ms Antwortzeit
# E-Mail ausgefallen? Bestellung wird trotzdem erfolgreich abgeschlossen. Später erneut versucht.
return Response({'order_id': order.id})
# Ereignis-Handler (separate Worker)
@celery_app.task(bind=True, max_retries=5, default_retry_delay=60)
def handle_order_completed(self, event_data):
"""Jeder Handler ist unabhängig und kann erneut versucht werden"""
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 redisimport
jsonfrom datetime import
datetimeclass EventBus:
def __init__(self):
self.redis = redis.Redis(host='redis', port=6379, db=0)
def publish(self, event_type: str, data: dict):
"""Ereignis an Redis Stream senden"""
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):
"""Ereignisse mit einer Verbrauchergruppe lesen (jedes Ereignis wird einmal verarbeitet)"""
try:
self.redis.xgroup_create(
f'events:{event_type}', group, id='0', mkstream=True
)
except redis.ResponseError:
pass # Gruppe existiert bereits
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
