Idempotens i distribuerade system som faktiskt fungerar
Stoppa dubbla sideffekter
Idempotens i distribuerade system är den egenskap som räddar dig när nätverket ljuger, köen gör om försöket, klienten paniker och operatören spelar upp om händelser. I produktionssystem är dubbel leverans normalt. Dubbla biverkningar är buggen.
HTTP definierar en idempotent metod som en där flera identiska begäran har samma avsedda effekt på servern som en enda begäran. Därför är PUT, DELETE och säkra metoder idempotenta enligt protokollets semantik och kan återförsökas automatiskt efter ett kommunikationsfel.

Den definitionen är användbar, men den är inte tillräcklig. I verkliga arkitekturer är idempotens inte ett trivia-svar om HTTP. Det är en affärs garanti. Om en kund klickar på “betala” en gång, får du inte debitera två gånger för att en timeout inträffade mellan commit och svar. Om en worker uppdaterar lagret och kraschar innan den bekräftar meddelandet, får du inte minska lagret två gånger för att brokeren levererade meddelandet igen. Det är måttet.
Mistaken 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 den bredare ramen för hur dessa integrationsbeslut passar servicegränser och persistensavvägningar, börja med App Architecture in Production: Integration Patterns, Code Design, and Data Access.
Var dubletter kommer ifrån i produktion
Dubletter dyker inte upp för att team är slarviga. De dyker upp för att distribuerade system gör om försök, sorterar om och spelar upp händelser.
En klient kan skicka en skapningsbegäran, servern kan commita den, och svaret kan ändå försvinna på linjen. 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-least-once-leverans (leverans minst en gång) innebär att en konsument kan anropas upprepade gånger för samma meddelande, och en handler kan uppdatera databasen framgångsrikt men misslyckas innan bekräftelse, vilket får brokeren att leverera samma meddelande igen.
Webhooks är inget undantag. GitHub säger att webhook-leveranser kan komma i fel ordning, misslyckade leveranser levereras inte automatiskt om, och varje leverans bär på en unik X-GitHub-Delivery-GUID som du bör använda när du skyddar mot uppspelning. För en praktisk arkitektursyn på chattändpunkter som interaktionsgränser, se Chat Platforms as System Interfaces in Modern Systems.
Även system som marknadsför starkare garantier lämnar fortfarande arbetet åt dig. Kafka kan förhindra dubbla poster i Kafka-loggar med idempotenta producenter och kan erbjuda exactly-once-leverans (exakt en gång) för read-process-write-flöden som stannar inom Kafka med transaktioner och read_committed-konsumenter. Men Kafkas egen design-dokumentation är tydlig med att externa system fortfarande kräver samordning med offset och utdata. Google Cloud Pub/Sub:s exactly-once-leverans är begränsad till pull-prenumerationer, inom en molnregion, och kräver fortfarande att klienter spår behandlingsframsteg tills bekräftelse lyckas.
Min opinionerade sammanfattning är enkel. Anta att transporten kommer att göra om försöket. Anta att operatörer kommer att spela upp händelser. Anta att webhooks kommer sent. Designa skrivvägen så att en upprepad intent inte kan skapa en andra affärseffekt.
Det API-avtal jag faktiskt litar på
Hur idempotensnycklar förhindrar dubbla API-begäran
Det enda API-avtal jag litar på för muterande operationer är anropare-försedd intent plus serverbaserad persistens.
AWS rekommenderar en anropare-försedd begäranidentifierare och varnar för att tjänsten måste atomiskt registrera idempotens-token tillsammans med den muterande arbetet. Stripe lagrar den första statuskoden och svarskroppen för en nyckel, jämför senare parametrar med den ursprungliga begäran och returnerar samma resultat för återförsök. PayPal använder PayPal-Request-Id på stödda POST-API:er och returnerar den senaste statusen för den tidigare begäran med samma header.
Det leder till ett praktiskt avtal:
- Klienten genererar en idempotensnyckel för en affärsoperation.
- Servern scoper den nyckeln efter tenant och operationsnamn.
- Servern lagrar en begäran-hash så att samma nyckel inte kan återanvändas för en annan payload.
- Servern registrerar tillstånd som
pending(pågående),completed(slutförd) ellerfailed(misslyckad). - Återförsök med samma nyckel returnerar antingen det lagrade utfallet eller en stabil pekare till det.
- Återförsök med samma nyckel och en annan payload misslyckas med tydligt fel.
Det finns ett IETF-utkast till Idempotency-Key-header, 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 kommando återanvända samma nyckel. Om användaren menar “lägg en andra order”, måste det använda en annan nyckel.
En begäran-ID är för tracing. En idempotensnyckel är för korrekthet. Om du blandar ihop dem, ser dina dashboards snygga ut medan ditt pengar rör sig 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-handler emitterar en ny downstream-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 ruttens namn 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ättning eller upsert-operation. Använd POST när du skapar kommandon eller åtgärder. Men för 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 Integration Patterns for Alerts and Workflows och Discord Integration Pattern for Alerts and Control Loops. Dolda biverkningar är där arkitektur 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 rensas efter minst 24 timmar. PayPal säger att retention är API-specifik och ger exempel som kan varar upp till 45 dagar. Amazon SQS FIFO deduplicerar endast inom ett 5-minuters fönster. GitHub behåller senaste leveranser i 3 dagar för manuell omleverans. Dessa siffror är vilt 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 in i din affärslag.
Behåll idempotensregister i minst maximum av dessa fönster:
- klientens återförsökshorisont
- köns omleveranshorisont
- webhooks uppspelningshorisont
- operatörens uppspelningshorisont
- settlement- eller kompenseringshorisont för pengar-rörande operationer
För betalningar, bokningar och provisioning innebär det ofta timmar eller dagar, inte minuter.
AWS pekar också ut 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 otillförlitliga. Lagra inte blindt hela begäranpayload som dedup-registrering för varje begäran, eftersom det skadar prestanda och skalbarhet. Lagra en normaliserad begäran-hash plus den minsta svarstillståndet du behöver för att spela upp säkert. Om du måste reproducera det första svaret byte för byte, lagra den kanoniska svarskroppen 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. Unikets begränsningar (unique constraints) säkerställer 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 uniketsstrid. PostgreSQL dokumenterar också att ON CONFLICT DO UPDATE garanterar ett atomiskt insert-or-update-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 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 konkurrens kan två begäran passera kontollen och båda skapa biverkningen. En uniketsbegränsning är inte valfri. Det är mekanismen som förvandlar din arkitektur från optimistisk folktron till något du kan bevisa under last.
Här är regeln jag använder i granskningar. Om dedup-besluset 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 processade meddelande-ID:n i samma databastransaktion som affärsuppdateringen. Chris Richardson beskriver PROCESSED_MESSAGES-tabellapprochen direkt, med en primär nyckel på subscriber och meddelande-ID så att dubletter misslyckas rent och kan ignoreras.
Många team kallar den explicita processed_messages-lagret för en inbox-tabell. Etiketten är mindre viktig ä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 exactly-once-stöd är utmärkt när du stannar inom Kafkas egen transaktionsmodell, men Kafkas dokumentation varnar fortfarande för att externa destinationer behöver samarbete. SQS FIFO minskar dubbla sändningar endast inom sitt 5-minuters dedup-fönster. Pub/Sub:s exactly-once förväntar sig fortfarande att prenumerenten spår framsteg och undviker dubbelarbete när bekräftelser misslyckas.
Exactly-once är oftast en lokal optimering. Idempotenta biverkningar är systemgarantin.
Kombinera dedup med outbox-mönstret
Om din tjänst uppdaterar lokal tillstånd 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 har commitats.
Det är varför det transaktionella outbox-mönstret är viktigt. 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 tillstånd och händelserna som andra tjänster konsumerar. NServiceBus går längre och visar hur outbox-behandling deduplicerar inkommande meddelanden och undviker zombie-poster och fantommeddelanden.
Detta är arkitekturen jag rekommenderar för tjänster som äger data och publicerar integrationshändelser:
- Validera och persista kommandot under en idempotensnyckel.
- Skriv affärstillstånd och outbox-händelse i en lokal transaktion.
- Låt CDC eller en outbox-dispatcher publicera händelsen.
- Gör downstream-konsumenter idempotenta också.
Outbox tar bort behovet av idempotenta konsumenter? Nej. Det tar bort behovet av att låtsas att en databas-commit 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 opålitlig nätverkskant.
GitHub dokumenterar att leveranser kan komma i fel ordning, rekommenderar att använda X-Hub-Signature-256 för att verifiera autentisitet, och tillhandahåller X-GitHub-Delivery som den unika leveransidentifieraren. Det noteras också att omleveranser återanvänder samma leverans-ID.
Så arkitekturen är rakt:
- verifiera signaturen först
- använd leverans-GUID som dedup-nyckel
- persista kvittot innan biverkningar
- gör hanterare ordningsmedvetna istället för att anta ankomstordning
- köa det tunga arbetet och returnera snabbt
Om din webhook-handler skriver direkt till affärstabeller innan den registrerar kvittot, är den inte produktionsklar. Den är bara snabbare på att göra dubbla misstag.
Sagor och arbetsflödese motorer behöver fortfarande idempotens
Sagor och hållbara arbetsflödese motorer tar inte bort problemet. De gör det synligt.
Temporal rekommenderar att skriva Activities (aktiviteter) för att vara idempotenta eftersom Activities kan återförsökas efter fel eller timeouts. Dess dokumentation pekar till och med ut grannfallet där en worker slutför en extern biverkning framgångsrikt men kraschar innan den rapporterar slutförande, vilket får Activity att köra igen. Temporal föreslår också att använda en kombination av Workflow Run ID och Activity ID som en stabil idempotensnyckel när man anropar downstream-tjänster. Om du tillämpar detta i serviceorkestrering, Go Microservices for AI/ML Orchestration täcker de bredare arbetsflödesavvägningarna.
Det är exakt rätt mentala modell. En arbetsflödese motor kan bevara exekveringshistorik och samordna återförsök. Den kan inte retroaktivt avdebitera ett kort eller osända ett e-postmeddelande om inte din applikation ger den idempotenta steg och idempotent kompensation.
Samma gäller för sagor. Temporals egen sagoguidance 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 kommandohandler och varje kompensation som rör den yttre världen bör antingen vara naturligt idempotent eller bära en riktig idempotensnyckel till downstream-systemet.
Hur man testar idempotens innan produktion
De flesta team testar happy paths och verkar sedan förvånade när återförsök händer. Det är inte tillräckligt.
Du bör ha automatiserade tester för minst dessa fall:
- servern commitar mutationen men svaret når aldrig klienten
- två identiska begäran tävlar med samma idempotensnyckel
- samma nyckel återanvänds med en annan payload
- en konsument commitar sitt databasarbete och kraschar innan ack
- en webhook spelas upp 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örandet rapporteras
- en idempotenspost går ut och en genuin sen återförsök ankommer
AWS rekommenderar explicit omfattande testsuites som inkluderar lyckade begäran, misslyckade begäran och dubbla begäran. Det rådet är vardagligt och absolut korrekt.
Jag skulle lägga till ett misslyckande drill till. Verifiera att det uppspelade 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 underliggande tillstånd har ändrats. Det är skillnaden mellan “ingen extra biverkning hände” och “anroparen har fortfarande ett konsistent avtal.”
Opinionerade regler som räddar verkliga system
Här är reglerna jag skulle genomföra i en arkitektursgranskning.
Först, idempotensnycklar tillhör affärsintent, inte transportförsök.
För det andra, scoper varje nyckel efter tenant och operation. Globala nyckelutrymmen är hur orelaterade begäran kolliderar.
För det tredje, persista dedup-beslutet atomiskt med mutationen. Om det inte är sant, är designen fel.
För det fjärde, avvisa återförsök med samma nyckel men annan payload. Stripe och AWS gör detta av goda skäl.
För det femte, behåll nycklar för hela uppspelningshorisonten för affärsprocessen, inte för det kortaste köfönstret.
För det sjätte, kombinera producenter med outbox och konsumenter med meddelande-ID-spårning. En sida utan den andra är halva designen.
För det sjunde, propagera samma operationsidentitet downstream när affärsåtgärden är densamma. AWS rekommenderar explicit att skicka idempotens-token längs bearbetningskedjan.
För det åttonde, anta aldrig att exactly-once-marknadsföring tar bort behovet av idempotenta biverkningar.
Om det låter strikt, bra. Idempotens är där optimistisk arkitektur möter produktionsverkligheten. Du behöver inte komplexitet överallt. Men överallt där dubbla biverkningar skulle skada pengar, tillstånd eller förtroende, bör idempotens vara en förstaklass-del av avtalet.