Ідемпотентність і ключі
Що таке ідемпотентність
Ідемпотентність - властивість операції, при якому повтор з тим же ідентифікатором не змінює підсумкового ефекту. У розподілених системах це основний спосіб зробити результат еквівалентним «рівно одній обробці», незважаючи на ретраї, дублікати повідомлень і таймаути.
Ключова ідея: кожна потенційно повторювана операція повинна бути позначена ключем, за яким система розпізнає «це вже робили» і застосовує результат не більше одного разу.
Де це важливо
Платежі та баланси: списання/зарахування за'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 - реалістичними, а тести - зловмисними. Тоді ретраї і дублікати стануть рутиною, а не інцидентами.