Idempotencja w systemach rozproszonej, która naprawdę działa

Zatrzymaj zduplikowane skutki uboczne

Page content

Idempotentyczność w systemach rozproszonych to cecha, która ratuje Cię, gdy sieć kłamie, kolejka wykonuje ponowną próbę, klient panikuje, a operator naciska przycisk odtworzenia. W systemach produkcyjnych dostarczanie duplikatów jest normą. Duplikatne efekty uboczne to błąd.

Protokół HTTP definiuje metodę idempotentną jako taką, w której wiele identycznych żądań ma ten sam zamierzony efekt na serwerze, co jedno żądanie. Dlatego metody PUT, DELETE oraz metody bezpieczne są idempotentne w semantyce protokołu i mogą być automatycznie powtarzane po awarii komunikacji.

integration message flow: idempotency

Ta definicja jest przydatna, ale niewystarczająca. W rzeczywistych architekturach idempotentyczność nie jest odpowiedzią na trywialne pytanie o HTTP. To gwarancja biznesowa. Jeśli klient naciśnie „zapłać” jeden raz, nie możesz pobierać opłaty dwa razy, ponieważ wystąpił limit czasu między zatwierdzeniem a odpowiedzią. Jeśli pracownik aktualizuje stan magazynowy i ulega awarii przed potwierdzeniem odbioru wiadomości, nie możesz zmniejszyć stanów magazynowych dwa razy, ponieważ broker ponownie dostarczył tę samą wiadomość. To jest poziom, na którym musimy grać.

Błąd, który widzę znowu i znowu, to traktowanie idempotentyczności jako cechy transportowej, a nie właściwości systemu. Deduplikacja w kolejce, czasowniki HTTP i ponowne próby klientów pomagają, ale żadne z nich nie uratuje projektu, który pozwala temu samemu zamiarowi biznesowemu stworzyć drugi efekt uboczny. Jeśli chcesz szerszego ujęcia, jak te decyzje integracyjne pasują do granic usług i kompromisów związanych z trwałością danych, zacznij od App Architecture in Production: Integration Patterns, Code Design, and Data Access.

Skąd pochodzą duplikaty w środowisku produkcyjnym

Duplikaty nie pojawiają się z powodu niedbalstwa zespołów. Pojawiają się, ponieważ systemy rozproszone wykonują ponowne próby, zmieniają kolejność i odtwarzają dane.

Klient może wysłać żądanie utworzenia, serwer może je zatwierdzić, a odpowiedź może zniknąć w trakcie transmisji. Dlatego właśnie HTTP rozróżnia metody idempotentne i dlatego interfejsy API płatności, takie jak Stripe i PayPal, udostępniają jawne mechanizmy idempotentyczności dla metod niebezpiecznych, takich jak POST.

Brokery wiadomości sprawiają, że problem staje się jeszcze bardziej oczywisty. Dostarczanie co najmniej raz oznacza, że konsument może być wywoływany wielokrotnie dla tej samej wiadomości, a obsługa może z powodzeniem zaktualizować bazę danych, ale ulec awarii przed potwierdzeniem, co spowoduje, że broker dostarczy tę samą wiadomość ponownie.

Wywołania zwrotne (webhooki) nie są inne. GitHub informuje, że dostawy webhooków mogą przybywać w innej kolejności, nieudane dostawy nie są automatycznie ponawiane, a każda dostawa zawiera unikalny GUID X-GitHub-Delivery, którego należy używać przy ochronie przed odtworzeniem. Dla praktycznego ujęcia architektury punktów końcowych czatu jako granic interakcji zobacz Chat Platforms as System Interfaces in Modern Systems.

Nawet systemy reklamujące silniejsze gwarancje nadal wymagają od Ciebie pracy. Kafka może zapobiegać duplikatom wpisów w logach Kafka dzięki idempotentnym producentom i zapewnia dostarczanie dokładnie raz dla przepływów odczyt-obróbkę-zapis, które pozostają wewnątrz Kafka dzięki transakcjom i konsumentom read_committed. Ale dokumentacja projektowa samej Kafi jest jasna: zewnętrzne systemy nadal wymagają koordynacji z offsetami i wyjściami. Dostarczanie dokładnie raz w Google Cloud Pub/Sub jest ograniczone do subskrypcji pull, w obrębie regionu chmurowego i nadal wymaga od klientów śledzenia postępu przetwarzania do momentu pomyślnego potwierdzenia.

Moja subiektywna podsumowanie jest proste. Zakładaj, że transport będzie wykonywał ponowne próby. Zakładaj, że operatorzy będą odtwarzać dane. Zakładaj, że webhooki przybyją z opóźnieniem. Zaprojektuj ścieżkę zapisania tak, aby powtarzający się zamiar nie mógł stworzyć drugiego efektu biznesowego. Projektowanie błędów jest ściśle powiązane: to, jak błędy są owijane, tłumaczone i klasyfikowane jako powtarzalne versus niepowtarzalne, jest częścią tej samej dyscypliny granic — Go Error Handling Architecture: Boundaries and Patterns omawia klasyfikację błędów powtarzalnych, tłumaczenie granic i wzorce sentineli, które pozwalają logice ponownych prób podejmować uzasadnione decyzje. Gdy ponowne próby nieustannie uderzają w niezdrową zależność, circuit breaker at the integration boundary szybko kończy działanie, zanim burze ponownych prób wzmacniają duplikatną pracę.

Umowa API, której naprawdę ufam

Jak klucze idempotentyczności zapobiegają duplikatom żądań API

Jedyną umową API, której ufam w przypadku operacji modyfikujących, jest zamiar dostarczony przez wywołującego oraz trwałość po stronie serwera.

AWS zaleca identyfikator żądania dostarczony przez wywołującego i ostrzega, że usługa musi atomowo zarejestrować token idempotentyczności wraz z pracą modyfikującą. Stripe przechowuje pierwszy kod statusu i ciało odpowiedzi dla klucza, porównuje późniejsze parametry z oryginalnym żądaniem i zwraca ten sam wynik dla ponownych prób. PayPal używa PayPal-Request-Id w obsługiwanych interfejsach API POST i zwraca najnowszy status poprzedniego żądania z tym samym nagłówkiem.

Prowadzi to do praktycznej umowy:

  1. Klient generuje klucz idempotentyczności dla operacji biznesowej.
  2. Serwer zakresuje ten klucz według najemcy (tenant) i nazwy operacji.
  3. Serwer przechowuje hash żądania, aby ten sam klucz nie mógł zostać ponownie użyty dla innego ładunku (payload).
  4. Serwer rejestruje stan, taki jak pending (w toku), completed (zakończony) lub failed (nieudany).
  5. Ponowne próby z tym samym kluczem zwracają albo przechowywany wynik, albo stabilny wskaźnik do niego.
  6. Ponowne próby z tym samym kluczem i innym ładunkiem kończą się głośnym błędem.

IETF ma projekt nagłówka Idempotency-Key, ale na dzień 2026-05-09 jest on nadal wymieniony w śledniku IETF jako wygasły Internet-Draft, a nie opublikowana norma RFC. W praktyce nazwa nagłówka jest nadal szeroko użyteczna jako de facto konwencja, ale powinieneś udokumentować umowę w swoim własnym interfejsie API, zamiast udawać, że standard jest gotowy.

Co powinien reprezentować klucz? Zamiar. Nie próbę HTTP. Nie połączenie TCP. Nie licznik ponownych prób. Jeśli użytkownik oznacza „utwórz zamówienie 123 jeden raz”, każda ponowna próba dla tego samego polecenia musi ponownie użyć tego samego klucza. Jeśli użytkownik oznacza „złóż drugie zamówienie”, musi użyć innego klucza.

Identyfikator żądania służy do śledzenia. Klucz idempotentyczności służy do poprawności. Jeśli je pomieszasz, Twoje pulpity będą wyglądać schludnie, podczas gdy Twoje pieniądze będą się poruszać dwukrotnie.

Dlaczego PUT nie jest wystarczający

Nie, HTTP PUT nie wystarczy, aby uczynić operację idempotentną.

Tak, RFC 9110 nadaje PUT idempotentną semantykę. Ale jeśli Twój obsługa PUT emituje nowe zdarzenie w dół strumienia, wysyła e-mail przy każdej ponownej próbie lub ponownie nalicza zewnętrznego dostawcę, to Twoja implementacja naruszyła umowę biznesową, nawet jeśli nazwa trasy wygląda poważnie.

Wybór czasownika pomaga klientom zrozumieć zamiar. Nie implementuje go za Ciebie.

Używaj PUT, gdy model zasobów naprawdę pasuje do operacji pełnej wymiany lub upsert. Używaj POST, gdy tworzysz polecenia lub akcje. Ale dla każdej modyfikacji, która może zostać powtórzona poprzez granice sieciowe, udokumentuj jawne umowy idempotentyczności. Jeśli Twoje akcje modyfikujące są wyzwalane z przepływów czatu, ta sama umowa obowiązuje w Slack Integration Patterns for Alerts and Workflows i Discord Integration Pattern for Alerts and Control Loops. Ukryte efekty uboczne to miejsce, gdzie architektura umiera.

Jak długo należy przechowywać klucz idempotentyczności

Dłużej, niż chce Twój zespół transportowy.

Stripe mówi, że klucze można usuwać po co najmniej 24 godzinach. PayPal mówi, że czas retencji zależy od interfejsu API i podaje przykłady, które mogą trwać do 45 dni. Amazon SQS FIFO deduplikuje tylko w oknie 5-minutowym. GitHub przechowuje ostatnie dostawy przez 3 dni w celu ręcznego ponownego dostarczenia. Te liczby są dzikie różne, ponieważ odpowiedni okres retencji to decyzja biznesowa, a nie domyślna wartość protokołu.

Jeśli przechowujesz klucze tylko przez pięć minut, ponieważ tak robi Twoja kolejka, nie projektujesz idempotentyczności. Kopiujesz ograniczenie transportowe do warstwy biznesowej.

Przechowuj rekordy idempotentyczności co najmniej przez maksymalne z tych okien czasowych:

  • horyzont ponownych prób klienta
  • horyzont ponownego dostarczenia kolejki
  • horyzont odtwarzania webhooków
  • horyzont odtwarzania przez operatora
  • horyzont rozliczenia lub kompensacji dla operacji przenoszących pieniądze

W przypadku płatności, rezerwacji i provisioningu oznacza to często godziny lub dni, a nie minuty.

AWS wskazuje również dwa antywzorce, z którymi całkowicie się zgadzam. Nie używaj znaczników czasu jako klucza, ponieważ przesunięcie zegara i kolizje czynią je niezawodnymi. Nie przechowuj ślepo całych ładunków żądań jako rekordu deduplikacji dla każdego żądania, ponieważ szkodzi to wydajności i skalowalności. Przechowuj znormalizowany hash żądania plus minimalny stan odpowiedzi potrzebny do bezpiecznego odtworzenia. Jeśli musisz odtworzyć pierwszą odpowiedź bajt po bajcie, przechowuj kanoniczne ciało odpowiedzi tak, jak robi to Stripe.

Wzorce baz danych, które czynią idempotentyczność realną

Idempotentyczność staje się realna, gdy warstwa trwałości danych może wygrać wyścig dokładnie raz.

PostgreSQL daje Ci tu dwa krytyczne prymitywy. Ograniczenia unikalności wymuszają unikalność na jednej lub więcej kolumnach, a INSERT ... ON CONFLICT pozwala zdefiniować alternatywną akcję zamiast błędu przy naruszeniu unikalności. PostgreSQL dokumentuje również, że ON CONFLICT DO UPDATE gwarantuje atomowy wynik wstawienia lub aktualizacji przy jednoczesności.

Oznacza to, że warstwa idempotentyczności powinna zwykle zaczynać się od tabeli takiej jak ta:

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

A przepływ obsługi powinien wyglądać tak:

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

Ważną częścią nie jest składnia. Ważną częścią jest atomowość. Rejestrowanie klucza i wykonywanie modyfikacji musi się powieść lub nie powieść razem. AWS mówi to wprost w przypadku idempotentyczności interfejsu API, a ta sama zasada obowiązuje w usługach opartych na SQL.

Nie wykonuj naiwnego sekwencji sprawdź-następnie-uczynij, takiej jak „wybierz klucz; jeśli brakujący, to wstaw zamówienie”. Przy jednoczesności dwa żądania mogą przejść przez sprawdzenie i oba stworzą efekt uboczny. Ograniczenie unikalności nie jest opcjonalne. To mechanizm, który przekształca Twoją architekturę z optymistycznej mitologii w coś, co można udowodnić pod obciążeniem.

Oto zasada, której używam w recenzjach. Jeśli decyzja deduplikacyjna nie jest chroniona przez tę samą granicę transakcyjną co modyfikacja, nie masz idempotentyczności. Masz nadzieję.

Wiadomości, zdarzenia i webhooki potrzebują własnej granicy

Jak konsumenci obsługują duplikatne zdarzenia i wiadomości

Dla konsumentów wiadomości klasyczny wzorzec jest nadal właściwy. Rejestruj identyfikatory przetworzonych wiadomości w tej samej transakcji bazy danych co aktualizacja biznesowa. Chris Richardson opisuje bezpośrednio podejście tabeli PROCESSED_MESSAGES, używając klucza podstawowego na subskrybencie i identyfikatorze wiadomości, aby duplikaty kończyły się czystym błędem i mogły być ignorowane.

Wiele zespołów nazywa ten jawny magazyn processed_messages tabelą skrzynki odbiorczej (inbox). Etykieta ma mniejsze znaczenie niż zasada. Odbiorca musi utrwaląć dowód, że już obsłużył wiadomość, zanim ponowna próba może bezpiecznie nic nie zrobić.

Minimalna forma wygląda tak:

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

A przepływ konsumenta jest tak samo rygorystyczny jak przepływ HTTP:

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

Ten wzorzec jest nudny. Dobrze. Idempotentyczność powinna być nudna.

Jest to również zwykle lepsze niż opieranie się na marketingowych terminach brokera. Obsługa dokładnie raz w Kafce jest doskonała, gdy pozostajesz w modelu transakcyjnym samej Kafki, ale dokumentacja Kafki nadal ostrzega, że zewnętrzne miejsca docelowe wymagają współpracy. SQS FIFO redukuje wysyłanie duplikatów tylko w swoim 5-minutowym oknie deduplikacji. Dokładnie raz w Pub/Sub nadal oczekuje, że subskrybent będzie śledził postęp i unikał duplikatnej pracy, gdy potwierdzenia zawiodą.

Dokładnie raz to zwykle optymalizacja lokalna. Idempotentne efekty uboczne to gwarancja systemu.

Połącz deduplikację ze wzorcem outbox

Jeśli Twoja usługa aktualizuje stan lokalny i publikuje również zdarzenie, idempotentna konsumpcja sama w sobie nie wystarczy. Potrzebujesz również bezpiecznego sposobu wyprowadzenia zdarzenia po zatwierdzeniu lokalnej transakcji.

Dlatego transactional outbox pattern ma znaczenie. Chris Richardson opisuje podstawową ideę jako zapisywanie zdarzenia do tabeli outbox w tej samej transakcji co aktualizacja biznesowa, a następnie publikowanie go asynchronicznie. Debezium mówi, że wzorzec outbox unika niespójności między stanem wewnętrznym usługi a zdarzeniami spożywanymi przez inne usługi. NServiceBus idzie dalej i pokazuje, jak przetwarzanie outbox deduplikuje przychodzące wiadomości i unika rekordów zombie oraz wiadomości duchów.

Oto architektura, którą polecam dla usług, które posiadają dane i publikują zdarzenia integracyjne:

  1. Zwaliduj i utrwal polecenie pod kluczem idempotentyczności.
  2. Zapisz stan biznesowy i zdarzenie outbox w jednej lokalnej transakcji.
  3. Pozwól CDC lub dysponentowi outbox na publikację zdarzenia.
  4. Zrób również konsumenty dół strumienia idempotentnymi.

Outbox nie usuwa potrzeby idempotentnych konsumentów. Usuwa potrzebę udawania, że zatwierdzenie bazy danych i publikacja brokera mogą być jedną magiczną transakcją rozproszoną, gdy zwykle nie mogą.

Webhooki to tylko wiadomości z lepszym marketingiem

Traktuj przychodzące webhooki dokładnie jak wiadomości z niezaufanej krawędzi sieci.

GitHub dokumentuje, że dostawy mogą przybywać w innej kolejności, zaleca używanie X-Hub-Signature-256 do weryfikacji autentyczności i dostarcza X-GitHub-Delivery jako unikalny identyfikator dostawy. Wspomina również, że ponowne dostawy ponownie używają tego samego identyfikatora dostawy.

Więc architektura jest prosta:

  • weryfikuj podpis jako pierwszy
  • użyj GUID dostawy jako klucza deduplikacji
  • utrwal odbiór przed efektami ubocznymi
  • zrób obsługi świadome kolejności, zamiast zakładać kolejność przybycia
  • wstaw ciężką pracę do kolejki i zwróć szybko

Jeśli Twój obsługa webhooków zapisuje bezpośrednio do tabel biznesowych przed zarejestrowaniem odbioru, nie jest gotowa do produkcji. Jest tylko szybsza w robieniu duplikatnych błędów.

Saga i silniki przepływów pracy nadal potrzebują idempotentyczności

Saga i trwałe silniki przepływów pracy nie usuwają problemu. Sprawiają, że jest widoczny.

Temporal zaleca pisanie Aktywności (Activities) w sposób idempotentny, ponieważ Aktywności mogą być powtarzane po awariach lub limitach czasu. Jego dokumentacja nawet wskazuje przypadek graniczny, w którym pracownik z powodzeniem kończy zewnętrzny efekt uboczny, ale ulega awarii przed zgłoszeniem zakończenia, co powoduje ponowne uruchomienie Aktywności. Temporal sugeruje również używanie kombinacji ID uruchomienia przepływu pracy i ID Aktywności jako stabilnego klucza idempotentyczności przy wywoływaniu usług dół strumienia. Jeśli stosujesz to w orkiestracji usług, Go Microservices for AI/ML Orchestration omawia szersze kompromisy przepływów pracy.

To dokładnie właściwy model umysłowy. Silnik przepływu pracy może zachować historię wykonywania i koordynować ponowne próby. Nie może retroaktywnie anulować obciążenia karty lub cofnąć wysłania e-maila, chyba że Twoja aplikacja da mu idempotentne kroki i idempotentne kompensacje.

To samo dotyczy sag. Własne wytyczne Temporal dla sag opisują akcje kompensacyjne, które uruchamiają się, gdy krok zawiedzie. Te kompensacje muszą być również idempotentne. Jeśli „zwrot płatności” uruchomi się dwa razy, możesz rozwiązać oryginalny błąd, tworząc nowy.

Moja zasada tutaj jest brutalna i prosta. Każda Aktywność, każdy command handler i każda kompensacja, która dotyka świata zewnętrznego, powinna być naturalnie idempotentna lub przenosić prawdziwy klucz idempotentyczności do systemu dół strumienia.

Jak testować idempotentyczność przed produkcją

Większość zespołów testuje szczęśliwe ścieżki, a potem jest zaskoczona, gdy nastąpią ponowne próby. To nie wystarczy. Dla zespołów Go, Testing Concurrent Go Code with testing/synctest omawia, jak pisać szybkie, deterministyczne testy dla pętli ponownych prób i zachowań limitu kontekstu bez śpienia przez sztuczne opóźnienia.

Powinieneś mieć zautomatyzowane testy co najmniej dla tych przypadków:

  • serwer zatwierdza modyfikację, ale odpowiedź nigdy nie dociera do klienta
  • dwa identyczne żądania rywalizują z tym samym kluczem idempotentyczności
  • ten sam klucz jest ponownie używany z innym ładunkiem
  • konsument zatwierdza swoją pracę w bazie danych i ulega awarii przed ack
  • webhook jest odtwarzany z tym samym identyfikatorem dostawy
  • dysponent outbox publikuje to samo zdarzenie więcej niż raz
  • Aktywność przepływu pracy kończy zewnętrzne wywołanie i ulega awarii przed zgłoszeniem zakończenia
  • rekord idempotentyczności wygasa i przybywa prawdziwa późna ponowna próba

AWS wprost zaleca kompleksowe zestawy testów, które obejmują udane żądania, nieudane żądania i żądania duplikatów. Ta rada jest prozaiczna i absolutnie poprawna.

Dodałbym jeszcze jedną próbę awarii. Zweryfikuj, że odtworzona odpowiedź jest semantycznie równoważna z pierwszym wynikiem. AWS omawia późno przybywające ponowne próby i argumentuje za odpowiedziami, które zachowują oryginalne znaczenie, nawet jeśli stan podstawowy się zmienił. To jest różnica między „nie stał się żaden dodatkowy efekt uboczny” a „wywołujący nadal ma spójną umowę”.

Subiektywne zasady, które ratują prawdziwe systemy

Oto zasady, które wymuszam w recenzji architektury.

Po pierwsze, klucze idempotentyczności należą do zamiaru biznesowego, a nie do prób transportowych.

Po drugie, zakresuj każdy klucz według najemcy i operacji. Globalne przestrzenie kluczy to sposób, w jaki niezwiązane żądania kolizują.

Po trzecie, utrwal decyzję deduplikacji atomowo z modyfikacją. Jeśli to nie jest prawdą, projekt jest błędny.

Po czwarte, odrzucaj ponowne próby z tym samym kluczem i innym ładunkiem. Stripe i AWS robią to z dobrej przyczyny.

Po piąte, przechowuj klucze przez pełny horyzont odtwarzania procesu biznesowego, a nie przez najkrótsze okno kolejki.

Po szóste, łącz producentów z outboxem, a konsumentów ze śledzeniem ID wiadomości. Jedna strona bez drugiej to połowa projektu.

Po siódme, propaguj tę samą tożsamość operacji dół strumienia, gdy akcja biznesowa jest taka sama. AWS wprost zaleca przekazywanie tokena idempotentyczności wzdłuż łańcucha przetwarzania.

Po ósme, nigdy nie zakładaj, że marketing „dokładnie raz” usuwa potrzebę idempotentnych efektów ubocznych.

Jeśli to brzmi surowo, to dobrze. Idempotentyczność to miejsce, gdzie optymistyczna architektura spotyka się z produkcyjną rzeczywistością. Nie potrzebujesz złożoności wszędzie. Ale tam, gdzie duplikatne efekty uboczne uszkodziłyby pieniądze, stan lub zaufanie, idempotentyczność powinna być pierwszoplanową częścią umowy.

Te same zasady obowiązują bezpośrednio w tle agentów AI. Agenci pollingowi, którzy zgłaszają zadania, emitują powiadomienia lub uruchamiają wywołania narzędzi, potrzebują kluczy deduplikacji i idempotentnych protokołów zgłaszania tak samo jak interfejsy API płatności. Aby zobaczyć, jak wzorzec zgłaszania i deduplikacji działa w produkcji asystentów AI, zobacz Polling Agents in AI Assistants: 11 Implementation Patterns.

Przydatne linki

Subskrybuj

Otrzymuj nowe wpisy o systemach, infrastrukturze i inżynierii AI.