Идемпотентность фоновых задач под нагрузкой в Python
Воркер Celery отправил письмо клиенту дважды, а платёж списал трижды. Разбираем, почему повторный запуск задачи — норма под нагрузкой, и что ломается первым, когда идемпотентности нет.

Клиенту приходит два письма о подтверждении заказа. В логах — одна задача send_confirmation, но воркер её выполнил дважды с интервалом в 40 секунд. Дальше хуже: задача начисления бонусов отработала три раза, и у пользователя вместо 500 баллов оказалось 1500. Никто ничего не ломал руками — просто вырос трафик, брокер придержал сообщение, воркер не успел подтвердить его и получил задачу снова.
Это не баг конкретной библиотеки. Повторная доставка сообщения — штатное поведение любой очереди с гарантией at-least-once: Celery поверх RabbitMQ или Redis, RQ, Dramatiq, arq. Задача, которая не переживает повторный запуск без побочных эффектов, под нагрузкой выстрелит обязательно. Разбираем, где именно и как это чинить. Ретраи и обработка ошибок — смежная тема, здесь только идемпотентность.
Почему задача выполняется дважды
Брокер держит сообщение до тех пор, пока воркер не подтвердит его (ack). Если воркер упал, деплой перезапустил процесс, или сообщение висело дольше таймаута видимости — брокер решает, что задача не выполнена, и отдаёт её другому воркеру. В Celery по умолчанию acks_late=False: сообщение подтверждается до выполнения. Это защищает от дублей, но теряет задачу при падении воркера. Многие включают acks_late=True ради надёжности — и тут же получают дубли, потому что теперь ack идёт после успешного выполнения, а между «выполнил» и «подтвердил» есть окно.
at-least-once — гарантия доставки «хотя бы один раз»: сообщение точно дойдёт, но может продублироваться. Противоположность — at-most-once (не больше раза, но может потеряться). Ровно один раз (exactly-once) на уровне брокера почти всегда фикция; на практике его собирают из at-least-once плюс идемпотентность на стороне обработчика.
Что ломается первым — по порядку
- Внешние вызовы без ключа идемпотентности. Списание с карты, отправка письма, вызов чужого API. Их видно раньше всего, потому что жалуется клиент.
- Неатомарные инкременты.
balance = balance + 100, счётчики, начисление бонусов. Повтор задачи — двойное начисление. - Создание записей без уникального ключа. Задача создаёт заказ, дубль создаёт второй заказ с новым автоинкрементным id.
- Составные задачи. Задача делает три шага, падает на третьем, перезапускается — и первые два выполняются повторно.
Уровень 1: ключ идемпотентности во внешних вызовах
Если внешний сервис поддерживает Idempotency-Key (Stripe, YooKassa и многие платёжные API), передавайте стабильный ключ, привязанный к задаче, а не сгенерированный внутри неё. Сервис сам отсечёт повтор.
# Celery 5.x, requests
import requests
def charge_payment(order_id: int, amount_kopecks: int):
# Ключ детерминированный: одинаков при любом повторе задачи для этого заказа
idempotency_key = f"charge-order-{order_id}"
response = requests.post(
"https://api.example-payments.ru/v3/payments",
json={"amount": amount_kopecks, "currency": "RUB"},
headers={"Idempotency-Key": idempotency_key},
timeout=15,
)
response.raise_for_status()
return response.json()Ключевое слово — детерминированный. Если сгенерировать uuid4() внутри задачи, при повторе будет новый ключ, и защита не сработает. Ключ должен выводиться из бизнес-данных: id заказа, id платежа, пары «пользователь + операция».
Уровень 2: своя таблица обработанных задач
Когда внешний сервис ключа не поддерживает или побочный эффект внутри вашей базы, заводите таблицу-журнал. Перед выполнением проверяете, не отмечена ли операция как выполненная; после — отмечаете. Критично, чтобы проверка и отметка не разъезжались под конкурентными воркерами.
# Django 5.x + PostgreSQL 16
from django.db import IntegrityError, transaction
from myapp.models import ProcessedTask
def process_once(dedup_key: str) -> bool:
"""True — можно выполнять, False — уже выполнено другим воркером."""
try:
with transaction.atomic():
ProcessedTask.objects.create(dedup_key=dedup_key)
return True
except IntegrityError:
# dedup_key под UNIQUE — вставка отклонена, значит задача уже шла
return FalseМодель с уникальным полем:
class ProcessedTask(models.Model):
dedup_key = models.CharField(max_length=255, unique=True)
created_at = models.DateTimeField(auto_now_add=True)Тонкость: вставку в журнал и сам побочный эффект нужно завернуть в одну транзакцию, иначе появится окно, где журнал записан, а эффект — нет (или наоборот). Если побочный эффект — вызов внешнего API, единой транзакцией это не накрыть, и тогда возвращаемся к уровню 1 или к паттерну outbox.
Идемпотентность — это не «сделать так, чтобы задача не повторялась». Это сделать так, чтобы повтор был безвреден. Первое недостижимо, второе — ваша ответственность.
Уровень 3: атомарные операции в базе вместо чтения-записи
Классическая ошибка — прочитать баланс в Python, прибавить, записать. Между чтением и записью влезает второй воркер. Даже с журналом это опасно, если журнал и апдейт в разных транзакциях. Правильнее — атомарный инкремент средствами БД или UPDATE ... WHERE, который сам по себе идемпотентен для нужной логики.
# Начисление бонуса ровно один раз через условный UPDATE
from django.db import transaction
from myapp.models import Account, BonusLog
def grant_bonus(account_id: int, order_id: int, points: int):
with transaction.atomic():
# Пытаемся записать факт начисления; order_id уникален в BonusLog
_, created = BonusLog.objects.get_or_create(
account_id=account_id,
order_id=order_id,
defaults={"points": points},
)
if not created:
return # уже начисляли за этот заказ
# F() — инкремент на стороне БД, без гонки чтения-записи
from django.db.models import F
Account.objects.filter(id=account_id).update(
balance=F("balance") + points
)Здесь get_or_create и update в одной транзакции: либо оба применились, либо ни один. Уникальность пары (account_id, order_id) в BonusLog гарантирует, что повтор задачи не начислит дважды.
Сравнение подходов
| Подход | Когда применять | Цена |
|---|---|---|
| Idempotency-Key во внешнем API | Сервис поддерживает ключ (платежи) | Ноль на вашей стороне, зависите от чужой реализации |
| Таблица обработанных задач | Побочный эффект в вашей базе | Рост таблицы, нужна чистка старых ключей |
| Атомарный UPDATE / get_or_create | Инкременты, начисления, счётчики | Логика привязана к SQL, сложнее переносить |
| Redis SETNX с TTL | Дешёвая защита, где допустимо изредка выполнить дважды | Не транзакционно с базой, TTL истёк — защита исчезла |
Про Redis SETNX — осторожно
Соблазн поставить redis.set(key, 1, nx=True, ex=3600) и на этом закончить велик, но это защита от повтора в пределах TTL, а не идемпотентность. Если задача выполнялась дольше TTL, если Redis сбросился, или ключ истёк до второго прихода задачи — дубль пройдёт. Для денег этого недостаточно. Для «не отправить пуш дважды в течение часа» — приемлемо.
Что ломается на границах
- Составная задача. Три шага, из которых два идемпотентны, а один нет. Повтор безопасен ровно настолько, насколько безопасен худший шаг. Разбивайте на отдельные задачи или делайте журнал по каждому шагу.
- acks_late и долгие задачи. С
acks_late=Trueзадача, идущая дольшеvisibility_timeout(для Redis-брокера), будет выдана второму воркеру, пока первый ещё работает. Два воркера крутят одну задачу параллельно — журнал спасёт только при уникальном ограничении и правильных транзакциях, гонку в Python-памяти он не закроет. - Рост таблицы журнала. Миллионы ключей без чистки убьют производительность вставки. Заведите фоновую задачу на удаление записей старше N дней или партиционирование по дате.
- Транзакция вокруг внешнего вызова. HTTP-запрос нельзя откатить вместе с транзакцией БД. Если после успешного платежа откатится локальная запись — рассогласование. Здесь помогает outbox: сначала фиксируете намерение в базе в одной транзакции, отдельная задача вычитывает outbox и делает внешний вызов с ключом идемпотентности.
Что делать дальше
Начните с аудита: пройдите по всем задачам и отметьте, какие имеют побочный эффект вовне (письма, платежи, чужие API) и какие меняют данные неатомарно. Это и есть ваш список первых кандидатов на поломку. Для платёжных задач включите Idempotency-Key сегодня же — это самый дешёвый шаг. Для внутренних начислений переведите чтение-запись на F()-выражения и уникальные ограничения.
В проде мерьте не «сколько задач выполнено», а сколько задач выполнено повторно: логируйте случаи, когда process_once вернул False или get_or_create вернул created=False. Всплеск этого счётчика при деплое или под нагрузкой — сигнал, что acks_late и таймауты настроены так, что дубли идут потоком, и защита работает на пределе. Точные имена настроек брокера и их значения по умолчанию сверяйте в документации вашей версии Celery — они менялись между мажорными релизами.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Celery — Tasks and idempotency (официальная документация), Django — F() expressions и атомарные обновления
Частые вопросы
У меня Celery 4, а не 5 — приёмы применимы?
Если задача уже выполнилась дважды, приёмы это исправят?
Достаточно ли Redis SETNX, чтобы не думать про базу?
Что делать, если внешний API не поддерживает Idempotency-Key?
Не сработало: задача всё равно дублирует запись, хотя есть UNIQUE
Как понять, что дубли реально идут в проде, а не в теории?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.