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

Zatrzymaj zduplikowane skutki uboczne

Page content

Idempotentność w systemach rozproszonych to właściwość, która ratuje Cię, gdy sieć kłamie, kolejka ponownie wysyła wiadomości, klient panikuje, a administrator uruchamia odtworzenie. W systemach produkcyjnych wielokrotna dostawa jest normą. Wielokrotne skutki uboczne to błąd.

Protokół HTTP definiuje metodę idempotentną jako taką, przy której wielokrotne, identyczne żądania mają ten sam zamierzony efekt na serwerze, co pojedyncze żądanie. Dlatego też metody PUT, DELETE oraz metody bezpieczne są idempotentne w semantyce protokołu i mogą być automatycznie powtarzane po awarii połączenia.

przepływ komunikacji integracyjnej: idempotentność

Ta definicja jest przydatna, ale niewystarczająca. W rzeczywistych architekturach idempotentność to nie odpowiedź na pytanie z trivia o HTTP. To gwarancja biznesowa. Jeśli klient naciśnie przycisk „zapłać” raz, nie możesz pobierać opłaty dwukrotnie tylko dlatego, że wystąpił limit czasu między zatwierdzeniem a odpowiedzią. Jeśli worker aktualizuje stan magazynu i zawodzi przed potwierdzeniem odbioru wiadomości, nie możesz obniżyć stan magazynowy dwukrotnie tylko dlatego, że broker ponownie dostarczył tę wiadomość. To jest standard.

Błąd, który widzę znowu i znowu, to traktowanie idempotentności jako cechy transportowej, a nie właściwości systemu. Deduplikacja w kolejce, czasowniki HTTP i ponowe próby klientów pomagają, ale żaden z nich nie uratuje architektury, która pozwala, by ten sam zamiar biznesowy wygenerował drugi skutek uboczny. Jeśli chcesz szerszego ujęcia, jak te decyzje integracyjne wpisują się w granice usług i kompromisy trwałości danych, zacznij od Architektury aplikacji w środowisku produkcyjnym: wzorce integracyjne, projekt kodu i dostęp do danych.

Skąd w środowisku produkcyjnym pochodzą duplikaty

Duplikaty nie pojawiają się, ponieważ zespoły są niedbałe. Pojawiają się, ponieważ systemy rozproszone ponawiają 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 API płatności, takie jak Stripe i PayPal, oferują explicitne mechanizmy idempotentności dla metod niebezpiecznych, takich jak POST.

Brokery wiadomości jeszcze bardziej uwypuklają ten problem. Dostawa typu „przynajmniej raz” oznacza, że konsument może być wywoływany wielokrotnie dla tej samej wiadomości, a handler może pomyślnie zaktualizować bazę danych, ale zawieść przed potwierdzeniem, co spowoduje, że broker dostarczy tę samą wiadomość ponownie.

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ć do ochrony przed odtworzeniem. Praktyczne ujęcie architektury endpointów czatowych jako granic interakcji znajdziesz w artykule Platformy czatowe jako interfejsy systemowe w nowoczesnych systemach.

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 może zapewnić dostawę „dokładnie raz” dla przepływów odczyt-obróbkę-zapis, które pozostają wewnątrz Kafki przy użyciu transakcji i konsumentów read_committed. Jednak dokumenty projektowe samej Kafki jasno wskazują, że systemy zewnętrzne nadal wymagają koordynacji z offsetami i wyjściami. Dostawa „dokładnie raz” w Google Cloud Pub/Sub jest ograniczona do subskrypcji pull, w obrębie regionu chmurowego i nadal wymaga od klientów śledzenia postępu przetwarzania do momentu pomyślnego potwierdzenia.

Moja opiniotwórcza podsumowanie jest proste. Zakładaj, że transport będzie ponawiać próby. Zakładaj, że operatorzy będą odtwarzać dane. Zakładaj, że webhooki przybędą z opóźnieniem. Zaprojektuj ścieżkę zapisu tak, aby powtarzający się zamiar nie mógł wygenerować drugiego skutku biznesowego.

Umowa API, której faktycznie ufam

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

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

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

Prowadzi to do praktycznej umowy:

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

Istnieje projekt nagłówka Idempotency-Key od IETF, ale na dzień 2026-05-09 jest on nadal wymieniony w IETF Datatracker 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 API, zamiudawać, że standard jest już ukończony.

Co powinien reprezentować klucz? Zamiar. Nie próbę HTTP. Nie połączenie TCP. Nie licznik ponownych prób. Jeśli użytkownik ma na myśli „utwórz zamówienie 123 raz”, każda ponowna próba dla tej samej komendy musi używać tego samego klucza. Jeśli użytkownik ma na myśli „złóż drugie zamówienie”, musi to używać innego klucza.

Identyfikator żądania służy do śledzenia. Klucz idempotentności służy do poprawności. Jeśli pomylisz te pojęcia, Twoje panele będą wyglądać schludnie, podczas gdy Twoje pieniądze będą przesuwać się dwukrotnie.

Dlaczego PUT nie wystarczy

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

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

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

Używaj PUT, gdy model zasobów rzeczywiście pasuje do operacji pełnej zastąpienia lub upsert. Używaj POST, gdy tworzysz komendy lub akcje. Ale dla dowolnej mutacji, która może być ponawiana przez granice sieciowe, udokumentuj explicitną umowę idempotentności. Jeśli Twoje akcje modyfikujące są wyzwalane z przepływów pracy czatowych, ta sama umowa obowiązuje w Wzorach integracji Slack dla powiadomień i przepływów pracy oraz Wzorach integracji Discord dla powiadomień i pętli kontrolnych. Ukryte skutki uboczne to tam, gdzie architektura umiera.

Jak długo powinny być przechowywane klucze idempotentności

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

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

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

Przechowuj rekordy idempotentności przez co najmniej maksymalny z tych okresów:

  • horyzont ponownych prób klienta
  • horyzont ponownego napędu 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 niezrozumiałymi. Nie skłaniaj się do ślepego przechowywania całego ładunku żądania 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 w sposób, w jaki robi to Stripe.

Wzorce bazodanowe, które czynią idempotentność realną

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

PostgreSQL daje Ci tutaj dwa krytyczne prymitywy. Ograniczenia unikalności wymuszają unikalność na jednej lub więcej kolumnach, a instrukcja INSERT ... ON CONFLICT pozwala zdefiniować alternatywną akcję zamiast awarii przy naruszeniu unikalności. PostgreSQL dokumentuje również, że ON CONFLICT DO UPDATE gwarantuje atomowy wynik wstawienia lub aktualizacji w warunkach współbieżności.

Oznacza to, że Twoja warstwa idempotentności powinna zwykle zaczynać się od tabeli takiej jak poniżej:

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ć następująco:

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

Istotną częścią nie jest składnia. Istotną częścią jest atomowość. Rejestrowanie klucza i wykonywanie mutacji muszą odnieść sukces lub zawieść razem. AWS mówi o tym explicitnie w przypadku idempotentności API, a ta sama zasada obowiązuje w usługach opartych na SQL.

Nie wykonuj naiwnej sekwencji „sprawdź, a następnie działaj”, takiej jak „select key; if missing then insert order”. W warunkach współbieżności dwa żądania mogą przejść przez sprawdzenie i oba utworzą skutek uboczny. Ograniczenie unikalności nie jest opcjonalne. To mechanizm, który przekształca Twoją architekturę z optymistycznego folkloru w coś, co możesz udowodnić pod obciążeniem.

Oto reguła, z której korzystam w recenzjach. Jeśli decyzja o deduplikacji nie jest chroniona przez tę samą granicę transakcyjną co mutacja, to nie masz idempotentności. Masz nadzieję.

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

Jak konsumenci obsługują duplikaty zdarzeń i wiadomości

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

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

Minimalna forma wygląda następująco:

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. Idempotentność powinna być nudna.

Jest również zwykle lepszy niż poleganie na marketingowych terminach brokera. Obsługa „dokładnie raz” w Kafka jest doskonała, gdy pozostajesz wewnątrz własnego modelu transakcyjnego Kafki, ale dokumentacja Kafki nadal ostrzega, że zewnętrzne miejsca docelowe wymagają współpracy. SQS FIFO redukuje wysyłanie duplikatów tylko w ramach swojego 5-minutowego okna deduplikacji. Dostawa „dokładnie raz” w Pub/Sub nadal oczekuje, że subskrybent będzie śledzić postęp i unikać duplikowania pracy, gdy potwierdzenia zawiodą.

„Dokładnie raz” to zwykle optymalizacja lokalna. Idempotentne skutki uboczne to gwarancja systemu.

Połącz deduplikację ze wzorcem outbox

Jeśli Twoja usługa aktualizuje stan lokalny i jednocześnie publikuje zdarzenie, samo idempotentne konsumowanie nie wystarczy. Potrzebujesz również bezpiecznego sposobu wysłania zdarzenia po zatwierdzeniu lokalnej transakcji.

Dlatego wzorzec transakcyjnego outboxu 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 outboxu unika niespójności między stanem wewnętrznym usługi a zdarzeniami konsumowanymi przez inne usługi. NServiceBus idzie dalej i pokazuje, jak przetwarzanie outboxu deduplikuje przychodzące wiadomości i unika rekordów zombie oraz wiadomości fantomowych.

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

  1. Zwaliduj i utrwal komendę pod kluczem idempotentności.
  2. Napisz stan biznesowy i zdarzenie outbox w jednej lokalnej transakcji.
  3. Pozwól CDC lub dispatcherowi outboxu opublikować zdarzenie.
  4. Czynid również dółstrumieniowych konsumentów 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 lepszą marką

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. Sugeruje również, że ponowne dostawy używają tego samego identyfikatora dostawy.

Dlatego architektura jest prosta:

  • najpierw zweryfikuj podpis
  • użyj GUID dostawy jako klucza deduplikacji
  • utrwal odbiór przed skutkami ubocznymi
  • czynid handlery świadomymi kolejności, zamiast zakładać kolejność przybycia
  • wstaw ciężką pracę do kolejki i zwróć odpowiedź szybko

Jeśli Twój handler webhooku zapisuje bezpośrednio do tabel biznesowych przed zarejestrowaniem odbioru, nie jest gotowy do produkcji. Po prostu szybciej popełnia błędy duplikacji.

Sagas i silniki przepływów pracy nadal potrzebują idempotentności

Sagas i trwałe silniki przepływów pracy nie usuwają problemu. Sprawiają, że staje się on widoczny.

Temporal zaleca pisanie Aktywności tak, aby były idempotentne, ponieważ Aktywności mogą być ponawiane po awariach lub przekroczeniach limitu czasu. Ich dokumentacja nawet wskazuje przypadek skrajny, w którym worker pomyślnie kończy zewnętrzny skutek uboczny, ale zawodzi przed zgłoszeniem zakończenia, co powoduje, że Aktywność uruchamia się ponownie. Temporal sugeruje również używanie kombinacji ID uruchomienia przepływu pracy i ID Aktywności jako stabilnego klucza idempotentności przy wywołaniu usług dółstrumieniowych. Jeśli stosujesz to w orkiestracji usług, artykuł Mikrousługi Go do orkiestracji AI/ML omawia szersze kompromisy w przepływach pracy.

To jest dokładnie właściwy model umysłowy. Silnik przepływu pracy może zachować historię wykonania i koordynować ponowne próby. Nie może jednak retrospektywnie anulować obciążenia karty lub cofnięcie wysłania e-maila, chyba że aplikacja poda mu idempotentne kroki i idempotentne kompensacje.

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

Moja zasada tutaj jest brutalna i prosta. Każda Aktywność, każdy handler komendy i każda kompensacja, która dotyka świata zewnętrznego, powinna być albo naturalnie idempotentna, albo przenosić rzeczywisty klucz idempotentności do systemu dółstrumieniowego.

Jak testować idempotentność przed produkcją

Większość zespołów testuje szczęśliwe ścieżki, a następnie jest zdziwiona, gdy wystąpią ponowne próby. To nie wystarczy.

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

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

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

Dodam 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 na rzecz odpowiedzi, które zachowują oryginalny смысл nawet po zmianie stanu leżącego u podstaw. To jest różnica między „nie nastąpił dodatkowy skutek uboczny” a „wywołujący nadal ma spójną umowę”.

Opiniotwórcze reguły, które ratują rzeczywiste systemy

Oto reguły, które wymuszyłbym w recenzji architektury.

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

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

Po trzecie, utrwalaj decyzję o deduplikacji atomowo z mutacją. Jeśli tak nie jest, projekt jest błędny.

Po czwarte, odrzucaj ponowne próby z tym samym kluczem, ale 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 identyfikatorów wiadomości. Jedna strona bez drugiej to połowa projektu.

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

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

Jeśli to brzmi surowo, to dobrze. Idempotentność to miejsce, gdzie optymistyczna architektura spotyka się z rzeczywistością produkcyjną. Nie potrzebujesz złożoności wszędzie. Ale tam, gdzie duplikaty skutków ubocznych uszkodziłyby pieniądze, stan lub zaufanie, idempotentność powinna być pierwszoklasową częścią umowy.

Przydatne linki

Subskrybuj

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