Идемпотентность фоновых задач в Python: как устроено внутри
Воркер перезапустился, задача выполнилась дважды, клиенту списали деньги два раза. Разбираем, почему брокеры доставляют задачи повторно и как построить идемпотентность на уровне ключа и БД.

Воркер Celery упал в середине обработки платежа. Брокер (RabbitMQ) не получил ack и через visibility_timeout вернул сообщение в очередь. Другой воркер подхватил его и провёл транзакцию повторно — клиенту списали 4900 рублей дважды. Причина не в баге кода задачи, а в том, что почти любая очередь сообщений даёт гарантию at-least-once, а не exactly-once. Разбираемся, где именно возникает повторная доставка и как устроена защита от неё изнутри.
Почему задача выполняется дважды
Модель доставки в брокерах строится на подтверждении. Воркер забирает сообщение, обрабатывает, отправляет ack. Пока ack не пришёл, брокер считает сообщение недоставленным и рано или поздно отдаст его снова. Повторная доставка происходит в трёх типичных точках:
- Воркер упал после работы, но до ack. Задача сделала всё, что нужно, но брокер об этом не знает и переотправит.
- Истёк visibility timeout. В SQS и Redis-брокерах у сообщения есть окно невидимости. Если обработка дольше окна — сообщение всплывает и его берёт второй воркер, пока первый ещё работает.
- Продьюсер отправил задачу дважды. Ретрай HTTP-запроса на стороне клиента, который в итоге дошёл оба раза.
Ни один из этих случаев не устраняется настройкой брокера. RabbitMQ и Kafka маркетингово упоминают «exactly-once», но это относится к внутренней транзакционности брокера, а не к побочным эффектам вашего кода — списанию денег, отправке письма, вызову внешнего API. Побочный эффект вне брокера, и гарантировать его однократность может только сам код задачи.
Exactly-once доставки на уровне бизнес-эффекта не существует. Есть at-least-once доставка плюс идемпотентный обработчик. Всё остальное — маркетинг.
Идемпотентность* здесь означает: сколько бы раз ни выполнилась задача с одними и теми же входными данными, наблюдаемый результат один и тот же. Второй запуск не создаёт второй платёж, не шлёт второе письмо, не увеличивает счётчик ещё раз.
* Идемпотентная операция даёт тот же результат при повторном применении. Классический пример — SET x = 5 идемпотентен, а x = x + 1 нет.
Механизм: ключ идемпотентности и его хранилище
Базовая конструкция — идемпотентный ключ: стабильный идентификатор конкретного намерения, а не конкретной доставки. Ключ приходит вместе с задачей, воркер запоминает уже обработанные ключи и при повторе просто выходит.
Ключ должен генерировать продьюсер, а не воркер. Если воркер сгенерирует ключ сам в момент старта, две параллельные доставки получат два разных ключа — защита не сработает. Хороший ключ — это UUID, привязанный к бизнес-событию: order:5512:payment, или uuid4(), сохранённый в момент создания заказа.
Где хранить отметку об обработке
Три реалистичных варианта, у каждого своя цена.
| Хранилище | Плюс | Цена |
|---|---|---|
Redis SET NX с TTL | Быстро, атомарно из коробки | Ключ истечёт по TTL — после этого повтор пройдёт; при падении Redis защита теряется |
| Таблица в той же БД, что и эффект | Отметка и бизнес-запись в одной транзакции — самый надёжный вариант | Нагрузка на основную БД, нужен уникальный индекс |
| Отдельная таблица inbox/outbox | Разделяет ответственность, аудируемо | Больше кода, отдельная миграция |
Ключевое различие: Redis-вариант защищает от повторов, но не связан транзакционно с самим эффектом. Между «пометили ключ в Redis» и «списали деньги в БД» есть окно, в котором воркер может упасть — ключ помечен, а денег нет. Для денег это неприемлемо. Для «не отправить письмо дважды» — допустимо, письмо не так критично.
Транзакционный вариант в PostgreSQL
Надёжнее всего положить отметку и бизнес-эффект в одну транзакцию. Тогда либо применилось всё, либо ничего. Пример на Python с Celery 5.x, SQLAlchemy 2.x и PostgreSQL 16.
from sqlalchemy import create_engine, text
from sqlalchemy.exc import IntegrityError
engine = create_engine("postgresql+psycopg://app:pass@localhost/app")
def process_payment(idempotency_key: str, order_id: int, amount_cents: int) -> str:
with engine.begin() as conn: # begin() = транзакция с автокоммитом на выходе
try:
# Пытаемся застолбить ключ. Уникальный индекс не даст вставить дубль.
conn.execute(
text("INSERT INTO processed_tasks (idempotency_key) VALUES (:k)"),
{"k": idempotency_key},
)
except IntegrityError:
# Ключ уже есть — задачу с этим намерением уже обработали.
return "skipped_duplicate"
# Сам эффект — в той же транзакции, что и вставка ключа.
conn.execute(
text("UPDATE orders SET paid_cents = paid_cents + :a WHERE id = :id"),
{"a": amount_cents, "id": order_id},
)
return "processed"
Схема таблицы отметок — минимальная, вся защита держится на уникальном индексе:
CREATE TABLE processed_tasks (
idempotency_key TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Почему это работает даже при гонке двух воркеров: INSERT с первичным ключом захватывает блокировку строки. Второй воркер, попытавшийся вставить тот же ключ, либо дождётся коммита первого и получит нарушение уникальности, либо откатится. Ровно один воркер доходит до UPDATE.
Граничные случаи, где защита ломается
Наивная реализация «проверил — если нет, обработал» разваливается на нескольких сценариях. Их стоит держать в голове.
- Check-then-act без атомарности. Если сначала
SELECT, потом отдельным запросомINSERTи эффект — два воркера пройдут проверку одновременно (оба увидят «ключа нет») и оба обработают. Спасает только атомарная вставка с уникальным индексом или блокировка, а не последовательность из двух запросов. - Redis без транзакции с эффектом. Пометили ключ, упали до эффекта — потеряли задачу молча. Обратный порядок (сначала эффект, потом пометка) даёт дубли. Redis годится, когда эффект сам идемпотентен или его потеря терпима.
- Ретраи внутри задачи. Celery умеет
retryпри ошибке. Если ключ уже помечен, а эффект упал на середине, ретрай увидит «уже обработано» и не доделает работу. Помечать ключ нужно в той же транзакции, что и эффект, иначе частичное выполнение маскируется под завершённое. - Внешний API без своего ключа идемпотентности. Транзакция в вашей БД не откатит уже отправленный HTTP-запрос к платёжному шлюзу. Если внешний сервис поддерживает заголовок
Idempotency-Key— передавайте туда тот же ключ. Если нет — эффект неоткатываемый, и порядок операций надо продумывать отдельно. - Разрастание таблицы отметок.
processed_tasksрастёт бесконечно. Нужен фоновый джоб, удаляющий записи старше разумного окна (например, того, за которое брокер точно не переотправит). Удалять слишком рано — риск пропустить поздний дубль.
Что мерить и с чего начать
Перед тем как ставить защиту, определите, какие задачи действительно недопустимо выполнять дважды. Отправка welcome-письма — терпит один лишний экземпляр. Списание денег и начисление бонусов — нет. Городить транзакционную идемпотентность на всё подряд дорого.
- Пометьте критичные задачи и добавьте им обязательный аргумент
idempotency_keyот продьюсера. - Для денежных операций используйте вставку ключа в одной транзакции с эффектом, как в примере выше.
- Для внешних вызовов проверьте, поддерживает ли API заголовок идемпотентности, и прокидывайте туда тот же ключ.
- Заведите метрику
skipped_duplicate: сколько раз задача была отброшена как повтор. Ненулевое значение подтверждает, что повторы реальны, а защита работает. - Настройте очистку таблицы отметок с окном не меньше
visibility_timeoutброкера.
Инвалидация ключей и настройка самого брокера (prefetch, acks_late) — отдельная тема; здесь мы разбирали только механизм однократного эффекта на стороне обработчика. Дальше стоит посмотреть, как ваш брокер ведёт себя при acks_late=True: без него сообщение подтверждается до выполнения, и падение воркера просто теряет задачу вместо дубля.
Риелтор и ипотечный брокер: Сравнение льготных ипотек, приёмка квартиры, проверка ДДУ.
Спросить за 15 ₽Источники: Celery — Tasks: acks_late and idempotency, PostgreSQL 16 — Concurrency Control (Transaction Isolation)
Частые вопросы
А если у меня Celery с acks_late=False — идемпотентность вообще нужна?
Можно обойтись только Redis SET NX без базы?
Что делать, если внешний API не поддерживает Idempotency-Key?
Не сработало: две задачи всё равно прошли обе. Где искать?
Как выбрать TTL или окно очистки таблицы отметок?
Подойдёт ли этот подход для Kafka вместо Celery?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.