Your Company
Асинхронный Django в продакшене: что ломается под нагрузкой

Асинхронный Django в продакшене: что ломается под нагрузкой

Автор: Кирилл Сермягин

Асинхронные представления есть в Django с версии 3.1, асинхронный интерфейс ORM — с 4.1, и за пять лет вокруг них накопилось много ожиданий. Чаще всего async включают в надежде, что сервис «станет быстрее». Под нагрузкой выясняется другое: асинхронное представление ускоряет только ожидание внешних вызовов, а база данных, пул соединений и синхронный код вокруг остаются прежними.

В этом гайде разбираем сервис расчёта доставки из доклада на Moscow Python Meetup №110: где асинхронность дала выигрыш, что сломалось на 800 запросах в секунду и какие настройки PostgreSQL и пула соединений это исправили. Код проверен на Python 3.13 и Django 6.0.

Что на самом деле делает async-представление

Под ASGI-сервером (uvicorn, granian, daphne) каждый воркер — это процесс с циклом событий. Асинхронное представление выполняется прямо в этом цикле: пока оно ждёт ответа от сети, воркер обслуживает другие запросы. Синхронные представления под ASGI тоже работают, но каждое уходит в отдельный поток через sync_to_async — это дороже, чем под WSGI.

Поэтому смысл async-представления — выполнять несколько независимых ожиданий одновременно. В нашем сервисе ответ складывается из заказа в базе и двух внешних API: тарифов перевозчиков и прогноза времени доставки.

import asyncio

import httpx
from django.http import JsonResponse

from .models import Order

TIMEOUT = httpx.Timeout(2.0, connect=0.5)


async def delivery_quote(request, order_id: int):
    order = await Order.objects.select_related("warehouse").aget(pk=order_id)
    async with httpx.AsyncClient(timeout=TIMEOUT) as client:
        rates, eta = await asyncio.gather(
            fetch_rates(client, order),
            fetch_eta(client, order),
        )
    return JsonResponse({"order": order.pk, "rates": rates, "eta": eta})

Два запроса к внешним API идут параллельно: вместо 40 + 45 мс ждём около 45. На этом выигрыш и заканчивается — запрос к базе по-прежнему выполняется синхронно, просто в другом потоке. Клиент httpx в продакшене лучше держать один на процесс — об этом в чек-листе в конце.

Синхронное middleware

Django проверяет каждое middleware в цепочке. Если хотя бы одно умеет работать только синхронно, обработчик адаптируется в поток на каждом запросе, и выгода от async пропадает. Включите логгер django.request на уровне DEBUG: строки «Synchronous handler adapted for middleware…» покажут, кто виноват. У нас это было самописное middleware аудита и старая версия CORS-пакета.

Отключение клиента

С Django 5.0 асинхронное представление узнаёт, что клиент закрыл соединение: в нём выбрасывается asyncio.CancelledError. Для сервиса доставки это значит, что запросы к перевозчикам, которые уже никому не нужны, отменяются вместе с представлением. Если после отмены нужно что-то записать — например, метрику, — перехватите исключение, сделайте это и выбросьте его снова.

Синхронное представление так не умеет: поток доработает до конца, даже если пользователь давно ушёл со страницы. На медленных эндпоинтах под нагрузкой это заметная экономия воркеров.

Когда async не нужен

  • Представление только читает из базы и рендерит шаблон — выигрыша не будет, а переключение потоков добавит задержку.
  • Тяжёлые вычисления на CPU блокируют цикл событий целиком — все запросы воркера ждут.
  • Синхронные библиотеки без async-версии: каждый вызов придётся оборачивать в sync_to_async.

ORM и sync_to_async

Методы с префиксом a — aget(), acount(), asave(), итерация через async for — выглядят асинхронными, но внутри ORM синхронная. Каждый такой вызов Django отправляет в поток через sync_to_async(thread_sensitive=True): в рамках одного запроса все они выполняются в одном потоке, по очереди.

Отсюда первая ловушка: десяток мелких ORM-вызовов в async-представлении — это десяток переключений между циклом событий и потоком. На нашем стенде одно переключение стоило 0,1–0,3 мс, а в представлении корзины их набиралось больше сорока. Решение — собрать работу с базой в одну синхронную функцию и переключиться один раз.

from asgiref.sync import sync_to_async
from django.db import transaction

from .models import Order, Reservation


def _reserve(order_id: int, slots: list[int]) -> Order:
    with transaction.atomic():
        order = Order.objects.select_for_update().get(pk=order_id)
        Reservation.objects.bulk_create(
            Reservation(order=order, slot_id=slot) for slot in slots
        )
        order.status = Order.Status.RESERVED
        order.save(update_fields=["status"])
    return order


reserve = sync_to_async(_reserve)  # одно переключение вместо пяти

Для одиночных операций обёртки не нужны: у менеджеров и QuerySet есть асинхронные версии почти всех методов. Старые примеры из документации и статей лучше обновить.

Итерация по большим выборкам

async for order in Order.objects.filter(…) загружает выборку частями, но каждая часть — отдельное переключение в поток. Для выгрузки на десятки тысяч строк это медленнее, чем один синхронный проход с .iterator(chunk_size=2000) внутри функции, обёрнутой в sync_to_async. Правило то же, что и выше: чем крупнее кусок работы в потоке, тем меньше накладных расходов.

Пул соединений PostgreSQL

Под WSGI соединение с базой живёт в потоке воркера и переиспользуется благодаря CONN_MAX_AGE. Под ASGI так не получается: потоки для sync_to_async создаются под запросы, постоянные соединения копятся в них, пока PostgreSQL не ответит too many clients already. Именно это случилось у нас на 800 запросах в секунду.

С Django 5.1 у бэкенда PostgreSQL есть встроенный пул на psycopg 3. Он включается одной опцией, а CONN_MAX_AGE при этом должен быть равен нулю:

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "delivery",
        "CONN_MAX_AGE": 0,  # с пулом — только 0
        "OPTIONS": {
            "pool": {
                "min_size": 4,
                "max_size": 20,
                "timeout": 5,  # секунд ждать свободное соединение
            },
        },
    },
}

Размер пула считайте от лимита сервера: max_size, умноженный на число воркеров на всех подах, должен оставаться меньше max_connections с запасом для миграций и администраторов. Если подов больше пяти, поставьте перед базой PgBouncer в режиме transaction и выключите серверные курсоры — DISABLE_SERVER_SIDE_CURSORS = True, иначе .iterator() начнёт падать.

Пример расчёта: у нас три пода по четыре воркера uvicorn и max_connections = 300. При max_size = 20 получается 3 × 4 × 20 = 240 соединений — остаётся 60 на миграции, cron-задачи и подключения дежурных. При автомасштабировании до пяти подов лимит закончится, поэтому на этом этапе мы и поставили PgBouncer.

timeout в настройках пула — сколько запрос ждёт свободное соединение. Слишком большое значение прячет проблему: запросы копятся в очереди, и вместо понятной ошибки пользователь получает ответ через 30 секунд. Пять секунд и алерт на PoolTimeout — разумный старт.

Что мониторить

МетрикаНормаГде смотреть
Активные соединения< 80 % от max_connectionspg_stat_activity
Ожидание свободного соединения< 5 мс, p95метрики psycopg_pool
Таймауты пула0логи приложения, PoolTimeout
Время запросов к базене растёт вместе с RPSpg_stat_statements

Долгие ответы и стриминг

Второй сценарий, где async окупается без оговорок, — долгие ответы. Расчёт сложного маршрута у нас занимает до восьми секунд: раньше клиент ждал, а поток gunicorn был занят целиком. Теперь представление отдаёт прогресс через Server-Sent Events, а воркер в это время обслуживает другие запросы.

import json

from django.http import StreamingHttpResponse


async def route_progress(request, route_id: int):
    async def events():
        async for step in planner.stream(route_id):
            payload = json.dumps({"step": step.name, "done": step.done})
            yield f"data: {payload}\n\n"

    return StreamingHttpResponse(events(), content_type="text/event-stream")

Асинхронный итератор в StreamingHttpResponse поддерживается с Django 4.2. Под WSGI такой ответ держит поток всё время стрима, под ASGI — только корутину. Не забудьте выключить буферизацию на прокси: для nginx — заголовок X-Accel-Buffering: no, иначе клиент получит все события разом в конце.

Замеры

Стенд: четыре воркера uvicorn против gunicorn с четырьмя воркерами по восемь потоков, PostgreSQL 17 на отдельной машине, внешние API заменены заглушками с задержкой 40–45 мс. Нагрузку подавали Locust ступенями от 100 до 1600 запросов в секунду, по пять минут на ступень.

График p95 латентности: sync-представления растут до 370 мс на 1600 запросах в секунду, async с пулом соединений — до 160 мс
p95 латентности эндпоинта расчёта доставки. Серая линия — синхронные представления под gunicorn, синяя — async-представления под uvicorn с пулом на 20 соединений. Данные стенда — пример.
Нагрузка, RPSsync, p95async + пул, p95
10038 мс34 мс
40096 мс55 мс
800210 мс96 мс
1600372 мс158 мс

Выигрыш в 2,3 раза на 1600 RPS дают параллельные вызовы внешних API и то, что воркер не простаивает, пока они идут. Запрос к базе в обоих вариантах занимает одинаковые 3–4 мс. Без пула async-вариант на 800 RPS был хуже синхронного: 12 % ответов с ошибкой 500 из-за исчерпанных соединений.

Расход ресурсов тоже изменился: на 1600 RPS синхронный вариант держал 32 потока и около 1,9 ГБ памяти на под, асинхронный — 4 процесса и 0,8 ГБ. Загрузка CPU почти не отличалась: сервис упирается в ожидание сети, а не в вычисления. Если бы эндпоинт считал маршруты сам, картина была бы обратной.

Сценарий нагрузки — обычный Locust-файл. Число пользователей равно целевому RPS: каждый делает один запрос в секунду, пока сервис успевает отвечать.

import random

from locust import FastHttpUser, constant_throughput, task


class QuoteUser(FastHttpUser):
    wait_time = constant_throughput(1)  # запрос в секунду на пользователя

    @task
    def quote(self):
        order_id = random.randint(1, 50_000)
        self.client.get(f"/api/orders/{order_id}/quote", name="/quote")

Чек-лист перед переносом в прод

  1. Найдите представления, которые ждут сеть: только их имеет смысл переводить на async.
  2. Включите пул psycopg и поставьте CONN_MAX_AGE = 0.
  3. Соберите работу с базой в синхронные функции и вызывайте их через sync_to_async один раз.
  4. Держите один httpx.AsyncClient на процесс и задавайте таймауты явно.
  5. Проверьте middleware: одно синхронное заставит Django переключаться в поток на каждом запросе.
  6. Снимите p95 до и после под нагрузкой, похожей на продакшен.

Как выкатывать

Мы переводили эндпоинты по одному: async-версия представления жила рядом с синхронной, а балансировщик отправлял на неё сначала 5 %, потом 50 % и наконец весь трафик. Откат — переключение веса, без деплоя. Сравнивать варианты удобно по одному дашборду: p95, доля ошибок и ожидание пула на соседних графиках.

Запуск, на котором сняты замеры выше:

uvicorn config.asgi:application \
  --workers 4 --loop uvloop --http httptools \
  --limit-concurrency 400 --timeout-keep-alive 5

Итоги

Асинхронный Django окупается там, где сервис ждёт сеть: внешние API, очереди, LLM-провайдеры. Если представление в основном ходит в базу, сначала займитесь запросами и индексами — поможет гайд про N+1. А если решили переходить, начните с пула соединений: без него асинхронность под нагрузкой делает только хуже.

Что изменилось за год эксплуатации, Алексей рассказал в выпуске подкаста 212. Вопросы по гайду задавайте в чате сообщества — ответы с примерами добавим в следующую редакцию.