Уведомления в Django: почему письма отправлялись синхронно
На проекте средней нагрузки регистрация зависала на 3-4 секунды из-за отправки писем прямо в обработчике запроса. Разбираем, где ломается синхронная отправка и как её вынести из цикла запроса.

Форма регистрации на сервисе бронирования отдавала ответ за 3,2 секунды при пустой базе и локальном профиле. При ста параллельных регистрациях gunicorn начинал возвращать таймауты, а очередь воркеров забивалась. Причина оказалась в одной строке: приветственное письмо отправлялось через send_mail() прямо в post_save-сигнале модели пользователя. SMTP-хендшейк до внешнего почтового провайдера держал воркер занятым всё время отправки. Разбираем, почему так вышло, как это выглядело в замерах и какие варианты вынести отправку из цикла запроса. Речь про Django 5.x, но проблема ровно та же начиная с 3.x.
Как выглядел инцидент
Симптом: p95 времени ответа на POST /register/ держался около 3 секунд, изредка прыгал до 8. Логи gunicorn показывали [CRITICAL] WORKER TIMEOUT при всплесках регистраций. База данных при этом была практически не нагружена — profiler показывал два коротких запроса на INSERT.
Трейс запроса через django-silk дал разбивку: сам INSERT пользователя — 4 мс, весь остальной бюджет уходил на вызов внутри сигнала.
# models.py — как было (упрощённо)
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.mail import send_mail
from django.contrib.auth import get_user_model
User = get_user_model()
@receiver(post_save, sender=User)
def send_welcome_email(sender, instance, created, **kwargs):
if not created:
return
# блокирующий вызов: SMTP-хендшейк + отправка в теле запроса
send_mail(
subject="Добро пожаловать",
message="Спасибо за регистрацию.",
from_email="noreply@example.com",
recipient_list=[instance.email],
)
Вызов send_mail() синхронный. Пока Django устанавливает TLS-соединение с SMTP-сервером, ждёт ответа 250 на каждую команду и закрывает соединение, воркер WSGI занят и не обрабатывает другие запросы. При внешнем провайдере с задержкой сети 200-400 мс на команду набегают те самые секунды.
Почему это не видно на разработке
Локально в settings.py обычно стоит EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend", который печатает письмо в консоль мгновенно. Проблема проявляется только с реальным SMTP-бэкендом на проде — классический разрыв между окружениями.
Что здесь на самом деле сломано
Две отдельные проблемы, которые удобно не путать:
- Отправка блокирует ответ. Пользователь ждёт письмо, хотя ему нужен только результат регистрации. Отправку нужно увести из цикла запроса.
- Сигнал сработает даже в откатанной транзакции.
post_saveвызывается внутри транзакции запроса. Если после сохранения пользователя дальше по коду произойдёт исключение и транзакция откатится, письмо уже уйдёт — пользователь получит приветствие, а аккаунта не будет.
Инвалидация кеша и повторные попытки доставки — отдельные темы, здесь разбираем только вынос отправки из запроса и привязку к успешной транзакции.
Варианты, чем платим за каждый
Разберём четыре подхода — от самого дешёвого в реализации до самого надёжного.
1. transaction.on_commit — минимальная правка
Отложить отправку до успешного коммита транзакции. Письмо не уйдёт при откате, но отправка по-прежнему происходит в том же процессе после коммита — то есть частично внутри жизненного цикла запроса.
# хук выполнится только после успешного COMMIT
from django.db import transaction
@receiver(post_save, sender=User)
def send_welcome_email(sender, instance, created, **kwargs):
if not created:
return
transaction.on_commit(
lambda: send_mail(
subject="Добро пожаловать",
message="Спасибо за регистрацию.",
from_email="noreply@example.com",
recipient_list=[instance.email],
)
)
Это чинит проблему двойного письма при откате, но не убирает блокировку: SMTP-хендшейк всё ещё держит воркер. Годится, если писем мало и провайдер быстрый.
2. Отдельный поток внутри процесса
Запустить отправку в threading.Thread. Ответ вернётся быстро, но у подхода нет ретраев, нет видимости, поток умрёт вместе с воркером при рестарте, а при массовых регистрациях вы плодите потоки без ограничения. Для одного письма при регистрации иногда терпимо, для системы уведомлений — нет.
3. Celery + брокер
Классический вынос в очередь. Отправка становится задачей, которую забирает отдельный воркер. Есть ретраи, есть контроль над параллельностью, отправка полностью вне цикла запроса.
# tasks.py (Celery 5.x)
from celery import shared_task
from django.core.mail import send_mail
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_welcome_email_task(self, email):
try:
send_mail(
subject="Добро пожаловать",
message="Спасибо за регистрацию.",
from_email="noreply@example.com",
recipient_list=[email],
)
except Exception as exc:
# повторим до 3 раз при сбое SMTP
raise self.retry(exc=exc)
# в сигнале ставим задачу в очередь, но только после коммита
from django.db import transaction
@receiver(post_save, sender=User)
def enqueue_welcome_email(sender, instance, created, **kwargs):
if not created:
return
transaction.on_commit(
lambda: send_welcome_email_task.delay(instance.email)
)
Ставить задачу в очередь тоже нужно внутри on_commit: иначе воркер Celery может подхватить задачу и обратиться к базе за пользователем, которого ещё нет из-за незавершённой транзакции. Это распространённая гонка.
4. django-tasks / встроенная фоновая обработка
Для проектов, где не хочется тянуть брокер, есть более лёгкие очереди на базе БД. Актуальное состояние встроенного в Django механизма фоновых задач стоит сверять по документации вашей версии — API в этой области ещё меняется, и я не буду называть имена методов, в которых не уверен для 5.x. Если брокера в инфраструктуре пока нет, начните с оценки нагрузки, а не с внедрения Redis ради одного письма.
Синхронная отправка письма в обработчике запроса — не оптимизация, которую отложили на потом. Это скрытая связь надёжности регистрации со здоровьем чужого SMTP-сервера.
Сравнение подходов
| Подход | Блокирует ответ | Ретраи | Переживает рестарт | Инфраструктура |
|---|---|---|---|---|
| on_commit + send_mail | Да, частично | Нет | Нет | Ничего |
| Отдельный поток | Нет | Нет | Нет | Ничего |
| Celery + Redis/RabbitMQ | Нет | Да | Да | Брокер + воркер |
| Очередь на БД | Нет | Зависит | Да | Только БД |
Когда что выбирать
- Писем единицы в час, провайдер быстрый — хватит
on_commitвокруг синхронной отправки. Не усложняйте. - Регулярный поток уведомлений, важна доставка — Celery с ретраями и отдельным воркером. Блокировки уходят, сбой SMTP не роняет регистрацию.
- Нужна фоновая обработка, но брокер разворачивать не хочется — очередь на базе БД, с оговоркой про версионность API.
- Отдельный поток — только как временная заплатка, не как решение для системы уведомлений.
Что ломается на практике
- Задача поставлена вне on_commit. Воркер берёт задачу раньше коммита, читает несуществующего пользователя, падает с DoesNotExist. Оборачивайте постановку задачи в
transaction.on_commit. - В задачу передают объект модели, а не id. Celery сериализует аргументы; передавайте
instance.emailилиinstance.pk, а не сам объект — иначе получите устаревшие данные или ошибку сериализации. - Тесты не видят on_commit. В обычном
TestCaseтранзакция откатывается, и хук не срабатывает. ИспользуйтеTestCase.captureOnCommitCallbacks()илиTransactionTestCase, чтобы проверить отправку. - Воркер Celery не перезапущен после деплоя. Старый воркер держит устаревший код задачи. Перезапуск воркеров — обязательный шаг деплоя, а не опциональный.
Что сделать дальше
После выноса отправки замерьте p95 ответа на регистрацию под нагрузкой заново — на нашем кейсе он упал с ~3,2 с до ~40 мс, потому что в бюджет запроса остался только INSERT. Дальше смотрите на два показателя в проде: длину очереди задач (растёт — не хватает воркеров) и долю задач, ушедших в ретрай (растёт — проблема на стороне SMTP-провайдера). Инвалидация и дедупликация уведомлений, когда одно событие не должно порождать два письма, — следующая тема для отдельного разбора.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: Django: Performing actions after a transaction commit, Celery: Tasks — Basics
Частые вопросы
А если у меня Django 3.2, а не 5.x?
Отправка через on_commit не сработала в тестах, писем нет
Обязательно ли ставить задачу в очередь внутри on_commit?
Стоит ли внедрять Celery ради одного приветственного письма?
Почему проблема не проявлялась на локальной машине?
Что будет с потоком, запущенным через threading, при рестарте воркера?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.