Перейти к основному содержимому

Идемпотентность и семантика доставки

Всем

Инженеры часто формулируют требование как «доставку exactly-once». На практике устойчивые системы строят на at-least-once и компенсируют дубликаты идемпотентной бизнес-логикой. Эта статья связывает HTTP, очереди и Kafka в одну модель: где заканчиваются гарантии брокера и начинается ответственность приложения.

Когда читать: после асинхронной коммуникации и брокеров сообщений, до углубления в RabbitMQ и Kafka.


Идемпотентность

Идемпотентность — свойство операции: повторное выполнение с теми же входными данными даёт тот же результат, что и первый успешный вызов. Формально операция f идемпотентна, если ∀x: f(f(x)) = f(x).

В распределённых системах повторы неизбежны: таймаут сети, retry клиента, повторная доставка из очереди, рестарт воркера между side-effect и commit в БД. Идемпотентность — гарантия на уровне приложения: «второй раз обработать то же событие — безопасно».

КонтекстПримерЧто считается «тем же результатом»
HTTPPUT /orders/123 с телом { "status": "paid" }Состояние заказа 123 — paid, без второго списания
HTTPPOST /orders без ключаКаждый вызов может создать новый заказ — не идемпотентно
ОчередьConsumer получил order.paid с eventId=evt_42Повторная обработка evt_42 не меняет баланс и не шлёт второе письмо
Side-effectВызов платёжного шлюзаПовтор с тем же idempotencyKey возвращает тот же paymentId

Идемпотентность и дедупликация

Дедупликация — механизм «уже видели этот ключ — пропускаем». Идемпотентность — свойство операции «повтор безопасен». На практике дедуп-таблица или Idempotency-Key реализуют идемпотентность; без них handler может быть идемпотентным по задумке, но не по факту (например, PUT, который каждый раз вызывает charge).

Метод PUT и реализация handler
В REST методы PUT и DELETE считаются идемпотентными по контракту HTTP, но handler может списать деньги дважды, если внутри нет проверки «уже выполнено». Контракт метода и идемпотентность реализации — разные вещи.

Подробнее про HTTP-методы и заголовок Idempotency-Key — в методах и ключе идемпотентности, HTTP и проектировании API.


Откуда берутся повторы

Дубликаты появляются на каждом hop. Брокер с EOS снимает только часть из них.

ИсточникЧто происходитКто должен защититься
Клиент HTTPТаймаут → retry того же POSTAPI с Idempotency-Key или идемпотентный PUT
ProducerRetry после acks timeout; повторная публикация «логически того же» события с новым ключомИдемпотентный producer (Kafka), стабильный messageId в payload
БрокерRedelivery после nack или expiry lockAt-least-once по дизайну; consumer идемпотентен
ConsumerОбработка прошла, ack / commit offset не успелИдемпотентный handler + запись «обработано» до или вместе с side-effect
Несколько инстансовДва воркера взяли одну задачу (race)Lease, SELECT … FOR UPDATE, partition key
Relay / outboxДва инстанса relay отправили одну строку outboxFOR UPDATE SKIP LOCKED, published_at

Неопределённость ответа
Клиент не может отличить «запрос не дошёл» от «запрос выполнился, ответ потерялся». Любой retry без идемпотентности на сервере создаёт риск второго заказа или второго списания. Поэтому retry на клиенте и идемпотентность на сервере проектируют парой.


Семантика доставки

Три базовые модели описывают, сколько раз сообщение может попасть к consumer (или быть потеряно). Это гарантии транспорта и брокера, а не бизнес-эффекта.

СемантикаГарантияКак обычно получаютГде уместна
At-most-onceДоставлено ≤ 1 раза; возможна потеряAck / commit offset до обработки; fire-and-forgetТелеметрия, метрики, некритичная аналитика
At-least-onceНе теряется; возможны дубликатыAck / commit после успешной обработки; retry producerБольшинство prod-сценариев + идемпотентный consumer
Exactly-once (EOS)Запись/offset один раз внутри контура брокераKafka: enable.idempotence, транзакции, read_committedStream processing; сквозной эффект — outbox + idempotent sink

Платежи и заказы
Финансовые операции обычно строят на at-least-once плюс идемпотентный handler (ключ операции, таблица «уже обработано», upsert). Включение EOS в Kafka снимает дубликаты на стороне брокера, но не отменяет идемпотентность для записи в PostgreSQL, HTTP-вызова или отправки email.

Сквозная доставка «ровно один раз от кнопки до банка» в реальных системах не встречается: на каждом hop остаётся at-most-once или at-least-once, а целостность эффекта достигается паттернами на более высоком уровне.

Как выбрать семантику

ТребованиеРекомендация
Потеря 1% событий допустимаAt-most-once
Потеря недопустима, дубликат переживаем идемпотентностьюAt-least-once (базовый выбор)
Дубликат на wire внутри Kafka ломает агрегациюEOS Kafka + идемпотентный sink в БД
Side-effect вне Kafka (email, HTTP, 1С)Идемпотентность handler; EOS брокера недостаточен

Три слоя гарантий

Надёжность интеграции удобно мыслить слоями. На каждом слое свои границы ответственности.

СлойЧто контролируетТипичная семантика
Сеть и клиентTCP, HTTP retry, таймаутыПовтор запроса без знания, выполнился ли предыдущий
БрокерПерсистентность, ack, EOS producer/consumerAt-least-once; EOS — в пределах кластера Kafka
ПриложениеБизнес-логика, БД, внешние APIИдемпотентность — единственная сквозная защита эффекта

Сбой между side-effect и ack

Типичный инцидент, который брокер сам не закрывает:

Consumer успешно списал деньги или отправил письмо, упал до commit offset или обновления статуса в БД — при рестарте сообщение придёт снова. Разбор на примере email — рассылка как распределённая система.

Порядок операций в handler:

  1. Проверить dedup-ключ (eventId, Idempotency-Key) — если уже обработано, вернуть сохранённый результат или no-op.
  2. Выполнить side-effect (charge, email, webhook).
  3. Зафиксировать «обработано» и ack / commit offset в одной транзакции, где это возможно; иначе — outbox или idempotency key у внешней системы.

Сквозной пример — оплата заказа

Цепочка: API → outbox → Kafka → payment-service → платёжный шлюз.

  1. Клиент шлёт POST /orders с Idempotency-Key: uuid-1. API создаёт заказ и строку outbox в одной транзакции PostgreSQL.
  2. Relay читает outbox, публикует order.created в Kafka (at-least-once при retry relay).
  3. Payment-service consumer получает событие с eventId=evt_abc. Проверяет processed_events — пусто.
  4. Вызывает шлюз с тем же idempotencyKey=evt_abc. Шлюз списывает деньги один раз.
  5. Consumer пишет в processed_events и commit offset.

Повтор на шаге 4 (crash после charge, до commit): Kafka отдаёт evt_abc снова → шаг 3 находит запись в processed_events → charge не повторяется → effectively exactly-once для бизнеса.

Повтор на шаге 1 (клиент retry POST): API находит uuid-1 в store → возвращает тот же orderId без второго заказа.

Без ключей на шагах 1 и 4 любой retry даёт второй заказ или второе списание, даже при EOS в Kafka.


Effectively exactly-once

Effectively exactly-once (иногда «semantic exactly-once») — практическая цель системы: каждое бизнес-событие влияет на состояние ровно один раз, хотя транспорт работает как at-least-once.

Effectively exactly-once = at-least-once + идемпотентная обработка

EOS (Kafka)Effectively exactly-once
ОбластьКластер Kafka, stream jobБизнес-состояние, БД, внешние API
От чего защищаетRetry producer, дубликат offset в jobЛюбой повтор того же eventId / ключа
Чего не закрываетЗапись в PostgreSQL, HTTP, emailДубликат с новым ключом при повторной публикации
Типичные инструментыenable.idempotence, транзакцииDedup-таблица, Idempotency-Key, upsert, outbox

Kafka EOS (enable.idempotence, транзакционный producer, read_committed) — механизм инфраструктурного слоя. Ограничения и outbox для связки с БД — семантика exactly-once в Kafka.


Паттерны идемпотентности

ПаттернСутьКогда применять
Idempotency-Key (HTTP)Клиент передаёт UUID; сервер сохраняет результат первого вызоваPOST с риском retry (оформление заказа, платёж)
Natural key / upsertINSERT … ON CONFLICT или «обновить, если версия совпала»Синхронизация сущностей по orderId, externalId
Таблица обработанных событийprocessed_events(event_id) с уникальным индексомConsumer очереди или Kafka
Lease / lock на строкуОдин воркер держит lock на задачу до TTLДолгая обработка, recovery после падения
Transactional outboxСобытие пишется в ту же транзакцию, что и изменение агрегата«Commit в БД» и «опубликовать в broker» атомарно

Таблица processed_events

Минимальная схема для consumer:

CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
handled_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Handler: сначала попытка занять слот события, затем бизнес-логика только при успехе:

-- Шаг 1: атомарно «забронировать» event_id (0 строк → уже обработано)
INSERT INTO processed_events (event_id) VALUES ('evt_42')
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;

-- Шаг 2: если RETURNING вернул строку — side-effect и UPDATE заказа
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 123 AND status = 'pending';
-- charge у платёжного API с idempotencyKey = evt_42
COMMIT;
-- Шаг 3: ack / commit offset

Для HTTP-store ключей и TTL 24–72 ч — пример на C# в методах и ключе идемпотентности.

Пример outbox и webhook — mTLS, JWS и AsyncAPI с outbox. Saga и компенсации — Saga / Outbox.


Типичные ошибки

ОшибкаПоследствиеЧто делать
Ack до обработки при «надёжной» очередиAt-most-once, потеря сообщений при crashAck после успеха; осознанный выбор семантики
Dedup после charge / emailДубликат side-effect при redeliveryПроверка ключа до необратимого действия
EOS Kafka «вместо» идемпотентности в сервисеДвойная запись в PostgreSQL или двойной webhookOutbox + idempotent sink
Новый UUID на каждый retry producerEOS не видит дубликатСтабильный eventId в payload или idempotency key
Dedup без TTL и без архивацииРост таблицы, деградация индексаTTL, партиционирование, «вечные» ключи только для финансов
«PUT идемпотентен» без проверки в кодеДва списания при retry клиентаUpsert статуса + idempotency у платёжки

Необратимый side-effect без ключа
Отправка SMS, списание с карты, выпуск билета без idempotency key у провайдера — зона, где at-least-once превращается в at-least-twice charged. Либо ключ у внешней системы, либо outbox + строгий порядок «записали факт → потом необратимое действие» с dedup до вызова.


Правило проектирования

С чего начинать
Если бизнес-логика под вашим контролем — закладывайте идемпотентность в handler до выбора брокера и до обсуждения «включим EOS». Гарантии транспорта уменьшают число дубликатов на wire; они не заменяют защиту от двойного списания, второго письма или повторного HTTP-вызова.

Чек-лист перед prod:

  • У каждой операции с деньгами или irreversible side-effect есть стабильный ключ (eventId, Idempotency-Key, (campaign_id, recipient_id)).
  • Consumer делает ack / commit offset после успешной обработки (at-least-once осознанно).
  • Dedup-проверка выполняется до charge, email, webhook.
  • Повторная обработка с тем же ключом возвращает тот же результат или no-op.
  • Внешние вызовы идемпотентны или идут через outbox + relay с SKIP LOCKED.
  • DLQ и метрика «повторная доставка того же eventId» — на случай багов в dedup.

См. также

ТемаГде углубиться
Асинхронность и retryАсинхронная коммуникация, гарантии доставки
Брокеры и дедупликацияБрокеры сообщений, микросервисы — брокеры
Kafka EOSKafka в разделе 2, Kafka в разделе 8
HTTP и RESTHTTP, методы и Idempotency-Key
ТерминыИдемпотентность, At-least-once, Effectively exactly-once
СамопроверкаВопросы по брокерам

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").