Свой бэкенд аутентификации в Django под нагрузкой: что ломается первым
Кастомный authentication backend в Django работает отлично на десяти пользователях и начинает убивать сервис на трёхстах одновременных логинах. Разбираем, какие места ломаются первыми, почему стандартные подходы к кешированию здесь опасны и где именно теряются секунды.

Когда логин становится узким горлом
Сервис на Django 5.x обрабатывал авторизацию через кастомный бэкенд: LDAP-запрос, проверка прав во внешнем API, запись в локальную таблицу. На стейджинге всё летало. В проде, при 300 одновременных запросах на /login/, среднее время ответа выросло с 120 мс до 4,7 секунд, а 502-е начали сыпаться через 40 секунд после пика. Проблема оказалась не в одном месте — сломалось сразу три звена, и каждое усиливало остальные.
Эта статья — разбор типичных точек отказа в кастомных AUTHENTICATION_BACKENDS под нагрузкой. Не о том, как написать бэкенд (это в документации Django), а о том, что именно в нём сдаётся первым и почему.
Как Django вызывает бэкенды
При вызове django.contrib.auth.authenticate() Django проходит по списку AUTHENTICATION_BACKENDS последовательно. Каждый бэкенд получает request и credentials, вызывает свой authenticate(), и если тот вернул None — Django идёт к следующему. Если вернул объект пользователя — цепочка останавливается.
Ключевой момент: вызовы синхронные. Даже если вы используете ASGI и Daphne/Uvicorn, стандартный auth flow в Django 5.x остаётся синхронным и блокирует рабочий поток на всё время работы метода authenticate(). Один медленный бэкенд — один заблокированный воркер.
Цепочка бэкендов как мультипликатор задержки
Если в AUTHENTICATION_BACKENDS три бэкенда и пользователь аутентифицируется через последний, Django последовательно вызовет все три. Первые два вернут None, но их I/O-операции (запрос в LDAP, обращение к внешнему API) уже выполнены. Задержка суммируется, а не выбирается по минимуму.
Точка отказа №1: синхронный I/O во внешние сервисы
Самая частая архитектура кастомного бэкенда: принять логин и пароль, сходить во внешний сервис (LDAP, OAuth-провайдер, корпоративный API), получить ответ, вернуть пользователя. На малой нагрузке запрос к внешнему сервису занимает 50–100 мс. Под нагрузкой начинаются проблемы:
- Внешний сервис сам замедляется, когда получает 300 запросов за секунду. Его время ответа растёт нелинейно.
- Django-воркеры (Gunicorn sync workers, uWSGI) блокируются на ожидании ответа. Пул воркеров исчерпан — новые запросы стоят в очереди.
- Таймауты по умолчанию в библиотеках вроде
python-ldapилиrequests— десятки секунд или вовсе не заданы. Один зависший запрос держит воркер минуту.
Цена: при 8 sync-воркерах Gunicorn и таймауте внешнего сервиса 30 секунд достаточно 8 медленных логинов, чтобы сервис перестал отвечать на любые запросы — не только на /login/.
Таймаут на внешний вызов внутри authenticate() — не оптимизация, а обязательная защита. Без него один зависший LDAP-сервер роняет весь Django.
Что с этим делать
Жёсткий таймаут на каждый внешний вызов — connect timeout и read timeout отдельно. Для LDAP через python-ldap это параметры OPT_NETWORK_TIMEOUT и OPT_TIMEOUT. Для HTTP — параметр timeout в requests. Разумные значения: connect 2–3 секунды, read 5 секунд. Если внешний сервис не ответил за это время, лучше вернуть ошибку пользователю, чем заблокировать воркер.
Второе: circuit breaker. Если внешний сервис начал отвечать ошибками, прекратите слать ему запросы на 10–30 секунд. Библиотека pybreaker решает задачу, но можно реализовать и на простом счётчике в Redis. Без circuit breaker повторные попытки только усугубляют каскад.
Точка отказа №2: запросы к базе на каждый authenticate()
Даже если внешний сервис быстрый, кастомный бэкенд часто делает дополнительные запросы к своей базе: найти пользователя по email, проверить флаг is_active, подгрузить профиль, записать время последнего входа. Каждый вызов authenticate() — это минимум один SELECT и нередко один UPDATE.
Скрытая стоимость get_or_create
Типичный паттерн: пользователь найден во внешнем сервисе, в локальной базе его ещё нет, бэкенд вызывает User.objects.get_or_create(). Под нагрузкой два параллельных запроса с одним и тем же логином попадают в гонку: оба делают SELECT, оба не находят, оба делают INSERT. Один из них получает IntegrityError. Если бэкенд не обрабатывает это исключение, пользователь видит 500.
Стандартный get_or_create в Django оборачивает этот случай в try/except IntegrityError и делает повторный SELECT, но только если уникальное ограничение задано на уровне базы. Если уникальность гарантируется только в коде — защита не работает, и под нагрузкой вы получаете дубликаты или ошибки.
| Точка отказа | Симптом | Порог нагрузки | Последствие |
|---|---|---|---|
| Синхронный I/O без таймаута | Растущее время ответа, потом 502 | Число воркеров × 1 (зависший вызов) | Полная недоступность сервиса |
| Гонка в get_or_create | IntegrityError, 500 на логине | 2+ параллельных логина одного пользователя | Потеря регистрации или дубли |
| UPDATE last_login на каждый вход | Lock contention на таблице auth_user | Сотни логинов в секунду | Замедление всех запросов к auth_user |
| Отсутствие circuit breaker | Каскадный отказ при деградации внешнего сервиса | 1 (достаточно одного сбоя) | Все воркеры стоят в ожидании |
Точка отказа №3: сигнал user_logged_in и запись last_login
Django по умолчанию подключает обработчик сигнала user_logged_in, который вызывает update_last_login(). Это UPDATE auth_user SET last_login = NOW() WHERE id = %s на каждый успешный вход. На 300 логинах в секунду — 300 обновлений одной таблицы. В PostgreSQL это означает 300 мёртвых кортежей в секунду и нарастающее давление на autovacuum. В MySQL с уровнем изоляции REPEATABLE READ — потенциальные блокировки строк, которые тормозят даже SELECT.
Если last_login вам не нужен, отключите сигнал. Если нужен — пишите его асинхронно: через Celery-задачу, через буфер в Redis с периодическим flush, через отдельную таблицу без частого vacuum.
Кеширование результата authenticate() — ловушка
Первая мысль при проблемах с производительностью: закешировать результат. Пользователь залогинился, положим результат в Redis на 5 минут, при повторном вызове не ходим во внешний сервис. Это работает, но создаёт два риска:
- Кеширование успешной аутентификации — если пароль пользователя изменился или аккаунт заблокирован во внешней системе, кеш продолжает впускать его. Окно уязвимости равно TTL кеша.
- Кеширование неуспешной аутентификации — если пользователь опечатался в пароле, а вы закешировали отказ, повторная попытка с правильным паролем не пройдёт.
Безопасный компромисс: кешировать не решение «пускать/не пускать», а данные о пользователе, которые не меняются часто (DN в LDAP, группы, атрибуты профиля). Проверку пароля всегда делать вживую. Это снимает часть I/O, не создавая дыры в безопасности.
Что и как мерить
Абстрактное «нагрузочное тестирование» не поможет, если вы не знаете, что измерять. Конкретные метрики для бэкенда аутентификации:
- p95 и p99 времени ответа на
/login/— не среднее. Среднее скрывает хвост распределения, где живут зависшие запросы. - Число активных воркеров Gunicorn/uWSGI — если все заняты, сервис не принимает новые соединения. В Gunicorn это видно через StatsD-экспорт или через эндпоинт
/statsв связке с мониторингом. - Время ответа внешнего сервиса (LDAP/API) — отдельной метрикой, не внутри общего request time. Иначе вы не отличите медленный LDAP от медленного Django.
- Число ошибок IntegrityError и таймаутов — логируйте их с отдельным уровнем, чтобы видеть корреляцию с нагрузкой.
- Dead tuples в таблице auth_user — для PostgreSQL:
SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'auth_user'.
Инструменты: locust или k6 для нагрузочных тестов, django-silk или OpenTelemetry для трассировки внутри Django, мониторинг базы через pg_stat_statements.
Граничные случаи и где подход ломается
Множественные бэкенды с разной скоростью
Если в AUTHENTICATION_BACKENDS стоят и быстрый ModelBackend, и медленный LDAP-бэкенд, порядок критичен. Django вызывает их по списку. Если медленный стоит первым и обслуживает 5% пользователей, 95% запросов платят за ненужный LDAP-запрос. Переставьте быстрый бэкенд первым или добавьте быструю проверку (например, по домену email), которая позволяет бэкенду мгновенно вернуть None, не начиная I/O.
Session backend усиливает проблему
После успешного authenticate() Django вызывает login(), который создаёт сессию. Если сессии хранятся в базе (django.contrib.sessions.backends.db), каждый логин — ещё один INSERT. На 300 логинов в секунду это дополнительные 300 записей в таблицу django_session. Переход на сессии в Redis или подписанных куках снимает эту нагрузку с базы.
Rate limiting отсутствует
Кастомный бэкенд без rate limiting — открытое приглашение для брутфорса, и каждая попытка подбора пароля стоит полного цикла: запрос к LDAP, запрос к базе, запись лога. Атакующий не только подбирает пароль, но и создаёт нагрузку, эквивалентную легитимному трафику. django-axes или rate limiting на уровне Nginx (limit_req_zone) ставьте до того, как запрос дойдёт до бэкенда аутентификации.
Бэкенд аутентификации* — класс Python, реализующий методы authenticate() и get_user(), который Django использует для проверки учётных данных. Один проект может использовать несколько бэкендов одновременно, перебирая их по порядку.
Что делать дальше
Прямо сейчас: проверьте таймауты на каждый внешний вызов внутри вашего бэкенда. Если их нет — поставьте. Это единственное изменение, которое превращает каскадный отказ в контролируемую ошибку. Затем — добавьте метрики p99 на эндпоинт логина отдельно от остальных, чтобы видеть деградацию до того, как её заметят пользователи. Если используете несколько бэкендов — проверьте порядок и убедитесь, что самый быстрый стоит первым. Всё остальное — кеширование атрибутов, асинхронная запись last_login, circuit breaker — вторая итерация, когда вы уже видите цифры и понимаете, где ваш конкретный bottleneck.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Django documentation — Customizing authentication, Django documentation — django.contrib.auth — authenticate()
Частые вопросы
Сколько одновременных логинов выдержит стандартный Gunicorn с sync-воркерами?
Поможет ли переход на Gunicorn с gevent-воркерами?
Можно ли сделать authenticate() асинхронным в Django 5.x?
Что будет, если внешний LDAP-сервер упадёт совсем, а таймаут не задан?
Если у меня другая версия Django (4.2 LTS), всё описанное актуально?
Какие подводные камни с кешированием аутентификации в проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.