Idempotenz in verteilten Systemen, die tatsächlich funktioniert

Vermeiden Sie doppelte Seiteneffekte

Inhaltsverzeichnis

Idempotenz in verteilten Systemen ist die Eigenschaft, die Sie rettet, wenn das Netzwerk versagt, die Warteschlange Nachrichten erneut sendet, der Client panisch reagiert und der Administrator eine Wiedergabe auslöst. In Produktionssystemen ist die doppelte Zustellung normal. Doppelte Seiteneffekte sind der Fehler.

HTTP definiert eine idempotente Methode als eine, bei der mehrere identische Anfragen die gleiche beabsichtigte Auswirkung auf den Server haben wie eine einzelne Anfrage. Deshalb sind PUT, DELETE und sichere Methoden in der Protokollsemantik idempotent und können nach einem Kommunikationsfehler automatisch wiederholt werden.

Integrationsnachrichtenfluss: Idempotenz

Diese Definition ist nützlich, aber nicht ausreichend. In echten Architekturen ist Idempotenz keine Trivia-Frage zu HTTP. Sie ist eine geschäftliche Garantie. Wenn ein Kunde einmal auf „Bezahlen" klickt, dürfen Sie nicht zweimal belastet werden, nur weil zwischen dem Commit und der Antwort ein Timeout aufgetreten ist. Wenn ein Worker den Bestand aktualisiert und vor der Bestätigung der Nachricht abstürzt, dürfen Sie den Lagerbestand nicht zweimal dekrementieren, nur weil der Broker die Nachricht erneut zugestellt hat. Das ist der Maßstab.

Der Fehler, den ich immer wieder sehe, ist die Behandlung von Idempotenz als Transportfeature statt als Systemeigenschaft. Warteschlangen-Entdopplung, HTTP-Verben und Client-Neuversuche helfen, aber keines davon rettet ein Design, das dieselbe Geschäftabsicht erlaubt, einen zweiten Seiteneffekt zu erzeugen. Wenn Sie den breiteren Rahmen dafür sehen möchten, wie diese Integrationsentscheidungen mit Service-Grenzen und Persistenz-Trade-offs zusammenhängen, beginnen Sie mit App-Architektur in der Produktion: Integrationsmuster, Code-Design und Datenzugriff.

Woher Duplikate in der Produktion stammen

Duplikate entstehen nicht, weil Teams sorglos sind. Sie entstehen, weil verteilte Systeme neu versuchen, neu ordnen und wiedergeben.

Ein Client kann eine Erstanfrage senden, der Server kann sie committen, und die Antwort kann trotzdem auf der Leitung verloren gehen. Genau deshalb unterscheidet HTTP idempotente Methoden und warum Zahlungs-APIs wie Stripe und PayPal für unsichere Methoden wie POST explizite Idempotenzmechanismen bereitstellen.

Nachrichtenbroker machen das Problem noch offensichtlicher. Zustellung „mindestens einmal" bedeutet, dass ein Consumer für dieselbe Nachricht wiederholt aufgerufen werden kann, und ein Handler kann die Datenbank erfolgreich aktualisieren, aber vor der Bestätigung fehlschlagen, wodurch der Broker dieselbe Nachricht erneut zustellt.

Webhooks sind nicht anders. GitHub besagt, dass Webhook-Zustellungen in falscher Reihenfolge eintreffen können, fehlgeschlagene Zustellungen nicht automatisch erneut gesendet werden und jede Zustellung einen einzigartigen X-GitHub-Delivery GUID enthält, den Sie bei der Abwehr von Wiedergabeangriffen verwenden sollten. Für eine praktische Architektursicht auf Chat-Endpunkte als Interaktionsgrenzen siehe Chat-Plattformen als Systemschnittstellen in modernen Systemen.

Selbst Systeme, die stärkere Garantien werben, lassen Ihnen noch Arbeit übrig. Kafka kann doppelte Einträge in Kafka-Logs mit idempotenten Produzenten verhindern und kann exakt-einmalige Zustellung für Read-Process-Write-Flows bieten, die innerhalb von Kafka mit Transaktionen und read_committed-Consumern bleiben. Aber Kafkas eigene Design-Dokumente sind klar, dass externe Systeme immer noch Koordinierung mit Offsets und Outputs erfordern. Die exakt-einmalige Zustellung von Google Cloud Pub/Sub ist auf Pull-Abonnements beschränkt, innerhalb einer Cloud-Region und erfordert immer noch, dass Clients den Verarbeitungsfortschritt verfolgen, bis die Bestätigung erfolgreich ist.

Meine leidenschaftliche Zusammenfassung ist einfach. Gehen Sie davon aus, dass der Transport neu versuchen wird. Gehen Sie davon aus, dass Operatoren wiedergeben werden. Gehen Sie davon aus, dass Webhooks spät ankommen. Gestalten Sie den Schreibpfad so, dass eine wiederholte Absicht keinen zweiten Geschäftseffekt erzeugen kann.

Der API-Vertrag, dem ich vertraue

Wie verhindern Idempotenzschlüssel doppelte API-Anfragen

Der einzige API-Vertrag, dem ich für mutierende Operationen vertraue, ist vom Aufrufer bereitgestellte Absicht plus serverseitige Persistenz.

AWS empfiehlt eine vom Aufrufer bereitgestellte Anfragekennung und warnt davor, dass der Dienst den Idempotenz-Token zusammen mit der mutierenden Arbeit atomar aufzeichnen muss. Stripe speichert den ersten Statuscode und Antwortkörper für einen Schlüssel, vergleicht spätere Parameter mit der ursprünglichen Anfrage und gibt dasselbe Ergebnis für Neuversuche zurück. PayPal verwendet PayPal-Request-Id bei unterstützten POST-APIs und gibt den neuesten Status für die vorherige Anfrage mit diesemselben Header zurück.

Das führt zu einem praktischen Vertrag:

  1. Der Client generiert einen Idempotenzschlüssel für eine Geschäftsoperation.
  2. Der Server scoping diesen Schlüssel nach Tenant und Operationsname.
  3. Der Server speichert einen Anfrage-Hash, sodass derselbe Schlüssel nicht für eine andere Payload wiederverwendet werden kann.
  4. Der Server zeichnet Zustände wie pending (ausstehend), completed (abgeschlossen) oder failed (fehlgeschlagen) auf.
  5. Neuversuche mit demselben Schlüssel geben entweder das gespeicherte Ergebnis zurück oder einen stabilen Zeiger darauf.
  6. Neuversuche mit demselben Schlüssel und einer anderen Payload schlagen laut fehl.

Es gibt einen IETF-Entwurf für den Idempotency-Key-Header, aber Stand 09.05.2026 ist er im IETF Datatracker immer noch als abgelaufener Internet-Draft und nicht als veröffentlichtes RFC aufgeführt. In der Praxis ist der Headername weiterhin weit verbreitet und nützlich als de-facto-Konvention, aber Sie sollten den Vertrag in Ihrer eigenen API dokumentieren, anstatt so zu tun, als wäre der Standard abgeschlossen.

Was sollte der Schlüssel repräsentieren? Absicht. Nicht einen HTTP-Versuch. Nicht eine TCP-Verbindung. Nicht einen Neuversuchs-Zähler. Wenn der Benutzer „Bestellung 123 einmal erstellen" meint, müssen alle Neuversuche für diesenselben Befehl denselben Schlüssel wiederverwenden. Wenn der Benutzer „eine zweite Bestellung aufgeben" meint, muss das einen anderen Schlüssel verwenden.

Eine Request-ID dient der Tracing. Ein Idempotenzschlüssel dient der Korrektheit. Wenn Sie diese verwechseln, sehen Ihre Dashboards ordentlich aus, während Ihr Geld doppelt bewegt wird.

Warum PUT nicht ausreicht

Nein, HTTP PUT reicht nicht aus, um eine Operation idempotent zu machen.

Ja, RFC 9110 gibt PUT idempotente Semantik. Aber wenn Ihr PUT-Handler ein neues Downstream-Ereignis aussendet, bei jedem Neuversuch eine E-Mail sendet oder einen externen Anbieter erneut belastet, dann hat Ihre Implementierung den Geschäftsvertrag verletzt, auch wenn Ihr Routenname respektabel aussieht.

Die Wahl des Verbs hilft Clients, die Absicht zu verstehen. Es implementiert die Absicht nicht für Sie.

Verwenden Sie PUT, wenn das Ressourcenmodell genuinely eine vollständige Ersetzung oder eine upsert-ähnliche Operation passt. Verwenden Sie POST, wenn Sie Befehle oder Aktionen erstellen. Aber für jede Mutation, die über Netzwerkgrenzen hinweg neu versucht werden könnte, dokumentieren Sie einen expliziten Idempotenzvertrag. Wenn Ihre mutierenden Aktionen aus Chat-Workflows ausgelöst werden, gilt derselbe Vertrag in Slack-Integrationsmuster für Alerts und Workflows und Discord-Integrationsmuster für Alerts und Kontrollschleifen. Versteckte Seiteneffekte sind der Ort, an dem Architekturen sterben.

Wie lange sollte ein Idempotenzschlüssel gespeichert werden

Länger, als Ihr Transport-Team möchte.

Stripe sagt, dass Schlüssel nach mindestens 24 Stunden bereinigt werden können. PayPal sagt, dass die Aufbewahrung API-spezifisch ist und Beispiele gibt, die bis zu 45 Tage dauern können. Amazon SQS FIFO dupliziert nur innerhalb eines 5-Minuten-Fensters. GitHub behält recente Zustellungen für 3 Tage für manuelle Neuübertragungen vor. Diese Zahlen sind wildly unterschiedlich, weil die richtige Aufbewahrungsfrist eine geschäftliche Entscheidung ist, kein Protokoll-Standardwert.

Wenn Sie Schlüssel nur für fünf Minuten behalten, weil Ihre Warteschlange das tut, gestalten Sie keine Idempotenz. Sie kopieren eine Transportbeschränkung in Ihre Geschäftsschicht.

Behalten Sie Idempotenzdatensätze mindestens für das Maximum dieser Fenster:

  • Client-Neuversuchs-Horizont
  • Warteschlangen-Redrive-Horizont
  • Webhook-Wiedergabe-Horizont
  • Operator-Wiedergabe-Horizont
  • Abwicklungs- oder Kompensations-Horizont für geldbewegende Operationen

Für Zahlungen, Buchungen und Provisionierung bedeutet das oft Stunden oder Tage, nicht Minuten.

AWS weist auch auf zwei Anti-Patterns hin, mit denen ich vollständig übereinstimme. Verwenden Sie keine Zeitstempel als Schlüssel, da Uhrenverschiebungen und Kollisionen sie unzuverlässig machen. Speichern Sie nicht blindwiderständig gesamte Anfrage-Payloads als Entdopplungsdatensatz für jede Anfrage, da dies Leistung und Skalierbarkeit beeinträchtigt. Speichern Sie einen normalisierten Anfrage-Hash plus den minimalen Antwortzustand, den Sie für eine sichere Wiedergabe benötigen. Wenn Sie die erste Antwort bytegenau reproduzieren müssen, speichern Sie den kanonischen Antwortkörper, wie es Stripe tut.

Die Datenbankmuster, die Idempotenz real machen

Idempotenz wird real, wenn die Persistenzschicht ein Rennen genau einmal gewinnen kann.

PostgreSQL gibt Ihnen hier zwei kritische Primitives. Unique-Constraints erzwingen Eindeutigkeit auf einer oder mehreren Spalten, und INSERT ... ON CONFLICT lässt Sie eine alternative Aktion definieren, anstatt bei einer Eindeutigkeitverletzung zu scheitern. PostgreSQL dokumentiert auch, dass ON CONFLICT DO UPDATE ein atomares insert-or-update-Ergebnis unter Konkurrenz garantiert.

Das bedeutet, dass Ihre Idempotenzschicht normalerweise mit einer Tabelle wie dieser beginnen sollte:

create table api_idempotency (
    tenant_id text not null,
    operation text not null,
    idempotency_key text not null,
    request_hash text not null,
    state text not null,
    status_code integer,
    response_body jsonb,
    resource_type text,
    resource_id text,
    created_at timestamptz not null default now(),
    expires_at timestamptz not null,
    primary key (tenant_id, operation, idempotency_key)
);

Und der Handhabungsfluss sollte so aussehen:

begin transaction

try insert (tenant_id, operation, idempotency_key, request_hash, state='pending')
on conflict do nothing

load row for (tenant_id, operation, idempotency_key) for update

if row.request_hash != incoming_request_hash
    fail with conflict or validation error

if row.state = 'completed'
    return stored response

if row.state = 'pending' and row was created by another live request
    either wait briefly, or fail fast with a retryable response

perform local business mutation

store stable result in idempotency row
set state = 'completed'

commit
return result

Der wichtige Teil ist nicht die Syntax. Der wichtige Teil ist die Atomicität. Das Aufzeichnen des Schlüssels und das Durchführen der Mutation müssen zusammen erfolgreich sein oder zusammen scheitern. AWS sagt dies explizit für API-Idempotenz, und dieselbe Regel gilt für SQL-gestützte Dienste.

Führen Sie keine naive check-then-act-Folge wie „select key; if missing then insert order" durch. Unter Konkurrenz können zwei Anfragen die Prüfung bestehen und beide den Seiteneffekt erzeugen. Ein Unique-Constraint ist nicht optional. Er ist der Mechanismus, der Ihre Architektur von optimistischem Volkswissen in etwas verwandelt, das Sie unter Last beweisen können.

Hier ist die Regel, die ich in Reviews verwende. Wenn die Entdopplungsentscheidung nicht durch dieselbe transaktionale Grenze geschützt ist wie die Mutation, haben Sie keine Idempotenz. Sie haben Hoffnung.

Nachrichten, Ereignisse und Webhooks brauchen ihre eigene Grenze

Wie Consumer doppelte Ereignisse und Nachrichten handhaben

Für Message-Consumer ist das klassische Muster immer noch das richtige. Speichern Sie verarbeitete Nachrichten-IDs in derselben Datenbanktransaktion wie die geschäftliche Aktualisierung. Chris Richardson beschreibt den PROCESSED_MESSAGES-Tabellenansatz direkt, unter Verwendung eines Primärschlüssels auf Subscriber und Nachrichten-ID, sodass Duplikate sauber scheitern und ignoriert werden können.

Viele Teams nennen diesen expliziten processed_messages-Speicher eine Inbox-Tabelle. Die Bezeichnung ist weniger wichtig als die Regel. Der Empfänger muss den Beweis persistieren, dass er die Nachricht bereits verarbeitet hat, bevor ein Neuversuch sicher nichts tun kann.

Eine minimale Form sieht so aus:

create table processed_messages (
    subscriber_id text not null,
    message_id text not null,
    processed_at timestamptz not null default now(),
    primary key (subscriber_id, message_id)
);

Und der Consumer-Flow ist genauso streng wie der HTTP-Flow:

begin transaction

insert into processed_messages (subscriber_id, message_id)
values (?, ?)
on conflict do nothing

if no row inserted
    rollback
    ack and ignore duplicate

apply business mutation

commit
ack message

Dieses Muster ist langweilig. Gut. Idempotenz sollte langweilig sein.

Es ist auch normalerweise besser, als sich auf Marketingbegriffe von Brokern zu verlassen. Kafkas exakt-einmalige Unterstützung ist hervorragend, wenn Sie innerhalb von Kafkas eigenem transaktionellem Modell bleiben, aber Kafkas Dokumente warnen immer noch davor, dass externe Ziele Kooperation benötigen. SQS FIFO reduziert doppelte Sendungen nur innerhalb seines 5-Minuten-Entdopplungsfensters. Pub/Sub exakt-einmalig erwartet immer noch, dass der Abonnent den Fortschritt verfolgt und doppelte Arbeit vermeidet, wenn Bestätigungen fehlschlagen.

Exakt-einmalig ist normalerweise eine lokale Optimierung. Idempotente Seiteneffekte sind die Systemgarantie.

Kombinieren Sie Entdopplung mit dem Outbox-Muster

Wenn Ihr Dienst lokalen Status aktualisiert und auch ein Ereignis veröffentlicht, reicht idempotenter Konsum allein nicht aus. Sie brauchen auch einen sicheren Weg, das Ereignis nach dem Commit der lokalen Transaktion nach außen zu bringen.

Deshalb ist das transaktionale Outbox-Muster wichtig. Chris Richardson beschreibt die grundlegende Idee als Schreiben des Ereignisses in eine Outbox-Tabelle in derselben Transaktion wie die geschäftliche Aktualisierung, und dann asynchrones Veröffentlichen. Debezium sagt, das Outbox-Muster vermeidet Inkonsistenzen zwischen dem internen Status eines Dienstes und den von anderen Diensten konsumierten Ereignissen. NServiceBus geht weiter und zeigt, wie Outbox-Verarbeitung eingehende Nachrichten entdoppt und Zombie-Datensätze sowie Geister-Nachrichten vermeidet.

Dies ist die Architektur, die ich für Dienste empfehle, die Daten besitzen und Integrationsereignisse veröffentlichen:

  1. Validieren und persistieren Sie den Befehl unter einem Idempotenzschlüssel.
  2. Schreiben Sie Geschäftsstatus und Outbox-Ereignis in einer lokalen Transaktion.
  3. Lassen Sie CDC oder einen Outbox-Dispatcher das Ereignis veröffentlichen.
  4. Machen Sie Downstream-Consumer ebenfalls idempotent.

Outbox entfernt nicht die Notwendigkeit idempotenter Consumer. Es entfernt die Notwendigkeit, so zu tun, als ob ein Datenbank-Commit und ein Broker-Veröffentlichen eine magische verteilte Transaktion sein können, wenn sie das normalerweise nicht können.

Webhooks sind nur Nachrichten mit besserem Branding

Behandeln Sie eingehende Webhooks genau wie Nachrichten von einer un vertrauenswürdigen Netzwerkkante.

GitHub dokumentiert, dass Zustellungen in falscher Reihenfolge eintreffen können, empfiehlt die Verwendung von X-Hub-Signature-256 zur Verifikation der Authentizität und stellt X-GitHub-Delivery als einzigartigen Zustellungsbezeichner bereit. Es stellt auch fest, dass Neuübertragungen dieselbe Zustellungs-ID wiederverwenden.

Also ist die Architektur straightforward:

  • verifizieren Sie zuerst die Signatur
  • verwenden Sie die Zustellungs-GUID als Entdopplungsschlüssel
  • persistieren Sie den Empfang vor Seiteneffekten
  • machen Sie Handler reihenfolgebewusst, anstatt Ankunftsreihenfolge anzunehmen
  • queueen Sie die schwere Arbeit und kehren Sie schnell zurück

Wenn Ihr Webhook-Handler direkt in Geschäftstabellen schreibt, bevor er den Empfang aufzeichnet, ist er nicht produktionsreif. Er macht nur schneller doppelte Fehler.

Sagas und Workflow-Engines brauchen immer noch Idempotenz

Sagas und durable Workflow-Engines löschen das Problem nicht. Sie machen es sichtbar.

Temporal empfiehlt, Activities idempotent zu schreiben, weil Activities nach Fehlern oder Timeouts neu versucht werden können. Ihre Dokumente weisen sogar auf den Randfall hin, in dem ein Worker einen externen Seiteneffekt erfolgreich abschließt, aber vor der Meldung der Fertigstellung abstürzt, wodurch die Activity erneut ausgeführt wird. Temporal schlägt auch vor, eine Kombination aus Workflow Run ID und Activity ID als stabilen Idempotenzschlüssel zu verwenden, wenn man Downstream-Dienste aufruft. Wenn Sie dies in der Service-Orchestrierung anwenden, deckt Go Microservices für AI/ML Orchestrierung die breiteren Workflow-Trade-offs ab.

Das ist genau das richtige mentale Modell. Eine Workflow-Engine kann Ausführungsverlauf bewahren und Neuversuche koordinieren. Sie kann eine Karte nicht rückgängig belasten oder eine E-Mail nicht rückgängig senden, es sei denn, Ihre Anwendung gibt ihr idempotente Schritte und idempotente Kompensationen.

Das Gleiche gilt für Sagas. Temporals eigene Saga-Leitlinie beschreibt kompensierende Aktionen, die ausgeführt werden, wenn ein Schritt fehlschlägt. Diese Kompensationen müssen ebenfalls idempotent sein. Wenn „Zahlung erstatten" zweimal ausgeführt wird, können Sie den ursprünglichen Fehler gelöst haben, indem Sie einen neuen erstellt haben.

Meine Regel hier ist brutal und einfach. Jede Activity, jeder BefehlsHandler, und jede Kompensation, die die Außenwelt berührt, sollte entweder natürlich idempotent sein oder einen echten Idempotenzschlüssel an das Downstream-System weitergeben.

Wie man Idempotenz vor der Produktion testet

Die meisten Teams testen Happy Paths und sind dann überrascht, wenn Neuversuche passieren. Das reicht nicht.

Sie sollten automatisierte Tests für mindestens diese Fälle haben:

  • der Server committet die Mutation, aber die Antwort erreicht nie den Client
  • zwei identische Anfragen konkurrieren mit demselben Idempotenzschlüssel
  • derselbe Schlüssel wird mit einer anderen Payload wiederverwendet
  • ein Consumer committet seine Datenbankarbeit und stürzt vor dem Ack ab
  • ein Webhook wird mit derselben Zustellungs-ID wiedergegeben
  • ein Outbox-Dispatcher veröffentlicht dasselbe Ereignis mehr als einmal
  • eine Workflow-Activity schließt den externen Aufruf ab und stürzt vor der Meldung der Fertigstellung ab
  • ein Idempotenzdatensatz läuft ab und ein echter spätz Versuch kommt an

AWS empfiehlt explizit umfassende Testsuiten, die erfolgreiche Anfragen, fehlgeschlagene Anfragen und doppelte Anfragen enthalten. Dieser Rat ist pedestrian und absolut korrekt.

Ich würde noch einen weiteren Failure-Drill hinzufügen. Verifizieren Sie, dass die wiedergegebene Antwort semantisch äquivalent zum ersten Ergebnis ist. AWS diskutiert spät ankommende Neuversuche und argumentiert für Antworten, die die ursprüngliche Bedeutung bewahren, selbst nachdem sich der zugrunde liegende Status geändert hat. Das ist der Unterschied zwischen „kein zusätzlicher Seiteneffekt ist passiert" und „der Aufrufer hat immer noch einen konsistenten Vertrag".

Opinionated Regeln, die echte Systeme retten

Hier sind die Regeln, die ich in einer Architektur-Review durchsetzen würde.

Erstens, Idempotenzschlüssel gehören zur Geschäftsabsicht, nicht zu Transportversuchen.

Zweitens, scoping Sie jeden Schlüssel nach Tenant und Operation. Globale Schlüsselräume sind der Ort, an dem unverwandte Anfragen kollidieren.

Drittens, persistieren Sie die Entdopplungsentscheidung atomar mit der Mutation. Wenn das nicht wahr ist, ist das Design falsch.

Viertens, lehnen Sie Neuversuche mit gleichem Schlüssel und anderer Payload ab. Stripe und AWS tun dies aus gutem Grund.

Fünftens, behalten Sie Schlüssel für den vollständigen Wiedergabe-Horizont des Geschäftsprozesses, nicht für das kürzeste Warteschlangenfenster.

Sechstens, koppeln Sie Produzenten mit einem Outbox und Consumer mit Nachrichten-ID-Tracking. Eine Seite ohne die andere ist ein halbes Design.

Siebtens, propagieren Sie dieselbe Operationsidentität nach unten, wenn die Geschäftsaktion dieselbe ist. AWS empfiehlt explizit, den Idempotenz-Token entlang der Verarbeitungskette weiterzugeben.

Achtens, gehen Sie niemals davon aus, dass exakt-einmaliges Marketing die Notwendigkeit idempotenter Seiteneffekte entfernt.

Wenn das streng klingt, gut. Idempotenz ist der Ort, an dem optimistische Architektur auf Produktionsrealität trifft. Sie brauchen nicht überall Komplexität. Aber überall, wo doppelte Seiteneffekte Geld, Status oder Vertrauen schädigen würden, sollte Idempotenz ein First-Class-Teil des Vertrags sein.

Abonnieren

Neue Beiträge zu Systemen, Infrastruktur und KI-Engineering.