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

Сегодня рассмотрим проблему N+1 запросов в Django. N+1 запросы появляются, когда ваш код делает много мелких SQL-запросов вместо нескольких крупных.

Пример. У нас есть модели:

class Author(models.Model):
    name = models.CharField(max_length=100)

class Book(models.Model):
    title = models.CharField(max_length=100)
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books')

И вы решили вывести авторов с их книгами:

authors = Author.objects.all()
for author in authors:
    print(author.name, [book.title for book in author.books.all()])

Кажется, всё нормально, пока не взглянем на SQL:

  1. Первый запрос на получение авторов:

    SELECT * FROM author;
  2. Потом по одному запросу на каждую связь (для каждого автора):

    SELECT * FROM book WHERE author_id = 1;
    SELECT * FROM book WHERE author_id = 2;
    SELECT * FROM book WHERE author_id = 3;
    ...

Если у нас 100 авторов, будет 101 запрос. А если 10 000 авторов? Можете попрощаться с производительностью.

Почему это проблема?

  • Время ответа: время выполнения растёт линейно с количеством записей. Это катастрофа для больших данных.

  • Нагрузка на сервер: сотни мелких запросов перегружают базу.

  • Деньги: если вы платите за облачные ресурсы, это прямой удар по бюджету.

Решаем проблему: основы Django ORM

select_related

Этот метод для оптимизации связей ForeignKey и OneToOneField. Он выполняет SQL JOIN, вытягивая все данные за один запрос.

Когда использовать?

  • Если вам нужно получить данные по прямым связям ForeignKey или OneToOneField.

  • Когда важно минимизировать количество запросов к базе.

Пример: связь ForeignKey

Представим, есть модели:

class Author(models.Model):
    name = models.CharField(max_length=100)

class Book(models.Model):
    title = models.CharField(max_length=100)
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books')

Если вы пишете такой код:

books = Book.objects.all()
for book in books:
    print(book.title, book.author.name)

ORM выполнит 2 + N запросов:

  1. Один запрос для получения всех книг:

    SELECT * FROM book;
  2. Ещё один запрос для получения автора каждой книги:

    SELECT * FROM author WHERE id = <author_id>;

Исправим это с помощью select_related:

books = Book.objects.select_related('author')
for book in books:
    print(book.title, book.author.name)

Теперь ORM выполнит всего 1 запрос:

SELECT "book"."id", "book"."title", "book"."author_id", 
       "author"."id", "author"."name"
FROM "book"
INNER JOIN "author" ON ("book"."author_id" = "author"."id");

prefetch_related

Это метод для оптимизации связей ManyToManyField и обратных ForeignKey. Он выполняет несколько запросов, но объединяет данные в Python, чтобы избежать лавины запросов.

Когда использовать?

  • Если у вас есть отношения многие-ко-многим ManyToManyField.

  • Для обратных связей ForeignKey, где связь идёт от детей к родителю.

Пример: обратная связь ForeignKey

Возьмём ту же модель, но теперь хотим вывести всех авторов с их книгами:

class AuthorQuerySet(models.QuerySet):
    def with_books(self):
        return self.prefetch_related('books')

Django выполнит:

  1. Один запрос для авторов:

    SELECT * FROM author;
  2. Один запрос на каждую связь author.books.all():

    SELECT * FROM book WHERE author_id = 1;
    SELECT * FROM book WHERE author_id = 2;
    ...

Исправляем с помощью prefetch_related:

class Author(models.Model):
    name = models.CharField(max_length=100)

    objects = AuthorQuerySet.as_manager()

Теперь ORM выполнит всего два запроса:

  1. Получение всех авторов:

    SELECT * FROM author;
  2. Получение всех книг для этих авторов:

    SELECT * FROM book WHERE author_id IN (1, 2, 3, ...);

Django автоматически связывает книги с соответствующими авторами, используя Python.

Эти методы так же можно использовать вместе, чтобы оптимизировать сложные запросы.

Как кастомные QuerySets спасают положение?

Инструменты, которые мы рассмотрели, вроде мощные, но их применение требует аккуратности. Если вы пытаетесь оптимизировать сложные запросы, проще вынести логику в кастомный QuerySet. Это позволяет инкапсулировать сложные выборки и избавляет от дублирования кода.

Вот как можно создать QuerySet, который сразу подгружает авторов с их книгами:

class AuthorQuerySet(models.QuerySet):
    def with_books_from_year(self, year):
        return self.prefetch_related(
            models.Prefetch(
                'books',
                queryset=Book.objects.filter(published_year=year)
            )
        )

Теперь подключим его к модели:

authors = Author.objects.with_books_from_year(2023)
for author in authors:
    print(author.name, [book.title for book in author.books.all()])

Используем:

authors = Author.objects.with_books()
for author in authors:
    print(author.name, [book.title for book in author.books.all()])

SQL-запросы:

  1. Получение всех авторов:

    SELECT * FROM author;
  2. Один запрос для книг всех авторов:

    SELECT * FROM book WHERE published_year = 2023 AND author_id IN (1, 2, 3, ...);

Итог: 2 запроса вместо N+1.

А теперь сделаем QuerySet, который подгружает только книги, опубликованные в 2023 году:

class AuthorQuerySet(models.QuerySet):
    def with_books_from_year(self, year):
        return self.prefetch_related(
            models.Prefetch(
                'books',
                queryset=Book.objects.filter(published_year=year)
            )
        )

Используем:

authors = Author.objects.with_books_from_year(2023)
for author in authors:
    print(author.name, [book.title for book in author.books.all()])

SQL-запросы:

  1. Получение авторов:

    CREATE INDEX idx_book_published_year ON book (published_year);
  2. Получение книг 2023 года:

    authors = Author.objects.prefetch_related('books')[:100]

Оптимизация на уровне базы данных

Не забывайте, что без оптимальной базы данных все усилия могут пойти прахом.

  1. Индексы: обязательно добавьте индексы для полей, которые используются в фильтрации.

    CREATE INDEX idx_book_published_year ON book (published_year);
  2. LIMIT и OFFSET: если данные большие, используйте пагинацию.

    authors = Author.objects.prefetch_related('books')[:100]
  3. EXPLAIN: не ленитесь анализировать запросы:

    EXPLAIN SELECT * FROM book WHERE published_year = 2023;

Теперь напишем QuerySet, который подгружает авторов, их книги и издателей этих книг:

class AuthorQuerySet(models.QuerySet):
    def with_books_and_publishers(self):
        return self.prefetch_related(
            models.Prefetch(
                'books',
                queryset=Book.objects.select_related('publisher')
            )
        )

Используем:

authors = Author.objects.with_books_and_publishers()
for author in authors:
    print(author.name)
    for book in author.books.all():
        print(f"  {book.title} ({book.publisher.name})")

SQL-запросы:

  1. Получение авторов:

    SELECT * FROM author;
  2. Получение книг с их издателями:

    SELECT * FROM book 
    LEFT JOIN publisher ON book.publisher_id = publisher.id
    WHERE author_id IN (1, 2, 3, ...);

Кастомные QuerySets позволяют:

  • Инкапсулировать сложные выборки.

  • Сократить количество запросов.

  • Повысить производительность.

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

В заключение напоминаю об открытом уроке 23 января, посвященном клиентской оптимизации веб-приложений.

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

Комментарии (1)


  1. Mi11er
    22.01.2025 11:13

    1000 + 1 статья про оптимизацию запросов (