Idempotentie in gedistribueerde systemen die daadwerkelijk werkt

Verwijder dubbele side effects

Inhoud

Idempotentie in gedistribueerde systemen is de eigenschap die je redt nadat het netwerk heeft gelogen, de wachtrij opnieuw heeft geprobeerd, de client in paniek raakte en de operator op afspelen drukt. 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 op de server hebben als één verzoek. Daarom zijn PUT, DELETE en veilige methoden idempotent in protocolsemantiek en kunnen ze automatisch opnieuw worden geprobeerd na een communicatiefout.

integratieberichtenstroom: idempotentie

Die definitie is nuttig, maar niet voldoende. In echte architecturen 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 was tussen commit en respons. Als een worker de voorraad bijwerkt en crasht voordat het bericht bevestigd wordt, mag je de voorraad niet twee keer verlagen omdat de broker het bericht opnieuw heeft geleverd. Dat is de maatstaf.

De fout die ik keer op keer zie, is het behandelen van idempotentie als een transportfunctie in plaats van een systeemeigenschap. Wachtrijdeduplicatie, HTTP-werkwoorden en client-herpogingen helpen, maar redden geen ontwerp dat dezelfde bedrijfsintentie toestaat een tweede bijwerking te creëren. Als je de bredere framing wilt 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 verschijnen niet omdat teams slordig zijn. Ze verschijnen omdat gedistribueerde systemen opnieuw proberen, herschikken en afspelen.

Een client kan een create-verzoek verzenden, de server kan het committen, en de respons kan toch verloren gaan op het netwerk. Dat is precies waarom HTTP idempotente methoden onderscheidt en waarom betaal-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 afspelen. 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 invoer in Kafka-logboeken 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 ontwerpdokumentatie van Kafka is duidelijk dat externe systemen nog steeds coördinatie nodig hebben met offsets en outputs. Google Cloud Pub/Sub exactly-once-levering is beperkt tot pull-abonnementen, binnen een cloudregio, en vereist nog steeds dat clients de verwerkingsvoortgang bijhouden tot bevestiging slaagt.

Mijn mening is eenvoudig. Ga ervan uit dat het transport opnieuw zal proberen. Ga ervan uit dat operators zullen afspelen. Ga ervan uit dat webhooks laat zullen aankomen. Ontwerp het schrijfpad zodat een herhaalde intentie geen tweede bedrijfseffect kan creëren. Foutontwerp is nauw verwant: hoe fouten worden ingepakt, vertaald en geclassificeerd als opnieuw probeerbaar versus niet-opnieuw probeerbaar is onderdeel van dezelfde grensdiscipline — Go Error Handling Architecture: Boundaries and Patterns behandelt de classificatie van opnieuw probeerbare fouten, grensvertaling en de sentinel-patronen die herpogingslogica in staat stellen om geluid beslissingen te nemen. Wanneer herpogingen blijven stoten op een ongezonde afhankelijkheid, faalt een circuit breaker at the integration boundary snel voordat herpogingsstormen dubbel werk versterken.

Het API-contract dat ik daadwerkelijk vertrouw

Hoe voorkomen idempotentiekeys dubbele API-verzoeken

Het enige API-contract dat ik vertrouw voor muterende operaties is door de aanroeper geleverde intentie plus serverside persistentie.

AWS beveelt een door de aanroeper verstrekt verzoekidentificatie aan en waarschuwt dat de service de idempotentietoken atomiek moet registreren samen met het muterende werk. Stripe slaat de eerste statuscode en responslichaam op voor een key, vergelijkt latere parameters met het oorspronkelijke verzoek en retourneert hetzelfde resultaat voor herpogingen. PayPal gebruikt PayPal-Request-Id op ondersteunde POST-API’s en retourneert de nieuwste status voor het eerdere verzoek met diezelfde header.

Dat leidt tot een praktisch contract:

  1. De client genereert een idempotentiekey voor een bedrijfsoperatie.
  2. De server scope die key per tenant en operatienaam.
  3. De server slaat een verzoekhash op zodat dezelfde key niet kan worden hergebruikt voor een ander payload.
  4. De server registreert status zoals pending, completed, of failed.
  5. Herpogingen met dezelfde key retourneren ofwel de opgeslagen uitkomst of een stabiele pointer naar deze.
  6. Herpogingen met dezelfde key en een ander payload falen hard.

Er is een IETF-ontwerp voor een Idempotency-Key-header, maar per 09-05-2026 staat deze nog steeds in de IETF Datatracker als een verlopen Internet-Draft in plaats van een gepubliceerde RFC. In de praktijk is de headernaam nog steeds veelzijdig nuttig als een de facto conventie, maar je moet het contract documenteren in je eigen API in plaats van te doen alsof de standaard af is.

Wat moet de key vertegenwoordigen? Intentie. Niet een HTTP-poging. Niet een TCP-verbinding. Niet een herpogenteller. Als de gebruiker “create order 123 één keer” bedoelt, moet elke herpoging voor datzelfde commando dezelfde key hergebruiken. Als de gebruiker “een tweede order plaatsen” bedoelt, moet dat een andere key gebruiken.

Een request ID is voor tracing. Een idempotentiekey is voor correctheid. Als je die verwar, zien je dashboards er netjes uit terwijl je geld twee keer beweegt.

Waarom PUT niet voldoende is

Nee, HTTP PUT is niet voldoende 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 herpoging, of een externe provider opnieuw debiteert, dan heeft je implementatie het zakelijke contract geschonden, ook al ziet je routenaam er respectabel uit.

Werkwoordkeuze helpt clients om intentie te begrijpen. Het implementeert intentie niet voor je.

Gebruik PUT wanneer het resourcemodel werkelijk past bij een volledige vervanging of upsert-stijl operatie. Gebruik POST wanneer je commando’s of acties creëert. Maar voor elke mutatie die kan worden herpogd 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 opgeslagen

Langer dan je transportteam wil.

Stripe zegt dat keys kunnen worden verwijderd na minimaal 24 uur. 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 behoudt recente leveringen voor 3 dagen voor handmatige wederomlevering. Die cijfers zijn wildelijk verschillend omdat de juiste retentieperiode een bedrijfsbeslissing is, geen protocolstandaard.

Als je keys slechts vijf minuten behoudt omdat je wachtrij dat doet, ontwerp je geen idempotentie. Je kopieert een transportbeperking naar je bedrijfslaag.

Behoud idempotentieregisters voor minimaal het maximum van deze vensters:

  • client herpoghorizon
  • wachtrij redrive horizon
  • webhook afspelhoorizon
  • operator afspelhoorizon
  • afrekening of compensatiehorizon voor geld-verplaatsende operaties

Voor betalingen, boekingen en provisionering betekent dat vaak uren of dagen, niet minuten.

AWS noemt ook twee anti-patronen waar ik het volledig mee eens ben. Gebruik geen tijdstippen als key, omdat klokverschuiving en botsingen ze onbetrouwbaar maken. Sla niet blindeling hele verzoekpayloads op als dedup-record voor elk verzoek, omdat dat prestaties en schaalbaarheid schaadt. Sla een genormaliseerde verzoekhash op plus de minimale responsstatus die je nodig hebt om veilig af te spelen. Als je de eerste respons byte voor byte moet reproduceren, sla dan het canonieke responslichaam op 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 kritieke primitieven. Unieke beperkingen afdwingen van uniciteit op één of meer kolommen, en INSERT ... ON CONFLICT laat je een alternatieve actie definiëren in plaats van te falen bij een uniciteitsviolatie. PostgreSQL documenteert ook dat ON CONFLICT DO UPDATE een atomiek insert-or-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 afhandelingstroom zou er als volgt uit moeten zien:

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 syntaxis. Het belangrijke deel is de atomiciteit. Het registreren van de key en het uitvoeren van de mutatie moet samen slagen of falen. AWS stelt dit expliciet voor API-idempotentie, en dezelfde regel geldt in SQL-achtergestelde services.

Voer geen naïef check-then-act-sequentie uit zoals “selecteer key; als ontbrekend dan order invoegen”. Onder concurrentie kunnen twee verzoeken de check passeren en beide de bijwerking creëren. Een unieke beperking is niet optioneel. Het is het mechanisme dat je architectuur omzet van optimistisch folklore in iets dat je onder belasting kunt bewijzen.

Hier is de regel die ik gebruik in reviews. Als het dedup-besluit niet wordt beschermd door dezelfde transactionele grens als de mutatie, heb je geen idempotentie. Je hebt hoop.

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. Registreer verwerkte bericht-ID’s in dezelfde databasetransactie als de bedrijfsupdate. Chris Richardson beschrijft de PROCESSED_MESSAGES-tabelbenadering direct, met een primaire key op subscriber en bericht-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 persisten dat het het bericht al heeft verwerkt voordat een herpoging veilig niets kan doen.

Een minimale vorm ziet er als volgt 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 consumentstroom 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 te proberen te leunen op broker marketingtermen. 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 systeengarantie.

Combineer dedup met het outbox-patroon

Als je service lokale status bijwerkt en ook een event publiceert, is idempotente consumptie alleen niet voldoende. Je hebt ook een veilige manier nodig om het event naar buiten te brengen nadat de lokale transactie is gecommiteerd.

Dat is waarom het transactional outbox pattern belangrijk is. Chris Richardson beschrijft het basisidee als het schrijven van het event naar een outbox-tabel in dezelfde transactie als de bedrijfsupdate, 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 eigenaar zijn van data en integratie-events publiceren:

  1. Valideer en persisteer het commando onder een idempotentiekey.
  2. Schrijf bedrijfsstatus en outbox-event in één lokale transactie.
  3. Laat CDC of een outbox-dispatcher het event publiceren.
  4. Maak downstream-consumenten ook idempotent.

Outbox verwijdert niet de noodzaak voor idempotente consumenten. Het verwijdert de noodzaak om te doen alsof een database-commit en een broker-publicatie één magische gedistribueerde transactie kunnen zijn wanneer ze dat meestal niet kunnen.

Webhooks zijn gewoon berichten met betere branding

Behandel inkomende webhooks exact als berichten van een onbetrouwbaar netwerkind.

GitHub documenteert dat leveringen buiten volgorde kunnen aankomen, beveelt X-Hub-Signature-256 aan om authenticiteit te verifiëren, en biedt X-GitHub-Delivery aan als de unieke leveringsidentificator. Het merkt ook op dat wederomleveringen dezelfde leverings-ID hergebruiken.

Dus de architectuur is straightforward:

  • verifieer eerst de handtekening
  • gebruik de delivery GUID als de dedup-key
  • persisteer ontvangst voordat bijwerkingen plaatsvinden
  • maak handlers volgorde-aware in plaats van aan te nemen dat aankomstvolgorde
  • queue het zware werk en keer snel terug

Als je webhook-handler direct naar bedrijfstabellen schrijft voordat het ontvangst registreert, is het niet productieklaar. 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 beveelt aan om Activities idempotent te schrijven omdat Activities kunnen worden herpogd na failures of time-outs. Zijn docs noemen zelfs de edge-case waarbij een worker een externe bijwerking succesvol voltooit maar crasht voordat voltooiing wordt gerapporteerd, waardoor de Activity opnieuw loopt. Temporal suggereert ook om een combinatie van Workflow Run ID en Activity ID te gebruiken als een stabiele idempotentiekey bij het aanroepen van downstream-services. Als je dit toepast in service-orchestratie, Go Microservices for AI/ML Orchestration behandelt de bredere workflow-afwegingen.

Dat is precies het juiste mentale model. Een workflow-engine kan uitvoeringsgeschiedenis behouden en herpogingen coördineren. Het kan niet retroactief een kaart ontdeden of een e-mail onverzenden 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 loopt, heb je de oorspronkelijke bug mogelijk opgelost door een nieuwe te creëren.

Mijn regel hier is brutaal en eenvoudig. Elke Activity, elke command handler, en elke compensatie die de buitenwereld raakt, moet ofwel natuurlijk idempotent zijn of een echte idempotentiekey naar het downstream-systeem dragen.

Hoe je idempotentie test voordat productie

De meeste teams testen happy paths en doen dan verbaasd wanneer herpogingen plaatsvinden. Dat is niet genoeg. Voor Go-teams, Testing Concurrent Go Code with testing/synctest behandelt hoe je snelle, deterministische tests schrijft voor herpogingslussen en context-deadline-gedrag zonder te slapen door kunstmatige vertragingen.

Je moet geautomatiseerde tests hebben voor minimaal deze gevallen:

  • de server commit de mutatie maar de respons bereikt de client nooit
  • twee identieke verzoeken racen met dezelfde idempotentiekey
  • dezelfde key wordt hergebruikt met een ander payload
  • een consument commit zijn database werk en crasht voordat ack
  • een webhook wordt afgespeeld met dezelfde delivery ID
  • een outbox-dispatcher publiceert hetzelfde event meer dan één keer
  • een workflow Activity voltooit de externe aanroep en crasht voordat voltooiing wordt gerapporteerd
  • een idempotentierecord verstrijkt en een echte late herpoging arriveert

AWS beveelt expliciet uitgebreide testsuites aan die succesvolle verzoeken, mislukte verzoeken en dubbele verzoeken omvatten. Dat advies is pedestrian en absoluut correct.

Ik zou nog één meer failure drill toevoegen. Verifieer dat de afgespeelde respons semantisch equivalent is aan het eerste resultaat. AWS bespreekt laat-aankomende herpogingen en pleit voor responsen die de oorspronkelijke betekenis behouden ook nadat onderliggende status is veranderd. Dat is het verschil tussen “geen extra bijwerking gebeurde” en “de aanroeper heeft nog steeds een consistent contract.”

Meninghebbende regels die echte systemen redden

Hier zijn de regels die ik zou afdwingen in een architectuurreview.

Ten eerste, idempotentiekeys behoren tot bedrijfsintentie, niet transportpogingen.

Ten tweede, scope elke key per tenant en operatie. Globale key-ruimten zijn hoe ongerelateerde verzoeken botsen.

Ten derde, persisteer het dedup-besluit atomiek met de mutatie. Als dat niet waar is, is het ontwerp fout.

Ten vierde, verwerp same-key different-payload herpogingen. Stripe en AWS doen dit allebei om goede redenen.

Ten vijfde, behoud keys voor de volledige afspelhoorizon van het bedrijfsproces, niet voor het kortste wachtrijvenster.

Ten zesde, combineer producers met een outbox en consumenten met message-ID-tracking. De ene kant zonder de andere is een halve ontwerp.

Ten zevende, propageer dezelfde operatie-identiteit downstream wanneer de bedrijfsactie hetzelfde is. AWS beveelt expliciet aan om de idempotentietoken langs de verwerkingsketen te doorgeven.

Ten achtste, neem nooit aan dat exactly-once marketing de noodzaak voor idempotente bijwerkingen verwijdert.

Als dat streng klinkt, goed. Idempotentie is waar optimistische architectuur productie-realiteit 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.

Dezezelfde regels zijn direct van toepassing op achtergrond AI-agents. Polling-agents die taken claimen, notificaties uitzenden of toolaanroepen triggeren, hebben dedupe-keys en idempotente claim-protocollen net zo nodig als betaal-API’s. Voor hoe het claim-and-dedupe-patroon werkt binnen productieve AI-assistants, zie Polling Agents in AI Assistants: 11 Implementation Patterns.

Abonneren

Ontvang nieuwe berichten over systemen, infrastructuur en AI-engineering.