Концепт-урок Idempotency: как делать retry безопасными. Показывает 4 сценария — наивный POST с двойным charge, спасение через Idempotency-Key + Redis dedup store, conditional update (compare-and-set с version), и counter increment trap с op_id deduplication. Топология: mobile client, payment API, idempotency store (Redis), payments ledger (Postgres), card processor.
В распределённой системе retry неизбежен. Network drop, request timeout, slow ack, дропнутый mobile connection между application и transport уровнями — клиент часто не знает, дошёл ли запрос. Единственное безопасное поведение клиента — повторить. И если операция на сервере не идемпотентна, каждый повтор дублирует эффект: двойной charge на карте, два письма, дубль ордера в системе.
Идемпотентность — это контракт «сколько бы раз ни выполнили — результат тот же». Она даёт защиту от двойной обработки без блокирующей координации, без распределённых локов, без двухфазного коммита. Только дедупликация по ключу или версии, локально на каждом сервисе.
В финансовых системах двойное списание = chargeback, fraud claim, regulatory issue. В мессенджерах двойная отправка = пользователь думает что сервис сломан. В аналитике двойной event = испорченные метрики. Идемпотентность — это базовая гигиена, как валидация input.
«f(x) = f(f(x)). HTTP GET идемпотентен по определению (только читает). PUT идемпотентен (replace целиком). POST по умолчанию — нет, каждый POST создаёт новый ресурс. Чтобы сделать POST идемпотентным — нужен идентификатор
conceptual request(Idempotency-Key) и server-side дедупликация.»
Ключевая идея: идемпотентность не свойство кода, а свойство пары (операция, идентификатор операции). INSERT сам по себе не идемпотентен, но INSERT ... ON CONFLICT (op_id) DO NOTHING — идемпотентен относительно op_id. Charge сам по себе не идемпотентен, но charge(idempotency_key) — идемпотентен относительно ключа.
Payment Service с четырьмя нодами: API (принимает запросы клиента), Idempotency Store (Redis с TTL 24h для кэша key→response), Payments Ledger (Postgres для записи списаний), Card Processor (внешний PSP типа Stripe). Клиент — Mobile App, который под капотом теряет ack-и из-за плохой связи.
Связи отражают физическую топологию: клиент шлёт POST в API, API ходит в Redis за ключом, в Postgres за ledger-ом, в провайдера за реальным charge. Никаких прямых связей клиент→ledger или клиент→provider — всё через API, которое и владеет идемпотентностью.
Четыре сценария показывают одну и ту же ситуацию (потерянный ack между API и клиентом) с разной защитой: без защиты, с Idempotency-Key, с conditional update, и для отдельного класса non-idempotent операций (counter increment).
Naive POST — двойной charge. Клиент шлёт POST /charges без Idempotency-Key. API списывает $100 через провайдера, пишет в ledger, возвращает ответ — но ack теряется. Клиент ретраит. API не знает что это повтор → списывает ещё $100. Customer видит $200, открывает chargeback. Это базовый failure mode без идемпотентности.
Idempotency-Key спасает. Клиент генерит UUID K1 и шлёт его в заголовке. API проверяет Redis: MISS → продолжает, делает charge, в одной транзакции пишет в ledger и в idempotency store (K1 → {status:200, payment_id:pay_abc}). Ack теряется. Клиент ретраит с тем же K1. API проверяет Redis: HIT → возвращает cached response без второго charge. Customer списан ровно один раз.
Conditional update (compare-and-set). Клиент читает баланс с version=5. Шлёт POST /debit amount=20, expected_version=5. API делает UPDATE balance=80, version=6 WHERE version=5 — успех, версия теперь 6. Ack теряется. Клиент ретраит. API делает тот же UPDATE ... WHERE version=5 — 0 rows updated, потому что версия уже 6. Это safe noop — operation natural idempotent через optimistic locking, без отдельного Idempotency-Key.
Counter increment trap. UPDATE posts SET likes = likes + 1 фундаментально не идемпотентен — retry двоит счётчик. Фикс: INSERT INTO like_ops(op_id) ON CONFLICT DO NOTHING перед инкрементом. Если op_id уже видели — INSERT даёт 0 rows, INCREMENT пропускается. Сценарий показывает обе версии: naive (двоит likes 100 → 101 → 102) и защищённую (видит conflict, skip).
ADR-001: Idempotency-Key как контракт.
Контекст. Mobile-клиенты регулярно теряют ack из-за дропнутого TCP. Сервер не контролирует retry policy клиента. Полагаться на «клиент не должен ретраить» нельзя — клиент обязан ретраить, иначе UX превращается в «нажми Pay ещё раз, мы не уверены прошло ли». При этом дублирование charge = катастрофа.
Решение. Каждый mutating endpoint обязан принимать заголовок Idempotency-Key (UUID v4, генерируется клиентом per-conceptual-request). Server хранит (key, response, status) в Redis с TTL 24h. На повторный запрос с тем же ключом — returns cached response без повторного выполнения side effects. Запись в idempotency_store идёт в одной транзакции с эффектом (списанием/insert-ом), чтобы не было окна между «сделал charge» и «записал key». Пара (key, user_id) уникальна — защита от cross-user collision.
Цена. Каждый mutating запрос делает дополнительный roundtrip в Redis. На write-heavy load это +30-50% к latency endpoint-а. Storage растёт: при 10K RPS и 24h TTL держим ~860M записей в памяти Redis (требует cluster). TTL 24h — компромисс: дольше = больше storage, короче = retry за пределами окна снова двоит.
Альтернативы. Не делать ничего → возвращаемся к chargeback risk. Idempotency через client-side throttling → не работает с потерянными ack-ами. Pessimistic lock per user → блокирует concurrent transactions того же юзера, теряем throughput.
POST /charges, POST /customers, POST /refunds) принимают Idempotency-Key header. Stripe хранит response 24h, possible TTL extension до 7 дней для критичных endpoint-ов. Их движок дедупликации — отдельный сервис перед роутингом.idempotency_key в JSON body (не header).PayPal-Request-Id header для всех POST/PUT endpoints, TTL ~72h.MessageDeduplicationId (5-min dedup window), либо content-based deduplication через SHA256(body).enable.idempotence=true) — broker дедуплицирует по (producer_id, sequence_number). В сочетании с transactional commits даёт exactly-once semantics внутри Kafka pipeline.compare-and-swap на key→version, аналог HTTP If-Match для CAS.INSERT IGNORE без unique index. Многие думают что ON CONFLICT DO NOTHING сам по себе дедуплицирует — нет, нужен unique constraint, иначе conflict никогда не срабатывает и дубли проходят.UPDATE x SET cnt = cnt + 1. Фундаментально non-idempotent, фикс через INSERT op_id.Идемпотентность стоит дополнительного roundtrip и storage. Если operation уже natural idempotent — например, чистый PUT /users/42 {...} который полностью заменяет ресурс, или DELETE /orders/99 — отдельный Idempotency-Key избыточен. HTTP semantics уже гарантируют что повтор не сломает state.
Read-only операции (GET, HEAD, поиск, listing) не требуют идемпотентности — там нет side effects.
Внутренние fire-and-forget события, где дубликат дешевле дедупликации (например, телеметрия с aggregation по window-у — дубль event-а сместит метрику на 0.0001%, дешевле принять). Если стоимость защиты больше стоимости дубля — не защищайтесь.
Стриминговые pipeline-ы с exactly-once gauarantee на уровне фреймворка (Flink checkpoint-ы, Kafka transactions) — там идемпотентность встроена в runtime, ручная дедупликация в business logic — overkill.
Не путайте идемпотентность с exactly-once delivery (мифом для distributed networks). Реальный exactly-once = at-least-once delivery + idempotent consumer. Если вы пытаетесь построить exactly-once без идемпотентности на consumer — вы строите фикцию.