Die Exception wird weitergereicht. Djangos Error Reporting schickt einen Alert, und der Webhook antwortet mit 500, worauf SendGrid das Event innerhalb von Minuten erneut zustellt und der Handler von vorn beginnt. Ein Event müsste zehnmal hintereinander in einen Deadlock laufen, um verloren zu gehen. In der Produktion ist das nicht passiert.
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
raiseEin 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
| Event | Zeile | Signal, Write, Commit, Enrich | Status |
|---|---|---|---|
| #4f2adelivered | msg 9c1e | Angereichert | |
| #b7d3opened | msg 9c1e | Angereichert2 Versuche | |
| #e10cdelivered | msg 27a4 | Angereichert | |
| #58f9bounced | msg d3b0 | Angereichert | |
| #a6c1clicked | msg 27a4 | Angereichert2 Versuche | |
| #0d7edelivered | msg f412 | Angereichert | |
| #4f2adelivered | msg 9c1e | Duplikat, 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
Manuel Kasbarian - Geschäftsführer, SophistiXSeit 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.
Tritt in die Fußstapfen von Manuel und erwecke deine Vision zum Leben.
Frei für neue ProjekteSchreibe mir