Idempotent Consumer
Idempotent Consumer — это потребитель сообщений, который гарантирует, что повторная обработка одного и того же сообщения (например, при сбое сети или перезапуске) не вызовет нежелательных побочных эффектов, таких как двойное списание средств.
Введение: Защита от двойной оплаты
Представьте, что вы платите за кофе картой. Терминал показывает “ошибка”. Вы прикладываете карту снова. Деньги списались дважды. Кофе вы получили один. Знакомая ситуация?
В мире сообщений то же самое. Брокер может доставить сообщение дважды. Сеть могла потерять подтверждение. Consumer упал после обработки, но до подтверждения. Consumer перезапустился и получил то же сообщение снова.
Idempotent Consumer (Идемпотентный потребитель) — это потребитель, который обрабатывает сообщение так, что повторная обработка того же сообщения не приводит к побочным эффектам. Если сообщение пришло дважды, результат будет таким же, как если бы оно пришло один раз.
Для системного аналитика идемпотентность — это ключевая техника для обеспечения exactly-once обработки в системах с at-least-once доставкой. Вместо того чтобы пытаться гарантировать, что сообщение придёт ровно один раз (что очень сложно), вы гарантируете, что повторная обработка не навредит.
Почему сообщения могут дублироваться
Сценарий 1: Сбой после обработки, до ack
1. Consumer получил сообщение
2. Обработал (записал в БД)
3. Сбой перед отправкой ack
4. Сообщение возвращается в очередь
5. Другой consumer получает то же сообщение
6. Обрабатывает снова → дубликат
Сценарий 2: Сбой сети
1. Consumer получил сообщение
2. Обработал
3. Отправил ack
4. Ack потерялся в сети
5. Брокер не получил ack
6. Отправляет сообщение снова → дубликат
Сценарий 3: Retry от производителя
1. Producer отправил сообщение
2. Не получил подтверждение
3. Отправил снова (с тем же idempotency key)
4. Брокер получил два одинаковых сообщенияИдемпотентность vs Exactly-Once
Exactly-Once:
- Брокер гарантирует, что сообщение будет доставлено ровно один раз
- Сложно, дорого, не все брокеры поддерживают
At-Least-Once + Idempotent Consumer:
- Брокер гарантирует, что сообщение не потеряется (может быть доставлено несколько раз)
- Consumer гарантирует, что повторная обработка безопасна
- Проще, дешевле, работает вездеКак сделать потребителя идемпотентным
Основная идея
Алгоритм:
1. Получить сообщение с уникальным идентификатором (idempotency key)
2. Проверить в хранилище: ключ уже обработан?
3. Если да → пропустить обработку, подтвердить
4. Если нет → обработать, сохранить ключ, подтвердитьВарианты реализации
| Подход | Как работает | Где хранить ключи |
|---|---|---|
| База данных | Проверка в БД перед вставкой | Таблица processed_events |
| Redis | Проверка по ключу в Redis | Redis SETNX |
| Брокер транзакции | Kafka transactions | Сам брокер |
| Idempotency key в API | Клиент передаёт ключ | На стороне сервера |
Хранение идемпотентности
В базе данных
Таблица processed_events:
- idempotency_key (PRIMARY KEY)
- processed_at (timestamp)
- result (json)
Алгоритм:
- BEGIN TRANSACTION
- SELECT * FROM processed_events WHERE idempotency_key = ?
- Если есть → COMMIT (ничего не делаем)
- Если нет → INSERT INTO processed_events...
- Выполнить бизнес-логику
- COMMIT
Преимущества: надёжно, транзакционно
Недостатки: медленно, нагрузка на БДВ Redis
Команда: SETNX idempotency_key "processed" EX 86400
Алгоритм:
- result = SETNX(key, "processed", TTL=24h)
- Если result == 0 → ключ уже есть → пропускаем
- Если result == 1 → ключа не было → обрабатываем
Преимущества: быстро
Недостатки: ключи могут потеряться (Redis не персистентен)В Kafka (транзакции)
Kafka транзакции:
- Producer: send(record) в транзакции
- Consumer: read_committed (видит только зафиксированные)
- Idempotent producer: enable.idempotence=true
Преимущества: встроено в брокер
Недостатки: только в Kafka, снижает производительностьIdempotency Key
Что это
Уникальный идентификатор операции, который клиент передаёт вместе с сообщением.
Заголовки сообщения:
- idempotency-key: 123e4567-e89b-12d3-a456-426614174000Требования к ключу
Уникальность:
- Разные операции → разные ключи
- Одна операция → один ключ (даже при повторах)
Предсказуемость:
- Клиент может сгенерировать ключ до отправки
- При повторной отправке ключ тот же
Примеры:
- UUID (v4): 123e4567-e89b-12d3-a456-426614174000
- Составной: order-123-creation-attempt-1Срок жизни ключа
Время хранения:
- Не меньше, чем максимальное время между повторными попытками
- Обычно 24 часа или 7 дней
Почему не вечно:
- Ключей становится слишком много
- Старые операции уже не повторятсяПример: Обработка платежа
Без идемпотентности (опасно)
1. Получено сообщение "платёж 1000 рублей"
2. Проверить баланс
3. Списать 1000 рублей
4. Отправить подтверждение
Проблема:
- Сообщение пришло дважды
- Деньги списались дважды
- Клиент потерял 1000 рублейС идемпотентностью (безопасно)
1. Получено сообщение с idempotency_key="pay-123"
2. SELECT * FROM payments WHERE idempotency_key = "pay-123"
3. Запись не найдена
4. Проверить баланс
5. Списать 1000 рублей
6. INSERT INTO payments (idempotency_key, amount, status) VALUES (...)
7. Отправить подтверждение
Если сообщение пришло второй раз:
1. Получено сообщение с тем же ключом
2. SELECT * FROM payments WHERE idempotency_key = "pay-123"
3. Запись найдена
4. Пропустить обработку
5. Отправить подтверждение (уже было)Idempotent Consumer в разных брокерах
RabbitMQ
Подход:
- Идемпотентность на стороне consumer
- Хранилище: БД или Redis
- Уникальный идентификатор в заголовках сообщенияKafka
Подход:
- enable.idempotence=true (producer)
- transactional API
- Consumer: read_committed
Ограничения:
- Идемпотентность в пределах одной партиции
- Для глобальной идемпотентности нужно своё хранилищеAWS SQS
Подход:
- FIFO очередь (exactly-once в пределах очереди)
- Или idempotent consumer с DynamoDB
FIFO очередь:
- deduplicationId (аналог idempotency key)
- Гарантирует, что сообщение с тем же ID не будет доставлено дваждыОчистка старых ключей
Проблема
Ключи накапливаются:
- 1 млн операций в день
- Через год → 365 млн записей
- Хранилище растёт бесконечноРешение
TTL (Time To Live):
- Хранить ключи 24 часа (или 7 дней)
- Автоматическое удаление старых записей
Redis:
- EXPIRE key 86400
База данных:
- Поле created_at
- Периодическая очистка: DELETE WHERE created_at < NOW() - INTERVAL '7 days'Преимущества и недостатки
Преимущества
| Преимущество | Объяснение |
|---|---|
| Exactly-once обработка | При at-least-once доставке |
| Простота | Проще, чем exactly-once от брокера |
| Не зависит от брокера | Работает с любым брокером |
| Устойчивость к сбоям | Повторные сообщения безопасны |
Недостатки
| Недостаток | Объяснение |
|---|---|
| Дополнительное хранилище | Нужно хранить ключи |
| Задержка | Дополнительный запрос в хранилище |
| Очистка | Нужно удалять старые ключи |
| Распределённые транзакции | Сложно, если операция не идемпотентна по природе |
Когда операция не может быть идемпотентной
Примеры неидемпотентных операций:
- Отправка email (повтор отправит два письма)
- Инкремент счётчика (повтор увеличит дважды)
- Добавление в лог (повтор добавит две записи)
Что делать:
- Сделать операцию идемпотентной по ключу
- Email: проверять, отправляли ли уже это письмо
- Счётчик: хранить не счётчик, а список событий (event sourcing)
- Лог: добавить idempotency key в записьРаспространённые ошибки
Ошибка 1: Идемпотентность только на уровне БД
Проверили ключ в БД, но операция уже сделала что-то вне БД (отправила email).
Решение: Идемпотентность должна покрывать всю операцию.
Ошибка 2: Слишком короткий TTL
Ключ хранится 1 час. Повторная попытка через 2 часа. Ключ уже удалён.
Решение: TTL должен быть больше, чем максимальное время между повторами.
Ошибка 3: Хранение ключей в памяти
При падении consumer все ключи теряются. При перезапуске повторные сообщения будут обработаны снова.
Решение: Использовать持久化 хранилище (БД, Redis с AOF).
Ошибка 4: Нет очистки ключей
Ключи накапливаются, хранилище переполняется.
Решение: TTL или периодическая очистка.
Ошибка 5: Игнорирование идемпотентности для критичных операций
Думают, что брокер гарантирует exactly-once. Но брокер даёт at-least-once.
Решение: Всегда предполагать at-least-once и делать consumer идемпотентным.
Практический пример
Задача: Система платежей
Настройка:
- Брокер: RabbitMQ
- Гарантии: at-least-once (publisher confirms + manual ack)
- Хранилище ключей: PostgreSQL
Очередь: payments.queue
Consumer:
1. Получить сообщение с idempotency_key
2. BEGIN;
3. SELECT 1 FROM processed_payments WHERE idempotency_key = ? FOR UPDATE
4. Если запись есть:
COMMIT;
ack();
return
5. INSERT INTO processed_payments (idempotency_key) VALUES (?)
6. Выполнить платёж (списание денег)
7. INSERT INTO payments (idempotency_key, amount, status) VALUES (...)
8. COMMIT;
9. ack();
TTL: 30 дней (очистка по расписанию)
Результат:
- Платёж не может быть обработан дважды
- Даже если сообщение пришло повторно
- Даже если consumer упал после платежа, но до ackРезюме
Idempotent Consumer — потребитель, который безопасно обрабатывает повторные сообщения. Результат повторной обработки тот же, что и однократной.
Зачем нужен: брокеры часто гарантируют at-least-once (сообщение не потеряется, но может быть доставлено несколько раз). Идемпотентный consumer превращает at-least-once в exactly-once с точки зрения эффекта.
Как работает: запомнить idempotency key обработанных сообщений. При повторном получении — пропустить.
Idempotency key — уникальный идентификатор операции. Генерируется клиентом. Передаётся в сообщении.
Хранилище ключей: БД (надёжно), Redis (быстро), Kafka transactions (встроено).
TTL: ключи должны храниться ограниченное время (24 часа, 7 дней). Иначе хранилище переполнится.
Когда обязательно: платежи, заказы, инвентаризация — любые операции, где повтор приводит к ошибке.