Idempotentie in gedistribueerde systemen die daadwerkelijk werkt
Voorkom dubbele bijwerkingen
Idempotentie in gedistribueerde systemen is de eigenschap die je redt nadat het netwerk faalt, de wachtrij opnieuw probeert, de client paniekt en de operator een replay uitvoert. In productiesystemen is dubbele levering normaal. Dubbele bijwerkingen zijn de bug.
HTTP definieert een idempotente methode als een methode waarbij meerdere identieke verzoeken hetzelfde beoogde effect hebben op de server als één verzoek. Daarom zijn PUT, DELETE en veilige methoden volgens de protocalsemantiek idempotent en kunnen ze automatisch opnieuw worden geprobeerd na een communicatiefout.

Die definitie is nuttig, maar niet voldoende. In echte architectuur is idempotentie geen triviaal antwoord over HTTP. Het is een zakelijke garantie. Als een klant één keer op ‘betalen’ drukt, mag je niet twee keer debiteren omdat er een time-out is opgetreden tussen de commit en het antwoord. Als een worker de voorraad bijwerkt en crasht voordat het bericht bevestigd wordt, mag je de voorraad niet twee keer verminderen omdat de broker het bericht opnieuw heeft geleverd. Dat is de norm.
De fout die ik steeds weer zie, is het behandelen van idempotentie als een transportfunctie in plaats van als een systeemeigenschap. Wachtrijdeduplicatie, HTTP-werkwoorden en client-herstelprocedures helpen, maar ze redden geen ontwerp dat hetzelfde businessintent laat leiden tot een tweede bijwerking. Als je de bredere context wilt zien van hoe deze integratiebeslissingen passen bij servicegrenzen en persistentieafwegingen, begin dan met App Architecture in Production: Integration Patterns, Code Design, and Data Access.
Waar duplicaten in productie vandaan komen
Duplicaten ontstaan niet omdat teams slordig zijn. Ze ontstaan omdat gedistribueerde systemen opnieuw proberen, herschikken en opnieuw afspelen.
Een client kan een create-verzoek sturen, de server kan het committen, en het antwoord kan toch verloren gaan op de draad. Dat is precies waarom HTTP idempotente methoden onderscheidt en waarom betalings-API’s zoals Stripe en PayPal expliciete idempotentiemechanismen blootstellen voor onveilige methoden zoals POST.
Berichtbrokers maken het probleem nog duidelijker. At-least-once-levering betekent dat een consument herhaaldelijk kan worden aangeroepen voor hetzelfde bericht, en een handler kan de database succesvol bijwerken maar falen voordat bevestiging plaatsvindt, waardoor de broker hetzelfde bericht opnieuw levert.
Webhooks zijn niet anders. GitHub stelt dat webhookleveringen buiten volgorde kunnen aankomen, mislukte leveringen niet automatisch opnieuw worden geleverd, en dat elke levering een unieke X-GitHub-Delivery GUID bevat die je moet gebruiken bij bescherming tegen replay. Voor een praktische architectuurvisie op chat-endpoints als interactiegrenzen, zie Chat Platforms as System Interfaces in Modern Systems.
Zelfs systemen die sterkere garanties adverteren, laten je nog steeds werk over. Kafka kan dubbele entries in Kafka-logs voorkomen met idempotente producers en kan exactly-once-levering bieden voor read-process-write-stromen die binnen Kafka blijven met transacties en read_committed-consumenten. Maar de eigen ontwerpprincipes van Kafka zijn duidelijk dat externe systemen nog steeds coördinatie vereisen met offsets en outputs. Exactly-once-levering van Google Cloud Pub/Sub is beperkt tot pull-abonnementen, binnen een cloudregio, en vereist nog steeds dat clients de verwerkingsvoortgang bijhouden tot bevestiging slaagt.
Mijn opiniëerde samenvatting is eenvoudig. Ga er vanuit dat het transport opnieuw zal proberen. Ga er vanuit dat operators een replay zullen uitvoeren. Ga er vanuit dat webhooks laat zullen aankomen. Ontwerp de schrijfpath zo dat een herhaald intent geen tweede business-effect kan creëren.
Het API-contract waar ik echt op vertrouw
Hoe voorkomen idempotentiekeys dubbele API-verzoeken
Het enige API-contract waar ik op vertrouw voor muterende operaties is door de aanrover geleverd intent plus server-side persistentie.
AWS adviseert een door de aanrover geleverd verzoekidentificatienummer en waarschuwt dat de service het idempotentietoken atomiek moet vastleggen samen met het muterende werk. Stripe bewaart de eerste statuscode en antwoordlichaam voor een key, vergelijkt latere parameters met het oorspronkelijke verzoek, en retourneert hetzelfde resultaat voor herstelprocedures. PayPal gebruikt PayPal-Request-Id op ondersteunde POST-API’s en retourneert de laatste status voor het eerdere verzoek met dezelfde header.
Dat leidt tot een praktisch contract:
- De client genereert een idempotentiekey voor een businessoperatie.
- De server scope die key per tenant en operatienaam.
- De server bewaart een verzoekhash zodat dezelfde key niet kan worden hergebruikt voor een andere payload.
- De server registreert status zoals
pending,completedoffailed. - Herstelprocedures met dezelfde key retourneren ofwel het opgeslagen resultaat of een stabiele pointer ernaar.
- Herstelprocedures met dezelfde key en een andere payload falen luidruchtig.
Er is een IETF Idempotency-Key header-ontwerp, maar per 09-05-2026 staat het in de IETF Datatracker nog steeds als een verlopen Internet-Draft in plaats van als een gepubliceerde RFC. In de praktijk is de headernaam nog steeds breed nuttig als een de facto conventie, maar je moet het contract in je eigen API documenteren in plaats te doen alsof de standaard af is.
Wat moet de key vertegenwoordigen? Intent. Niet een HTTP-poging. Niet een TCP-verbinding. Niet een herstelcounter. Als de gebruiker bedoelt “maak order 123 één keer”, moet elke herstelprocedure voor datzelfde commando dezelfde key hergebruiken. Als de gebruiker bedoelt “een tweede order plaatsen”, moet dat een andere key gebruiken.
Een verzoek-ID is voor tracing. Een idempotentiekey is voor correctheid. Als je die verwart, zien je dashboards er netjes uit terwijl je geld twee keer beweegt.
Waarom PUT niet genoeg is
Nee, HTTP PUT is niet genoeg om een operatie idempotent te maken.
Ja, RFC 9110 geeft PUT idempotente semantiek. Maar als je PUT-handler een nieuw downstream-event uitstoot, een e-mail verstuurt bij elke herstelprocedure, of een externe provider opnieuw debiteert, dan hebt je implementatie het businesscontract geschonden, ook al ziet je routenaam er respectabel uit.
Werkwoordkeuze helpt clients om intent te begrijpen. Het implementeert intent niet voor je.
Gebruik PUT wanneer het resourcemodel echt past bij een volledige vervanging of upsert-stijl operatie. Gebruik POST wanneer je commando’s of acties aanmaakt. Maar voor elke mutatie die kan worden hersteld over netwerkgrenzen heen, documenteer een expliciet idempotentiecontract. Als je muterende acties worden getriggerd vanuit chatworkflows, geldt hetzelfde contract in Slack Integration Patterns for Alerts and Workflows en Discord Integration Pattern for Alerts and Control Loops. Verborgen bijwerkingen zijn waar architectuur gaat sterven.
Hoe lang moet een idempotentiekey worden bewaard
Langer dan je transportteam wil.
Stripe zegt dat keys na minimaal 24 uur kunnen worden verwijderd. PayPal zegt dat retentie API-specifiek is en geeft voorbeelden die tot 45 dagen kunnen duren. Amazon SQS FIFO dedupliceert alleen binnen een venster van 5 minuten. GitHub bewaart recente leveringen voor 3 dagen voor handmatige herlevering. Die nummers zijn wild verschillend omdat de juiste retentieperiode een businessbeslissing is, geen protocolstandaard.
Als je keys slechts vijf minuten bewaart omdat je wachtrij dat doet, ontwerp je dan geen idempotentie. Je kopieert een transportbeperking naar je businesslaag.
Bewaar idempotentie-records minimaal voor de maximale van deze vensters:
- client herstelhorizon
- wachtrij redrive horizon
- webhook replay horizon
- operator replay horizon
- afwikkelings- of compensatiehorizon voor geldverplaatsende operaties
Voor betalingen, boekingen en provisioning betekent dat vaak uren of dagen, niet minuten.
AWS noemt ook twee anti-patronen waar ik het helemaal mee eens ben. Gebruik geen tijdstempels als key, omdat klokverschuiving en botsingen ze onbetrouwbaar maken. Bewaar niet blindelings volledige verzoekpayloads als het dedup-record voor elk verzoek, omdat dat prestaties en schaalbaarheid schaadt. Bewaar een genormaliseerde verzoekhash plus de minimale antwoordstatus die je nodig hebt om veilig te kunnen herhalen. Als je het eerste antwoord byte-voor-byte moet reproduceren, bewaar dan het canonieke antwoordlichaam zoals Stripe doet.
De databasepatronen die idempotentie echt maken
Idempotentie wordt echt wanneer de persistentielaag een race precies één keer kan winnen.
PostgreSQL geeft je hier twee cruciale primitieven. Unieke constraints handhaven uniciteit op één of meerdere kolommen, en INSERT ... ON CONFLICT stelt je in staat een alternatieve actie te definiëren in plaats van te falen bij een uniciteitsviolatie. PostgreSQL documenteert ook dat ON CONFLICT DO UPDATE een atomiek insert-of-update-resultaat garandeert onder concurrentie.
Dat betekent dat je idempotentie-laag meestal moet beginnen met een tabel zoals deze:
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)
);
En de afhandelingsstroom moet er zo uitzien:
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
Het belangrijke deel is niet de syntax. Het belangrijke deel is de atomiciteit. Het vastleggen van de key en het uitvoeren van de mutatie moeten samen slagen of falen. AWS zegt dit expliciet voor API-idempotentie, en dezelfde regel geldt voor SQL-gebackte services.
Doe geen naïef check-then-act-sequence zoals “selecteer key; als ontbrekend dan insert order”. Onder concurrentie kunnen twee verzoeken de check passeren en beiden de bijwerking creëren. Een unieke constraint is geen optie. Het is het mechanisme dat je architectuur transformeert van optimistisch folklore naar iets wat je onder belasting kunt bewijzen.
Hier is de regel die ik gebruik bij reviews. Als het dedup-beslissing niet wordt beschermd door dezelfde transactionele grens als de mutatie, heb je geen idempotentie. Je hebt hopen.
Berichten, events en webhooks hebben hun eigen grens nodig
Hoe gaan consumenten om met dubbele events en berichten
Voor berichtconsumenten is het klassieke patroon nog steeds het juiste. Bewaar verwerkte bericht-IDs in dezelfde database-transactie als de business-update. Chris Richardson beschrijft de PROCESSED_MESSAGES-tabelbenadering direct, met een primaire key op subscriber en message-ID zodat duplicaten schoon falen en kunnen worden genegeerd.
Veel teams noemen die expliciete processed_messages-opslag een inbox-tabel. De label is minder belangrijk dan de regel. De ontvanger moet bewijs vastleggen dat het bericht al is verwerkt voordat een herstelprocedure veilig niets kan doen.
Een minimale vorm ziet er zo uit:
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)
);
En de consumentenstroom is net zo streng als de HTTP-stroom:
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
Dat patroon is saai. Goed. Idempotentie moet saai zijn.
Het is ook meestal beter dan proberen te leunen op marketingtermen van brokers. Kafka’s exactly-once-ondersteuning is uitstekend wanneer je binnen Kafka’s eigen transactionele model blijft, maar Kafka’s docs waarschuwen nog steeds dat externe bestemmingen samenwerking nodig hebben. SQS FIFO vermindert dubbele verzendingen alleen binnen zijn 5-minuten dedup-venster. Pub/Sub exactly-once verwacht nog steeds dat de subscriber de voortgang bijhoudt en dubbel werk vermijdt wanneer bevestigingen falen.
Exactly-once is meestal een lokale optimalisatie. Idempotente bijwerkingen zijn de systeegarantie.
Combineer dedup met het outbox-patroon
Als je service lokale status bijwerkt en ook een event publiceert, is idempotente consumptie alleen niet genoeg. Je hebt ook een veilige manier nodig om het event naar buiten te brengen na de lokale transactie-commit.
Daarom is het transactionele outbox-patroon belangrijk. Chris Richardson beschrijft het basisidee als het schrijven van het event naar een outbox-tabel in dezelfde transactie als de business-update, en het vervolgens asynchroon publiceren. Debezium zegt dat het outbox-patroon inconsistenties vermijdt tussen de interne status van een service en de events die door andere services worden geconsumeerd. NServiceBus gaat verder en toont hoe outbox-verwerking inkomende berichten dedupliceert en zombie-records en ghost-berichten vermijdt.
Dit is de architectuur die ik aanbeveel voor services die data eigen zijn en integratie-events publiceren:
- Valideer en persisteer het commando onder een idempotentiekey.
- Schrijf businessstatus en outbox-event in één lokale transactie.
- Laat CDC of een outbox-dispatcher het event publiceren.
- Maak downstream-consumenten ook idempotent.
Outbox verwijdert niet de behoefte aan idempotente consumenten. Het verwijdert de behoefte om te doen alsof een database-commit en een broker-publish één magische gedistribueerde transactie kunnen zijn wanneer ze dat meestal niet kunnen.
Webhooks zijn gewoon berichten met betere branding
Behandel inkomende webhooks precies zoals berichten van een onbetrouwbare netwerkedge.
GitHub documenteert dat leveringen buiten volgorde kunnen aankomen, adviseert het gebruik van X-Hub-Signature-256 om authenticiteit te verifiëren, en biedt X-GitHub-Delivery als het unieke leveringsidentificatienummer. Het merkt ook op dat herleveringen hetzelfde leverings-ID hergebruiken.
Dus de architectuur is straightforward:
- verifieer eerst de signature
- gebruik de delivery GUID als dedup-key
- persisteer ontvangst voordat bijwerkingen plaatsvinden
- maak handlers volgordebewust in plaats van aan te nemen dat ze in volgorde aankomen
- queue het zware werk en keer snel terug
Als je webhook-handler direct naar business-tabellen schrijft voordat het ontvangst registreert, is het niet productie-klaar. Het maakt alleen sneller dubbele fouten.
Sagas en workflow-engines hebben nog steeds idempotentie nodig
Sagas en duurzame workflow-engines verwijderen het probleem niet. Ze maken het zichtbaar.
Temporal adviseert om Activities idempotent te schrijven omdat Activities kunnen worden hersteld na fouten of time-outs. Zijn docs noemen zelfs het edge-case scenario waarbij een worker een externe bijwerking succesvol voltooit maar crasht voordat voltooiing wordt gemeld, waardoor de Activity opnieuw wordt uitgevoerd. Temporal suggereert ook om een combinatie van Workflow Run ID en Activity ID te gebruiken als stabiele idempotentiekey bij het aanroepen van downstream-services. Als je dit toepast in service-orchestratie, Go Microservices for AI/ML Orchestration bespreekt de bredere workflow-afwegingen.
Dat is precies het juiste mentale model. Een workflow-engine kan uitvoeringsgeschiedenis behouden en herstelprocedures coördineren. Het kan niet retroactief een kaart ondebiteren of een e-mail onverzonden tenzij je applicatie het idempotente stappen en idempotente compensaties geeft.
Hetzelfde geldt voor sagas. Temporal’s eigen saga-richtlijnen beschrijven compenserende acties die worden uitgevoerd wanneer een stap faalt. Die compensaties moeten ook idempotent zijn. Als “betaling terugstorten” twee keer wordt uitgevoerd, heb je misschien de oorspronkelijke bug opgelost door een nieuwe te creëren.
Mijn regel hier is brutaal en eenvoudig. Elke Activity, elke commandohandler, en elke compensatie die de buitenwereld raakt, moet ofwel natuurlijk idempotent zijn of een echte idempotentiekey meenemen naar het downstream-systeem.
Hoe idempotentie te testen voordat productie
De meeste teams testen happy paths en doen dan verbaasd wanneer herstelprocedures plaatsvinden. Dat is niet genoeg.
Je moet geautomatiseerde tests hebben voor minimaal deze gevallen:
- de server commit de mutatie maar het antwoord bereikt de client nooit
- twee identieke verzoeken racen met dezelfde idempotentiekey
- dezelfde key wordt hergebruikt met een andere payload
- een consument commit zijn database-werk en crasht voordat ack
- een webhook wordt gereplayed met hetzelfde delivery-ID
- een outbox-dispatcher publiceert hetzelfde event meer dan één keer
- een workflow-Activity voltooit de externe call en crasht voordat voltooiing wordt gemeld
- een idempotentie-record verloopt en een echte late herstelprocedure arriveert
AWS adviseert expliciet uitgebreide testsets die succesvolle verzoeken, mislukte verzoeken en dubbele verzoeken omvatten. Dat advies is banal en absoluut correct.
Ik zou nog één meer failure drill toevoegen. Verifieer dat het gereplayde antwoord semantisch equivalent is aan het eerste resultaat. AWS bespreekt laat aankomende herstelprocedures en pleit voor antwoorden die de oorspronkelijke betekenis behouden zelfs nadat onderliggende status is veranderd. Dat is het verschil tussen “er is geen extra bijwerking gebeurd” en “de aanrover heeft nog steeds een consistent contract.”
Opiniëerde regels die echte systemen redden
Hier zijn de regels die ik zou afdwingen in een architectuurreview.
Ten eerste, idempotentiekeys behoren tot businessintent, niet tot transportpogingen.
Ten tweede, scope elke key per tenant en operatie. Globale key-ruimtes zijn hoe ongerelateerde verzoeken botsen.
Ten derde, persisteer het dedup-beslissing atomiek met de mutatie. Als dat niet waar is, is het ontwerp fout.
Ten vierde, verwerp herstelprocedures met dezelfde key maar andere payload. Stripe en AWS doen dit om goede redenen.
Ten vijfde, bewaar keys voor de volledige replay-horizon van het businessproces, niet voor het kortste wachtrijvenster.
Ten zesde, combineer producers met een outbox en consumenten met message-ID-tracking. Eén kant zonder de ander is een half ontwerp.
Ten zevende, propageer dezelfde operatie-identiteit downstream wanneer het businessactie hetzelfde is. AWS adviseert expliciet om het idempotentietoken langs de verwerkingsketen door te geven.
Ten achtste, neem nooit aan dat exactly-once-marketing de behoefte aan idempotente bijwerkingen verwijdert.
Als dat streng klinkt, goed. Idempotentie is waar optimistische architectuur productierealiteit ontmoet. Je hebt niet overal complexiteit nodig. Maar waar dubbele bijwerkingen geld, status of vertrouwen zouden schaden, moet idempotentie een first-class onderdeel van het contract zijn.