Пошук уроків, статей та іншого контенту
Виявлятимете проблему N+1 запитів і оптимізуватимете завантаження пов’язаних даних у Node.js-застосунках.
Проблема N+1 виникає, коли застосунок:
виконує один запит, щоб отримати список основних сутностей;
для кожної отриманої сутності окремо завантажує пов’язані дані.
Якщо перший запит повернув N записів, загальна кількість запитів становить N + 1.
Наприклад, потрібно повернути користувачів разом з їхніми замовленнями:
const users = await db.query('SELECT id, name FROM users');
for (const user of users.rows) {
const orders = await db.query(
'SELECT id, total FROM orders WHERE user_id = $1',
[user.id]
);
user.orders = orders.rows;
}Якщо в таблиці 100 користувачів, код виконає:
1 запит для отримання користувачів;
100 запитів для отримання замовлень.
Разом — 101 запит.
Кількість запитів зростає разом із кількістю записів, а не лише з обсягом даних. Це створює зайві мережеві переходи між Node.js і базою даних, збільшує навантаження на пул з’єднань і погіршує час відповіді.
Окремий SQL-запит має накладні витрати:
отримання з’єднання з пулу;
передавання запиту мережею;
планування та виконання запиту в базі даних;
передавання результату назад;
обробка результату в Node.js.
Навіть якщо кожен запит виконується швидко, сотні послідовних переходів можуть зробити endpoint повільним.
Особливо проблемним є такий код:
for (const user of users.rows) {
user.orders = (
await db.query(
'SELECT id, total FROM orders WHERE user_id = $1',
[user.id]
)
).rows;
}Запити тут виконуються послідовно. Наступний запит починається лише після завершення попереднього.
Заміна await на Promise.all може зменшити час очікування, але не усуває N+1:
const users = await db.query('SELECT id, name FROM users');
const usersWithOrders = await Promise.all(
users.rows.map(async (user) => {
const result = await db.query(
'SELECT id, total FROM orders WHERE user_id = $1',
[user.id]
);
return {
...user,
orders: result.rows,
};
})
);Тепер запити виконуються паралельно, але їх усе ще N + 1. Такий підхід може додатково перевантажити базу даних і пул з’єднань.
Шукайте запити до бази даних усередині:
for;
for...of;
while;
map;
функцій, які викликаються для кожного елемента списку;
серіалізаторів або resolver-функцій, що завантажують пов’язані дані.
Підозрілий шаблон має такий вигляд:
const entities = await loadEntities();
return Promise.all(
entities.map((entity) => loadRelatedData(entity.id))
);Сам по собі map не є помилкою. Проблема полягає в тому, що loadRelatedData виконує окремий запит для кожного entity.id.
На етапі розробки корисно логувати SQL-запити та вимірювати їхню кількість для одного HTTP-запиту.
Для PostgreSQL із пакетом pg можна тимчасово додати обгортку над pool.query:
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
let queryCount = 0;
async function query(text, params = []) {
queryCount += 1;
const startedAt = performance.now();
try {
return await pool.query(text, params);
} finally {
const duration = performance.now() - startedAt;
console.log({
queryNumber: queryCount,
durationMs: Math.round(duration),
sql: text,
params,
});
}
}
async function loadUsersWithOrders() {
queryCount = 0;
const usersResult = await query(
'SELECT id, name FROM users ORDER BY id'
);
const users = [];
for (const user of usersResult.rows) {
const ordersResult = await query(
`
SELECT id, total
FROM orders
WHERE user_id = $1
ORDER BY id
`,
[user.id]
);
users.push({
...user,
orders: ordersResult.rows,
});
}
console.log(`Загальна кількість запитів: ${queryCount}`);
return users;
}
loadUsersWithOrders()
.then((users) => {
console.log(JSON.stringify(users, null, 2));
})
.catch((error) => {
console.error(error);
})
.finally(() => {
pool.end();
});Для п’яти користувачів результат буде близьким до шести запитів. Для 500 користувачів — до 501.
У production не варто безконтрольно виводити в журнали параметри запитів: вони можуть містити персональні або конфіденційні дані. Для діагностики краще логувати ідентифікатор запиту, тривалість і агреговані метрики.
Перевіряти потрібно не лише кількість запитів, а й:
сумарний час виконання;
кількість рядків, які повертає кожен запит;
час очікування з’єднання з пулу;
використання індексів;
обсяг переданих даних.
Для окремого SQL-запиту PostgreSQL надає EXPLAIN (ANALYZE, BUFFERS). Наприклад:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total
FROM orders
WHERE user_id = 42;Якщо запит до orders виконується багато разів, перевірте, чи існує індекс для orders.user_id. Індекс може прискорити кожен окремий запит, але не усуне саму проблему N+1. Основною оптимізацією залишається зменшення кількості запитів.
Найпростіший спосіб позбутися N+1 — завантажити пов’язані записи одним запитом через WHERE ... IN (...).
Спочатку отримуємо користувачів:
SELECT id, name
FROM users
ORDER BY id;Потім одним запитом отримуємо замовлення для всіх цих користувачів:
SELECT id, user_id, total
FROM orders
WHERE user_id = ANY($1::int[])
ORDER BY user_id, id;У Node.js результати потрібно згрупувати за user_id:
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function loadUsersWithOrders() {
const client = await pool.connect();
try {
await client.query('BEGIN');
const usersResult = await client.query(`
SELECT id, name
FROM users
ORDER BY id
`);
const users = usersResult.rows;
if (users.length === 0) {
await client.query('COMMIT');
return [];
}
const userIds = users.map((user) => user.id);
const ordersResult = await client.query(
`
SELECT id, user_id, total
FROM orders
WHERE user_id = ANY($1::int[])
ORDER BY user_id, id
`,
[userIds]
);
const ordersByUserId = new Map();
for (const order of ordersResult.rows) {
const userOrders = ordersByUserId.get(order.user_id) ?? [];
userOrders.push(order);
ordersByUserId.set(order.user_id, userOrders);
}
const result = users.map((user) => ({
...user,
orders: ordersByUserId.get(user.id) ?? [],
}));
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
loadUsersWithOrders()
.then((users) => {
console.log(JSON.stringify(users, null, 2));
})
.catch((error) => {
console.error(error);
})
.finally(() => {
pool.end();
});Тепер кількість запитів не залежить від кількості користувачів:
1 запит для користувачів;
1 запит для замовлень.
Замість N + 1 маємо 2 запити.
ANY($1::int[]) є PostgreSQL-специфічним синтаксисом. Важливо явно вказати тип масиву, щоб PostgreSQL коректно визначив тип параметра.
Якщо структура відповіді допускає плоский набір даних, пов’язані записи можна отримати одним JOIN:
SELECT
u.id AS user_id,
u.name AS user_name,
o.id AS order_id,
o.total AS order_total
FROM users AS u
LEFT JOIN orders AS o
ON o.user_id = u.id
ORDER BY u.id, o.id;LEFT JOIN важливий, якщо потрібно включити користувачів, у яких немає замовлень. У такому разі колонки o.* матимуть значення NULL.
Результат JOIN потрібно перетворити з плоского списку на вкладену структуру:
const result = await pool.query(`
SELECT
u.id AS user_id,
u.name AS user_name,
o.id AS order_id,
o.total AS order_total
FROM users AS u
LEFT JOIN orders AS o
ON o.user_id = u.id
ORDER BY u.id, o.id
`);
const usersById = new Map();
for (const row of result.rows) {
let user = usersById.get(row.user_id);
if (!user) {
user = {
id: row.user_id,
name: row.user_name,
orders: [],
};
usersById.set(row.user_id, user);
}
if (row.order_id !== null) {
user.orders.push({
id: row.order_id,
total: row.order_total,
});
}
}
const users = [...usersById.values()];Переваги JOIN:
один запит;
фільтрація і сортування виконуються в базі даних;
не потрібно передавати список ідентифікаторів із Node.js назад у базу.
Однак JOIN дублює дані батьківського запису в кожному рядку. Якщо один користувач має тисячі замовлень або додається кілька зв’язків типу «один-до-багатьох», кількість рядків може швидко зрости.
потрібно повернути невеликий або контрольований набір пов’язаних рядків;
дані природно фільтруються одним SQL-запитом;
потрібно відсортувати або відфільтрувати результат на рівні бази даних;
дублювання колонок у результаті не створює суттєвого обсягу даних.
відповідь має вкладену структуру;
пов’язані записи потрібно групувати окремо;
JOIN породжує надто багато повторюваних рядків;
для різних зв’язків потрібні різні правила завантаження.
У складних endpoint’ах допустимий компроміс: один запит для основних записів і по одному пакетному запиту для кожного типу зв’язку. Наприклад:
користувачі;
замовлення всіх користувачів;
платежі всіх замовлень.
Це все ще значно краще за запит у циклі для кожного користувача або замовлення.
Великий список ідентифікаторів може створити занадто великий параметр або надмірне навантаження на один SQL-запит. У такому разі список можна розбити на частини.
async function loadOrdersInBatches(pool, userIds, batchSize = 500) {
const orders = [];
for (let offset = 0; offset < userIds.length; offset += batchSize) {
const batch = userIds.slice(offset, offset + batchSize);
const result = await pool.query(
`
SELECT id, user_id, total
FROM orders
WHERE user_id = ANY($1::int[])
ORDER BY user_id, id
`,
[batch]
);
orders.push(...result.rows);
}
return orders;
}Це зменшує піковий розмір одного запиту, але загальна кількість запитів стає приблизно:
1 + ceil(N / batchSize)Розмір пакета потрібно підбирати за вимірюваннями. Надто малий пакет знову створює зайві мережеві переходи, а надто великий може збільшити час виконання і споживання пам’яті.
У GraphQL N+1 часто з’являється через resolver поля, яке завантажує зв’язок:
const resolvers = {
User: {
orders: async (user, _, { db }) => {
const result = await db.query(
'SELECT id, total FROM orders WHERE user_id = $1',
[user.id]
);
return result.rows;
},
},
};Якщо запит GraphQL повертає 100 користувачів, resolver orders може виконатися 100 разів.
Для такого сценарію використовують пакетний шар завантаження. Його завдання:
зібрати ідентифікатори, які запитали протягом одного циклу обробки;
виконати один пакетний запит;
повернути кожному resolver’у відповідні дані.
Важливо, щоб пакетний завантажувач повертав результат у тому самому порядку, що й вхідні ключі, або явно зіставляв результати за ідентифікатором. Порядок рядків бази даних не можна вважати гарантованим без ORDER BY.
Незалежно від конкретного GraphQL-інструмента принцип залишається таким самим: resolver не повинен виконувати однаковий SQL-запит окремо для кожного батьківського об’єкта.
Пагінація обмежує кількість основних записів, але сама по собі не усуває N+1.
Наприклад, endpoint із 20 користувачами все одно виконає 21 запит, якщо кожен користувач завантажує замовлення окремо. Правильна схема:
отримати одну сторінку користувачів;
взяти їхні ідентифікатори;
одним пакетним запитом отримати пов’язані записи;
згрупувати результати в Node.js.
Під час пакетного завантаження потрібно також визначити, чи має пагінація застосовуватися:
до списку користувачів;
до замовлень кожного користувача;
до загального плоского результату JOIN.
Якщо кожен користувач може мати багато пов’язаних записів, завантаження всіх їх одним запитом може бути надто дорогим. Тоді для зв’язку потрібні окремі обмеження, наприклад limit на рівні бізнес-логіки або окремий endpoint для повного списку.
N+1 легко повертається після рефакторингу. Корисно тестувати не лише правильність відповіді, а й кількість запитів.
Ідея тесту:
let queryCount;
beforeEach(() => {
queryCount = 0;
db.query = async (...args) => {
queryCount += 1;
// Тут тестовий double повертає заздалегідь підготовлені дані.
return executeFakeQuery(...args);
};
});
it('завантажує користувачів і замовлення пакетно', async () => {
const result = await loadUsersWithOrders();
expect(result).toHaveLength(3);
expect(queryCount).toBe(2);
});У production-коді не потрібно прив’язувати бізнес-логіку до лічильника. Лічильник має бути частиною тестового double, middleware або інструмента спостережуваності.
Тест на фіксовану кількість запитів особливо корисний для endpoint’ів, які повертають списки та вкладені об’єкти.
Promise.all робить незалежні запити паралельними, але не зменшує їхню кількість.
Якщо потрібно усунути N+1, замініть запити в циклі на JOIN або пакетне завантаження.
Пакетний запит може повернути мільйони рядків, якщо основний список великий або зв’язок дуже широким.
Потрібно контролювати:
розмір сторінки;
максимальний розмір пакета;
обсяг полів у SELECT;
максимальний обсяг пов’язаних даних.
IN через конкатенацію рядківНебезпечний варіант:
const ids = userIds.join(',');
const sql = `SELECT * FROM orders WHERE user_id IN (${ids})`;SQL не слід будувати шляхом вставлення неперевірених значень у текст запиту. Використовуйте параметри драйвера, наприклад ANY($1::int[]) у PostgreSQL.
Під час групування потрібно повертати порожній масив:
orders: ordersByUserId.get(user.id) ?? []Для SQL-запиту через JOIN потрібно перевіряти NULL у колонці ідентифікатора пов’язаного запису.
INNER JOIN, коли потрібні порожні зв’язкиINNER JOIN вилучить користувачів, у яких немає замовлень. Якщо такі користувачі мають залишитися у відповіді, використовуйте LEFT JOIN.
ORM може приховати SQL за методами на кшталт user.getOrders() або властивістю зв’язку. Це не означає, що запит буде оптимальним.
Потрібно перевіряти фактично виконаний SQL і кількість запитів, а не лише код сервісу.
Якщо endpoint не повертає замовлення, не потрібно завантажувати їх «про запас». Завантаження має відповідати конкретному сценарію та полям відповіді.
Визначте endpoint або resolver, який працює повільно.
Зафіксуйте кількість SQL-запитів для малої та великої кількості записів.
Знайдіть запит, який повторюється для кожної сутності.
Визначте форму потрібної відповіді.
Замініть цикл із запитами на JOIN або пакетний запит.
Згрупуйте результати в Node.js, якщо це потрібно.
Перевірте випадок порожнього списку та сутностей без зв’язків.
Перевірте план виконання і наявність індексу зовнішнього ключа.
Додайте тест або метрику, яка не дозволить проблемі повернутися.
N+1 — це один запит для списку та окремий запит для кожного елемента списку.
Promise.all може зменшити час очікування, але не усуває надмірну кількість запитів.
Основні рішення — JOIN і пакетне завантаження через IN або ANY.
Результати пакетного запиту зазвичай потрібно згрупувати в Map за зовнішнім ключем.
LEFT JOIN зберігає основні записи без пов’язаних даних.
Пагінація зменшує обсяг роботи, але не замінює пакетну стратегію.
Перевіряти потрібно і кількість запитів, і їхню тривалість, і обсяг результатів.
Надійна оптимізація підтверджується вимірюваннями та тестами, а не лише змінами в коді.