Пошук уроків, статей та іншого контенту
Виявляйте надлишкові запити з боку ORM та замінюйте їх ефективним завантаженням даних.
Проблема N+1 виникає, коли застосунок спочатку виконує один запит для отримання списку сутностей, а потім додатковий запит для пов’язаних даних кожної сутності.
Наприклад, потрібно показати замовлення разом з іменами клієнтів:
один запит отримує всі замовлення;
для кожного замовлення окремий запит отримує клієнта.
Якщо замовлень 100, загальна кількість запитів становить:
1 + 100 = 101 запитСаме тому проблема називається N+1:
1 — запит для основного списку;
N — по одному запиту для кожного елемента списку.
При цьому дані можна було отримати одним SQL-запитом із
JOINНехай у базі даних є такі таблиці:
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
total NUMERIC(10, 2) NOT NULL
);
INSERT INTO customers (name)
VALUES
('Олена'),
('Андрій'),
('Марія');
INSERT INTO orders (customer_id, total)
VALUES
(1, 120.50),
(2, 80.00),
(3, 245.99);Наївна логіка застосунку може відповідати такому SQL:
SELECT id, customer_id, total
FROM orders
ORDER BY id;Після цього для кожного замовлення виконується окремий запит:
SELECT id, name
FROM customers
WHERE id = 1;
SELECT id, name
FROM customers
WHERE id = 2;
SELECT id, name
FROM customers
WHERE id = 3;Для трьох замовлень це вже чотири запити. Для тисячі замовлень кількість запитів зростатиме разом із розміром списку.
JOINТі самі дані можна отримати одним запитом:
SELECT
orders.id,
orders.total,
customers.id AS customer_id,
customers.name AS customer_name
FROM orders
JOIN customers ON customers.id = orders.customer_id
ORDER BY orders.id;Результат міститиме одразу і замовлення, і пов’язаного клієнта:
order_id | total | customer_id | customer_name
---------+--------+-------------+--------------
1 | 120.50 | 1 | Олена
2 | 80.00 | 2 | Андрій
3 | 245.99 | 3 | МаріяЦей підхід не означає, що кожен JOIN автоматично розв’язує будь-яку проблему продуктивності. Але для зв’язку «багато до одного» або «один до одного» це зазвичай правильна стратегія.
Перший крок — подивитися, які SQL-запити реально виконує застосунок.
Ознаки N+1 у логах:
один однаковий запит повторюється багато разів;
змінюється лише значення параметра id;
кількість запитів залежить від кількості елементів у списку.
Наприклад:
SELECT ... FROM orders;
SELECT ... FROM customers WHERE id = 1;
SELECT ... FROM customers WHERE id = 2;
SELECT ... FROM customers WHERE id = 3;У production-середовищі SQL-логування потрібно налаштовувати обережно: велика кількість логів може створити додаткове навантаження і розкрити чутливі дані.
Корисно перевіряти не лише результат відповіді, а й кількість SQL-запитів.
Якщо endpoint повертає 20 замовлень, тест може зафіксувати очікувану кількість запитів. Тоді випадкове додавання звернення до пов’язаного об’єкта не призведе до непомітного N+1.
Для окремого запиту можна використати:
EXPLAIN (ANALYZE, BUFFERS)
SELECT
orders.id,
orders.total,
customers.name
FROM orders
JOIN customers ON customers.id = orders.customer_id
ORDER BY orders.id;EXPLAIN ANALYZE показує фактичний план і час виконання. Він допомагає аналізувати конкретний SQL-запит, але не виявляє N+1 як властивість коду сам по собі. Для N+1 потрібно побачити сукупність запитів, які виконує операція або HTTP-запит.
У Django зв’язки між моделями часто завантажуються ліниво. Це означає, що звернення до пов’язаного об’єкта може виконати SQL-запит у момент доступу до атрибута.
Приклад моделей:
from django.db import models
class Customer(models.Model):
name = models.CharField(max_length=200)
class Order(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
total = models.DecimalField(max_digits=10, decimal_places=2)Наївний код:
orders = Order.objects.all()
for order in orders:
print(order.customer.name)Такий код може виконати:
один запит для Order.objects.all();
окремий запит для order.customer кожної ітерації.
select_related для одиничних зв’язківДля зв’язків ForeignKey і OneToOneField використовуйте select_related:
orders = (
Order.objects
.select_related("customer")
.all()
)
for order in orders:
print(order.customer.name)Django завантажить замовлення і клієнтів одним SQL-запитом із JOIN.
Зазвичай це підходить для зв’язків, де кожен об’єкт має одного пов’язаного об’єкта:
ForeignKey;
OneToOneField.
prefetch_related для колекційРозглянемо зворотний зв’язок: один клієнт має багато замовлень.
customers = Customer.objects.all()
for customer in customers:
for order in customer.order_set.all():
print(customer.name, order.total)Без оптимізації ORM може виконати:
один запит для клієнтів;
окремий запит для замовлень кожного клієнта.
Для колекцій використовуйте prefetch_related:
customers = (
Customer.objects
.prefetch_related("order_set")
.all()
)
for customer in customers:
for order in customer.order_set.all():
print(customer.name, order.total)У цьому випадку Django виконає зазвичай два запити:
для всіх клієнтів;
для всіх замовлень, що належать цим клієнтам.
Потім ORM зв’яже об’єкти в пам’яті Python.
prefetch_related підходить для:
ManyToManyField;
зворотних зв’язків ForeignKey;
інших зв’язків, які повертають набір об’єктів.
from django.db import models
class Customer(models.Model):
name = models.CharField(max_length=200)
class Order(models.Model):
customer = models.ForeignKey(
Customer,
on_delete=models.CASCADE,
related_name="orders",
)
total = models.DecimalField(max_digits=10, decimal_places=2)
def print_orders_with_customers():
orders = (
Order.objects
.select_related("customer")
.only("id", "total", "customer__name")
.order_by("id")
)
for order in orders:
print(f"{order.id}: {order.customer.name} — {order.total}")
def print_customers_with_orders():
customers = (
Customer.objects
.prefetch_related("orders")
.order_by("id")
)
for customer in customers:
print(customer.name)
for order in customer.orders.all():
print(f" Замовлення: {order.total}")У першій функції select_related("customer") завантажує клієнта разом із замовленням.
У другій функції prefetch_related("orders") завантажує колекцію замовлень окремим спільним запитом, а не по одному запиту для кожного клієнта.
Метод only обмежує набір вибраних полів. Його потрібно використовувати уважно: звернення до поля, якого немає серед завантажених, може спричинити додатковий запит. Якщо код пізніше звернеться до order.customer.email, а поле email не було завантажене, оптимізація може втратити очікуваний ефект.
select_related і prefetch_relatedЗв’язки різних типів можна оптимізувати разом:
customers = (
Customer.objects
.prefetch_related(
"orders__customer",
)
)Однак у цьому конкретному прикладі orders__customer може бути зайвим, оскільки клієнт уже відомий через об’єкт customer.
Важливо будувати запит відповідно до фактичного способу використання даних, а не додавати всі можливі зв’язки наперед.
Для складніших випадків можна передати Prefetch і окремий QuerySet:
from django.db.models import Prefetch
customers = Customer.objects.prefetch_related(
Prefetch(
"orders",
queryset=Order.objects.order_by("-id"),
to_attr="recent_orders",
)
)
for customer in customers:
for order in customer.recent_orders:
print(customer.name, order.total)Тут замовлення завантажуються один раз і зберігаються в атрибуті recent_orders.
Для select_related ORM зазвичай формує запит на зразок:
SELECT
orders.id,
orders.total,
customers.id,
customers.name
FROM orders
INNER JOIN customers
ON customers.id = orders.customer_id;Для prefetch_related типовий підхід складається з двох запитів:
SELECT id, name
FROM customers;SELECT id, customer_id, total
FROM orders
WHERE customer_id IN (1, 2, 3);Це все ще більше ніж один запит, але це не N+1: кількість запитів не зростає для кожного окремого клієнта.
select_related і prefetch_relatedКористуйтеся таким правилом:
select_related — для одного пов’язаного об’єкта;
prefetch_related — для колекції пов’язаних об’єктів.
Приклади:
# ForeignKey або OneToOneField
Order.objects.select_related("customer")
# Зворотний ForeignKey або ManyToManyField
Customer.objects.prefetch_related("orders")Різниця пов’язана не лише з назвою методів, а й зі структурою результату:
select_related розширює рядок результату через SQL JOIN;
prefetch_related виконує окремий запит і об’єднує результати на рівні ORM.
select_related для колекційselect_related не призначений для завантаження довільних колекцій. Для зворотного ForeignKey або ManyToManyField використовуйте prefetch_related.
Назва зв’язку в ORM має точно відповідати моделі:
Order.objects.select_related("customer")
Customer.objects.prefetch_related("orders")Якщо в моделі не задано related_name="orders", зворотний зв’язок може мати стандартну назву на кшталт order_set.
prefetch_relatedЦей код може виконати додатковий запит:
customers = Customer.objects.prefetch_related("orders")
for customer in customers:
large_orders = customer.orders.filter(total__gte=100)prefetch_related("orders") завантажив усі замовлення, але виклик filter() створює новий QuerySet із новим SQL-запитом.
Якщо потрібна відфільтрована колекція, задайте її явно через Prefetch:
from django.db.models import Prefetch
customers = Customer.objects.prefetch_related(
Prefetch(
"orders",
queryset=Order.objects.filter(total__gte=100),
to_attr="large_orders",
)
)
for customer in customers:
for order in customer.large_orders:
print(order.total)Можна позбутися N+1, але водночас завантажити надто багато даних.
Потрібно враховувати:
кількість рядків;
кількість полів;
розмір пов’язаних колекцій;
реальний формат відповіді API.
Оптимізація полягає не в максимальній кількості JOIN або prefetch_related, а в отриманні саме потрібних даних із передбачуваною кількістю запитів.
На десяти записах N+1 може здаватися непомітним. Перевіряйте поведінку на обсязі, близькому до реального:
список із десятків або сотень елементів;
кілька пов’язаних колекцій;
типові параметри пагінації.
N+1 може бути швидким на локальній базі, але повільним у production через мережеві затримки. Значення має не тільки час виконання одного SQL, а й загальна кількість мережевих звернень до PostgreSQL.
Знайдіть повільний endpoint або операцію.
Увімкніть безпечне логування SQL у development або тестовому середовищі.
Виконайте операцію зі списком об’єктів.
Перевірте, чи повторюється однаковий запит для різних ідентифікаторів.
Визначте тип зв’язку:
одиничний — select_related;
колекція — prefetch_related.
Перевірте кількість запитів після зміни.
Переконайтеся, що результат містить усі потрібні поля.
Додайте тест, який захищає від повернення N+1.
N+1 — це один запит для списку та по одному додатковому запиту для кожного його елемента.
Основна ознака проблеми — повторювані SQL-запити, кількість яких зростає разом із кількістю об’єктів.
У Django select_related оптимізує ForeignKey і OneToOneField.
prefetch_related оптимізує колекції та виконує спільне завантаження пов’язаних об’єктів.
JOIN зменшує кількість звернень до PostgreSQL для одиничних зв’язків.
Після оптимізації потрібно перевіряти фактичні SQL-запити та їхню кількість.
Усунення N+1 не повинно призводити до завантаження непотрібних даних.