Kontext
Ich bin Hauptentwickler einer produktiven Plattform bei Formilo, gebaut mit Amplify Gen 2, AppSync, Lambda und DynamoDB. Sie findet Unternehmen, scrapt ihre Websites, bewertet sie mit KI und verschickt zeitgesteuerte Outreach-E-Mail-Kampagnen.
Das Website-Scraping ist der schwerste asynchrone Teil. Ein Lauf legt pro Website einen Task an, und die Jobs, die wir gemessen haben, umfassten jeweils etwa 2.700 bis 3.600 Websites. Jeder Task ist ein langsamer, fehleranfälliger Netzwerkjob gegen eine Website, die wir nicht kontrollieren.
Was ich zuerst versucht habe
Am Anfang sah die Architektur sehr sauber aus:
- einen Task in DynamoDB anlegen,
- das
INSERT-Event über DynamoDB Streams abgreifen, - Lambda auslösen, das die Verarbeitung automatisch startet.
Einfach, elegant und komplett serverless. In einem früheren Produkt habe ich das 2025 genau so gebaut: fünf DynamoDB-Stream → Lambda-Mappings, nur INSERT-Events, Batches von 10. Keines davon hatte ein Retry-Limit, Bisect bei Fehlern oder ein On-Failure-Ziel.
Was ich gelernt habe
Unter echter Produktionslast und in Fehlerszenarien kamen wichtige Details zum Vorschein.
Laut AWS-Dokumentation arbeitet die Integration von DynamoDB Streams und Lambda mit At-least-once-Zustellung:
A Lambda consumer for a DynamoDB stream doesn't guarantee exactly once delivery and may lead to occasional duplicates.
Dasselbe Event kann also mehrfach verarbeitet werden, Idempotenz wird entscheidend, und das Retry-Verhalten muss von Anfang an entworfen werden. In Fehlerfällen kann Lambda den gesamten Batch wiederholen. Das führt zu doppelter Verarbeitung, wiederholten Batch-Retries, wachsendem Iterator Age und mühsamen Teilfehlern im Batch. Mit den Standardeinstellungen wird ein fehlschlagender Batch so lange wiederholt, bis seine Einträge aus dem Stream verschwinden, und der Shard wartet dahinter.
Im Rückblick fand ich in diesem Code von 2025 zwei Fehler, die das verschärft haben:
- Ein Handler gab
batchItemFailures: []zurück, aber im Mapping warReportBatchItemFailuresnie aktiviert. Der Rückgabewert hatte schlicht keine Wirkung. - Ein anderer Handler fing seine Fehler ab und machte einfach weiter. Lambda sah einen Erfolg, also erreichte der Fehler nie einen Retry- oder Dead-Letter-Mechanismus.
Der Stream erledigte zwei Aufgaben auf einmal: Er meldete, dass Arbeit existiert, und er bestimmte, wie diese Arbeit wiederholt und getaktet wird. Das Erste kann er sehr gut. Für das Zweite wollte ich ein Werkzeug, das genau dafür gebaut ist.
Lösung
Im Januar 2026 habe ich das Website-Scraping von der Stream-gesteuerten Verarbeitung gelöst. DynamoDB bleibt die Quelle der Wahrheit; SQS übernimmt die dauerhafte Warteschlange und die Retries:
- Eine eigene AppSync-Mutation legt pro Website eine Task-Zeile im Zustand
Pendingan. - Eine Dispatcher-Lambda liest den Stream der Tabelle in Batches von 10. Sie betrachtet nur
INSERT-Events, verteilt nur Tasks, die nochPendingsind, und sendet pro Task eine SQS-Nachricht. Selbst scrapt sie nichts. - Die Scraper-Lambda liest die Queue mit Batch-Größe 1: ein Task, ein Aufruf.
- Nachrichten, die immer wieder scheitern, landen in einer Dead-Letter-Queue.
Die erste Version hatte einen Visibility Timeout von 16 Minuten (das Lambda-Timeout von 15 Minuten plus Puffer), maxReceiveCount 3 und eine DLQ, die Nachrichten 14 Tage aufbewahrt. Die Szenarien unten spielen die Fehlerfälle gegen das aktuelle Design durch: „Worker fällt aus“, „Doppelte Zustellung“ und „Lastspitze“.
Eine Komponente im Diagramm auswählen, um zu sehen, wozu sie dient.
Die Queue-Konfiguration heute
Die Werte wurden seitdem nachjustiert, teils von mir, teils vom Team, als wir echte Läufe gemessen haben. So sieht das aktuelle Setup vereinfacht aus:
const queue = new Queue(stack, "ScrapeQueue", {
// AWS-Empfehlung: mindestens das 6-Fache des Lambda-Timeouts
visibilityTimeout: Duration.minutes(90),
retentionPeriod: Duration.days(4),
deadLetterQueue: { queue: scrapeDlq, maxReceiveCount: 3 }, // DLQ hält 14 Tage
});
scraper.addEventSource(
new SqsEventSource(queue, {
batchSize: 1, // 1 Task = 1 Lambda-Aufruf
maxBatchingWindow: Duration.seconds(0),
maxConcurrency: 140, // Reserved Concurrency steht ebenfalls auf 140
// Ohne diese Option wird batchItemFailures ignoriert, und Nachrichten
// werden auch bei Fehlern gelöscht.
reportBatchItemFailures: true,
}),
);
Der Scraper läuft mit einem Timeout von 420 Sekunden und 1.536 MB Arbeitsspeicher. Ein Alarm auf der DLQ schlägt schon bei der ersten Nachricht an.
Fatale und wiederholbare Fehler
Alles zu wiederholen ist genauso falsch, wie nichts zu wiederholen. Der Scraper-Handler entscheidet pro Nachricht:
- Nicht lesbare Nachricht: scheitert ohne Retry. Ein kaputter Body wird durch einen Retry nicht besser.
- Task ist bereits
FinishedoderFailed: bestätigen und überspringen. Genau das macht eine doppelte Zustellung harmlos. FatalScrapeError(etwa eine ungültige URL): Task aufFailedsetzen, kein Retry.- Alles andere: die Nachricht in
batchItemFailuresmelden, damit SQS sie nach dem Visibility Timeout erneut zustellt.
Innerhalb eines Tasks wird eine einzelne URL höchstens dreimal abgerufen, bevor der Scraper sie aufgibt.
Retries sind keine Wiederherstellung
Früher war die erneute Zustellung durch SQS der einzige Heilungsmechanismus, und sie endet nach maxReceiveCount. Stirbt ein Worker, nachdem er einen Task auf Running gesetzt hat, bleibt eine Zeile zurück, die kein Retry je anfasst. Deshalb laufen heute alle 5 Minuten EventBridge-Regeln: Sie verteilen Tasks, die in Pending hängen, erneut und setzen Tasks, die in Running hängen, auf einen Endzustand.
Später hat das Team die Dispatcher-Lambda durch eine EventBridge Pipe ersetzt (Stream → SQS, gefiltert auf Tasks, die Pending werden, mit Bisect bei Fehlern und eigener DLQ) und einen atomaren Claim ergänzt, sodass nur eine Zustellung einen Task auf Running setzen kann. Das Grundgerüst blieb gleich, und genau darum geht es: Sind Speicherung und Verarbeitung getrennt, lässt sich das Stück dazwischen austauschen.
Abwägungen
- Mehr bewegliche Teile. Dispatcher, Queue, DLQ, Alarme und ein Reconciler ersetzen ein einzelnes Stream-Abonnement. Jedes Teil ist einfach, aber es gibt mehr zu deployen und zu überwachen.
- Retries sind absichtlich langsam. Eine wiederholte Website kommt erst nach dem Visibility Timeout zurück, nicht nach Sekunden. Für einen Hintergrundjob ist das in Ordnung, für Arbeit, auf die ein Nutzer wartet, wäre es das nicht.
- Ein begrenzter Pool braucht länger. Höchstens 140 Scraper laufen gleichzeitig, ein großer Job dauert also länger als ein unbegrenzter Fan-out. Eine vorhersehbare Last ist uns mehr wert als ein paar Minuten früher fertig zu sein.
- Idempotenz bleibt unsere Aufgabe. Auch SQS-Standard-Queues stellen mindestens einmal zu. Die Prüfung auf Endzustände verhindert, dass ein Duplikat die Arbeit doppelt macht.
DynamoDB Streams bleiben ein hervorragendes Werkzeug: für Change Data Capture, Audit-Pipelines, Cache-Invalidierung, Projektionen und Analytics. Wird aber ein Datenbank-Insert zum primären Auslöser eines asynchronen Geschäftsablaufs, brauchen Retries, Idempotenz und Wiederherstellung deutlich mehr Aufmerksamkeit, als es zunächst scheint.
Was ich heute anders machen würde
- Retry-Verhalten ab dem ersten Tag entwerfen. Jedes Stream-Mapping braucht ein Retry-Limit und ein Fehlerziel, und jeder Handler, der
batchItemFailureszurückgibt, braucht aktiviertesReportBatchItemFailures. Sonst ist es nur ein Kommentar im Code. - Fehler nie verschlucken, damit eine Funktion grün aussieht. Ein verschluckter Fehler ist ein verlorenes Event.
- Den Reconciler zusammen mit der Queue bauen, nicht erst nach dem ersten hängenden Task.
Ergebnis
- Fehler bleiben auf eine Website begrenzt: Ein wiederholbarer Fehler wiederholt nur seine eigene Nachricht, ein fataler setzt seinen Task sofort auf
Failed. - Nicht verarbeitbare Nachrichten landen nach drei Empfängen in einer DLQ, mit einem Alarm bei der ersten.
- Die Nebenläufigkeit ist eine Einstellung (140) und kein Nebeneffekt davon, wie der Stream in Shards aufgeteilt ist.
- Jobs mit etwa 2.700 bis 3.600 Websites pro Lauf gehen durch diese Pipeline; der größte gemessene hatte 3.575.