В системах, де використовуються реляційні бази даних та ORM (Object-Relational Mapping), може виникнути проблема N+1, коли для кожного елементу необхідно виконувати додаткові запити до бази даних під час обробки набору даних. У Django, при роботі з базою даних, ми використовуємо QuerySet для виконання операцій фільтрації, додавання, редагування та видалення.
QuerySet можна розглядати як список об'єктів моделі, яку ми створюємо. У випадку, коли потрібно обробити дані з кількох таблиць, використовуючи сотні або тисячі записів, оптимізація QuerySet дозволяє уникнути проблеми N+1, що означає зменшення кількості запитів до бази даних та зниження навантаження на саму базу. Для цього спершу потрібно зрозуміти основи роботи з QuerySet.
QuerySet є ітерованою (iterable) структурою. В основному, запити до таблиці, які стосуються лише одного об'єкта, виконуються тільки при першому запуску, після чого результати зберігаються в пам'яті і використовуються для ітерацій.
# Запит до бази даних виконується лише під час першого проходу циклу
for user in User.objects.all():
print(user.email)
# SELECT * FROM user;
А що, якщо в цьому циклі ми хочемо використати дані з іншої таблиці, зв'язаної з користувачем? Наприклад, у нас є дві таблиці, які зв'язані таким чином:
from django.db import models
class Team(models.Model):
name = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class User(models.Model):
email = models.EmailField(unique=True)
first_name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255)
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='members')
У нас є таблиця User і таблиця Team. Як видно, кожен користувач може бути членом команди, і зв'язок між таблицями реалізований через ForeignKey. Тепер, якщо ми хочемо доступити інформацію про користувача та його команду, ми можемо зробити це наступним чином. Якщо ми підемо неправильним шляхом, то отримаємо наступну ситуацію, що спричинить проблему N+1.
for user in User.objects.all():
print(user.email)
print(user.team.name)
# SELECT * FROM user; (виконується один раз)
# SELECT * FROM team WHERE id = ; (виконується для кожного користувача)
Основна помилка тут — це очікування того, що QuerySet оптимізує запит для нас, незважаючи на те, що інформація про команду зберігається в іншій таблиці. Скільки б користувачів не було, стільки разів буде виконано запит до таблиці Team. Якщо у нас 100 користувачів, то буде виконано 101 запит (N+1).
Правильний підхід полягає в тому, щоб заздалегідь вказати в QuerySet, що ми хочемо використовувати інформацію про команду. Тут нам допомагає selectrelated_.
Select Related
Цей метод використовується для запобігання виконання окремих запитів до кожної зв'язаної моделі при доступі до неї через Django ORM. Він дозволяє отримати зв'язані моделі, використовуючи лише один запит. Тепер розглянемо, як ми можемо поліпшити ситуацію з проблемою N+1 у нашому прикладі, зробивши лише невелике доповнення.
for user in User.objects.select_related('team').all():
print(user.email)
print(user.team.name)
# SELECT user.*, team.* FROM user
# LEFT OUTER JOIN team ON user.team_id = team.id; (виконується один раз)
Ми заздалегідь вказали, що будемо використовувати таблицю Team, і QuerySet автоматично об'єднає таблиці та поверне дані користувачів разом з інформацією про їх команди.
А що, якби ми хотіли отримати дані всіх користувачів, що належать до певної команди? Якщо ми б використовували неправильний підхід, наш код виглядав би ось так:
for team in Team.objects.all():
print(team.name)
for member in team.members.all(): #related_name=members
print(member.email)
# SELECT * FROM team; (виконується один раз)
# SELECT * FROM user WHERE team_id = ; (виконується для кожного члена команди)
Цей запит, де ми очікуємо оптимізацію з боку QuerySet, як і слід було очікувати, призведе до проблеми N+1. Що ж, як можна зменшити кількість запитів і оптимізувати це? Тут нам на допомогу приходить prefetchrelated_.
Prefetch Related
Цей метод дозволяє виконувати запити для кожного зв'язку окремо, але оптимізує їх виконання, щоб уникнути повторних запитів. Він використовується для відносин ManyToMany або Reverse ForeignKey (коли поле належить до іншої моделі через ForeignKey). Тепер давайте оптимізуємо наш код з проблемою, додавши невелику зміну, щоб зменшити кількість запитів до мінімуму.
for team in Team.objects.prefetch_related('members').all():
print(team.name)
for member in team.members.all():
print(member.email)
# SELECT * FROM team; (виконується один раз)
# SELECT * FROM user WHERE team_id IN ; (виконується один раз)
У цьому випадку prefetchrelated_, який використовує members, посилається на related_name, через який ми зв'язуємо таблиці. Застосоване рішення дозволяє нам отримати доступ до всіх користувачів за допомогою одного запиту WHERE.
Щодо оптимізації QuerySet для зменшення розміру даних, що отримуються з бази, можна використовувати такі конструкції як Only, Defer, Annotate та Exists, але основна увага в цьому випадку зосереджена саме на вирішенні проблеми N+1.
Перекладено з: Django’da N+1 Problemi