Idempotentie in gedistribueerde systemen die daadwerkelijk werkt

Voorkom dubbele neveneffecten

Inhoud

Idempotentie in gedistribueerde systemen is de eigenschap die je redt nadat het netwerk liegt, de wachtrij opnieuw probeert, de client paniek raakt en de operator op herhalen drukt. In productiesystemen is dubbele levering normaal. Dubbele neveneffecten zijn de bug.

HTTP definieert een idempotente methode als een methode waarbij meerdere identieke verzoeken hetzelfde beoogde effect op de server hebben als één enkel verzoek. Daarom zijn PUT, DELETE en veilige methoden idempotent in protocolsemantiek en kunnen ze automatisch worden herhaald na een communicatiefout.

integratie berichtstroom: idempotentie

Deze definitie is nuttig, maar niet voldoende. In echte architecturen is idempotentie geen triviaal antwoord op een HTTP-vraag. Het is een zakelijke garantie. Als een klant één keer op “betalen” drukt, mag je niet tweemaal in rekening worden gebracht omdat er een time-out trad op tussen commit en respons. Als een worker de voorraad bijwerkt en crasht voordat het bericht wordt bevestigd (acked), mag je de voorraad niet tweemaal verminderen 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 systeem-eigenschap. Wachtrij-deduplicatie, HTTP-werkwoorden en client-retries helpen, maar geen enkele van deze reddt een ontwerp dat dezelfde zakelijke intentie een tweede neveneffect laat ontstaan. Als je de bredere context wilt begrijpen over hoe deze integratiebesluiten passen binnen servicegrenzen en persistentietrucs, begin dan met App Architectuur in Productie: Integratiepatronen, Codeontwerp en Data-toegang.

Waar duplicaten vandaan komen in productie

Duplicaten verschijnen niet omdat teams slordig zijn. Ze verschijnen omdat gedistribueerde systemen opnieuw proberen, herschikken en herhalen.

Een client kan een create-verzoek sturen, de server kan het committen, en de respons kan toch verdwijnen in de draad. Dat is precies waarom HTTP onderscheid maakt tussen idempotente methoden en waarom betaal-API’s zoals Stripe en PayPal expliciete idempotentiemechanismen blootleggen 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 het bericht wordt bevestigd, waardoor de broker hetzelfde bericht opnieuw levert.

Webhooks zijn daar geen uitzondering op. GitHub stelt dat webhookleveringen in onjuiste volgorde kunnen aankomen, mislukte leveringen niet automatisch opnieuw worden geleverd, en elke levering een unieke X-GitHub-Delivery GUID bevat die je moet gebruiken ter bescherming tegen herhaling. Voor een praktisch architectuurperspectief op chat-eindpunten als interactiegrenzen, zie Chatplatformen als Systeeminterfaces in Moderne Systemen.

Zelfs systemen die sterkere garanties adverteren, laten je nog steeds werk over. Kafka kan dubbele invoegingen in Kafka-logs voorkomen met idempotente producers en kan exact-once levering bieden voor read-process-write flows die binnen Kafka blijven met transacties en read_committed consumers. Maar Kafka’s eigen ontwerpdokumentatie is duidelijk: externe systemen vereisen nog steeds coördinatie met offsets en outputs. Google Cloud Pub/Sub exact-once levering is beperkt tot pull-subscrities, binnen een cloudregio, en vereist nog steeds dat clients het verwerkingsvoortgang bijhouden totdat de bevestiging succesvol is.

Mijn eigen mening samengevat is simpel. Ga ervan uit dat het transport zal herhalen. Ga ervan uit dat operators zullen herhalen. Ga ervan uit dat webhooks laat aankomen. Ontwerp de schrijfpad zodat een herhaalde intentie geen tweede zakelijk effect kan creëren. Foutafhandeling is nauw verwant: hoe fouten worden verpakt, vertaald en geclassificeerd als herhaalbaar versus niet-herhaalbaar, maakt deel uit van dezelfde grensdiscipline — Go Foutafhandelingsarchitectuur: Grenzen en Patronen behandelt de classificatie van herhaalbare fouten, grensverplaatsing en de sentinelpatronen die herhaallogica in staat stellen verstandige beslissingen te nemen. Als retries blijven doelen op een ongezonde afhankelijkheid, faalt een circuit breaker aan de integratiegrens snel voordat herhaalde stormen dubbel werk vermenigvuldigen.

Het API-contract dat ik echt vertrouw

Hoe voorkomen idempotentiesleutels dubbele API-verzoeken

Het enige API-contract dat ik vertrouw voor mutatiebewerkingen is door de aanroeper aangeleverde intentie plus serverzijige persistentie.

AWS beveelt een door de aanroeper aangeleverde request-identifier aan en waarschuwt dat de service de idempotentietoken atomisch moet vastleggen samen met de mutatiewerkzaamheden. Stripe slaat de eerste statuscode en responsbody op voor een sleutel, vergelijkt latere parameters met het oorspronkelijke verzoek en retourneert hetzelfde resultaat voor retries. PayPal gebruikt PayPal-Request-Id op ondersteunde POST-API’s en retourneert de laatste status voor het vorige verzoek met dezelfde header.

Dat leidt tot een praktisch contract:

  1. De client genereert een idempotentiesleutel voor een zakelijke bewerking.
  2. De server scant die sleutel per tenant en operatienaam.
  3. De server slaat een request-hash op zodat dezelfde sleutel niet opnieuw kan worden gebruikt voor een ander payload.
  4. De server registreert statussen zoals pending, completed of failed.
  5. Retries met dezelfde sleutel retourneren ofwel de opgeslagen uitkomst ofwel een stabiele verwijzing ernaar.
  6. Retries met dezelfde sleutel en een andere payload falen luidruchtig.

Er is een IETF Idempotency-Key header draft, maar per 2026-05-09 staat deze nog steeds in de IETF Datatracker vermeld als een vervallen Internet-Draft in plaats van een gepubliceerde RFC. In de praktijk is de koptekstnaam nog steeds veel nuttig als een de facto conventie, maar je moet het contract in je eigen API documenteren in plaats van te doen alsof de standaard afgerond is.

Wat moet de sleutel representeren? Intentie. Niet een HTTP-poging. Niet een TCP-verbinding. Niet een retry-teller. Als de gebruiker “bestel order 123 één keer” betekent, moet elke retry voor datzelfde commando dezelfde sleutel hergebruiken. Als de gebruiker “plaats een tweede bestelling” betekent, moet dat een andere sleutel gebruiken.

Een request-ID is voor tracing. Een idempotentiesleutel is voor correctheid. Als je die verwisselt, zien je dashboards er netjes uit terwijl je geld tweemaal bewaagt.

Waarom PUT niet genoeg is

Nee, HTTP PUT is niet genoeg om een bewerking idempotent te maken.

Ja, RFC 9110 geeft PUT idempotente semantiek. Maar als je PUT-handler een nieuw downstream-gebeurtenis afvuurt, een e-mail verzendt bij elke retry, of een externe provider opnieuw in rekening brengt, dan heeft je implementatie het zakelijke contract geschonden, zelfs als je route-naam er fatsoenlijk uitziet.

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

Gebruik PUT wanneer het resource-model echt past bij een volledige vervanging of upsert-stijl bewerking. Gebruik POST wanneer je commando’s of acties aanmaakt. Maar voor elke mutatie die over netwerkgrenzen heen kan worden herhaald, documenteer je een expliciet idempotentiecontract. Als je muterende acties worden getriggerd vanuit chat-routines, geldt hetzelfde contract in Slack Integratiepatronen voor Alerts en Workflows en Discord Integratiepatroon voor Alerts en Control Loops. Verborgen neveneffecten zijn waar architectuur sterft.

Hoe lang moet een idempotentiesleutel worden opgeslagen

Langer dan je transportteam wil.

Stripe zegt dat sleutels na minimaal 24 uur kunnen worden opgeruimd. PayPal zegt dat retentie API-specifiek is en geeft voorbeelden die tot 45 dagen kunnen duren. Amazon SQS FIFO dedupeert alleen binnen een venster van 5 minuten. GitHub houdt recente leveringen 3 dagen aan voor handmatige herlevering. Die cijfers zijn enorm verschillend omdat de juiste retentieperiode een zakelijke beslissing is, geen protocolstandaard.

Als je sleutels maar vijf minuten bewaart omdat je wachtrij dat doet, ontwerp je geen idempotentie. Je kopieert een transportbeperking naar je zakelijke laag.

Bewaar idempotentiegegevens gedurende ten minste het maximum van deze vensters:

  • client retry-horizon
  • wachtrij redrive-horizon
  • webhook replay-horizon
  • operator replay-horizon
  • settlement of compensatie-horizon voor geldbewegende bewerkingen

Voor betalingen, boekingen en provisioning betekent dit vaak uren of dagen, niet minuten.

AWS wijst ook op twee anti-patronen waar ik het volledig mee eens ben. Gebruik geen tijdstempels als sleutel, omdat kloomschommelingen en collisions ze onbetrouwbaar maken. Sla niet blindelings volledige request-gegevens op als dedup-record voor elk verzoek, omdat dat de prestaties en schaalbaarheid schaadt. Sla een genormaliseerde request-hash plus de minimale responsstatus op die je nodig hebt om veilig te herhalen. Als je de eerste respons byte voor byte moet reproduceren, sla dan de canonieke responsbody op zoals Stripe dat doet.

De databasemodellen die idempotentie echt maken

Idempotentie wordt echt wanneer de persistentielaag een race exact één keer kan winnen.

PostgreSQL geeft je twee kritieke primitieven hier. Unieke beperkingen afdwingen uniciteit op een of meer kolommen, en INSERT ... ON CONFLICT stelt je in staat een alternatieve actie te definiëren in plaats van te falen bij een uniciteitsinbreuk. PostgreSQL documenteert ook dat ON CONFLICT DO UPDATE een atomische insert-or-update uitkomst garandeert onder concurrentie.

Dat betekent dat je idempotentielayer meestal begint 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 verwerkingsstroom eruit als volgt:

begin transaction

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

laad rij voor (tenant_id, operation, idempotency_key) for update

if row.request_hash != incoming_request_hash
    faal met conflict of validatiefout

if row.state = 'completed'
    return opgeslagen respons

if row.state = 'pending' en row is gemaakt door een andere live request
    wacht kort, of faal snel met een herhaalbare respons

voer lokale zakelijke mutatie uit

sla stabiel resultaat op in idempotentie-rij
zet state = 'completed'

commit
return resultaat

Het belangrijke deel is niet de syntax. Het belangrijke deel is de atomiciteit. Het registreren van de sleutel en het uitvoeren van de mutatie moeten samen slagen of falen. AWS zegt dit expliciet voor API-idempotentie, en dezelfde regel geldt in SQL-ondersteunde services.

Doe geen naïeve check-then-act volgorde zoals “selecteer sleutel; als ontbreend dan insert bestelling”. Onder concurrentie kunnen twee verzoeken de check passeren en beide het neveneffect creëren. Een unieke beperking is niet optioneel. Het is het mechanisme dat je architectuur van optimistische folklore transformeert naar iets dat je onder belasting kunt bewijzen.

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

Berichten, gebeurtenissen en webhooks hebben hun eigen grens nodig

Hoe gaan consumers om met dubbele gebeurtenissen en berichten

Voor berichtconsumers is het klassieke patroon nog steeds het juiste. registreer verwerkte bericht-ID’s in dezelfde database-transactie als de zakelijke update. Chris Richardson beschrijft de PROCESSED_MESSAGES tabelbenadering direct, met een primary 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. Het label maakt minder uit dan de regel. De ontvanger moet bewijs van verwerking persistent vastleggen voordat een retry veilig niets hoeft te 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 consumer-stroom is net zo streng als de HTTP-stroom:

begin transaction

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

if geen rij ingevoegd
    rollback
    ack en negeer duplicate

pas zakelijke mutatie toe

commit
ack bericht

Dat patroon is saai. Goed. Idempotentie saai moet zijn.

Het is ook meestal beter dan te proberen te vertrouwen op broker-marketingtermen. Kafka’s exactly-once ondersteuning is uitstekend als je binnen Kafka’s eigen transactionele model blijft, maar Kafka’s docs waarschuwen nog steeds dat externe bestemmingen samenwerking nodig hebben. SQS FIFO reduceert dubbele verzendingen alleen binnen zijn 5-minuten dedup-venster. Pub/Sub exactly-once verwacht nog steeds dat de subscriber voortgang bijhoudt en dubbel werk vermijdt wanneer bevestigingen falen.

Exactly-once is meestal een lokale optimalisatie. Idempotente neveneffecten zijn de systeemgarantie.

Koppel dedup aan het outbox-patroon

Als je service lokale status bijwerkt en ook een gebeurtenis publiceert, is idempotente consumptie alleen niet genoeg. Je hebt ook een veilige manier nodig om de gebeurtenis na de lokale transactie te laten committen.

Daarom is het transactionele outbox-patroon van belang. Chris Richardson beschrijkt het basisidee als het schrijven van de gebeurtenis naar een outbox-tabel in dezelfde transactie als de zakelijke update, en deze dan asynchroon publiceren. Debezium zegt dat het outbox-patroon inconsistenties vermijdt tussen de interne status van een service en de gebeurtenissen die door andere services worden geconsumeerd. NServiceBus gaat verder en laat zien hoe outbox-verwerking dubbele binnenkomende berichten dedupeert en zombie-records en ghost-berichten vermijdt.

Dit is de architectuur die ik aanbevel voor services die data bezitten en integratiegebeurtenissen publiceren:

  1. Valideer en persisteer het commando onder een idempotentiesleutel.
  2. Schrijf zakelijke status en outbox-gebeurtenis in één lokale transactie.
  3. Laat CDC of een outbox-dispatcher de gebeurtenis publiceren.
  4. Maak downstream-consumers ook idempotent.

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

Webhooks zijn gewoon berichten met betere branding

Behandel inkomende webhooks exact als berichten van een wantrouwende netwerkrand.

GitHub documenteert dat leveringen in onjuiste volgorde kunnen aankomen, beveelt het gebruik van X-Hub-Signature-256 aan om authenticiteit te verifiëren, en biedt X-GitHub-Delivery aan als de unieke leveringsidentifier. Het merkt ook op dat herleveringen dezelfde leverings-ID hergebruiken.

Dus de architectuur is eenvoudig:

  • verifieer eerst de handtekening
  • gebruik de delivery GUID als dedup-sleutel
  • persisteer ontvangst voordat neveneffecten optreden
  • maak handlers order-aware in plaats van aanname van aankomstvolgorde
  • enqueue het zware werk en retourneer snel

Als je webhook-handler direct naar zakelijke tabellen schrijft voordat het ontvangst registreert, is het niet productieklaar. Het maakt gewoon 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 Activities idempotent te schrijven omdat Activities kunnen worden herhaald na fouten of time-outs. Zijn docs wijzen zelfs op de randcase waar een worker een extern neveneffect succesvol voltooit maar crasht voordat voltooiing wordt gerapporteerd, wat veroorzaakt dat de Activity opnieuw loopt. Temporal suggereert ook het gebruik van een combinatie van Workflow Run ID en Activity ID als een stabiele idempotentiesleutel wanneer downstream-services worden aangeroepen. Als je dit toepast in service-orchestratie, behandelt Go Microservices voor AI/ML Orchestratie de bredere workflow trade-offs.

Dat is precies het juiste mentale model. Een workflow-engine kan uitvoeringshistorie behouden en retries coördineren. Het kan niet retroactief een kaart debeteren of een e-mail onzenden tenzij je applicatie het idempotente stappen en idempotente compensaties geeft.

Dit geldt ook voor sagas. Temporal’s eigen saga-richtlijnen beschrijven compenserende acties die lopen wanneer een stap faalt. Die compensaties moeten ook idempotent zijn. Als “betaal terug” tweemaal loopt, heb je misschien de oorspronkelijke bug opgelost door een nieuwe te creëren.

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

Hoe test je idempotentie voordat je in productie gaat

De meeste teams testen happy paths en doen dan verrast wanneer retries gebeuren. Dat is niet genoeg. Voor Go-teams behandelt Testen van Concurrente Go-code met testing/synctest hoe je snelle, deterministische tests kunt schrijven voor retry-loops en context-deadline-gedrag zonder te slapen door kunstmatige vertragingen.

Je moet geautomatiseerde tests hebben voor ten minste deze gevallen:

  • de server commit de mutatie maar de bereikt nooit de client
  • twee identieke verzoeken racen met dezelfde idempotentiesleutel
  • dezelfde sleutel wordt hergebruikt met een andere payload
  • een consumer commit zijn database-werk en crasht voordat ack
  • een webhook wordt herhaald met dezelfde delivery-ID
  • een outbox-dispatcher publiceert dezelfde gebeurtenis meer dan één keer
  • een Workflow Activity voltooit de externe aanroep en crasht voordat voltooiing wordt gerapporteerd
  • een idempotentie-record verloopt en een echte late retry arriveert

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

Ik zou nog één missiedrill toevoegen. Verifieer dat de herhaalde respons semantisch equivalent is aan het eerste resultaat. AWS bespikt late aankomende retries en betoogt voor responsen die de oorspronkelijke betekenis behouden, zelfs na wijzigingen in de onderliggende status. Dat is het verschil tussen “er heeft geen extra neveneffect plaatsgevonden” en “de aanroeper heeft nog steeds een consistente contract.”

Eigen meningige regels die echte systemen redden

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

Ten eerste, idempotentiesleutels behoren tot zakelijke intentie, niet transportpogingen.

Ten tweede, scope elke sleutel per tenant en operatie. Globale sleutelruimtes zijn hoe ongerelateerde verzoeken botsen.

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

Ten vierde, verwerp dezelfde-sleutel verschillende-payload retries. Stripe en AWS doen dit om goede redenen.

Ten vijfde, bewaar sleutels voor de volledige replay-horizon van het zakelijke proces, niet voor het kortste wachtrijvenster.

Ten zesde, koppel producers met een outbox en consumers met bericht-ID-tracking. Een kant zonder de andere is een half ontwerp.

Ten zevende, propageer dezelfde operatie-identiteit downstream wanneer de zakelijke actie hetzelfde is. AWS beveelt expliciet aan om de idempotentietoken door de verwerkingsketen te laten gaan.

Ten achtste, neem nooit aan dat exactly-once marketing de behoefte aan idempotente neveneffecten verwijdert.

Als dat streng klinkt, goed. Idempotentie is waar optimistische architectuur de productierealiteit ontmoet. Je hebt niet overal complexiteit nodig. Maar waar dubbele neveneffecten geld, status of vertrouwen zouden schaden, zou idempotentie een eerste-klas onderdeel van het contract moeten zijn.

Deze dezelfde regels zijn direct van toepassing op achtergronds AI-agents. Polling-agents die taken claimen, meldingen uitzenden of tool-calls triggeren, dedup-sleutels en idempotente claim-protocollen net zo goed nodig hebben als betaal-API’s. Voor hoe het claim-en-dedupe-patroon werkt binnen productie AI-assistenten, zie Polling Agents in AI-assistenten: 11 Implementatiepatronen.

Abonneren

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