Idempotenz als Konzept ist ein wesentlicher Aspekt moderner robuster und leicht wiederherstellbarer Systeme. Als solches ist es wichtig, unabhängig davon, ob die betrachtete Arbeitslast in einer Cloud-Umgebung läuft oder nicht. Aufgrund von Eigenschaften, die Public-Cloud-Anbietern inhärent sind, ist Idempotenz jedoch für korrekt funktionierende Systeme viel wichtiger geworden als früher in On-Premises-Systemen. Daher soll dieser Artikel beleuchten, warum Idempotenz so wichtig geworden ist und wie sie korrekt implementiert wird. Wir werden keine bereits verfügbare Dokumentation wiederholen; stattdessen werden wir das Warum und Wie auf konzeptioneller Ebene hervorheben. Nach dem Lesen dieses Artikels wisst ihr, wie man Systeme korrekt für Idempotenz entwirft.

Zu beachten ist, dass viele der hier behandelten Aspekte nur beim Betrieb in großem Maßstab auftreten werden. Das hat technische Gründe, die wir später erläutern werden. Insbesondere ist es unwahrscheinlich, dass Entwickler auf die hier erwähnten Fallstricke stoßen, wenn sie Demo-Anwendungen von Cloud-Anbietern verwenden. Daher werden Programme, die bei kleinem Maßstab einwandfrei funktionieren, kaputt gehen, wenn sie unter tatsächliche Last gestellt werden. Das macht es umso wichtiger, Entwickler von Anfang an weiterzubilden, um nicht offensichtliche Probleme in der Zukunft zu vermeiden.

Die hier vorgestellten Konzepte werden mit den bekannten AWS Powertools für Python präsentiert, gelten aber gleichermaßen für andere Sprach-Stacks.

Motivation

Per Definition ist eine Operation idempotent, wenn ihre wiederholte Ausführung genau dasselbe Ergebnis liefert. Das ist unter anderem in folgenden Szenarien hilfreich:

Deduplizierung: Wenn ein bestimmter Prozess auf Basis einer Queue oder eines Bus ausgeführt wird, stellt Idempotenz sicher, dass duplizierte Nachrichten nicht zu duplizierten Aktionen führen. Sie dedupliziert damit Nachrichten.

Fehlerbehebung: Stellen wir uns einen Workflow vor, der aus den Schritten A → B → C besteht, wobei A erfolgreich ausgeführt wurde, B fehlgeschlagen ist und C aufgrund des Fehlers von B nicht ausgeführt wurde. Ein Ingenieur könnte das Grundproblem, das B zum Scheitern brachte, untersuchen und beheben. Nach dieser Behebung muss der Ingenieur noch sicherstellen, dass das System in einen gewünschten Zustand zurückkehrt; das erfordert in der Regel, dass der gesamte Workflow erfolgreich durchgelaufen ist. Ohne Idempotenz muss der Ingenieur eine Strategie zur Wiederherstellung nach dem fehlgeschlagenen Workflow entwickeln. Das könnte Folgendes umfassen:

  • Rückgängigmachen der Aktionen von A und B und anschließende Neuausführung von Grund auf
  • Rückgängigmachen von B und anschließender Neustart ab B → C

In allen Fällen ist das ein manueller Prozess, der Untersuchung und Aufwand auf der Engineering-Seite erfordert, wodurch die Reaktionszeiten bei Produktionsproblemen erheblich steigen. Zu beachten ist, dass eine partielle Workflow-Ausführung von großen Anbietern möglicherweise nicht gut unterstützt wird – insbesondere unterstützt AWS StepFunctions das derzeit nicht.

Wenn Idempotenz vorhanden ist, behebt der Ingenieur das zugrundeliegende Problem und startet den Workflow einfach von Grund auf neu.

Erklärbarkeit: Ein weiterer, eher subtiler Vorteil besteht darin, dass das Gesamtsystem viel einfacher zu durchdenken wird. Das liegt daran, dass Idempotenz Entwickler dazu zwingt, Eingabe, Aktionen und Ausgabe sauber zu trennen, was zu einem sauberen Design führt. Wir werden weiter unten zeigen, dass das kein Designprinzip ist, dem man folgen sollte, sondern eine harte technische Anforderung ist.

Warum Idempotenz heute relevanter denn je ist

Alle oben genannten Punkte gelten gleichermaßen für On-Premises- und Cloud-Systeme. Nun erklären wir kurz, warum Idempotenz in der Cloud besonders wichtig ist. Aufgrund ihres Designs sind viele Cloud-Dienste anfällig für Duplikate. Nehmen wir das einfachste mögliche Cloud-Beispiel: eine serverlose Funktion, die ein Blob-Store-Ereignis abonniert (in AWS-Terminologie: S3 → Lambda). Dieses Setup produziert im Allgemeinen Duplikate, da S3-Ereignisse nur garantiert mindestens einmal zugestellt werden. Wenn ihr also eine Lambda auf S3 abonniert habt und keine Duplikate berücksichtigt habt, liegt ihr bereits falsch. Der zugrundeliegende Grund ist, dass es in verteilten Systemen sehr kostspielig ist, eine genau-einmalige Zustellung zu gewährleisten, weshalb die Cloud-Anbieter standardmäßig einen leistungsfähigeren Ansatz wählen. Dieses Problem beschränkt sich jedoch nicht auf die zugrunde liegende Plattform, sondern betrifft auch Ressourcen auf Projektebene.

Betrachten wir die Skalierung. Für dieses Beispiel nehmen wir ECS-Container an, die über Fargate bereitgestellt werden. Angenommen, wir haben einen Container, der eine Datenbank nach Änderungen durchsucht und entdeckte Änderungen an einen Event-Bus pusht, auf den nachgelagerte Systeme reagieren können. Angenommen, ein zweiter Container kommt online. Das geschieht im Allgemeinen, wenn ein Health-Check fehlschlägt, wenn eine neue Version ausgerollt wird (um die Geschäftskontinuität zu gewährleisten) oder für die automatische Skalierung. Nun durchsuchen beide Instanzen die Datenbank nach Änderungen und veröffentlichen gleichzeitig dieselben Änderungen, bis eine von ihnen herunterfährt. Das führt zu duplizierten Nachrichten im Event-Bus, sofern keine besonderen Maßnahmen ergriffen werden. In diesem Beispiel kann ECS frei durch EC2, EKS oder einen anderen relevanten Dienst ersetzt werden.

Die gestiegene Relevanz liegt daran, dass die Cloud auf Skalierung ausgelegt ist und die einzelnen Komponenten von Natur aus entkoppelt sind. Wo es früher eine kleine Anzahl von Servern mit monolithischen Programmen gab und es tatsächlich sehr schwer war zu skalieren, wurde dieses Problem typischerweise auf Thread-Ebene auf derselben Maschine gelöst. Die Schwierigkeit bei der Skalierung machte das Problem weniger dringend, einfach weil weniger skaliert wurde. Heutzutage sollte jedoch idealerweise jede Komponente in einer Architektur unabhängig von den anderen Komponenten skalieren und replizieren können. Das bedeutet, dass es viel mehr Raum für Fehler durch Nebenläufigkeit gibt. Schlimmer noch: In verwalteten Umgebungen ist nicht immer klar, wie und wann Komponenten skalieren werden. Das bringt uns zurück zum oben genannten Punkt: Wenn wir viel Last auf die zugrunde liegende S3-Infrastruktur legen, ist es wahrscheinlicher, dass sie skaliert, was die Wahrscheinlichkeit duplizierter Ereignisse erhöht. Kurz gesagt: Ein Mangel an Idempotenz wird euch treffen, wenn ihr anfangt, Lösungen in der Produktion auszuführen.

Die Notwendigkeit einer dedizierten Idempotenz-Schicht

Bevor wir tiefer in spezifische Aspekte eintauchen, wollen wir kurz argumentieren, warum eine dedizierte Schicht notwendig ist. Zu beachten ist, dass viele Dienste eine integrierte Unterstützung für Deduplizierung haben werden; dazu gehören Queue-Dienste wie SQS auf AWS, aber auch umfangreichere Lösungen wie Azure Durable Functions.

Die Art und der Umfang der Deduplizierung variiert zwischen den Diensten: Insbesondere kann nicht davon ausgegangen werden, dass alle Dienste in eurem Stack Deduplizierung unterstützen werden. Gute Beispiele, die das wahrscheinlich nie unterstützen werden, sind Datenbanken wie RDS. Zu beachten ist, dass es auch keine Möglichkeit gibt, S3 selbst idempotent zu machen; mehrfaches Schreiben in S3 wird immer mehrere Ereignisse erzeugen – diese müssten von nachgelagerten Systemen wie einem Event-Bus dedupliziert werden.

Deduplizierungsunterstützung ist typischerweise zeitfensterbegrenzt: Die möglichen Zeitfenster variieren je nach Dienst und sind meist eng begrenzt, von Minuten bis maximal mehrere Stunden. Wenn eure Ingenieure mehr Zeit benötigen, um ein Problem zu beheben, seid ihr in ernsthaften Schwierigkeiten. Es gibt typischerweise keine Möglichkeit, den aktuellen Zustand länger zu bewahren.

Keine Möglichkeit, den aktuellen Zustand zu inspizieren: Je nach Problem möchtet ihr möglicherweise den aktuellen Zustand einer (idempotenten) Operation inspizieren und/oder ändern, um Produktionsprobleme zu beheben. Insbesondere möchtet ihr möglicherweise ein Element entfernen, um eine Aktion neu zu starten, die sonst übersprungen würde. Das ist in verwalteten Deduplizierungsspeichern nicht möglich.

Proprietäre Lock-in-Lösungen: Eine davon sind definitiv Azure Durable Functions. Diese versuchen, das Problem für euch zu lösen, erfordern aber im Gegenzug, dass ihr euren gesamten Code überarbeitet und innerhalb der Grenzen dessen entwerft, was euer Cloud-Anbieter für gut hält. Insbesondere muss der Aufrufer wissen, ob eine Funktion idempotent ist oder nicht, was Komponenten stark koppelt.

Eine gemeinsame Idempotenz-Schicht ermöglicht es euch, dienstunabhängig zu arbeiten und dabei volle Transparenz und die Möglichkeit zu behalten, bei Bedarf einzugreifen. Das hält die Reaktionszeiten niedrig und führt zu weniger eskalierenden Problemen.

Idempotenz implementieren

Mit AWS Powertools ist Idempotenz ein Kinderspiel. AWS Powertools ist generell für wichtige Sprach-Stacks wie Java, .NET und Python verfügbar; da eine ausgezeichnete Dokumentation vorhanden ist, präsentieren wir hier nur die wichtigsten Punkte. Insbesondere müsst ihr lediglich Folgendes tun, um eine Funktion idempotent zu machen:

Einen Idempotenz-Schlüssel wählen und/oder berechnen: Jeder Idempotenz-Schlüssel entspricht einem eindeutigen Satz von Parametern. Bei denselben Parametern – und damit demselben Idempotenz-Schlüssel – liefern mehrere Ausführungen der Funktion dasselbe Ergebnis. Wir sagen auch, die Funktion ist idempotent bezüglich eines gegebenen Idempotenz-Schlüssels. Dieser Schlüssel wird entweder als String angegeben oder als Hash über eine bereitgestellte Datenstruktur berechnet.

Einen oder mehrere Idempotenz-Backends wählen: Powertools unterstützt das Stapeln von Backends, sodass das schnellste Backend zuerst konsultiert wird. Eine typische Kette wäre inMemoryCache → DynamoDB. Für Pipelines mit viel Traffic sollte eine DynamoDB-DAX-Instanz oder – cloud-agnostisch – eine Redis-Instanz dazwischen eingefügt werden.

Ein Zeitfenster wählen: Standardmäßig legen sowohl Powertools als auch DynamoDB eine TTL fest. Das ist ein sinnvoller Standard; bewertet, wie lange ihr diese Daten behalten möchtet. Zu beachten ist, dass ihr die Item-Bereinigung jederzeit deaktivieren könnt, um Ingenieuren mehr Zeit zur Behebung von Produktionsproblemen zu geben. DynamoDB wird aufholen, wenn ihr die Bereinigung wieder aktiviert. Zu beachten ist auch, dass die Bereinigung typischerweise asynchron ist, sodass keine Garantie einer sofortigen Bereinigung nach Ablauf besteht.

Sobald diese Entscheidungen getroffen sind, kann die Funktion einfach mit einem der bereitgestellten Annotators annotiert werden – und voilà, sie ist idempotent bezüglich der Eingabe:

@idempotent_function(data_keyword_argument="idempotency_key", persistence_store=persistence_layer, config=config)
def handler(record: SQSMessage, idempotency_key: SomeIdentifier):
    # run some intricate logic ...
    # [...]
    return someResult

Zu beachten ist, dass es ratsam ist, idempotent_function in einem eigenen Decorator zu kapseln, um persistence_store und config aus einem Singleton- oder Identity-Pattern abzurufen. Das hilft, den Code DRY zu halten.

AWS Powertools ermöglicht es euch, den Idempotenz-Schlüssel auf verschiedene Weisen zu berechnen. Insbesondere könnt ihr JSONPATH nutzen, um dynamisch neue Objekte aus dem Satz der Eingabeparameter zur Verwendung als Idempotenz-Schlüssel zu konstruieren. Das ermöglicht es euch, den Aufrufer agnostisch gegenüber der Idempotenz des Aufgerufenen zu halten.

Der gesamte Ablauf – direkt aus der offiziellen Dokumentation – sieht so aus:

A sequence diagram showing how idempotency is handled for Lambda functions, detailing request flow, status updates, and checks in the persistence layer to ensure duplicate requests return consistent responses.

Anforderungen an Idempotenz

Das Ziel dieses Artikels ist es, euch in die Lage zu versetzen, Systeme für Idempotenz zu entwerfen. Insbesondere müsst ihr wissen, wie man Systeme so entwirft, dass sie bei Bedarf einfach idempotent gemacht werden können. Eine bestehende Code-Basis für Idempotenz zu refaktorisieren kann prohibitiv teuer sein. Angesichts des aufkommenden Bedarfs, wie oben beschrieben, macht es absolut Sinn, von vornherein für Idempotenz zu entwerfen, unabhängig von einem unmittelbaren Bedarf.

Idempotente Funktionen müssen folgende Prinzipien einhalten:

Ihre Payload muss serialisierbar sein, damit sich der Idempotenz-Schlüssel berechnen lässt.

Ihr Return-Wert muss serialisierbar sein, da der ursprüngliche Ausführungskontext beim Fortsetzen einer Funktion möglicherweise nicht verfügbar ist.

Die Funktion darf keine Seiteneffekte haben, die auf dem ursprünglichen Ausführungskontext beruhen, da dieser nicht zwingend verfügbar ist.

Serialisierbarkeit des Return-Werts: Betrachten wir das obige Beispiel: A → B → C. Angenommen weiter, dass B vom Output von A abhängt und C vom Output von B. Wenn B beim ersten Ausführen fehlschlägt und der Ingenieur entscheidet, durch erneutes Ausführen des gesamten Workflows wiederherzustellen, wird A beim nächsten Ausführen übersprungen. B wird jedoch weiterhin ausgeführt und hängt vom Return-Wert von A ab. Daher wird A’s Return-Wert von der Idempotenz-Schicht persistiert und bei der zweiten Ausführung an B übergeben. Das bedeutet typischerweise auch, dass der Return-Wert jeder idempotenten Funktion in der Größe begrenzt ist. Zu beachten ist, dass die meisten Idempotenz-Speicher wie DynamoDB und Redis strikte Grenzen für ihre Item-Größen haben.

Seiteneffekte: Wenn sich A und B im obigen Szenario eine Ausführungsumgebung teilen (zum Beispiel einen Prozess), stehen Änderungen, die A an dieser Umgebung vorgenommen hat, B bei der zweiten Workflow-Ausführung nicht notwendigerweise zur Verfügung, da B in einer anderen Ausführungsumgebung laufen kann als A. Im obigen Szenario teilen sich A und B eine Ausführungsumgebung (zum Beispiel: einen Prozess), sodass Änderungen, die A an der Umgebung vorgenommen haben könnte, B beim zweiten Workflow-Ausführen nicht notwendigerweise zur Verfügung stehen, da B in einer anderen Ausführungsumgebung laufen kann als A. Das verbietet insbesondere das Weitergeben von Referenzen haltenden Objekten, speziell (lokale) Dateiressourcen, Streams, ORM-gemappte Instanzen usw.

Wenn alle drei Anforderungen erfüllt sind, wird die (nachträgliche) Implementierung von Idempotenz sehr unkompliziert. Zu beachten ist, wie diese Anforderungen eine saubere Trennung zwischen Funktionen erzwingen und damit ein hohes Maß an Entkopplung durchsetzen. Das bedeutet auch, dass das bloße Entwerfen für Idempotenz die Code-Qualität erhöht.

Wahl der Idempotenz-Schlüssel

Lassen wir einige wichtige Aspekte bezüglich Idempotenz-Schlüsseln anmerken.

Umgebungen: Typischerweise werdet ihr mehrere Entwicklungsumgebungen innerhalb desselben Accounts oder der gleichen Subscription betreiben. Je nach Cloud-Plattform wird die genaue Trennungsmethode unterschiedlich sein. Trotzdem dürfen verschiedene Umgebungen nicht miteinander interferieren, d. h. der Umgebungsbezeichner sollte generell Teil des Idempotenz-Schlüssels sein.

Business-Schlüssel: Generell ist es ratsam, sich so weit wie möglich auf Business-Schlüssel für die Idempotenz-Schlüssel-Erstellung zu stützen. Der Grund ist, dass das Ergebnis typischerweise idempotent bezüglich der Eingabedaten ist, die wiederum typischerweise hauptsächlich durch ihre Business-Schlüssel definiert sind. Das vereinfacht die Konstruktion des Idempotenz-Schlüssels erheblich, da Business-Schlüssel nicht vom Verhalten einzelner Dienste in der Pipeline abhängen, die Werte bei einem Retry möglicherweise durchreichen oder neu erzeugen.

Um ein leicht verständliches Beispiel zu geben: Stellen wir uns vor, wir verarbeiten eine Zahlung für einen bestimmten Kunden in einer Queue. Eine eindeutige paymentID (GUID), die vom System generiert wird, ist ein Business-Schlüssel. Im Gegensatz dazu ist eine SQS-Nachrichten-ID, die zufällig generiert wird, wenn diese Zahlung durch eine SQS-Queue fließt, ein technischer Schlüssel. Technische Schlüssel ändern sich, wenn sich der Datenfluss ändert, wenn die Verarbeitung wiederholt wird und so weiter; Business-Schlüssel nicht.

Läufe: Idealerweise werden Aktionen genau einmal ausgeführt. In der Realität sind jedoch möglicherweise nicht alle nachgelagerten Systeme so gut gestaltet wie die Lösung, die ihr gerade baut. Daher kann es notwendig sein, Teile eures Workflows erneut auszuführen, selbst wenn das absolut nicht euer System verschuldet hat. Zu diesem Zweck kann es hilfreich sein, das Konzept von Läufen einzuführen. Normalerweise gibt es nur einen Lauf pro Business-Schlüssel-Kombination, sagen wir Lauf 0. Wenn ihr diese Berechnung je neu starten müsst, könnt ihr sie einfach durch Parametrisierung des Laufs mit 1 neu starten; auf diese Weise müsst ihr nicht künstlich um die Idempotenz-Mechanismen herumarbeiten, sondern mit ihnen arbeiten.

Bezeichner aus vorgelagerten Systemen: Wie oben dargelegt, ist es ratsam, sich wenn möglich immer auf Business-Schlüssel zu stützen. Das ist jedoch nicht unter allen Umständen möglich, insbesondere wenn die Quelle keine stabilen Business-Schlüssel bereitstellt. In diesem Fall ist es ratsam, sich auf automatisch generierte, eindeutige Bezeichner zu stützen, die von der Cloud-Plattform generiert werden. In diesem Fall kann zumindest garantiert werden, dass das System ab einem bestimmten Ausgangspunkt korrekt funktioniert, von dem angenommen wird, dass er zuverlässig ist. Zu beachten ist, dass ihr auch in diesem Fall möglicherweise zusätzliche Informationen wie Umgebungen oder Läufe (wie oben beschrieben) einbeziehen möchtet, sodass das alleinige Verlassen auf die automatisch generierte ID möglicherweise nicht ausreichend ist. Das ist oft notwendig bei der Implementierung von Fan-out.

Observability: Idempotenz-Eingaben werden zu einem String gehasht, um einen (hoffentlich eindeutigen) Idempotenz-Schlüssel zu erzeugen. Dieser wird dann verwendet, um idempotente Ausführung programmatisch sicherzustellen. Für den Betrieb möchte man jedoch oft den aktuellen Zustand der Idempotenz inspizieren. Das erfordert, dass das Backend (zumindest einen Teil der) Idempotenz-Eingabe für spätere Inspektion speichert. Während der Hash in der Größe streng begrenzt ist, ist die ursprüngliche Idempotenz-Eingabe es nicht. Daher neigt sie dazu, die vom Idempotenz-Backend gesetzten Grenzen zu überschreiten.

Wichtige Informationen sollten im Voraus festgelegt und in der Idempotenz-Tabelle unter Berücksichtigung der Größenbeschränkungen persistiert werden. Das ist entscheidend für die effiziente Lösung von Idempotenz-Problemen. Ein sehr unkomplizierter Ansatz besteht darin, Funktionsausführungen mit ihren Idempotenz-Schlüsseln zu kennzeichnen, nach diesen zu indizieren und die jeweiligen Informationen in der Telemetrielösung zu persistieren. Diese stellen typischerweise weniger strenge Einschränkungen auf und werden der Ausgangspunkt sein, von dem aus Operatoren ihre Debug-Reise beginnen. Insbesondere ermöglicht die Integration mit der Telemetrielösung es Operatoren, schnell festzustellen, welche Funktionsaufrufe tatsächlich ausgeführt wurden und welche aufgrund von Idempotenz übersprungen wurden.

Fazit

Wenn sie von Anfang an berücksichtigt wird, ist es sehr unkompliziert, für Idempotenz zu entwerfen. Der Artikel hat eine Reihe von Aspekten und/oder Fallstricken skizziert, die am besten vor dem Einstieg in eine Implementierung geklärt werden. Das stellt sicher, dass die Systemarchitektur nicht unnötig kompliziert ist und die Wiederherstellung so einfach wie möglich abläuft. Insbesondere haben wir festgestellt, dass eine dedizierte Schicht sinnvoll ist, um die Systemkomplexität niedrig zu halten.

Idempotenz-Praktiken werden am besten von einem Platform-Team festgelegt, das eine Grundlage bereitstellt, auf der individuelle Entwicklungsteams aufbauen können. Das entlastet einzelne Projektteams von unnötiger mentaler Belastung und gewährleistet einen kohärenten Ansatz über größere Plattformen hinweg. Insbesondere reduziert es die Wahrscheinlichkeit unangenehmer Überraschungen, wenn man sie am wenigsten möchte.