awesome-everything EN
↑ Обратно к восхождению

Очереди, потоки, события

Consumer-side dedup: самый дешёвый путь к exactly-once processing

Суть Как оборачивать side effect и dedup INSERT в одну DB-транзакцию, почему INSERT должен быть первым и паттерн Stripe Idempotency-Key для внешних API.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 12 min

Ты добавил dedup-проверку в payment consumer — быстрый SELECT перед списанием. Дубликаты упали с десятков в неделю до нуля. Потом, три недели спустя, истощение пула DB-коннекций вызвало тихий сбой проверки, и списание прошло дважды снова. Проверка была вне транзакции. Одна строка не в том месте.

Наивный dedup-паттерн и почему он не работает

Первый инстинкт — двухшаговая проверка:

SELECT 1 FROM processed WHERE msg_id = 'msg-7a3f';
-- если найдено: пропустить
-- если нет: вызвать Stripe, потом INSERT в processed

Это ломается при конкурентной доставке. Два consumer-а получают одно сообщение одновременно (возможно при rebalance или после visibility timeout). Оба делают SELECT в один момент, оба видят «не найдено», оба вызывают Stripe. Race condition. Два списания.

Правильный паттерн: INSERT первым, одна транзакция

Фикс: dedup INSERT и side effect в одной атомарной DB-транзакции, и INSERT первым:

BEGIN;
  INSERT INTO processed (msg_id, created_at)
  VALUES ('msg-7a3f', now());
  -- при UNIQUE violation: ROLLBACK и пропустить
  -- если insert успешен: выполнить side effect
  UPDATE orders SET status = 'paid' WHERE id = 'O-123';
COMMIT;

При unique-constraint violation транзакция откатывается — side effect никогда не выполняется. При commit обе записи — dedup-row и side effect — пишутся вместе. Нет crash-окна между ними.

Ключевое свойство: если consumer падает после commit DB-транзакции, но до ack брокера, брокер передоставляет. Следующий consumer пытается INSERT msg-7a3f снова, попадает на unique constraint, откатывается, ack-ит брокер. Side effect уже был выполнен один раз; дубликат тихо отбрасывается.

Структура транзакции: INSERT dedup-row первым
1BEGIN транзакции
2INSERT INTO processed (msg_id) — UNIQUE constraint
2aUNIQUE violation? → ROLLBACK. Лог «дубликат пропущен». Ack брокер.
3Выполнить side effect (UPDATE orders, отправить email job и т.д.)
4COMMIT транзакции
5Ack брокер — сообщение удалено из очереди

Внешние API: Stripe Idempotency-Key

Трюк с транзакцией работает только когда side effect — DB-запись внутри той же транзакции. А внешние API — Stripe, SES, Twilio? HTTP-вызов нельзя включить в Postgres-транзакцию.

Паттерн для внешних API: передавай Idempotency-Key header, derived из msg ID.

POST /v1/charges
Idempotency-Key: msg-7a3f

Stripe хранит ключ и первый response 24 часа. Если вызвать Stripe снова с тем же ключом (потому что брокер передоставил), Stripe возвращает кешированный response без повторного списания. PayPal, Square и большинство payment API следуют той же конвенции.

Когда внешний API поддерживает idempotency keys, паттерн:

  1. INSERT pending-строку: INSERT INTO stripe_intents (msg_id, status='pending') — в транзакции. Это intent-лог.
  2. Вызвать внешний API с Idempotency-Key = msg_id.
  3. При успехе: UPDATE строку в status='completed', charge_id=....

Если consumer падает между шагами 2 и 3, redelivery снова вызывает Stripe с тем же ключом (Stripe возвращает кешированный charge_id), потом завершает UPDATE. Двойного списания нет.

Викторина

Почему dedup INSERT должен быть в той же транзакции, что и side effect?

Викторина

Consumer использует Stripe Idempotency-Key, но без локального DB dedup. Stripe-вызов успешен, consumer падает до ack. На redelivery что происходит?

Расставь шаги по порядку

Поставь шаги корректного идемпотентного consumer-а с внешним payment API:

  1. 1 Получить msg-7a3f от брокера
  2. 2 BEGIN DB-транзакцию
  3. 3 INSERT INTO payment_intents (msg_id, status='pending') — unique на msg_id
  4. 4 COMMIT pending-строку
  5. 5 Вызвать Stripe с Idempotency-Key=msg-7a3f
  6. 6 UPDATE payment_intents SET status='done', charge_id=ch_abc123
  7. 7 Ack брокер — сообщение удалено из очереди
Вспомните перед уходом
  1. 01
    Какое crash-окно делает SELECT-then-act небезопасным для dedup?
  2. 02
    Если consumer падает после DB COMMIT, но до ack брокера, что происходит на redelivery?
  3. 03
    Каков Stripe Idempotency-Key TTL и что происходит после истечения?
Итог

Consumer-side dedup — самый дешёвый путь к effectively exactly-once processing: веди таблицу обработанных сообщений с UNIQUE constraint на msg ID, BEGIN транзакцию, INSERT dedup-строку первым, выполни side effect, COMMIT. UNIQUE violation на redelivery откатывает всю транзакцию, поэтому side effect никогда не повторяется. Для внешних API, живущих вне DB-транзакции, derive Idempotency-Key из msg ID и передавай с каждым вызовом — Stripe, PayPal и Square поддерживают эту конвенцию и кешируют первый response минимум 24 часа, делая retry безопасно идемпотентными через границу брокера.

Связанные уроки
встречается в202
Продолжить восхождение ↑Kafka exactly-once semantics: idempotent producer и транзакции
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.