Idempotens i distribuerade system som faktiskt fungerar

Sluta med dubbla biverkningar

Sidinnehåll

Idempotens i distribuerade system är den egenskap som räddar dig när nätverket ljuger, kön försöker igen, klienten panikar och operatören trycker på återuppspelning. I produktions-system är dubbel leverans normalt. Dubbla bieffekter är felet.

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. Det är därför PUT, DELETE och säkra metoder är idempotenta i protokollsemantik och kan försökas igen automatiskt efter ett kommunikationsfel.

integration message flow: idempotency

Den definitionen är användbar, men den räcker inte. I reella arkitekturer är idempotens inte ett enkelt HTTP-svar. 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 inträffade ett timeout mellan transaktionen och svaret. Om en arbete uppdaterar lagerstatusen och kraschar innan meddelandet bekräftas, får du inte dra bort lagret två gånger bara för att brokern levererade meddelandet igen. Det är nivån.

Det misstag jag ser gång på gång är att behandla idempotens som en transportfunktion istället för en systemegenskap. Ködeduplikation, HTTP-verb och klientupprepningar hjälper, men räddar inte en design som låter samma affärsintent skapa en andra bieffekt. Om du vill ha den bredare ramen för hur dessa integrationsbeslut passar in i tjänstegränser och hållbarhetsavvägningar, börja med App Architecture in Production: Integration Patterns, Code Design, and Data Access.

Varifrån duor komer i produktion

Dubbletter dyker inte upp för att team är slarviga. De dyker upp för att distribuerade system försöker igen, ordnar om och spelar tillbaka.

En klient kan skicka en skapa-förfrågan, servern kan commit:a den, och svaret kan fortfarande försvinna på ledningen. Det är exakt därför HTTP åtskiljer idempotenta metoder och varför betalnings-API:er som Stripe och PayPal exponerar explicita idempotensmekanismer för osäkra metoder som POST.

Meddelandebrokar gör problemmet ännu tydligare. “At-least-once-leverans” betyder 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 brokern att leverera samma meddelande igen.

Webhooks är inget undantag. GitHub säger att webhook-leveranser kan komma i fel ordning, misslyckade leveranser inte automatiskt försöker igen, och varje leverans bär ett unikt X-GitHub-Delivery-GUID som du bör använda för att skydda dig mot återuppspelning. För en praktisk arkitektursvy av chattändpunkter som interaktionsgränser, se Chat Platforms as System Interfaces in Modern Systems.

Även system som marknadsför starkare garantier lämnar fortfarande jobb för dig att göra. Kafka kan förhindra dubbletter i Kafka-loggar med idempotenta producenter och kan erbjuda “exactly-once-leverans” för read-process-write-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/Subs “exactly-once-leverans” är begränsad till pull-prenumerationer, inom en molnregion, och kräver fortfarande att klienter spårar bearbetningsframsteg tills bekräftelse lyckas.

Min åsiktsfulla sammanfattning är enkel. Antag att transporten försöker igen. Antag att operatörer spelar tillbaka. Antag att webhooks kommer sent. Designa skrivvägen så att en upprepad intent inte kan skapa en andra affärseffekt. Felhantering är nära besläktat: hur fel kapslas in, översätts och klassificeras som återförsökbara respektive icke-återförsökbara är en del av samma gränsdisciplin — Go Error Handling Architecture: Boundaries and Patterns täcker klassificering av återförsökbara fel, gränsöversättning och sentinel-mönster som låter återförsökslogik fatta sund beslut. När återförsök ständigt träffar en ohälsosam beroende, misslyckas en kretsbrytare vid integrationsgränsen snabbt innan återförsöksstormar förstärker dubbelarbete.

API-kontraktet jag faktiskt litar på

Hur förhindrar idempotensnycklar dubbletter i API-förfrågningar

Det enda API-kontrakt jag litar på för mutationer är anroparens intent plus server-sidig hållbarhet.

AWS rekommenderar en anropare-erhållen förfrågansidentifierare och varnar för att tjänsten måste atomiskt registrera idempotensnyckeln tillsammans med mutationsarbetet. Stripe sparar den första statuskoden och svarsbodyn 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å understödda POST-API:er och returnerar senaste statusen för föregående förfrågan med samma huvud.

Det leder till ett praktiskt kontrakt:

  1. Klienten genererar en idempotensnyckel för en affärsoperation.
  2. Servern begränsar den nyckeln per tenant och operationsnamn.
  3. Servern sparar en förfråganshash så att samma nyckel inte kan återanvändas för en annan payload.
  4. Servern registrerar tillstånd såsom pending, completed eller failed.
  5. Återförsök med samma nyckel returnerar antingen det sparade 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 Idempotency-Key-huvudförslag, 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 huvudnamnet fortfarande mycket användbart som en de facto-konvention, men du bör dokumentera kontraktet 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öksräknare. Om användaren menar “skapa order 123 en gång”, måste varje återförsök för samma kommando återanvända samma nyckel. Om användaren menar “lägg till en andra order”, måste det använda en annan nyckel.

En förfrågans-ID är för spårning. En idempotensnyckel är för korrekthet. Om du blandar ihop dessa ser dina dashboardar trevliga ut medan din 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 idempotenta semantik. Men om din PUT-hanterare skickar ett nytt nedströmsmeddelande, skickar ett e-postmeddelande vid varje återförsök, eller debiterar en extern leverantör igen, har din implementation åsidosatt affärskontraktet ä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 verkligen passar en fullständig ersättnings- eller upsert-typ av operation. Använd POST när du skapar kommandon eller åtgärder. Men för alla mutationer som kan försökas igen över nätverksgränser, dokumentera ett explicit idempotenskontrakt. Om dina mutationsåtgärder utlöses från chattarbetsflöden, gäller samma kontrakt i Slack Integration Patterns for Alerts and Workflows och Discord Integration Pattern for Alerts and Control Loops. Dolda bieffekter är där arkitekturen dör.

Hur länge ska en idempotensnyckel lagras

Längre än ditt transportteam vill.

Stripe säger att nycklar kan tas bort efter minst 24 timmar. PayPal säger att behållningstiden är API-specifik och ger exempel som kan vara upp till 45 dagar. Amazon SQS FIFO deduplicerar endast inom ett 5-minutersfönster. GitHub håller nyligen leveranser i 3 dagar för manuell återleverans. Dessa nummer är helt olika eftersom rätt behållningstid är ett affärsbeslut, inte en protokollstandard.

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 max av dessa fönster:

  • klientens återförsökshorisont
  • köns redrive-horisont
  • webhook-s återuppspelningshorisont
  • operatörens återuppspelningshorisont
  • avstämnings- eller kompenseringshorisont för pengarna-flyttande operationer

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

AWS lyfter också ut två anti-mönster som jag helt håller med om. Använd inte tidstämplar som nyckel, eftersom klockavvikelse och kollisioner gör dem opålitliga. Lagra inte blindt hela förfråganspayloads som dedupliceringspost för varje förfrågan, eftersom det skadar prestanda och skalbarhet. Lagra en normaliserad förfråganshash plus det minimuma svarsstate du behöver för att spela tillbaka säkert. Om du måste reproducera den första svarsbajten ordagrant, lagra den kanoniska svarsbodyn på samma sätt som Stripe gör.

Databasmö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 försäkrar 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 unikhetskränkning. PostgreSQL dokumenterar också att ON CONFLICT DO UPDATE garanterar ett atomiskt insert-eller-uppslututfall under konurrens.

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 atomiciteten. 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 konurrens kan två förfrågningar passera kontrollen och båda skapa bieffekten. En unik begränsning är inte valfri. Det är mekanismen som gör din arkitektur från optimistisk folktro till något du kan bevisa under belastning.

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

Meddelanden, händelser och webhooks behöver sin egen gräns

Hur konsumenter hanterar dubbletter i 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ärnyckel på prenumerant och meddelande-ID så att dubbletter misslyckas städat och kan ignoreras.

Många team kallar den explicita processed_messages-lagringen för en inbox-tabell. Etiketten spelar mindre roll än regeln. Mottagaren måste hållbarhetsbevisa 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å vanligtvis bättre än att försöka luta sig på brokmärkningstermer. Kafkas “exactly-once”-stöd är utmärkt när du stannar inom Kafkas egna transaktionsmodell, men Kafkas dokument varnar fortfarande för att externa destinationer behöver samarbete. SQS FIFO minskar skickade dubbletter endast inom dess 5-minuters dedupliceringsfönster. Pub/Subs “exactly-once” förväntar sig fortfarande att prenumeranten spårar framsteg och undviker dubbelarbete när bekräftelser misslyckas.

“Exactly-once” är vanligtvis en lokal optimering. Idempotenta bieffekter är systemgarantin.

Para deduplicering med outbox-mönstret

Om din tjänst uppdaterar lokalt state och också publicerar en händelse, räcker idempotent konsumtion inte. Du behöver också ett säkert sätt att få ut händelsen efter att den lokala transaktionen har commit:ats.

Det är av den anledningen att transactional outbox-mönstret betyder något. Chris Richardson beskriver grundidé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 state och händelserna som konsumeras av andra tjänster. NServiceBus går längre och visar hur outbox-behandling deduplicerar inkommande meddelanden och undviker zombie-poster och spökmeddelanden.

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ärsstate och outbox-händelse i en lokal transaktion.
  3. Låt CDC eller en outbox-dispatcher publicera händelsen.
  4. Gör nedströmskonsumenter idempotenta också.

Outbox tar inte bort behovet av idempotenta konsumenter. Den tar bort behovet av att låtsas att en databaskommitt och en brokernpublicering kan vara en magisk distribuerad transaktion när de vanligtvis inte kan det.

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

Behandla inkommande webhooks exakt som meddelanden från ett otryggt nätverkskanthål.

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 noterar också att återleveranser återanvänder samma leverans-ID.

Så arkitekturen är rakt på sak:

  • verifiera signaturen först
  • använd leverans-GUID:n som dedupliceringsnyckel
  • hållbarhetsbevisa mottagning innan bieffekter
  • göra hanterare ordningsmedvetna snarare än att anta ankomstordning
  • köa det tunga arbetet och returnera snabbt

Om din webhook-hanterare skriver direkt till affärsbord innan den registrerar mottagning är den inte produktionsklar. Den är bara snabbare vid att göra dubblettmisstag.

Sagor och arbetsflödesmotorer behöver fortfarande idempotens

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

Temporal rekommenderar att skriva Activities så att de är idempotent eftersom Activities kan försökas igen efter misslyckanden eller timeout. Deras dokument lyfter ut gränsfallet där en arbetare slutför en extern bieffekt framgångsrikt men kraschar innan den rapporterar slutförande, 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 när du anropar nedströms tjänster. Om du applicerar detta i tjänstekoordination, Go Microservices for AI/ML Orchestration täcker de bredare arbetsflödesavvägningarna.

Det är exakt rätt mental modell. En arbetsflödesmotor kan bevara exekveringshistorik och koordinera återförsök. Den kan inte retroaktivt debitera ett kort eller skicka bort ett e-postmeddelande om din applikation inte ger den idempotenta steg och idempotenta kompensationer.

Samma sak gäller för sagor. Tempals egna saga-rådgivning beskriver kompenserande åtgärder som körs när ett steg misslyckas. Dessa kompensationer måste också vara idempotent. Om “refund payment” körs två gånger kan du ha löst det ursprungliga felet genom att skapa ett nytt.

Min regel här är brutal och enkel. Varje Activity, varje kommandohanterare, och varje kompensation som vidrör den yttre världen bör antingen vara naturligt idempotent eller bära en riktig idempotensnyckel till nedströmsystemet.

Hur testar man idempotens innan produktion

De flesta team testar happy paths och verkar sedan förvånade när återförsök inträffar. Det räcker inte. För Go-team, Testing Concurrent Go Code with testing/synctest täcker hur man skriver snabba, deterministiska tester för återförsöksloopar och context-deadline-beteende utan att sova genom konstlade fördröjningar.

Du bör ha automatiserade tester för åtminstone dessa fall:

  • servern commit:ar mutationen men svaret når aldrig klienten
  • två identiska förfrågningar racer med samma idempotensnyckel
  • samma nyckel återanvänds med en annan payload
  • en konsument commit:ar sitt databasarbete och kraschar innan ack
  • en webhook spelas tillbaka med samma leverans-ID
  • en outbox-dispatcher publicerar samma händelse mer än en gång
  • en workflow Activity slutför det externa anropet och kraschar innan slutförande rapporteras
  • en idempotenspost löper ut och ett ärligt senare återförsök anländer

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

Jag skulle lägga till en ytterligare misslyckandedrill. Verifiera att det återspelade svaret är semantiskt ekvivalent med det första resultatet. AWS diskuterar sent ankomna återförsök och argumenterar för svar som bevarar den ursprungliga betydelsen även efter att underliggande state har ändrats. Det är skillnaden mellan “inga extra bieffekter hände” och “anroparen har fortfarande ett konsekvent kontrakt.”

Åsiktsfulla regler som räddar reella system

Här är de regler jag skulle upprätthålla i en arkitekturggranskning.

För det första, idempotensnycklar tillhör affärsintent, inte transportförsök.

För det andra, begränsa varje nyckel per tenant och operation. Globala nyckelutrymmen är hur orelaterade förfrågningar kolliderar.

För det tredje, hållbarhetsbevisa dedupliceringsbeslutet atomiskt med mutationen. Om det inte stämmer är designen fel.

För det fjärde, avslå återförsök med samma nyckel men olika payload. Stripe och AWS gör båda detta av goda skäl.

För det femte, behåll nycklar för hela affärsprocessens återuppspelningshorisont, inte för det kortaste köfönstret.

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

För det sjunde, propagera samma operationsidentitet nedströms när affärsaktionen är densamma. AWS rekommenderar explicit att skicka idempotensnyckeln längs bearbetningskedjan.

För det åttonde, antag aldrig att “exactly-once”-marknadsföring tar bort behovet av idempotenta bieffekter.

Om det låter strikt, bra. Idempotens är där optimistisk arkitektur möter produktionsrealitet. Du behöver inte komplexitet överallt. Men var som helst dubbletta bieffekter skulle skada pengar, state eller förtroende, bör idempotens vara en förstaklassad del av kontraktet.

Dessa samma regler gäller direkt för bakgrunds AI-agenter. Pollande agenter som hävdar uppgifter, avger notifieringar eller utlöser verktygsanrop behöver dedupliceringsnycklar och idempotenta hävdandeprotokoll lika mycket som betalnings-API:er gör. För hur claim-and-dedupe-mönstret fungerar inne i produktions-AI-assistanser, se Polling Agents in AI Assistants: 11 Implementation Patterns.

Användbara Länkar

Prenumerera

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