Idempotens i distribuerade system som faktiskt fungerar

Stoppa dubbla biverkningar

Sidinnehåll

Idempotens i distribuerade system är den egenskap som räddar dig efter att nätverket har lurat, köer har återförsökt, klienten har panikerat och operatören har spelat om. I produktionsmiljöer är dubbelleverans normalt. Dubbla biverkningar är buggen.

HTTP definierar en idempotent metod som en där flera identiska förfrågningar har samma avsedda effekt på servern som en enda förfrågan. Därför är PUT, DELETE och säkra metoder idempotenta enligt protokollsemantik och kan återförsökas automatiskt efter ett kommunikationsfel.

integrationsmeddelandeflöde: idempotens

Den definitionen är användbar, men den räcker inte. I verkliga arkitekturer är idempotens inte ett trivia-svar om HTTP. Det är en affärsgaranti. Om en kund trycker på “betala” en gång, får du inte debitera två gånger bara för att det uppstod en timeout mellan åtagande och svar. Om en arbetstjänst uppdaterar lagret och kraschar innan den bekräftar meddelandet, får du inte minska lagret två gånger bara för att broker levererade samma meddelande igen. Det är kraven.

Mistaket som jag ser gång på gång är att behandla idempotens som en transportfunktion istället för en systemegenskap. Kö-deduplicering, HTTP-verb och klientåterförsök hjälper till, men ingen av dem räddar en design som låter samma affärsintent skapa en andra biverkning. Om du vill ha en bredare ram för hur dessa integrationsbeslut passar in i tjänstegränser och kompromisser vid persistens, börja med App-arkitektur i produktion: Integrationsmönster, koddesign och dataåtkomst.

Var duplicat kommer ifrån i produktion

Duplicat dyker inte upp för att team är slarviga. De dyker upp för att distribuerade system återförsöker, sorterar om och spelar om.

En klient kan skicka en skapningsförfrågan, servern kan åtaga den, och svaret kan fortfarande försvinna på tråden. Det är exakt varför HTTP skiljer på idempotenta metoder och varför betalnings-API:er som Stripe och PayPal exponerar explicita idempotensmekanismer för osäkra metoder som POST.

Meddelandebroker gör problemet ännu mer uppenbart. At-minst-en-gång-leverans innebär att en konsument kan anropas upprepade gånger för samma meddelande, och en hanterare kan uppdatera databasen framgångsrikt men misslyckas innan bekräftelse, vilket får broker att leverera samma meddelande igen.

Webhooks är inget undantag. GitHub säger att webhook-leveranser kan komma i fel ordning, misslyckade leveranser återlevereras inte automatiskt, och varje leverans bär en unik X-GitHub-Delivery-GUID som du bör använda för att skydda mot uppspelning. För en praktisk arkitektursyn på chattändpunkter som interaktionsgränser, se Chattplattformar som systemgränssnitt i moderna system.

Även system som reklamerar starkare garantier lämnar fortfarande arbete åt dig. Kafka kan förhindra dubbla poster i Kafka-loggar med idempotenta producenter och kan erbjuda exakt-en-gång-leverans för läs-process-skriv-flöden som stannar inom Kafka med transaktioner och read_committed-konsumenter. Men Kafkas egna design-dokument är tydliga med att externa system fortfarande kräver samordning med offset och utdata. Google Cloud Pub/Sub:s exakt-en-gång-leverans är begränsad till dragprenumerationer, inom en molnregion, och kräver fortfarande att klienter spår bearbetningsframsteg tills bekräftelse lyckas.

Min åsiktsfulla sammanfattning är enkel. Anta att transporten kommer att återförsöka. Anta att operatörer kommer att spela om. Anta att webhooks kommer sent. Designa skrivvägen så att en upprepad intent inte kan skapa en andra affärseffekt. Felhantering är närbesläktad: hur fel kapslas in, översätts och klassificeras som återförsöksbara kontra icke-återförsöksbara är en del av samma gränsdisciplin — Go-felhanteringsarkitektur: Gränser och mönster täcker klassificering av återförsöksbara fel, gränstranslation och de mönster som låter återförsökslogik fatta välgrundade beslut. När återförsök fortsätter att träffa en ohälsosam beroende, misslyckas en strömbrytare vid integrationsgränsen snabbt innan återförsökstormar förstärker dubbelarbete.

Det API-avtal jag faktiskt litar på

Hur idempotensnycklar förhindrar dubbla API-förfrågningar

Det enda API-avtal jag litar på för muterande operationer är kalleleverantörens intent plus serverbaserad persistens.

AWS rekommenderar en kalleleverantörsidentifierare och varnar för att tjänsten måste atomiskt registrera idempotens-token tillsammans med den muterande arbetet. Stripe lagrar första statuskoden och svarsbröd för en nyckel, jämför senare parametrar med den ursprungliga förfrågan och returnerar samma resultat för återförsök. PayPal använder PayPal-Request-Id på stödda POST-API:er och returnerar senaste status för den tidigare förfrågan med samma header.

Det leder till ett praktiskt avtal:

  1. Klienten genererar en idempotensnyckel för en affärsoperation.
  2. Servern scoped nyckeln efter tenant och operationsnamn.
  3. Servern lagrar en förfrågningshash så att samma nyckel inte kan återanvändas för en annan payload.
  4. Servern registrerar status som pending, completed eller failed.
  5. Återförsök med samma nyckel returnerar antingen det lagrade utfallet eller en stabil pekare till det.
  6. Återförsök med samma nyckel och en annan payload misslyckas högljutt.

Det finns ett IETF-utkast för Idempotency-Key-headern, men per 2026-05-09 listas det fortfarande i IETF Datatracker som ett utgått Internet-Draft snarare än en publicerad RFC. I praktiken är headernamnet fortfarande brett användbart som en de facto-konvention, men du bör dokumentera avtalet i ditt eget API istället för att låtsas att standarden är klar.

Vad ska nyckeln representera? Intent. Inte ett HTTP-försök. Inte en TCP-anslutning. Inte en återförsökssräknare. Om användaren menar “skapa order 123 en gång”, måste varje återförsök för samma kommande återanvända samma nyckel. Om användaren menar “lägg in en andra order”, måste det använda en annan nyckel.

En förfrågnings-ID är för spårning. En idempotensnyckel är för korrekthet. Om du blandar ihop dem, ser dina dashboards snygga ut medan ditt pengar flyttas två gånger.

Varför PUT inte räcker

Nej, HTTP PUT räcker inte för att göra en operation idempotent.

Ja, RFC 9110 ger PUT idempotent semantik. Men om din PUT-hanterare emitterar ett nytt nedströms händelse, skickar ett e-postmeddelande vid varje återförsök eller debiterar en extern leverantör igen, har din implementering brutit mot affärsavtalet även om din routenamn ser respektabel ut.

Val av verb hjälper klienter att förstå intent. Det implementerar inte intent åt dig.

Använd PUT när resursmodellen genuint passar en fullständig ersättning eller upsert-stil operation. Använd POST när du skapar kommandon eller åtgärder. Men för någon mutation som kan återförsökas över nätverksgränser, dokumentera ett explicit idempotensavtal. Om dina muterande åtgärder utlöses från chattflöden, gäller samma avtal i Slack-integrationsmönster för aviseringar och flöden och Discord-integrationsmönster för aviseringar och kontrolllopp. Dolda biverkningar är där arkitekturen går för att dö.

Hur länge bör en idempotensnyckel lagras

Längre än ditt transportteam vill.

Stripe säger att nycklar kan klippas bort efter minst 24 timmar. PayPal säger att retention är API-specifik och ger exempel som kan varaade upp till 45 dagar. Amazon SQS FIFO deduplicerar endast inom ett 5-minutersfönster. GitHub behåller senaste leveranser i 3 dagar för manuell återleverans. Dessa nummer är vildt olika eftersom rätt retentionstid är ett affärsbeslut, inte ett protokollstandardvärde.

Om du bara behåller nycklar i fem minuter för att din kö gör det, designar du inte idempotens. Du kopierar en transportbegränsning till ditt affärslager.

Behåll idempotensposter i minst det maximum av dessa fönster:

  • klientåterförsökshorisont
  • köåterdrivningshorisont
  • webhookåterspelningshorisont
  • operatöråterspelningshorisont
  • settlements- eller kompenseringshorisont för pengar-flyttande operationer

För betalningar, bokningar och provisionering innebär det ofta timmar eller dagar, inte minuter.

AWS pekar också på två anti-mönster som jag helt håller med om. Använd inte tidsstämplar som nyckel, eftersom klockskew och kollisioner gör dem opålitliga. Lagra inte blindt hela förfrågningspayloads som dedup-post för varje förfrågan, eftersom det skadar prestanda och skalbarhet. Lagra en normaliserad förfrågningshash plus den minimala svarsstatus du behöver för att spela om säkert. Om du måste reproducera första svaret byte-for-byte, lagra den kanoniska svarsbröd på samma sätt som Stripe gör.

Databasermönster som gör idempotens verklig

Idempotens blir verklig när persistenslagret kan vinna en race exakt en gång.

PostgreSQL ger dig två kritiska primitiver här. Unika begränsningar genomför unikhet på en eller flera kolumner, och INSERT ... ON CONFLICT låter dig definiera en alternativ åtgärd istället för att misslyckas vid en unikhetsöverträdelse. PostgreSQL dokumenterar också att ON CONFLICT DO UPDATE garanterar ett atomiskt insert-eller-updatera-utfall under konkurrens.

Det betyder att ditt idempotenslager vanligtvis bör börja med en tabell som denna:

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)
);

Och hanteringsflödet bör se ut så här:

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

Den viktiga delen är inte syntaxen. Den viktiga delen är atomicitet. Att registrera nyckeln och utföra mutationen måste lyckas eller misslyckas tillsammans. AWS säger detta explicit för API-idempotens, och samma regel gäller i SQL-baserade tjänster.

Gör inte en naiv check-then-act-sekvens som “select key; if missing then insert order”. Under konkurrens kan två förfrågningar passera checken och båda skapa biverkningen. En unik begränsning är inte valfri. Det är mekanismen som förvandlar din arkitektur från optimistisk folktro till något du kan bevisa under belastning.

Här är regeln jag använder i granskningar. Om dedup-beslutet inte skyddas av samma transaktionsgräns som mutationen, har du inte idempotens. Du har hopp.

Meddelanden, händelser och webhooks behöver sina egna gränser

Hur konsumenter hanterar dubbla händelser och meddelanden

För meddelandekonsumenter är det klassiska mönstret fortfarande det rätta. Registrera bearbetade meddelande-ID:n i samma databastransaktion som affärsuppdateringen. Chris Richardson beskriver PROCESSED_MESSAGES-tabellapprochen direkt, med en primär nyckel på prenumerant och meddelande-ID så att duplicat misslyckas rent och kan ignoreras.

Många team kallar den explicita processed_messages-lagret för en inbox-tabell. Etiketten betyder mindre än regeln. Mottagaren måste persistera bevis på att den redan hanterade meddelandet innan ett återförsök säkert kan göra ingenting.

En minimal form ser ut så här:

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)
);

Och konsumentflödet är lika strikt som HTTP-flödet:

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

Det mönstret är tråkigt. Bra. Idempotens bör vara tråkig.

Det är också oftast bättre än att försöka luta sig mot broker-marknadstermer. Kafkas exakt-en-gång-stöd är utmärkt när du stannar inom Kafkas eget transaktionsmodell, men Kafkas dokument varnar fortfarande om att externa destinationer behöver samarbete. SQS FIFO minskar dubbla skickningar endast inom sitt 5-minuters dedup-fönster. Pub/Sub exakt-en-gång förväntar sig fortfarande att prenumeranten spår framsteg och undviker dubbelarbete när bekräftelser misslyckas.

Exakt-en-gång är oftast en lokal optimering. Idempotenta biverkningar är systemgarantin.

Kombinera dedup med outbox-mönstret

Om din tjänst uppdaterar lokal status och också publicerar en händelse, räcker inte idempotent konsumtion ensam. Du behöver också ett säkert sätt att få ut händelsen efter att den lokala transaktionen åtagits.

Det är varför transaktionellt outbox-mönster betyder något. Chris Richardson beskriver den grundläggande idén som att skriva händelsen till en outbox-tabell i samma transaktion som affärsuppdateringen, och sedan publicera den asynkront. Debezium säger att outbox-mönstret undviker inkonsekvenser mellan en tjänsts interna status och händelserna som konsumeras av andra tjänster. NServiceBus går längre och visar hur outbox-bearbetning deduplicerar inkommande meddelanden och undviker zombie-poster och spöksmeddelanden.

Detta är arkitekturen jag rekommenderar för tjänster som äger data och publicerar integrationshändelser:

  1. Validera och persistera kommandot under en idempotensnyckel.
  2. Skriv affärsstatus och outbox-händelse i en lokal transaktion.
  3. Låt CDC eller en outbox-dispatcher publicera händelsen.
  4. Gör nedströmskonsumenter också idempotenta.

Outbox tar inte bort behovet av idempotenta konsumenter. Det tar bort behovet av att låtsas att en databasåtagande och en broker-publicering kan vara en magisk distribuerad transaktion när de oftast inte kan.

Webhooks är bara meddelanden med bättre varumärke

Behandla inkommande webhooks exakt som meddelanden från en o betrodd nätverkskant.

GitHub dokumenterar att leveranser kan komma i fel ordning, rekommenderar användning av X-Hub-Signature-256 för att verifiera autenticitet, och tillhandahåller X-GitHub-Delivery som den unika leveransidentifieraren. Det noteras också att återleveranser återanvänder samma leverans-ID.

Så arkitekturen är rakt fram:

  • verifiera signaturen först
  • använd leverans-GUID som dedup-nyckel
  • persistera mottagning innan biverkningar
  • gör hanterare ordningsmedvetna istället för att anta ankomstsordning
  • köa det tunga arbetet och returnera snabbt

Om din webhook-hanterare skriver direkt till affärstabeller innan den registrerar mottagning, är den inte produktionsklar. Den är bara snabbare på att göra dubbla misstag.

Sagas och flödesmotorer behöver fortfarande idempotens

Sagas och hållbara flödesmotorer tar inte bort problemet. De gör det synligt.

Temporal rekommenderar att skriva Activities som är idempotenta eftersom Activities kan återförsökas efter fel eller timeouts. Dess dokument pekar också på grannfallet där en arbetstjänst fullbordar en extern biverkning framgångsrikt men kraschar innan den rapporterar fullbordande, vilket får Activity att köras igen. Temporal föreslår också att använda en kombination av Workflow Run ID och Activity ID som en stabil idempotensnyckel vid anrop till nedströmstjänster. Om du tillämpar detta i tjänstorkestrering, täcker Go Mikrotjänster för AI/ML Orkestrering de bredare flödeskompromisserna.

Det är exakt rätt mentala modell. En flödesmotor kan bevara exekveringshistorik och samordna återförsök. Den kan inte retroaktivt avdebitera ett kort eller avskicka ett e-postmeddelande om inte din application ger den idempotenta steg och idempotenta kompensationer.

Samma gäller för sagas. Temporals egen sagariktning beskriver kompenserande åtgärder som körs när ett steg misslyckas. Dessa kompensationer måste också vara idempotenta. Om “återbetal betalning” körs två gånger, kan du ha löst den ursprungliga buggen genom att skapa en ny.

Min regel här är brutal och enkel. Varje Activity, varje kommando-hanterare, och varje kompensation som berör den yttre världen bör antingen vara naturligt idempotent eller bära en verklig idempotensnyckel till nedströmssystemet.

Hur man testar idempotens före produktion

De flesta team testar lyckliga vägar och blir sedan förvånade när återförsök händer. Det räcker inte. För Go-team, täcker Testa samtidigt Go-kod med testing/synctest hur man skriver snabba, deterministiska tester för återförsökslopp och kontext-deadline-beteende utan att sova genom artificiella fördröjningar.

Du bör ha automatiserade tester för minst dessa fall:

  • servern åtagit mutationen men svaret når aldrig klienten
  • två identiska förfrågningar tävlar med samma idempotensnyckel
  • samma nyckel återanvänds med en annan payload
  • en konsument åtagit sitt databasarbete och kraschar innan ack
  • en webhook spelas om med samma leverans-ID
  • en outbox-dispatcher publicerar samma händelse mer än en gång
  • en workflow Activity fullbordar det externa anropet och kraschar innan fullbordande rapporteras
  • en idempotenspost löper ut och en äkta sen återförsök kommer

AWS rekommenderar explicit omfattande testsuites som inkluderar lyckade förfrågningar, misslyckade förfrågningar och dubbla förfrågningar. Det rådet är vardagligt och absolut korrekt.

Jag skulle lägga till ett felövning till. Verifiera att det återspelade svaret är semantiskt ekvivalent med det första resultatet. AWS diskuterar sent ankommande återförsök och argumenterar för svar som bevarar den ursprungliga betydelsen även efter att underliggande status har ändrats. Det är skillnaden mellan “ingen extra biverkning hände” och “kallearen har fortfarande ett konsistent avtal.”

Åsiktsfulla regler som räddar verkliga system

Här är reglerna jag skulle införa i en arkitektursgranskning.

Först, idempotensnycklar tillhör affärsintent, inte transportförsök.

Först, scope varje nyckel efter tenant och operation. Globala nyckelutrymmen är hur orelaterade förfrågningar kolliderar.

Tredje, persistera dedup-beslutet atomiskt med mutationen. Om det inte är sant, är designen fel.

Fjärde, avvisa återförsök med samma nyckel och annan payload. Stripe och AWS gör båda detta av goda skäl.

Femte, behåll nycklar för hela återförsökshorisonten för affärsprocessen, inte för det kortaste köfönstret.

Sjätte, para producenter med outbox och konsumenter med meddelande-ID-spårning. En sida utan den andra är halva en design.

Sjätte, propagera samma operationsidentitet nedströms när affärsåtgärden är densamma. AWS rekommenderar explicit att passera idempotens-token längs bearbetningskedjan.

Åttonde, anta aldrig att exakt-en-gång-marknadsföring tar bort behovet av idempotenta biverkningar.

Om det låter strikt, bra. Idempotens är där optimistisk arkitektur möter produktionsverklighet. Du behöver inte komplexitet överallt. Men var dubbler biverkningar skulle skada pengar, status eller förtroende, bör idempotens vara en förstaklassdel av avtalet.

Dessa samma regler gäller direkt för bakgrund AI-agenter. Pollning-agenter som ansöker om uppgifter, emitterar aviseringar eller utlöser verktygsanrop behöver dedupe-nycklar och idempotenta ansökningsprotokoll lika mycket som betalnings-API:er. För hur ansök-och-dedupe-mönstret fungerar inuti produktions AI-assistenter, se Polling-agenter i AI-assistenter: 11 implementeringsmönster.

Användbara länkar

Prenumerera

Få nya inlägg om system, infrastruktur och AI-ingenjörskonst.