Ctrl K
Урок

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

Интуиция: курьер и посылка

Представь курьера, который везёт тебе посылку. Он может работать по-разному:

  • Бросил через забор и уехал. Быстро, но если посылка упала в лужу, никто не узнает. Это at-most-once: доставим не больше одного раза, а может, и ни разу.
  • Звонит в дверь, пока ты не распишешься. Если ты расписался, а квитанция потерялась, курьер приедет снова, и посылок станет две. Это at-least-once: доставим точно, но возможны дубли.
  • Ровно одна посылка, всегда. Это exactly-once. Звучит идеально, но в распределённой системе честно гарантировать такое почти невозможно.

В очередях всё так же. Сообщение может потеряться по дороге. А может обработаться, но подтверждение (ack) не дойдёт до брокера, и он отправит сообщение повторно.

Три гарантии рядом

Гарантия Когда ack Риск Где уместно
At-most-once до обработки потеря сообщения метрики, логи, «лайк просмотрен»
At-least-once после обработки дубли платежи, заказы, почти всё важное
Exactly-once транзакционно сложность, ограничения внутри одной экосистемы (Kafka Streams)

Почему exactly-once — скорее маркетинг? Kafka умеет exactly-once, но только пока данные не выходят за её пределы. Как только обработчик пишет в чужую базу или вызывает платёжный API, брокер уже не может это контролировать. Между «сделал» и «подтвердил» всегда остаётся окно, где может случиться сбой.

Поэтому на практике собирают так: at-least-once + идемпотентный обработчик = эффективно exactly-once. Дубли приходят, но повторная обработка ничего не меняет.

На собесе скажи прямо: «Exactly-once между разными системами не бывает. Я беру at-least-once и делаю потребителя идемпотентным». Интервьюеры именно это и хотят услышать.

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

Идемпотентная операция при повторе даёт тот же результат. SET balance = 100 идемпотентна, а balance = balance + 10 — нет.

Если операцию нельзя сделать идемпотентной по природе (списать деньги, отправить письмо), помогает ключ идемпотентности. Это уникальный ID сообщения или запроса: message_id, order_id, заголовок Idempotency-Key. Его генерирует отправитель, и при ретраях ключ не меняется. Потребитель запоминает обработанные ключи и пропускает повторы.

msg + message_idможет прийти дваждыуже было?эффектProducerсервисОчередь (at-least-once)очередьConsumerворкерprocessed_messagesБДБизнес-таблицыБД
Потребитель проверяет ключ и применяет эффект в одной транзакции

Главное условие: запись ключа и бизнес-эффект должны попасть в одну транзакцию. Иначе между ними снова появится окно для сбоя.

BEGIN;
INSERT INTO processed_messages (message_id) VALUES ('m-42')
  ON CONFLICT DO NOTHING;            -- 0 строк? это дубль, выходим
UPDATE accounts SET balance = balance - 500 WHERE id = 7;
COMMIT;

Компромиссы, которые стоит назвать:

  • таблица ключей растёт, поэтому старые записи чистят по TTL (например, хранят 7 дней). Это должно быть дольше, чем самый долгий возможный ретрай;
  • если эффект во внешней системе (Stripe, SMS-шлюз), передавай ключ туда. Хорошие API принимают Idempotency-Key.

Outbox: запись в базу и событие без рассинхрона

Классическая ловушка: сервис сохраняет заказ в базу и публикует событие в Kafka. Это две разные системы, общей транзакции у них нет.

  • Сохранил в базу, упал до публикации — событие потеряно, склад о заказе не узнает.
  • Опубликовал, а транзакция в базе откатилась — по системе гуляет событие о заказе, которого нет.

Outbox-паттерн решает это так: событие пишется в таблицу outbox в той же транзакции, что и заказ. Отдельный процесс-ретранслятор (relay) читает outbox и публикует события в брокер.

POST /ordersодна транзакциячитает новыеpublishКлиентклиентOrder ServiceсервисPostgreSQL: orders + outboxБДOutbox Relay / CDC (Debezium)воркерKafkaочередьСкладсервисУведомлениясервис
Outbox: атомарно пишем заказ и событие, публикует отдельный процесс

Как работает relay:

  1. Забирает непубликованные строки: поллингом раз в N мс или через CDC, то есть чтение лога репликации (WAL).
  2. Публикует их в Kafka.
  3. Помечает строки отправленными.

Если relay упадёт между шагами 2 и 3, событие уйдёт повторно. Это снова at-least-once, поэтому потребители должны быть идемпотентными. Всё сходится: outbox гарантирует, что событие не потеряется, а идемпотентность защищает от дублей.

Компромиссы: появляется задержка (от поллинга, обычно десятки миллисекунд), лишняя нагрузка на базу и ещё один компонент, который надо мониторить.

Ретраи с экспоненциальной задержкой

Обработчик упал, потому что внешний сервис лёг. Если ретраить сразу и всем потребителям одновременно, ты сам добьёшь этот сервис (эффект thundering herd). Поэтому задержку удваивают и добавляют случайный jitter:

delay = min(cap, base × 2^attempt) + random(0, delay)

Попытка base × 2^n (base = 1 с) С потолком cap = 30 с
1 1 с 1 с
2 2 с 2 с
3 4 с 4 с
4 8 с 8 с
5 16 с 16 с
6 32 с 30 с

Итого 61 секунда ожидания за 6 попыток. После этого сообщение уходит в DLQ (dead letter queue): его разбирают вручную или отдельным процессом, а основная очередь не застревает.

Ретраи безопасны только поверх идемпотентности. Ретраить неидемпотентную операцию — значит дважды списать деньги.

Отличай временные ошибки от постоянных: таймаут или 503 стоит ретраить, а 400 «невалидный JSON» бессмысленно, такое сообщение сразу отправляй в DLQ.

Как это звучит на собесе

«Брокер даёт at-least-once, ack шлём после обработки. Каждое сообщение несёт message_id. Потребитель в одной транзакции пишет ключ в таблицу дедупликации и применяет эффект. Публикацию событий из сервиса делаем через outbox, чтобы запись в базу и событие не разъехались. Ретраи с экспоненциальной задержкой и jitter, потолок 30 секунд, после 6 попыток сообщение уходит в DLQ».

Частые ошибки

  • Обещать exactly-once «потому что Kafka умеет», забыв про внешние системы.
  • Генерировать ключ идемпотентности заново при каждом ретрае. Тогда он бесполезен.
  • Проверять ключ и применять эффект в разных транзакциях.
  • Делать «запись в базу + publish» подряд в коде и считать это надёжным.
  • Ретраить без jitter и без лимита попыток, а потом удивляться бесконечным циклам и лавине запросов.

Если ты можешь связать эти четыре идеи в одну цепочку (at-least-once → идемпотентность → outbox → ретраи с DLQ), ты уже отвечаешь на уровне уверенного мидла+.