Zurück zur Startseite

Webhook-Verarbeitung, die den Kampagnen-Burst übersteht

Die Lücke tauchte in einem Audit auf, nicht in einem Dashboard. Eine Compliance-Prüfung verglich das Zustellprotokoll eines B2B-Marktplatzes mit SendGrids eigenen Aufzeichnungen und fand fehlende Events, da schon Wochen alt. Jede transaktionale Mail, von der Registrierungsbestätigung bis zur Rechnung, meldete sich über Webhooks zurück. Niemandem war aufgefallen, dass manche dieser Meldungen nie in der Datenbank ankamen.

Leistungen

Backend Engineering, DevOps

Branche

B2B-Marktplatz

Jahr

2023-2024

DIE HERAUSFORDERUNG

Zwei Events, eine Zeile, ein Überlebender

Der Webhook-Endpunkt verarbeitete Events synchron in einem Django-Signal-Handler, und bei normaler Last war das in Ordnung. Während einer Kampagne gingen Tausende Nachrichten innerhalb von Minuten raus, und SendGrid feuerte Hunderte Tracking-Events pro Sekunde. Wenn zwei davon im selben Moment denselben EmailHooks-Datensatz anlegen wollten, lief die Datenbank in einen Deadlock. Django fing den IntegrityError, der Handler stürzte ab, und das zweite Event war weg. Kein Retry, keine Log-Zeile, kein Alert. Das Zustellprotokoll sah vollständig aus, bis es Wochen später jemand mit SendGrids Aufzeichnungen verglich.

DIE LÖSUNG

Den Deadlock wiederholen, die Anreicherung aufschieben

Jedes Event läuft jetzt durch einen transaction.atomic()-Block mit get_or_create auf der SendGrid-Event-ID, also kehrt eine doppelte Zustellung früh zurück, statt zweimal zu schreiben. Läuft der Write in einen Deadlock, wartet der Handler und versucht es erneut, bis zu fünfmal, mit einem Backoff, der in Halbsekundenschritten wächst; die Staffelung verhindert, dass parallele Retries ein zweites Mal kollidieren. Sobald der Write committed ist, reiht transaction.on_commit() einen Celery-Task ein, der die SendGrid Messages API aufruft und Betreff, Absender sowie Öffnungs- und Klickzahlen nachträgt. Die Alternative war eine richtige Queue vor dem Endpunkt, RabbitMQ oder SQS. Das hätte einen Umbau der Ingestion-Schicht bedeutet und Infrastruktur, für deren Betrieb das Team keine Kapazität hatte, für ein Problem, das in eine Datei passt. Celery lief ohnehin schon für andere Hintergrundarbeit.

Der Signal Handler mit Deadlock-Retry und aufgeschobener 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

Ein Burst trifft den Handler

Acht bis zehn Events kommen innerhalb einer Sekunde an, manche für dieselbe Nachrichtenzeile. Schau zu, wie ein Paar bei Write kollidiert: Mit dem neuen Handler wartet der Verlierer, wiederholt und kommt durch, und Enrich leuchtet erst nach Commit auf. Wechsle auf den alten Handler, um den Verlierer verschwinden zu sehen. Das letzte Event ist eine doppelte Zustellung.

Webhook-Burst
Bereit
EventZeileSignal, Write, Commit, EnrichStatus
#4f2adeliveredmsg 9c1eAngereichert
#b7d3openedmsg 9c1eAngereichert2 Versuche
#e10cdeliveredmsg 27a4Angereichert
#58f9bouncedmsg d3b0Angereichert
#a6c1clickedmsg 27a4Angereichert2 Versuche
#0d7edeliveredmsg f412Angereichert
#4f2adeliveredmsg 9c1eDuplikat, früh zurückgekehrt
Committed
6
Retries
2
Angereichert
6
Verloren
0

Burst beendet: 6 committed, 2 Retries, 6 angereichert, 0 verloren.

Der Celery-Task, der jeden Datensatz über die SendGrid-API anreichert, mit eigenen Retries:

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)
DAS ERGEBNIS

Das Protokoll stimmt mit dem Anbieter überein

In Spitzen-Kampagnenphasen nimmt der neue Handler mehr als fünfzigtausend Events am Tag an, und das Compliance-Team hat keine weitere Lücke im Zustellprotokoll gefunden. Deadlocks passieren unter Last weiterhin; sie werden jetzt vom Retry aufgelöst, ohne dass jemand alarmiert wird. Das Support-Team sieht die vollständigen Metadaten eines E-Mail-Events Sekunden nachdem es passiert ist und kann Zustellfragen beantworten, ohne beim Engineering nachzufragen.

WICHTIGE KENNZAHLEN

50000+Events pro Tag, Spitzen-Kampagnen
0Verlorene Events seit dem Start
99,8%Per Retry aufgelöste Deadlocks
KUNDENSTIMME

Früher war das Audit der Ort, an dem wir von fehlenden Datensätzen erfahren haben, Wochen zu spät. Jetzt stimmt das Zustellprotokoll mit dem von SendGrid überein, und der Support kann den Status jeder Mail nachschlagen, ohne uns zu fragen.

Engineering Lead

B2B-Marktplatz, Plattformbetrieb

FÜR DEIN PROJEKT

Wann es passt
Jeder Webhook- oder Callback-Endpunkt, der pro Event eine Zeile schreibt und seine Events in Schüben bekommt: E-Mail-Anbieter, Zahlungsanbieter, CRM-Sync. Das Symptom, nach dem du suchst, ist eine Event-Anzahl, die nicht zum Export des Anbieters passt.
Was du prüfen solltest
Ob dein Handler Deadlock und Constraint-Verletzung gleich behandelt, ob die Event-ID in einer Unique-Spalte liegt, damit eine Wiederholung harmlos ist, und ob Hintergrund-Tasks vor dem Commit eingereiht werden, von dem sie abhängen.
Was es braucht
Eine Dateiänderung im Handler, einen Unique Key auf der Event-ID und eine Task-Queue, die du schon betreibst, für die Anreicherung. Rechne mit einem API-Aufruf beim Anbieter pro Event; bei fünfzigtausend Events am Tag ist das die eigentliche Last, nicht der Datenbank-Write.

FAQ

TECHNOLOGIE-STACK

Django
Python
PostgreSQL

Seit mittlerweile 10 Jahren arbeiten wir immer wieder gerne mit Daniel zusammen. Wir schätzen dabei sehr seine schnellen Reaktionszeiten rund um die Uhr und sein Allroundwissen. Egal ob Serverkonfigurationen oder Programmierungen, er hat immer die passende Lösung.

Manuel Kasbarian - Geschäftsführer, SophistiX

Tritt in die Fußstapfen von Manuel und erwecke deine Vision zum Leben.

Schreibe mir