Идемпотентность и ключи
Что такое идемпотентность
Идемпотентность — свойство операции, при котором повтор с тем же идентификатором не меняет итогового эффекта. В распределенных системах это основной способ сделать результат эквивалентным «ровно одной обработке», несмотря на ретраи, дубликаты сообщений и таймауты.
Ключевая идея: каждая потенциально повторяемая операция должна быть помечена ключом, по которому система распознает «это уже делали» и применяет результат не более одного раза.
Где это важно
Платежи и балансы: списания/зачисления по `operation_id`.
Бронирования/квоты/лимиты: один и тот же слот/ресурс.
Вебхуки/уведомления: повторная доставка не должна дублировать эффект.
Импорт/миграции: повторный прогон файлов/пакетов.
Стрим-процессинг: дубли из брокера/CDC.
Виды ключей и область их действия
1. Operation key — идентификатор конкретной попытки бизнес-операции
Примеры: `idempotency_key` (HTTP), `operation_id` (RPC).
Область: сервис/агрегат; хранится в таблице дедупликации.
2. Event key — уникальный идентификатор события/сообщения
Примеры: `event_id` (UUID), `(producer_id, sequence)`.
Область: потребитель/группа потребителей; защищает проекции.
3. Business key — естественный ключ предметной области
Примеры: `payment_id`, `invoice_number`, `(user_id, day)`.
Область: агрегат; применяется в проверках уникальности/версии.
TTL и политика хранения
TTL ключей ≥ возможного окна повторов: ретенция лога + сетевые/процессные задержки.
Для критичных доменов (платежи) TTL — дни/недели; для телеметрии — часы.
Чистите дедуп-таблицы бэкграунд-джобами; для аудита — архивируйте.
Хранилища для ключей (дедупликация)
Транзакционная БД (рекомендуется): надежные upsert/unique-индексы, совместная транзакция с эффектом.
KV/Redis: быстро, удобно для короткого TTL, но без совместной транзакции с OLTP — осторожно.
State store стрим-процессора: локально + чейнджлог в брокере; хорошо в Flink/KStreams.
- idempotency_keys
`consumer_id` (или `service`), `op_id` (PK на пару), `applied_at`, `ttl_expires_at`, `result_hash`/`response_status` (опц.).
Индексы: `(consumer_id, op_id)` — уникальный.
Базовые приемы реализации
1) Транзакция «эффект + прогресс»
Запись результата и фиксация прогресса чтения/позиции — в одной транзакции.
pseudo begin tx if not exists(select 1 from idempotency_keys where consumer=:c and op_id=:id) then
-- apply effect atomically (upsert/merge/increment)
apply_effect(...)
insert into idempotency_keys(consumer, op_id, applied_at)
values(:c,:id, now)
end if
-- record reading progress (offset/position)
upsert offsets set pos=:pos where consumer=:c commit
2) Optimistic Concurrency (версия агрегата)
Защищает от двойного эффекта при гонках:sql update account set balance = balance +:delta,
version = version + 1 where id=:account_id and version=:expected_version;
-- if 0 rows are updated → retry/conflict
3) Идемпотентные sinks (upsert/merge)
Операция «начислить один раз»:sql insert into bonuses(user_id, op_id, amount)
values(:u,:op,:amt)
on conflict (user_id, op_id) do nothing;
Идемпотентность в протоколах
HTTP/REST
Заголовок `Idempotency-Key: <uuid|hash>`.
Сервер хранит запись ключа и повторно возвращает тот же ответ (или код `409`/`422` при конфликте инвариантов).
Для «небезопасных» POST — обязателен `Idempotency-Key` + устойчивый таймаут/ретрай-политика.
gRPC/RPC
Метаданные `idempotency_key`, `request_id` + deadline.
Серверная реализация — как в REST: таблица дедупа в транзакции.
Брокеры/стриминг (Kafka/NATS/Pulsar)
Продюсер: стабильный `event_id`/идемпотентный продюсер (где поддерживается).
Консьюмер: дедуп по `(consumer_id, event_id)` и/или по бизнес-версии агрегата.
Отдельный DLQ для неидемпотентных/поврежденных сообщений.
Вебхуки и внешние партнеры
Требуйте `Idempotency-Key`/`event_id` в контракте; повторная доставка должна быть безопасной.
Храните `notification_id` и статусы отправки; при ретрае — не дублируйте.
Проектирование ключей
Детерминированность: ретраи должны присылать тот же ключ (генерируйте заранее на клиенте/оркестраторе).
Область видимости: формируйте `op_id` как `service:aggregate:id:purpose`.
Коллизии: используйте UUIDv7/ULID или хэш от бизнес-параметров (с солью при необходимости).
Иерархия: общий `operation_id` на фронте → транслируется во все подоперации (идемпотентная цепочка).
UX и продуктовые аспекты
Повторный запрос по ключу должен возвращать тот же результат (включая тело/статус), либо явное «уже выполнено».
Отображайте пользователю статусы «операция обрабатывается/завершена» вместо повторной попытки «на удачу».
Для долгих операций — поллинг по ключу (`GET /operations/{op_id}`).
Наблюдаемость
Логируйте `op_id`, `event_id`, `trace_id`, исход: `APPLIED` / `ALREADY_APPLIED`.
Метрики: доля повторов, размер дедуп-таблиц, время транзакций, конфликтов версий, DLQ-ставка.
Трейс: ключ должен проходить через команду → событие → проекцию → внешний вызов.
Безопасность и комплаенс
Не храните PII в ключах; ключ — идентификатор, не payload.
Шифруйте чувствительные поля в записях дедупа при длительном TTL.
Политика хранения: TTL и архивы; право на забвение — через крипто-стирание ответов/метаданных (если они содержат PII).
Тестирование
1. Дубликаты: прогон одного сообщения/запроса 2–5 раз — эффект ровно один.
2. Падение между шагами: до/после записи эффекта, до/после фиксации офсета.
3. Рестарт/ребаланс потребителей: нет двойного применения.
4. Конкуренция: параллельные запросы с одним `op_id` → один эффект, второй — `ALREADY_APPLIED/409`.
5. Долгоживущие ключи: проверка истечения TTL и повторов после восстановления.
Антипаттерны
Случайный новый ключ на каждый ретрай: система не распознает повторы.
Два отдельные коммита: сначала эффект, потом офсет — падение между ними дублирует эффект.
Доверие только брокеру: отсутствие дедупа в синке/агрегате.
Отсутствие версии агрегата: повторное событие меняет состояние второй раз.
Fat keys: ключ включает бизнес-поля/PII → утечки и сложные индексы.
Отсутствие повторяемых ответов: клиент не может безопасно ретраить.
Примеры
Платежный POST
Клиент: `POST /payments` + `Idempotency-Key: k-789`.
Сервер: транзакция — создает `payment` и запись в `idempotency_keys`.
Повтор: возвращает тот же `201`/тело; при конфликте инварианта — `409`.
Начисление бонуса (sink)
sql insert into credits(user_id, op_id, amount, created_at)
values(:u,:op,:amt, now)
on conflict (user_id, op_id) do nothing;
Проекция из событий
Консьюмер хранит `seen(event_id)` и `version` агрегата; повтор — игнор/идемпотентный upsert.
Прогресс чтения фиксируется в той же транзакции, что и обновление проекции.
Чек-лист продакшена
- Для всех небезопасных операций определен идемпотентный ключ и его область видимости.
- Есть таблицы дедупа с TTL и уникальными индексами.
- Эффект и прогресс чтения коммитятся атомарно.
- В write-модели включена оптимистичная конкуренция (версия/sequence).
- Контракты API фиксируют `Idempotency-Key`/`operation_id` и поведение повторов.
- Метрики и логи содержат `op_id`/`event_id`/`trace_id`.
- Тесты на дубликаты, падения и гонки — в CI.
- Политика TTL/архива и безопасность PII соблюдены.
FAQ
Чем `Idempotency-Key` отличается от `Request-Id`?
`Request-Id` — трассировка; он может меняться на ретраях. `Idempotency-Key` — семантический идентификатор операции, обязателен одинаковый при повторах.
Можно ли делать идемпотентность без БД?
Для короткого окна — да (Redis/внутрипроцессный кеш), но без совместной транзакции растет риск дублей. В критичных доменах — лучше в одной БД-транзакции.
Что делать с внешними партнерами?
Договаривайтесь о ключах и повторяемых ответах. Если партнер не поддерживает — оборачивайте вызов в свой идемпотентный слой и храните «уже применялось».
Как выбирать TTL?
Суммируйте максимальные задержки: ретенция лога + worst-case сети/ребаланса + буфер. Добавляйте запас (×2).
Итог
Идемпотентность — это дисциплина ключей, транзакций и версий. Устойчивые идентификаторы операций + атомарная фиксация эффекта и прогресса чтения + идемпотентные sinks/проекции дают «ровно один эффект» без магии транспортного уровня. Делайте ключи детерминированными, TTL — реалистичными, а тесты — злонамеренными. Тогда ретраи и дубликаты станут рутиной, а не инцидентами.