Leistungen

Backend Engineering, DevOps

Branche

B2B-Marktplatz

Jahr

2023-2024

50.000 Webhook-Events pro Tag verarbeiten, ohne ein einziges zu verlieren.

Ein B2B-Marktplatz setzte auf SendGrid für transaktionale E-Mails: Registrierungsbestätigungen, Passwort-Resets, Rechnungsbenachrichtigungen. SendGrid feuert Webhook-Events für jede Zustellung, jeden Öffner, Klick und Bounce. Bei Skalierung treffen diese Events in Schüben von Hunderten pro Sekunde ein. Der bestehende Signal Handler verlor Events still, wenn zwei Webhooks gleichzeitig auf dieselbe Datenbankzeile zugreifen wollten. Niemandem fiel es auf, bis ein Compliance-Audit Lücken in den Zustellungsprotokollen offenlegte.

01.
DIE HERAUSFORDERUNG

Stiller Datenverlust unter Last

Der Webhook-Endpunkt verarbeitete Events synchron in einem Django-Signal-Handler. Bei normaler Last funktionierte alles. Während E-Mail-Kampagnen, wenn Tausende Nachrichten innerhalb von Minuten versendet wurden, feuerte SendGrid Hunderte Tracking-Events gleichzeitig. Zwei Webhooks, die denselben EmailHooks-Eintrag im selben Moment erstellen wollten, verursachten Datenbank-Deadlocks. Django fing den IntegrityError ab, der Signal Handler stürzte ab, und das Event ging verloren. Kein Retry, kein Log-Eintrag, kein Alert. Das Compliance-Team entdeckte die Lücken Wochen später, als Zustellbestätigungen nicht mit SendGrids eigenen Aufzeichnungen übereinstimmten.

Zwei Webhooks treffen gleichzeitig auf dieselbe Zeile. Einer gewinnt, einer stürzt still ab. Niemand bemerkt es wochenlang.

02.
DIE LÖSUNG

Deadlock-resistente Signal-Verarbeitung

Atomare Schreibvorgänge

Jedes Webhook-Event durchläuft einen transaction.atomic()-Block mit get_or_create, gekeys auf die event_id. Existiert der Eintrag bereits, kehrt der Handler sofort zurück. Das macht die Operation von Design her idempotent. Keine doppelten Schreibvorgänge, kein partieller Zustand.

Automatisches Retry

Wird ein Deadlock erkannt, versucht der Handler bis zu fünf Mal mit progressivem Backoff: 0,5s, 1,0s, 1,5s, bis zu 2,5s. Diese Staffelung durchbricht das Thundering-Herd-Muster, bei dem gleichzeitige Retries sofort wieder deadlocken würden. Nur echte Deadlocks werden wiederholt. Ein IntegrityError von einer echten Constraint-Verletzung wird sofort ausgelöst.

Verzögerte Anreicherung

Sobald der Datenbankschreibvorgang erfolgreich ist, reiht transaction.on_commit() einen Celery-Task ein, der die SendGrid Messages API aufruft und den Eintrag mit vollständigen Metadaten anreichert. Die Anreicherung läuft nie, wenn der Schreibvorgang fehlschlägt. Das garantiert, dass der asynchrone Task erst nach dem Commit feuert.

LIVE SIMULATION
Warte auf Events
0Verarbeitet
0Retries
0Datenverlust

Unter der Haube

Der Signal Handler mit Retry-Logik und verzögerter Anreicherung:

Python
MAX_RETRIES = 5

@receiver(event_received)
def handle_webhook_event(sender, event_data, **kwargs):
    for attempt in range(MAX_RETRIES):
        try:
            with transaction.atomic():
                hook, created = EmailHooks.objects.get_or_create(
                    event_id=event_data['sg_event_id'],
                    defaults={
                        'email': event_data['email'],
                        'event': event_data['event'],
                        'timestamp': parse_datetime(
                            event_data['timestamp']
                        ),
                        'sg_message_id': event_data.get('sg_message_id'),
                    }
                )
                if not created:
                    return  # Already processed — idempotent

                transaction.on_commit(
                    lambda pk=hook.pk: enrich_hook.delay(pk)
                )
            return

        except (IntegrityError, OperationalError) as exc:
            if 'Deadlock' in str(exc) and attempt < MAX_RETRIES - 1:
                sleep(0.5 * (attempt + 1))
                continue
            raise

Der Celery-Task, der jeden Eintrag über die SendGrid-API anreichert:

Python
@shared_task(bind=True, max_retries=3)
def enrich_hook(self, hook_id):
    hook = EmailHooks.objects.get(pk=hook_id)

    try:
        msg = sg_client.client.messages._(
            hook.sg_message_id
        ).get()

        hook.subject = msg['subject']
        hook.from_email = msg['from_email']
        hook.opens_count = msg.get('opens_count', 0)
        hook.clicks_count = msg.get('clicks_count', 0)
        hook.status = 'enriched'
        hook.save(update_fields=[
            'subject', 'from_email',
            'opens_count', 'clicks_count', 'status',
        ])

    except Exception as exc:
        raise self.retry(exc=exc, countdown=30)
03.
DAS ERGEBNIS

Null Datenverlust bei Skalierung

Der überarbeitete Handler verarbeitete während Spitzen-Kampagnenphasen über 50.000 Webhook-Events pro Tag, ohne ein einziges Event zu verlieren. Der Retry-Mechanismus fing Deadlocks transparent ab. Das Compliance-Team sah nie wieder eine Lücke in den Zustellungsprotokollen. Die Celery-Anreicherungspipeline fügte innerhalb von Sekunden vollständige Nachrichtenmetadaten hinzu und gab dem Support-Team Echtzeit-Einblick in den E-Mail-Zustellungsstatus.

WICHTIGE KENNZAHLEN

0+Tägliche Events
0Events verloren
0,8%Retry-Erfolg
WAS DER KUNDE SAGT

"Wir sind von der Entdeckung fehlender Zustellungsdaten Wochen nach dem Vorfall zu Echtzeiteinblick in jedes E-Mail-Event übergegangen. Das Compliance-Team vertraut den Daten endlich."

Engineering Lead

B2B-Marktplatz · Plattformbetrieb

FAQ

Warum nicht eine dedizierte Message Queue statt Django Signals verwenden?

Was passiert, wenn alle fünf Retries fehlschlagen?

Wie verhindert man, dass der Celery-Task bei einer zurückgerollten Transaktion läuft?

TECHNOLOGIE-STACK