Пошук уроків, статей та іншого контенту
Класична проблема продуктивності ORM — один запит перетворюється на сотні.
N+1 Query Problem — це ситуація, коли застосунок виконує один запит для отримання списку сутностей, а потім ще по одному запиту для пов’язаних даних кожної сутності.
У результаті замість одного або кількох запитів база отримує:
1 запит для основних записів;
N запитів для пов’язаних записів.
Якщо отримано 100 об’єктів, загальна кількість запитів може зрости до 101.
Наприклад, потрібно показати список статей разом з іменами авторів:
SELECT * FROM articles;
SELECT * FROM users WHERE id = 7;
SELECT * FROM users WHERE id = 12;
SELECT * FROM users WHERE id = 7;
SELECT * FROM users WHERE id = 25;
...На рівні коду це часто виглядає цілком природно:
articles = get_articles()
for article in articles:
print(article.title, article.author.name)Проблема в тому, що звернення до article.author може бути лінивим завантаженням. ORM не отримує автора разом зі статтею, а виконує окремий SQL-запит під час першого звернення до властивості.
Кожен запит до бази має накладні витрати:
встановлення або отримання з’єднання;
передавання запиту мережею;
парсинг і виконання SQL;
очікування відповіді;
перетворення результату на об’єкти ORM.
Навіть якщо кожен запит виконується за кілька мілісекунд, сотні послідовних запитів швидко збільшують час відповіді.
Особливо помітною проблема стає, коли:
список містить десятки або сотні записів;
база даних працює на окремому сервері;
запити виконуються послідовно;
пов’язаних рівнів кілька;
endpoint викликається часто;
застосунок працює в хмарній інфраструктурі з відчутною мережевою затримкою.
N+1 не завжди означає, що база буде перевантажена процесором. Часто головним вузьким місцем стає саме кількість мережевих звернень.
Уявімо такі таблиці:
CREATE TABLE authors (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author_id INTEGER NOT NULL REFERENCES authors(id)
);Неефективний сценарій:
SELECT id, title, author_id
FROM articles
LIMIT 100;
SELECT id, name
FROM authors
WHERE id = 1;
SELECT id, name
FROM authors
WHERE id = 2;
SELECT id, name
FROM authors
WHERE id = 3;Якщо в результаті 100 статей належать 20 різним авторам, застосунок все одно може виконати 100 запитів, якщо ORM не кешує вже завантажених авторів.
Ефективніший варіант — отримати все одним запитом:
SELECT
articles.id,
articles.title,
authors.id AS author_id,
authors.name AS author_name
FROM articles
JOIN authors ON authors.id = articles.author_id
LIMIT 100;Або використати два запити з пакетним завантаженням:
SELECT id, title, author_id
FROM articles
LIMIT 100;
SELECT id, name
FROM authors
WHERE id IN (1, 2, 3, 4, 5);Обидва підходи зазвичай набагато кращі за сотню окремих запитів.
Найпростіший спосіб — увімкнути логування SQL у середовищі розробки або тестування.
Підозрілий шаблон виглядає так:
SELECT ... FROM articles ...
SELECT ... FROM users WHERE id = 1
SELECT ... FROM users WHERE id = 2
SELECT ... FROM users WHERE id = 3
SELECT ... FROM users WHERE id = 4Особливо варто звертати увагу на:
однакові запити, що відрізняються лише значенням id;
велику кількість запитів під час одного HTTP-запиту;
запити, які повторюються в циклі;
збільшення кількості SQL-запитів пропорційно кількості елементів у списку.
Для важливих endpoint’ів корисно перевіряти не тільки результат, а й кількість запитів.
Наприклад, тест може гарантувати, що завантаження списку з пов’язаними даними виконує не більше кількох запитів:
def test_articles_endpoint_does_not_have_n_plus_one(db, client):
create_articles_with_authors(db, count=50)
with count_database_queries() as queries:
response = client.get("/articles")
assert response.status_code == 200
assert len(queries) <= 3Конкретний інструмент залежить від фреймворку, але принцип однаковий: кількість запитів не повинна зростати лінійно з кількістю об’єктів.
У production-середовищі проблему можна виявити за допомогою:
APM-систем;
логів SQL;
метрик кількості запитів на HTTP-запит;
трасування;
повільних query logs;
профайлерів ORM.
Корисна метрика — не лише середній час відповіді, а й кількість SQL-запитів для конкретного endpoint’а.
Якщо кожна стаття має одного автора, зв’язок можна завантажити одразу.
У Django для зв’язків ForeignKey або OneToOneField зазвичай використовують select_related:
articles = Article.objects.select_related("author").all()
for article in articles:
print(article.title, article.author.name)ORM сформує запит із приєднанням таблиць, тому звернення до article.author не створюватиме нового SQL-запиту для кожного елемента.
На рівні SQL це відповідає підходу з JOIN.
Для колекцій, наприклад списку коментарів статті, звичайний JOIN може бути не найкращим варіантом.
У Django для таких зв’язків використовують prefetch_related:
articles = Article.objects.prefetch_related("comments").all()
for article in articles:
for comment in article.comments.all():
print(comment.text)ORM виконає приблизно два запити:
SELECT * FROM articles;
SELECT * FROM comments
WHERE article_id IN (1, 2, 3, 4);Потім він розподілить коментарі між відповідними статтями в пам’яті.
Цей підхід особливо корисний для:
one-to-many;
many-to-many;
колекцій, які не варто приєднувати великим плоским JOIN.
JOINЯкщо потрібні лише поля пов’язаного об’єкта, можна отримати їх одним SQL-запитом:
SELECT
a.id,
a.title,
u.name AS author_name
FROM articles AS a
JOIN users AS u ON u.id = a.author_id
WHERE a.is_published = TRUE;Переваги:
один запит;
фільтрація на стороні бази;
можна отримати тільки потрібні колонки;
немає додаткових звернень під час формування відповіді.
Але JOIN потрібно використовувати обережно для колекцій.
Якщо одна стаття має 10 коментарів, рядок статті повториться 10 разів. Якщо одночасно приєднати авторів, коментарі та теги, кількість рядків може зрости як добуток розмірів колекцій.
INІноді зв’язки не потрібно приєднувати безпосередньо. Можна спочатку отримати ідентифікатори, а потім завантажити всі пов’язані записи одним пакетним запитом:
SELECT id, title, author_id
FROM articles
WHERE is_published = TRUE;
SELECT id, name
FROM users
WHERE id IN (7, 12, 25, 31);На рівні застосунку результат можна перетворити на словник:
authors_by_id = {author.id: author for author in authors}
for article in articles:
author = authors_by_id[article.author_id]
print(article.title, author.name)Така схема називається batch loading. Вона дає змогу замінити багато запитів одним або кількома пакетними запитами.
Іноді проблема полягає не лише в кількості запитів, а й у надмірному обсязі даних.
Якщо API повертає для списку статей тільки назву та ім’я автора, немає сенсу завантажувати весь об’єкт користувача з усіма колонками.
SELECT
a.id,
a.title,
u.name AS author_name
FROM articles AS a
JOIN users AS u ON u.id = a.author_id;Це зменшує:
обсяг даних, переданих мережею;
використання пам’яті;
час перетворення результату;
навантаження на ORM.
У GraphQL та інших системах, де поля запитуються динамічно, N+1 часто виникає на рівні резолверів.
Наприклад, резолвер кожного автора окремо звертається до бази. Для таких сценаріїв використовують DataLoader-підхід:
резолвери збирають усі потрібні ідентифікатори;
loader групує їх;
виконується один пакетний запит;
результати повертаються відповідним резолверам.
Спрощено це можна представити так:
const authorLoader = new DataLoader(async (authorIds) => {
const authors = await loadAuthorsByIds(authorIds);
const authorsById = new Map(
authors.map(author => [author.id, author])
);
return authorIds.map(id => authorsById.get(id));
});У production-коді конкретна реалізація залежить від бібліотеки та фреймворку, але ключова ідея — об’єднувати запити, а не виконувати їх у кожному резолвері окремо.
JOIN чи окремі запити?Вибір між JOIN та пакетним завантаженням залежить від типу зв’язку й обсягу даних.
JOIN доречний, коли:потрібен зв’язок many-to-one;
потрібно фільтрувати основні записи за пов’язаною таблицею;
результат не створює великої кількості дубльованих рядків;
потрібні кілька простих полів із пов’язаної таблиці.
завантажується колекція пов’язаних записів;
JOIN створює багато дублювань;
зв’язок має тип one-to-many або many-to-many;
пов’язані дані потрібні не для всіх основних об’єктів;
ORM має зручний механізм prefetch.
Не існує універсального правила «завжди використовувати один запит». Один дуже складний запит із великими JOIN може бути повільнішим за два прості та добре індексовані запити.
Проблема може виникати не лише для одного зв’язку:
1 запит: отримати статті
N запитів: отримати авторів
N запитів: отримати профілі авторів
N запитів: отримати коментаріНаприклад, код може звертатися до таких властивостей:
for article in articles:
print(article.author.profile.avatar_url)
for comment in article.comments:
print(comment.author.name)Тут потенційно ліниво завантажуються:
автор статті;
профіль автора;
коментарі;
автор кожного коментаря.
Потрібно аналізувати весь граф даних, який використовується під час формування відповіді, а не лише перший зв’язок.
Пагінація зменшує кількість об’єктів на одній сторінці, але сама по собі не усуває N+1.
Якщо сторінка містить 20 статей і для кожної виконується окремий запит автора, це все ще 21 запит.
Правильний підхід:
застосувати пагінацію до основного набору;
eager loading або batch loading пов’язаних даних;
перевірити кількість запитів для однієї сторінки.
Для великих таблиць також важливо враховувати спосіб пагінації. LIMIT/OFFSET може ставати повільним на великих offset, тому іноді застосовують пагінацію за курсором або за значенням indexed-поля.
Lazy loading не є безумовно поганим.
Воно може бути зручним, коли:
пов’язаний об’єкт потрібен рідко;
основний запит використовується в різних сценаріях;
завантаження пов’язаних даних наперед було б марнотратним;
об’єкт обробляється окремо, а не в циклі;
запит виконується поза критичним шляхом.
Проблема виникає тоді, коли lazy loading непомітно використовується всередині циклу або серіалізатора.
Для критичних endpoint’ів краще явно описувати, які зв’язки потрібні конкретному use case.
Механічне додавання eager loading для всіх можливих зв’язків може створити іншу проблему:
зайві JOIN;
великі результати;
дублювання рядків;
високе споживання пам’яті;
повільна серіалізація.
Завантажуйте лише ті дані, які потрібні конкретній операції.
Один запит не завжди кращий за два.
Потрібно аналізувати:
план виконання;
наявність індексів;
кількість прочитаних рядків;
обсяг результату;
час виконання;
використання пам’яті.
Оптимізація повинна покращувати реальний час відповіді, а не лише зменшувати лічильник SQL-запитів.
JOIN для кількох колекційПриєднання кількох колекцій може створити значне дублювання даних. Наприклад, стаття з 10 коментарями та 8 тегами може породити до 80 комбінацій рядків.
У таких випадках краще використати окремі пакетні запити або механізми prefetch.
N+1 часто прихований не в основному коді, а в серіалізаторі:
def serialize_article(article):
return {
"title": article.title,
"author": article.author.name,
}Якщо список статей передається в цю функцію без попереднього завантаження авторів, серіалізатор створить N додаткових запитів.
Серіалізатор не повинен випадково визначати стратегію доступу до бази. Залежності даних краще готувати на рівні запиту або сервісного шару.
Після оптимізації N+1 може повернутися під час рефакторингу.
Тести з обмеженням кількості запитів допомагають зафіксувати очікувану поведінку та виявити регресії.
Деякі команди повністю забороняють ліниве завантаження, щоб N+1 проявлялася одразу. Це може бути корисною політикою, але потребує підготовки:
усі потрібні зв’язки мають явно завантажуватися;
помилки доступу до незавантажених даних повинні бути зрозумілими;
запити треба перевіряти в тестах.
Саме вимкнення lazy loading не оптимізує застосунок — воно лише допомагає раніше виявити небезпечні місця.
Знайдіть endpoint або операцію, яка працює повільно.
Порахуйте SQL-запити для малого та великого набору даних.
Перевірте, чи зростає їх кількість разом із кількістю об’єктів.
Визначте зв’язок, який завантажується всередині циклу або серіалізатора.
Виберіть стратегію:
JOIN або select_related для одиничного зв’язку;
prefetch або batch loading для колекцій;
проєкція в DTO для обмеженого набору полів.
Перевірте SQL-план та індекси.
Виміряйте час відповіді й використання пам’яті після змін.
Додайте тест на кількість запитів, якщо сценарій критичний.
N+1 Query Problem виникає, коли один запит для списку об’єктів доповнюється окремим запитом для кожного пов’язаного об’єкта.
Ключові ознаки:
однакові SQL-запити в циклі;
кількість запитів зростає разом із кількістю записів;
повільні endpoint’и зі списками;
звернення до lazy-loaded властивостей у серіалізаторах.
Основні способи виправлення:
eager loading;
JOIN;
select_related для одиничних зв’язків;
prefetch_related для колекцій;
batch loading через IN;
DataLoader для GraphQL;
вибір лише потрібних полів;
тести на кількість SQL-запитів.
Найкраще рішення — не просто зменшити кількість запитів, а підібрати збалансовану стратегію завантаження даних для конкретного сценарію.