Exactly-once vs At-least-once
1) Навіщо взагалі обговорювати семантики
Семантика доставки визначає як часто одержувач побачить повідомлення при збоях і ретраях:- At-most-once - без повторів, але можлива втрата (рідко прийнятно).
- At-least-once - не втрачаємо, але можливі дублікати (дефолт більшості брокерів/черг).
- Exactly-once - кожне повідомлення обробляється рівно один раз з точки зору спостережуваного ефекту.
Ключова істина: в розподіленому світі без глобальних транзакцій і синхронної узгодженості «чисте» end-to-end exactly-once недосяжне. Ми будуємо ефективно exactly-once: допускаємо повтори на транспорті, але робимо обробку ідемпотентною так, щоб спостережуваний ефект був «ніби один раз».
2) Модель відмов і де виникають дублікати
Повтори з'являються через:- Втрати ack/commit (продюсер/брокер/консюмер «не почули» підтвердження).
- Перевиборів лідерів/реплік, відновлень після мережевих розривів.
- Таймаутів/ретраїв на будь-яких ділянках (kliyent→broker→konsyumer→sink).
Наслідок: не можна покладатися на «унікальність доставки» транспорту. Керуємо ефектами: запис в БД, списання грошей, відправка листа і т.п.
3) Exactly-once в постачальниках і що це насправді
3. 1 Kafka
Дає цеглинки:- Idempotent Producer (`enable. idempotence = true') - запобігає дублі на стороні продюсера при ретраях.
- Транзакції - атомарно публікують повідомлення в кілька партій і коммитят офсети споживання (патерн read-process-write без «перепусток»).
- Compaction - зберігає останнє значення за ключем.
Але «кінець ланцюга» (синк: БД/платіж/пошта) все одно вимагає ідемпотентності. Інакше дубль обробника викличе дубль ефекту.
3. 2 NATS / Rabbit / SQS
За замовчуванням - at-least-once з ack/redelivery. Exactly-once досягається на рівні додатку: ключі, дедуп-стор, upsert.
Висновок: Exactly-once транспортом ≠ exactly-once ефектом. Останній робиться в обробнику.
4) Як побудувати ефективно exactly-once поверх at-least-once
4. 1 Ідемпотентний ключ (idempotency key)
Кожна команда/подія несе природний ключ: `payment_id`, `order_id#step`, `saga_id#n`. Обробник:- Перевіряє «вже бачив?» - дедуп-стор (Redis/БД) з TTL/ретеншном.
- Якщо бачив - повторює раніше обчислений результат або робить no-op.
lua
-- SET key if not exists; expires in 24h local ok = redis.call("SET", KEYS[1], ARGV[1], "NX", "EX", 86400)
if ok then return "PROCESS" else return "SKIP" end
4. 2 Upsert в базі (ідемпотентний синк)
Записи робляться через UPSERT/ON CONFLICT з перевіркою версії/суми.
PostgreSQL:sql
INSERT INTO payments(id, status, amount, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (id) DO UPDATE
SET status = EXCLUDED.status,
updated_at = now()
WHERE payments.status <> EXCLUDED.status;
4. 3 Транзакційний Outbox/Inbox
Outbox: бізнес-транзакція і запис «події до публікації» відбуваються в одній транзакції БД. Фоновий публікатор читає outbox і відправляє в брокер → немає розбіжностей між станом і подією.
Inbox: для вхідних команд зберігаємо'message _ id'і результат до виконання; повторна обробка бачить запис і не повторює побічні ефекти.
4. 4 Консистентна обробка ланцюжка (read→process→write)
Kafka: транзакція «прочитав офсет → записав результати → коміт» в один атомарний блок.
Без транзакцій: «спочатку запиши результат/Inbox, потім ack»; при креші дублікат побачить Inbox і завершиться no-op.
4. 5 SAGA/компенсації
Коли ідемпотентність неможлива (зовнішній провайдер списав гроші), використовуємо компенсуючі операції (refund/void) і ідемпотентні зовнішні API (повторний'POST'з тим же'Idempotency-Key'дає той же підсумок).
5) Коли достатньо at-least-once
Оновлення кешів/матеріалізованих представлень з компакцією по ключу.
Лічильники/метрики, де повторна інкрементація прийнятна (або зберігаємо дельти з версією).
Нотифікації, де вторинне письмо не критично (краще все одно ставити ключ).
Правило: якщо дубль не змінює бізнес-сенс або легко виявимо → at-least-once + частковий захист.
6) Продуктивність і вартість
Exactly-once (навіть «ефективно») коштує дорожче: доп. записи (Inbox/Outbox), зберігання ключів, транзакцій, складніше діагностика.
At-least-once дешевше/простіше, краще по throughput/p99.
Оцінюйте: ціна дубля × ймовірність дубля vs вартість захисту.
7) Приклади конфігурацій і коду
7. 1 Kafka продюсер (ідемпотентність + транзакції)
properties enable.idempotence=true acks=all retries=INT_MAX max.in.flight.requests.per.connection=5 transactional.id=orders-writer-1
java producer.initTransactions();
producer.beginTransaction();
producer.send(recordA);
producer.send(recordB);
// также можно atomically commit consumer offsets producer.commitTransaction();
7. 2 Консюмер з Inbox (псевдокод)
pseudo if (inbox.exists(msg.id)) return inbox.result(msg.id)
begin tx if!inbox.insert(msg.id) then return inbox.result(msg.id)
result = handle(msg)
sink.upsert(result) # идемпотентный синк inbox.set_result(msg.id, result)
commit ack(msg)
7. 3 HTTP Idempotency-Key (зовнішні API)
POST /payments
Idempotency-Key: 7f1c-42-...
Body: { "payment_id": "p-123", "amount": 10.00 }
Повторний POST з тим же ключем → той же результат/статус.
8) Спостережуваність і метрики
'duplicate _ attempts _ total'- скільки разів ловили дубль (по Inbox/Redis).
'idempotency _ hit _ rate'- частка повторів, «врятованих» ідемпотентністю.
'txn _ abort _ rate'( Kafka/БД) - частка відкатів.
'outbox _ backlog'- відставання публікації.
'exactly _ once _ path _ latency {p95, p99}'vs'at _ least _ once _ path _ latency'- накладні витрати.
Аудит логів: связка `message_id`, `idempotency_key`, `saga_id`, `attempt`.
9) Тест-плейбуки (Game Days)
Повтор відправки: ретраї продюсера при штучних таймаутах.
Креш між «сінк і ack»: переконатися, що Inbox/Upsert запобігають дубль.
Пере-доставка: збільшити redelivery в брокері; перевірити дедуп.
Ідемпотентність зовнішніх API: повторні POST з тим же ключем - однакова відповідь.
Зміна лідера/розрив мережі: перевірити транзакції Kafka/поведінку консюмерів.
10) Анти-патерни
Покладатися на транспорт: «у нас Kafka з exactly-once, значить можна без ключів» - ні.
No-op ack до запису: ackнули, але синк впав → втрата.
Відсутність DLQ/ретраїв з джиттером: нескінченні повтори і шторм.
Випадкові UUID замість природних ключів: нема чим дедуплікувати.
Змішування Inbox/Outbox з прод-таблицями без індексів: гарячі блокування і p99-хвости.
Бізнес-операції без ідемпотентного API у зовнішніх провайдерів.
11) Чек-лист вибору
1. Ціна дубля (гроші/юридика/UX) vs ціна захисту (латентність/складність/вартість).
2. Чи є природний ключ події/операції? Якщо ні - придумайте стабільний.
3. Синк підтримує Upsert/версіонування? Інакше - Inbox + компенсації.
4. Чи потрібні глобальні транзакції? Якщо ні - сегментуйте на SAGA.
5. Потрібно реплей/довгий ретеншн? Kafka + Outbox. Потрібні швидкі RPC/низька затримка? NATS + Idempotency-Key.
6. Мульти-тенантність і квоти: ізоляція ключів/просторів.
7. Спостережуваність: метрики idempotency і backlog включені.
12) FAQ
Q: Чи можна досягти «математичного» exactly-once end-to-end?
A: Тільки у вузьких сценаріях з одним консистентним сховищем і транзакціями на всьому шляху. У загальному випадку - ні; використовуйте ефективно exactly-once через ідемпотентність.
Q: Що швидше?
A: At-least-once. Exactly-once додає транзакції/зберігання ключів → вище p99 і вартість.
Q: Де зберігати ключі ідемпотентності?
A: Швидкий стор (Redis) з TTL, або таблиця Inbox (PK = message _ id). Для платежів - довше (дні/тижні).
Q: Як вибирати TTL дедуп-ключів?
A: Мінімум = максимальний час повторної доставки + операційний запас (зазвичай 24-72 год). Для фінансів - більше.
Q: Чи потрібен ключ, якщо у мене compaction по ключу в Kafka?
A: Так. Compaction зменшить зберігання, але не зробить ваш синк ідемпотентним.
13) Підсумки
At-least-once - базова, надійна семантика транспорту.
Exactly-once як бізнес-ефект досягається на рівні обробника: Idempotency-Key, Inbox/Outbox, Upsert/версії, SAGA/компенсації.
Вибір - це компроміс вартість ↔ ризик дубля ↔ простота експлуатації. Проектуйте природні ключі, робіть синкі ідемпотентними, додавайте спостережуваність і регулярно проводите game days - тоді ваші пайплайни будуть передбачуваними і безпечними навіть при штормі ретраїв і відмов.