Привет, Хабр!

ORM в Django устроена так, что писать неоптимальный код в ней приятно и незаметно. Вы пишете обычный питон, он превращается в SQL где‑то далеко от вас, и пока в базе тысяча строк, всё летает. Проблемы начинаются, когда строк становится миллион, а пользователей — сотня одновременно.

Причём падает обычно не то, на что вы смотрели. N+1 запросы все давно знают и ловят, а вот подсчёты, которые незаметно выкачивают таблицу в память, транзакции, которые держат блокировку дольше нужного, и гонки в самых обычных save() — это уже интереснее.

Разберём шесть таких мест.

Ошибка первая: len() вместо count() и наоборот

Начнём с малого, но очень частого.

users = User.objects.filter(is_active=True)
print(f"Активных пользователей: {len(users)}")

Выглядит безобидно. На деле len() заставляет queryset выполниться целиком: Django выкачивает все строки, собирает из них питоновские объекты и только потом считает их длину. Миллион пользователей — миллион объектов в памяти ради одного числа.

count = User.objects.filter(is_active=True).count()   # SELECT COUNT(*), одно число

Но и обратная ошибка встречается не реже:

users = User.objects.filter(is_active=True)
print(f"Всего: {users.count()}")     # первый запрос
for user in users:                   # второй запрос, теперь уже за данными
    send_email(user)

Здесь count() наоборот лишний: вы всё равно собираетесь пройтись по всем объектам, значит, выборка уже будет в памяти, и длину можно взять бесплатно.

  • Если объекты дальше нужны — берите len() или сначала материализуйте queryset в список.

  • Если нужно только число — count().

Отдельно про проверку на пустоту. Вот так делать не надо:

if User.objects.filter(email=email).count() > 0:  # считает все совпадения
if User.objects.filter(email=email):              # выкачивает все объекты

Есть специальный метод, который на уровне SQL превращается в SELECT ... LIMIT 1:

if User.objects.filter(email=email).exists():

База останавливается на первой найденной строке и не считает остальные. На таблице с миллионом записей разница между exists() и count() — это разница между миллисекундой и секундами.

Ошибка вторая: ленивость, о которой забыли

Queryset ленивый — он не ходит в базу, пока его не попросят. Это удобно, пока вы не забываете, что каждое «попросят» — это отдельный поход.

orders = Order.objects.filter(status='paid')

total = sum(o.amount for o in orders)      # запрос №1
count = len(orders)                        # кеш, повезло
recent = orders[:10]                       # запрос №2 — новый queryset!
first = orders.first()                     # запрос №3

Первое обращение выполняет queryset и кеширует результат внутри объекта. Но срез orders[:10] создаёт новый queryset со своим LIMIT, и он идёт в базу заново. То же с .first(), .filter(), .exclude() — любой метод, возвращающий queryset, начинает историю с нуля.

Ещё веселее это выглядит в шаблонах:

{% if orders %}
    Всего заказов: {{ orders|length }}
    {% for order in orders %}...{% endfor %}
{% endif %}

Первое {% if %} выполнит queryset и закеширует, остальные возьмут из кеша. А вот если между ними затесался {{ orders.count }} — это уже отдельный запрос в базу.

Когда точно знаете, что будете обращаться несколько раз, материализуйте явно:

orders = list(Order.objects.filter(status='paid'))

Дальше это обычный список, никаких сюрпризов.

Обратная сторона — когда материализовать нельзя, потому что данных слишком много:

for order in Order.objects.all():        # весь миллион в память
    process(order)

for order in Order.objects.all().iterator(chunk_size=2000):   # порциями
    process(order)

iterator() не кеширует результат и тянет строки пачками.

Ошибка третья: select_related и prefetch_related выбраны наугад

Про N+1 знают все, но выбирают инструмент часто по принципу «какой вспомнил».

for order in Order.objects.all():
    print(order.customer.name)       # +1 запрос на каждый заказ

Тысяча заказов — тысяча один запрос. Фиксится в зависимости от типа связи.

select_related работает для «многие к одному» и «один к одному», то есть когда связь идёт через внешний ключ на вашей модели. Django добавляет JOIN и достаёт всё одним запросом.

Order.objects.select_related('customer', 'customer__city')

prefetch_related работает для «многие ко многим» и обратных связей. JOIN тут не годится — он бы размножил строки, поэтому Django делает второй запрос и склеивает объекты в питоне.

Order.objects.prefetch_related('items')

Путаница обходится дорого в обе стороны. Возьмёте select_related там, где нужен prefetch_related, получите ошибку. Возьмёте prefetch_related для простого внешнего ключа — получите лишний запрос там, где хватило бы JOIN.

И третья ловушка: prefetch_related с дополнительной фильтрацией внутри цикла всё ломает.

orders = Order.objects.prefetch_related('items')
for order in orders:
    active = order.items.filter(is_active=True)    # предзагрузка не используется!

Как только вы применяете .filter() к предзагруженной связи, Django идёт в базу заново — потому что в кеше лежит другой набор. Фильтровать надо сразу:

from django.db.models import Prefetch

orders = Order.objects.prefetch_related(
    Prefetch('items', queryset=OrderItem.objects.filter(is_active=True))
)

Теперь в кеше лежит именно то, что нужно, и цикл обходится без запросов.

Индексы, которых нет

Раз уж речь о связях, стоит сказать про соседнюю проблему. Django автоматически создаёт индекс на внешние ключи, а вот на обычные поля — нет. Поэтому фильтр вида.

Order.objects.filter(status='paid', created_at__gte=week_ago)

на большой таблице будет сканировать её целиком, пока вы не объявите индекс явно:

class Order(models.Model):
    class Meta:
        indexes = [
            models.Index(fields=['status', 'created_at']),
        ]

Порядок полей в составном индексе важен: база сможет использовать его для фильтра по status отдельно, но не для фильтра по одному created_at. Правило такое — сначала поля, по которым идёт точное сравнение, потом те, по которым диапазон.

Ошибка четвёртая: гонка в обычном save()

def add_view(product_id):
    product = Product.objects.get(id=product_id)
    product.views_count += 1
    product.save()

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

Хуже того, save() по умолчанию пишет все поля модели. Значит, вы затрёте и те, которые кто‑то поменял параллельно, не только счётчик.

Фиксится выражением F, которое переносит вычисление в базу:

Product.objects.filter(id=product_id).update(views_count=F('views_count') + 1)

Получается один атомарный UPDATE products SET views_count = views_count + 1, никаких гонок. Причём здесь же решается и вторая проблема: update() трогает только указанные колонки.

Если всё‑таки нужен именно save(), ограничивайте набор полей:

product.name = new_name
product.save(update_fields=['name'])

Заодно это заметно быстрее на широких таблицах.

А когда логика сложнее одного инкремента и F не спасает, берите блокировку строки:

with transaction.atomic():
    product = Product.objects.select_for_update().get(id=product_id)
    if product.stock >= quantity:
        product.stock -= quantity
        product.save(update_fields=['stock'])

select_for_update() ставит блокировку до конца транзакции, и второй запрос будет ждать. Два условия: работать только внутри transaction.atomic() (иначе Django бросит ошибку) и держать блок как можно короче.

Ошибка пятая: транзакция, в которой живёт лишнее

Транзакции легко расползаются.

@transaction.atomic
def create_order(request):
    order = Order.objects.create(...)
    send_confirmation_email(order)        # сеть, секунды
    charge_payment_gateway(order)         # сеть, ещё секунды
    generate_pdf_invoice(order)           # процессор, ещё секунды
    return order

Пока выполняется всё это, транзакция открыта, соединение с базой занято, а блокировки удерживаются.

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

Плюс здесь есть логическая ошибка: письмо уйдёт даже если транзакция потом откатится. Пользователь получит подтверждение заказа, которого не существует.

Правильный ход — держать в транзакции только работу с базой, а побочные эффекты вешать на успешный коммит:

def create_order(request):
    with transaction.atomic():
        order = Order.objects.create(...)
        OrderItem.objects.bulk_create(items)
        transaction.on_commit(lambda: send_confirmation_email.delay(order.id))
    return order

on_commit запускает колбэк только после того, как транзакция реально зафиксирована. Откатилась — колбэк не выполнится вообще.

Тот же приём обязателен при постановке задач в очередь. Без него получается классическая гонка: воркер Celery подхватывает задачу быстрее, чем коммитится транзакция, идёт в базу за объектом и не находит его.

# так задача может стартовать раньше коммита
process_order.delay(order.id)

# так — только после
transaction.on_commit(lambda: process_order.delay(order.id))

Ещё одна деталь про транзакции: ATOMIC_REQUESTS = True в настройках оборачивает каждый запрос целиком. Звучит удобно, а на практике означает открытую транзакцию на всё время обработки запроса, включая рендеринг шаблонов и походы во внешние сервисы. Для нагруженного проекта это плохая идея — лучше расставлять atomic точечно.

get_or_create и то, чем он кажется

Раз уж речь о гонках, стоит упомянуть метод, который выглядит атомарным, но таковым не является:

user, created = User.objects.get_or_create(email=email, defaults={'name': name})

Внутри это SELECT, а если не нашли — INSERT. Между ними успевает вклиниться другой запрос, и вы получите IntegrityError вместо ожидаемого объекта. Django ловит эту ситуацию и делает повторный SELECT, но только при одном условии: на поле должно стоять ограничение уникальности в базе.

Без unique=True метод молча создаст дубликаты, и вы об этом узнаете сильно позже — когда в таблице обнаружатся два пользователя с одной почтой.

Так что правило простое: get_or_create без уникального индекса на полях поиска — это не защита от дублей, а её имитация.

Ошибка шестая: массовые операции в цикле

Последнее, что убивает производительность на ровном месте.

for row in csv_rows:
    Product.objects.create(name=row['name'], price=row['price'])

Десять тысяч строк — десять тысяч отдельных INSERT, каждый со своим круговым походом до базы. Даже на локальной машине это минуты.

Product.objects.bulk_create(
    [Product(name=r['name'], price=r['price']) for r in csv_rows],
    batch_size=1000,
)

Один запрос на тысячу объектов. Разница обычно в десятки раз.

То же с обновлением:

for product in products:
    product.price *= 1.1
Product.objects.bulk_update(products, ['price'], batch_size=1000)

Только имейте в виду цену этой скорости. bulk_create и bulk_update не вызывают save() у моделей и не шлют сигналы pre_save и post_save. Если у вас на сигналах висит обновление поискового индекса, инвалидация кеша или запись в аудит‑лог, при массовой вставке всё это молча не сработает.

Это, кстати, хороший аргумент против того, чтобы вешать критичную логику на сигналы. Явный вызов функции виден в коде и не теряется при переходе на массовые операции; сигнал — не виден и теряется.

Если нужно и быстро, и с побочными эффектами, делайте их явно после вставки:

created = Product.objects.bulk_create(objs, batch_size=1000)
reindex_products.delay([p.id for p in created])

С Django 4.1 bulk_create возвращает объекты с проставленными id (на PostgreSQL это работало и раньше), так что собрать список для последующей обработки можно сразу.

Как всё это увидеть до прода

Хорошая новость: почти всё перечисленное ловится инструментами.

  • Django Debug Toolbar показывает количество запросов на странице и дублирующиеся среди них. Если на списке из двадцати элементов видно двадцать одинаковых запросов — вот вам N+1 без всякого расследования.

  • Тест на количество запросов. Самая недооценённая вещь: зафиксировать в тесте, сколько запросов должна делать вьюха.

def test_order_list_queries(client, django_assert_num_queries):
    with django_assert_num_queries(3):
        client.get('/orders/')

Кто‑нибудь добавит обращение к связанной модели в шаблон — тест покраснеет сразу, а не через полгода на проде. Это буквально одна строка защиты от самой частой проблемы в джанго.

  • Логирование медленных запросов. В настройках включается вывод SQL, а на стороне PostgreSQL — log_min_duration_statement. Смотреть на реальные запросы под реальной нагрузкой полезнее любых предположений.

.explain() прямо на queryset:

print(Order.objects.filter(status='paid').explain(analyze=True))

Показывает план выполнения. Если видите последовательное сканирование по большой таблице — скорее всего, не хватает индекса.

Посмотреть сгенерированный SQL можно вообще без всяких инструментов:

print(Order.objects.filter(status='paid').select_related('customer').query)

Полминуты работы, а показывает ровно то, что уедет в базу. Привычка заглядывать туда при написании сложного queryset экономит потом часы.

assertNumQueries в связке с фикстурами реального объёма. Тест на количество запросов бесполезен, если в тестовой базе три записи: N+1 там даст четыре запроса вместо трёх, и никто не заметит. Заводите фикстуру хотя бы на пару десятков связанных объектов — тогда разница станет очевидной.

Про asyn

В шестой версии асинхронная часть ORM подросла: асинхронных методов стало больше, появился AsyncPaginator, писать sync_to_async вокруг каждого обращения к базе больше не нужно.

Но одну вещь стоит понимать правильно, потому что вокруг неё много восторженных текстов. Асинхронные методы вроде aget, acreate, acount — это удобный интерфейс, а не что‑то невероятное: под капотом запрос по‑прежнему выполняется синхронным драйвером в пуле потоков. Настоящего асинхронного ввода‑вывода до базы в Django пока нет.

await перед запросом не делает его дешевле и не увеличивает пропускную способность базы. Он избавляет вас от блокировки цикла событий и от ручных обёрток. А вот количество одновременных подключений и время выполнения запроса остаются теми же, и все ошибки выше в асинхронном коде работают точно так же.

async def order_list(request):
    orders = [o async for o in Order.objects.select_related('customer')[:20]]
    total = await Order.objects.acount()
    return JsonResponse({...})

Обратите внимание на select_related — он тут так же обязателен, как в синхронном коде. Асинхронность не спасает от N+1, она просто делает его асинхронным.


Если посмотреть на шесть историй разом, у них одна природа: ORM позволяет не думать о базе, и большую часть времени это правда работает. Проблема в том, что расстояние между вашим питоном и настоящим SQL достаточно большое, чтобы туда помещался лишний запрос, лишняя транзакция или лишний миллион объектов в памяти.

Поэтому единственная привычка, которая реально помогает, — время от времени смотреть на то, во что превращается ваш код.

Проблемы ORM проще разбирать, когда видно, что происходит на уровне запросов, базы и нагрузки. На бесплатных занятиях можно закрыть отдельные пробелы, задать вопросы преподавателям‑практикам и заодно посмотреть, как устроено обучение в OTUS.

  • 11 августа, 20:00. «Работа с SQLAlchemy и Alembic в FastAPI». Записаться

  • 13 августа, 20:00. «Минимум для старта: как провести свое первое нагрузочное тестирование». Записаться

  • 19 августа, 20:00. «PostgreSQL на стероидах: большие данные, высокие нагрузки и масштабирование без боли». Записаться

Больше бесплатных уроков и других полезных подборок смотрите в дайджесте.