wie ein UNIQUE-Constraint doppelte Verarbeitung bei Retries verhindert
Ein Netzwerk-Timeout beim Aufruf einer Zahlungs- oder Bestell-API sagt nichts darüber aus, ob der Schreibvorgang auf dem Server tatsächlich fehlgeschlagen ist. Ein naiver Retry-Mechanismus riskiert deshalb doppelte Buchungen, doppelte Bestellungen oder doppelt versendete Benachrichtigungen. Ein Idempotenzschlüssel löst dieses Problem nicht in der Anwendungslogik, sondern strukturell auf Datenbankebene, mit einem UNIQUE-Constraint, der doppelte Verarbeitung selbst bei paralleler Zustellung derselben Anfrage zuverlässig verhindert. Dieser Artikel zeigt das praktische Tabellendesign, Response-Snapshots und eine saubere Aufräumstrategie.
Inhaltsverzeichnis
- 1. Warum Netzwerk-Retries doppelte Schreibvorgänge erzeugen können
- 2. Das Grundprinzip: ein eindeutiger Schlüssel pro logischer Operation
- 3. Praktisches Tabellendesign: Schlüssel, Status und Response-Snapshot
- 4. Race Conditions bei parallel eintreffenden identischen Requests
- 5. Warum ein Request-Hash gegen Schlüssel-Wiederverwendung schützt
- 6. Umgang mit fehlgeschlagener Verarbeitung und offenen Zuständen
- 7. Aufräumstrategie: warum Idempotenzeinträge nicht ewig leben dürfen
- 8. Grenzen des Musters: was ein Idempotenzschlüssel nicht löst
- 9. Praxisempfehlung: wo sich der Aufwand konkret lohnt
- 10. Zusammenfassung
- 11. FAQ
1. Warum Netzwerk-Retries doppelte Schreibvorgänge erzeugen können
Ein Client, der einen Schreib-Request an eine API schickt, kann bei einem Timeout oder Verbindungsabbruch nicht unterscheiden, ob der Request den Server nie erreicht hat, ob er erreicht und erfolgreich verarbeitet wurde, die Antwort aber verloren ging, oder ob die Verarbeitung mittendrin fehlgeschlagen ist. Aus Sicht des Clients ist ein automatischer Retry die einzig sinnvolle Reaktion auf einen Timeout, denn ein dauerhaft verlorener Request wäre für viele Geschäftsprozesse inakzeptabel.
Ohne zusätzliche Absicherung führt genau dieser Retry im zweiten der drei genannten Fälle zu einer zweiten, vollständig unabhängigen Verarbeitung derselben fachlichen Operation. Bei einer Zahlung bedeutet das eine doppelte Abbuchung, bei einer Bestellung eine doppelte Lieferung, bei einer Benachrichtigung eine doppelte Nachricht an den Kunden. Diese Fälle sind in der Praxis nicht selten, sondern eine direkte, statistisch vorhersagbare Folge jedes Systems mit Netzwerkkommunikation und Retry-Logik.
2. Das Grundprinzip: ein eindeutiger Schlüssel pro logischer Operation
Ein Idempotenzschlüssel ist ein vom Client generierter, eindeutiger Wert, typischerweise ein UUID, der eine einzelne logische Operation kennzeichnet, unabhängig davon, wie oft der zugehörige HTTP-Request tatsächlich übertragen wird. Der Client erzeugt diesen Schlüssel einmal pro fachlicher Aktion, etwa einmal pro Klick auf den Bestell-Button, und sendet ihn bei jedem Retry-Versuch identisch mit.
Der Server prüft bei jedem eingehenden Request zuerst, ob dieser Idempotenzschlüssel bereits verarbeitet wurde. Ist das der Fall, wird die ursprüngliche, bereits erzeugte Antwort erneut zurückgegeben, ohne die fachliche Operation ein zweites Mal auszuführen. Ist der Schlüssel neu, wird die Operation regulär ausgeführt und das Ergebnis unter diesem Schlüssel gespeichert.
3. Praktisches Tabellendesign: Schlüssel, Status und Response-Snapshot
Die zentrale Absicherung ist ein UNIQUE-Constraint auf der Idempotenzschlüssel-Spalte, kombiniert mit dem Kunden- oder Mandantenkontext, damit derselbe clientseitig erzeugte Schlüssel nicht versehentlich über verschiedene Mandanten hinweg kollidiert. Neben dem Schlüssel selbst speichert die Tabelle den Verarbeitungsstatus, damit parallel eintreffende Retries erkennen können, ob eine Verarbeitung bereits läuft, bereits abgeschlossen ist oder fehlgeschlagen ist.
Zusätzlich wird ein Snapshot der ursprünglichen Antwort gespeichert, typischerweise als JSON-Spalte mit Statuscode und Response-Body. Nur so kann bei einem erneuten Request mit demselben Schlüssel exakt dieselbe Antwort zurückgegeben werden, die der Client beim ersten, für ihn verlorenen Versuch eigentlich erhalten sollte.
CREATE TABLE idempotency_key (
idempotency_key UUID NOT NULL,
mandant_id BIGINT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('in_bearbeitung', 'abgeschlossen', 'fehlgeschlagen')),
request_hash TEXT NOT NULL,
response_status INT,
response_body JSONB,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT now(),
laeuft_ab_am TIMESTAMPTZ NOT NULL,
PRIMARY KEY (idempotency_key, mandant_id)
);
4. Race Conditions bei parallel eintreffenden identischen Requests
Netzwerk-Retries treffen nicht immer sauber nacheinander ein. Ein Client kann bei einem sehr langen Timeout einen zweiten Versuch starten, während der erste Request noch in Bearbeitung ist, sodass beide Requests praktisch gleichzeitig beim Server ankommen. Ohne zusätzliche Absicherung könnten dann beide Requests parallel prüfen, dass der Schlüssel noch nicht existiert, und beide mit der fachlichen Verarbeitung beginnen.
Der UNIQUE-Constraint löst genau dieses Problem strukturell: Der erste Request, der versucht, die Zeile mit dem Idempotenzschlüssel einzufügen, gewinnt das Insert, der zweite, parallel eintreffende Request erhält vom Datenbanksystem einen Constraint-Verletzungsfehler und weiß dadurch zuverlässig, dass bereits eine Verarbeitung läuft oder abgeschlossen ist, ganz ohne expliziten Lock oder Anwendungscode, der Race Conditions selbst auflösen müsste.
-- Insert versuchen; bei Constraint-Verletzung existiert
-- bereits ein Eintrag für diesen Idempotenzschlüssel
INSERT INTO idempotency_key
(idempotency_key, mandant_id, status, request_hash, laeuft_ab_am)
VALUES ($1, $2, 'in_bearbeitung', $3, now() + interval '24 hours')
ON CONFLICT (idempotency_key, mandant_id) DO NOTHING
RETURNING idempotency_key;
5. Warum ein Request-Hash gegen Schlüssel-Wiederverwendung schützt
Ein Idempotenzschlüssel garantiert nur, dass dieselbe logische Operation nicht doppelt ausgeführt wird, nicht aber, dass ein Client den Schlüssel korrekt verwendet. Würde derselbe Schlüssel versehentlich oder böswillig für eine inhaltlich andere Anfrage wiederverwendet, gäbe die API fälschlich die alte, zum ursprünglichen Request passende Antwort zurück, obwohl der neue Request eigentlich verarbeitet werden sollte.
Ein gespeicherter Hash über die relevanten Request-Parameter, etwa Betrag und Empfänger bei einer Zahlung, erlaubt es, bei jedem Request mit bekanntem Schlüssel zu prüfen, ob der Inhalt tatsächlich identisch ist. Weicht der Hash ab, kann die API einen expliziten Konfliktfehler zurückgeben, statt stillschweigend eine falsche, veraltete Antwort auszuliefern.
6. Umgang mit fehlgeschlagener Verarbeitung und offenen Zuständen
Schlägt die fachliche Verarbeitung nach dem erfolgreichen Insert des Idempotenzschlüssels fehl, etwa weil eine nachgelagerte Zahlungsabwicklung einen Fehler zurückgibt, muss der Status konsequent auf einen fehlgeschlagenen Zustand aktualisiert werden. Ein nachfolgender Retry mit demselben Schlüssel kann dann bewusst entscheiden, ob ein erneuter Versuch zulässig ist oder ob der fehlgeschlagene Zustand dauerhaft zurückgegeben werden soll.
Bleibt ein Eintrag dauerhaft im Status einer laufenden Verarbeitung hängen, etwa weil der verarbeitende Prozess abgestürzt ist, ohne den Status zu aktualisieren, braucht das System eine Timeout-Logik, die einen als hängend erkannten Eintrag nach einer angemessenen Frist als fehlgeschlagen markiert und damit einen erneuten Versuch wieder zulässt, statt den Client dauerhaft zu blockieren.
7. Aufräumstrategie: warum Idempotenzeinträge nicht ewig leben dürfen
Idempotenzschlüssel müssen nur so lange gespeichert bleiben, wie realistische Retry-Versuche eines Clients zu erwarten sind, üblicherweise ein Zeitraum von Stunden bis wenigen Tagen, nicht dauerhaft. Eine unbegrenzt wachsende Idempotenz-Tabelle vergrößert nicht nur den Speicherbedarf, sondern auch die Größe des zugehörigen UNIQUE-Index und verlangsamt dadurch mit der Zeit jeden Lookup bei eingehenden Requests.
Die in der Beispieltabelle enthaltene Spalte für das Ablaufdatum ermöglicht ein regelmäßiges Aufräumen per Batch-Job, der abgelaufene Einträge löscht. Ein zusätzlicher Index auf dieser Spalte hält diese Löschoperation auch bei großen Tabellen effizient, und in manchen Datenbanksystemen lässt sich das Ablaufverhalten sogar über eine native TTL-Funktion automatisieren, statt einen eigenen Batch-Job pflegen zu müssen.
-- Regelmäßiger Aufräum-Job für abgelaufene Einträge
DELETE FROM idempotency_key
WHERE laeuft_ab_am < now()
LIMIT 10000;
CREATE INDEX idx_idempotency_expiry ON idempotency_key (laeuft_ab_am);
8. Grenzen des Musters: was ein Idempotenzschlüssel nicht löst
Ein Idempotenzschlüssel schützt ausschließlich vor doppelter Verarbeitung derselben Anfrage, nicht vor fachlich unterschiedlichen, aber inhaltlich äquivalenten Operationen, etwa zwei separaten Bestellungen mit identischem Inhalt, die der Kunde bewusst zweimal auslöst. Auch Nebenwirkungen, die außerhalb der eigenen Transaktion liegen, etwa der Aufruf eines externen Zahlungsdienstleisters, müssen selbst idempotent gestaltet sein oder zusätzlich abgesichert werden, weil der eigene Idempotenzschlüssel diesen externen Aufruf nicht automatisch schützt.
Für verteilte Systeme mit mehreren beteiligten Diensten reicht ein einzelner Idempotenzschlüssel auf einer einzelnen Datenbank deshalb oft nicht aus. Der Schlüssel muss dann konsequent durch die gesamte Aufrufkette weitergereicht werden, damit jeder beteiligte Dienst dieselbe Garantie unabhängig durchsetzen kann.
9. Praxisempfehlung: wo sich der Aufwand konkret lohnt
Idempotenzschlüssel lohnen sich vor allem bei Schreiboperationen mit finanziellen oder anderweitig schwerwiegenden Folgen: Zahlungen, Bestellabschlüsse, Versandauslösungen und ähnliche Operationen, bei denen eine versehentliche Doppelverarbeitung teuer oder für Kunden sichtbar ist. Für rein lesende Operationen oder für Schreibvorgänge, die von Natur aus bereits idempotent sind, etwa das Setzen eines festen Statuswerts, ist der zusätzliche Aufwand meist nicht gerechtfertigt.
Für neue APIs empfiehlt sich, den Idempotenzschlüssel als expliziten, dokumentierten Bestandteil der Schnittstelle einzuführen, etwa als HTTP-Header, statt ihn implizit aus anderen Feldern abzuleiten. Das macht die Garantie für Client-Entwickler sichtbar und verhindert, dass Retry-Sicherheit von zufälligen Implementierungsdetails abhängt.
| Aspekt | Ohne Idempotenzschlüssel | Mit Idempotenzschlüssel | Praxisrelevanz |
|---|---|---|---|
| Retry bei Timeout | Risiko doppelter Verarbeitung | Zweite Anfrage liefert Original-Antwort | Direkter Schutz vor Doppelbuchungen |
| Parallele identische Requests | Race Condition möglich | UNIQUE-Constraint erzwingt Gewinner | Kein zusätzlicher Lock nötig |
| Fehlerhafte Schlüssel-Wiederverwendung | Unerkannt, falsche Antwort möglich | Request-Hash deckt Abweichung auf | Schützt vor fehlerhafter Client-Implementierung |
| Speicherwachstum | Nicht relevant | Aufräum-Job nach Ablaufdatum nötig | Sonst wachsender Index, langsamere Lookups |
| Externe Nebenwirkungen | Ungeschützt | Muss zusätzlich abgesichert werden | Idempotenzschlüssel allein reicht nicht |
Mironsoft
Datenbank-Optimierung, Query-Tuning und Migrationen
SQL-Abfragen, die bei Wachstum immer langsamer werden?
Wir analysieren und optimieren SQL-Datenbanken unabhängig vom eingesetzten System, planen sichere Migrationen und Schema-Änderungen und bringen Teams Query-Optimierung praxisnah bei.
Query-Optimierung
Langsame Abfragen analysieren und mit Indizes und Explain-Plänen gezielt beschleunigen.
Migrations-Planung
Schema-Änderungen und Datenmigrationen sicher und ohne Downtime umsetzen.
Team-Schulung
SQL-Grundlagen und Performance-Denken praxisnah im Entwicklerteam verankern.
10. Zusammenfassung
Idempotenzschlüssel in der Praxis
Kernidee
Ein clientseitig erzeugter, eindeutiger Schlüssel pro logischer Operation, abgesichert durch einen UNIQUE-Constraint, verhindert doppelte Verarbeitung bei Netzwerk-Retries strukturell.
Tabellendesign
Schlüssel plus Mandantenkontext als Primärschlüssel, Verarbeitungsstatus und ein JSON-Snapshot der ursprünglichen Antwort, damit Retries exakt dieselbe Antwort erhalten.
Race-Condition-Schutz
Der UNIQUE-Constraint entscheidet bei parallel eintreffenden identischen Requests strukturell den Gewinner, ganz ohne expliziten Anwendungs-Lock.
Aufräumen
Abgelaufene Einträge nach einem realistischen Retry-Zeitfenster per Batch-Job oder nativer TTL-Funktion löschen, um Index-Wachstum und langsamere Lookups zu vermeiden.