Гарантии доставки и идемпотентность
Интуиция: курьер и посылка
Представь курьера, который везёт тебе посылку. Он может работать по-разному:
- Бросил через забор и уехал. Быстро, но если посылка упала в лужу, никто не узнает. Это 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. Его генерирует отправитель, и при ретраях ключ не меняется. Потребитель запоминает обработанные ключи и пропускает повторы.
Главное условие: запись ключа и бизнес-эффект должны попасть в одну транзакцию. Иначе между ними снова появится окно для сбоя.
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 и публикует события в брокер.
Как работает relay:
- Забирает непубликованные строки: поллингом раз в N мс или через CDC, то есть чтение лога репликации (WAL).
- Публикует их в Kafka.
- Помечает строки отправленными.
Если 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), ты уже отвечаешь на уровне уверенного мидла+.