Усунення запитів N+1 та корелюованих підзапитів у API Node.js з Postgres
Розбір двох повільних кінцевих точок Express у Postgres: як відсутні індекси, вкладені цикли N+1 та підзапити для кожного рядка спричиняють проблеми, та як скоротити час виконання до менше ніж 200 мс.
Повільні кінцеві точки часто звинувачують у проблемах інфраструктури, проте багато з них працюють повільно через причини, які очевидні в самому коді обробника. У цьому аналізі розглядаються дві кінцеві точки Express, які працюють з базою даних Postgres: одна повертає інформацію про замовлення користувача за 2,24 секунди, а інша — звіти — за приблизно 29 секунд. Для виправлення проблем не потрібні Redis, додаткові сервери чи нова архітектура; майже весь час затримки спричиняє п’ять поширених помилок, а їх усунення дозволяє скоротити час відповіді обох кінцевих точок до менше ніж 200 мс. До кінця ви зможете розпізнавати такі ж закономірності у власному коді та знати, які формули запитів використовувати замість цього.
Якщо ви шукаєте загальний метод для виявлення «вузького місця» ще до того, як почнете працювати з кодом, наш посібник з пошуку справжнього вузького місця у повільному Node.js ендпоїнті розглядає аспекти профілювання. Тут увага зосереджена на конкретних шаблонах запитів.
Два ендпоїнти під мікроскопом
Перший обробник, GET /orders/:userId, завантажує замовлення користувача, потім проходить по кожному замовленню, щоб отримати його елементи, а далі проходить по кожному елементу, щоб отримати відповідний продукт. Для кожного елемента також обчислюється хеш SHA-256, який додається як поле etag.
// GET /orders/:userId
app.get('/orders/:userId', async (req, res) => {
const { userId } = req.params;
const ordersResult = await pool.query(
`SELECT * FROM orders WHERE user_id = ${userId} ORDER BY created_at DESC`
);
const orders = ordersResult.rows;
const enrichedOrders = [];
for (const order of orders) {
const itemsResult = await pool.query(
`SELECT * FROM order_items WHERE order_id = ${order.id}`
);
const items = itemsResult.rows;
const enrichedItems = [];
for (const item of items) {
const productResult = await pool.query(
`SELECT * FROM products WHERE id = ${item.product_id}`
);
const product = productResult.rows[0];
const etag = crypto
.createHash('sha256')
.update(JSON.stringify({ item, product }))
.digest('hex');
enrichedItems.push({ ...item, product, etag });
}
enrichedOrders.push({ ...order, items: enrichedItems });
}
res.json(enrichedOrders);
});
Другий обробник створює підсумок статусу замовлень. Він вибирає кожне замовлення разом із коефіцієнтом скасування на користувача, обчисленим у підзапиті, а потім перебирає отримані рядки за допомогою JavaScript, щоб підрахувати кількість замовлень за кожним статусом та зберегти цей коефіцієнт для кожного користувача. Хоча у фрагменті коду є SQL, сам обробник написаний на JavaScript.
app.get('/reports/order-status-summary', async (req, res) => {
const result = await pool.query(`
SELECT
o.id AS order_id,
o.user_id,
o.status,
(
SELECT round(
count(*) FILTER (WHERE o2.status = 'cancelled')::numeric
/ NULLIF(count(*), 0),
4
)
FROM orders o2
WHERE o2.user_id = o.user_id
) AS cancellation_rate
FROM orders o
`);
const rows = result.rows;
const totalsByUser = {};
const cancellationRateByUser = {};
for (const row of rows) {
totalsByUser[row.user_id] = totalsByUser[row.user_id] || {};
totalsByUser[row.user_id][row.status] =
(totalsByUser[row.user_id][row.status] || 0) + 1;
cancellationRateByUser[row.user_id] = Number(row.cancellation_rate) || 0;
}
res.json({
usersProcessed: Object.keys(totalsByUser).length,
totalsByUser,
cancellationRateByUser,
});
});
На перший погляд обидва рішення здаються розумними. Проблеми проявляються лише при реальних обсягах даних. У тестовій базі даних є:
- користувачі: 5,000
- товари: 2,000
- замовлення: 500,000
- елементи замовлень: 1,500,000
При такому обсязі ендпоїнт замовлень виконується протягом 2,24 секунди, що є занадто довгим терміном для простого екрана „Мої замовлення“, а ендпоїнт з підсумками — протягом 29,26 секунд.
Чому GET /orders/:userId виконується протягом 2,24 секунд
Користувач, який натискає „Мої замовлення“, не повинен чекати дві секунди під індикатором завантаження. Ця затримка виникає через три окремі причини, які накопичуються одна на одну.
Початковий пошук сканує та сортує всю таблицю
Вже перше запитання містить кілька проблем у лише кількох рядках.
const ordersResult = await pool.query(
`SELECT * FROM orders WHERE user_id = ${userId} ORDER BY created_at DESC`
);
- Не існує індексу на
orders.user_id, тому Postgres виконує послідовний скан усіх 500 000 рядків, щоб знайти приблизно 102 рядки, які належать користувачеві. - Також немає нічого, що б підтримувало
ORDER BY created_at DESC, тому після сканування відповідні рядки сортуються без жодної допомоги індексу. - Значення
userIdвставляється безпосередньо у рядок SQL. Це створює вразливість до втручання через SQL, оскільки значення надходить безпосередньо з URL, а також це перетворює кожен запит на унікальний за текстом запит.
Подвійно вкладений N+1
Саме у циклах витрачається більшість часу.
for (const order of orders) {
const itemsResult = await pool.query(`SELECT * FROM order_items WHERE order_id = ${order.id}`);
...
for (const item of items) {
const productResult = await pool.query(`SELECT * FROM products WHERE id = ${item.product_id}`);
- Це класичний шаблон N+1, вкладений двічі. Приблизно 102 замовлення генерують 102 запити до елементів, а близько 500 елементів створюють ще близько 500 запитів до продуктів. Це означає понад 600 поїздок до Postgres, виконаних одна за одною, оскільки кожен
awaitчекає на попередній, і кожна з них оплачує повну затримку мережі. Ті самі дані можна отримати за допомогою одного–трьох запитів з використаннямJOINабоWHERE id = ANY(...). order_items.order_idне має індексу, тому кожен з 102 запитів до елементів змушений самостійно прочитати 1,5 мільйона рядків.
Хешування, яке марно блокує цикл подій
Остання проблема полягає у роботі процесора, а не у введенні-виведенні даних.
const etag = crypto.createHash('sha256').update(JSON.stringify({ item, product })).digest('hex');
Хеш обчислюється синхронно для кожного елемента, приблизно 500 разів на запит. Оскільки Node.js виконує JavaScript у єдиному потоці, кожне з цих обчислень блокує цикл подій та затримує всі інші запити, якими займається процес. Ще гірше те, що отримане значення ніколи не використовується для кешування чи умовних запитів, тож виконана робота не приносить жодної користі. Наша стаття на тему того, як process.nextTick може блокувати цикл подій пояснює, чому блокування цього потоку є настільки дорогим з точки зору ресурсів.
У сукупності затримка складається із послідовної суми понад 600 поїздок туди-назад, кількох повних сканувань таблиць та сотень обчислень хешів.
Чому звіт підсумків формується приблизно за 29 секунд
Користувач, який відкриває резюме свого замовлення та чекає майже півхвилини, цілком може припустити, що сторінка не працює. Причина криється у одному SQL-запиті.
SELECT
o.id AS order_id, o.user_id, o.status,
(
SELECT round(
count(*) FILTER (WHERE o2.status = 'cancelled')::numeric / NULLIF(count(*), 0),
4
)
FROM orders o2
WHERE o2.user_id = o.user_id
) AS cancellation_rate
FROM orders o
- Корелювана підзапит виконується по одному разу для кожного зовнішнього рядка. Зовнішній запит повертає всі 500 000 замовлень, і для кожного з них Postgres перевиконує підзапит над
orders o2, відфільтрований заuser_idцього рядка. Якщо у кожного користувача приблизно 100 замовлень, той самий показник скасувань перераховується близько 100 разів для кожного користувача, що в сумі дає близько 500 000 виконань підзапиту. - Відсутній індекс для частих пошуків. Без індексу на
orders.user_idкожне з цих виконань вимагає повного сканування даних. Індекс міг би зробити кожне виконання дешевшим, але не усунув би надмірних витрат на обчислення однієї й тієї самої відповіді сотні разів.
totalsByUser та cancellationRateByUser. Це той самий процес об’єднання даних, який Postgres міг би виконати один раз за допомогою GROUP BY.Перший крок: додати індекси, які відповідають шаблону доступу
Індекси зазвичай є найдешевшим та найефективнішим першим рішенням. Тут потрібні два індекси: композитний індекс на orders (user_id, created_at DESC), щоб як фільтрація, так і сортування виконувалися за допомогою цього індексу, та індекс на order_items (order_id) для пошуку елементів. Наведена нижче команда створює обидва індекси всередині контейнера Postgres; оператор IF NOT EXISTS забезпечує можливість безпечного повторного виконання.
docker exec slow-api-postgres psql -U postgres -d shop -c "
CREATE INDEX IF NOT EXISTS idx_orders_user_id_created_at ON orders (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items (order_id);
"
Порядок стовпців у композитному індексі має значення. Якщо поставити user_id на перше місце, Postgres зможе безпосередньо перейти до рядків певного користувача, а оскільки ці рядки вже зберігаються у порядку created_at DESC всередині індексу, крок сортування зовсім не потрібен. У активній таблиці виробничого середовища розгляньте використання команди CREATE INDEX CONCURRENTLY, щоб процес створення індексу не блокував записи.
Після створення індексів обидва обробники можна переписати так, щоб вони отримували дані пакетно та дозволяли базі даних виконувати агрегацію.
Переписування GET /orders/:userId за допомогою пакетних запитів
Нова версія виконує рівно три запити. Спочатку за допомогою параметризованого запиту отримуються замовлення користувача, збираються їхні ідентифікатори, а потім завантажуються всі пов’язані елементи за умовою order_id = ANY($1). Далі ідентифікатори продуктів усуваються за допомогою структури Set, і ці продукти завантажуються ще одним запитом. Результати об’єднуються в пам’яті за допомогою двох пошуків у структурі Map, а для користувачів без замовлень використовується передчасне повернення.
app.get('/orders/:userId', async (req, res) => {
const { userId } = req.params;
const ordersResult = await pool.query(
'SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC',
[userId]
);
const orders = ordersResult.rows;
if (orders.length === 0) {
return res.json([]);
}
const orderIds = orders.map((o) => o.id);
const itemsResult = await pool.query(
'SELECT * FROM order_items WHERE order_id = ANY($1)',
[orderIds]
);
const items = itemsResult.rows;
const productIds = [...new Set(items.map((i) => i.product_id))];
const productsResult = await pool.query(
'SELECT * FROM products WHERE id = ANY($1)',
[productIds]
);
const productsById = new Map(productsResult.rows.map((p) => [p.id, p]));
const itemsByOrderId = new Map();
for (const item of items) {
const enrichedItem = { ...item, product: productsById.get(item.product_id) };
if (!itemsByOrderId.has(item.order_id)) {
itemsByOrderId.set(item.order_id, []);
}
itemsByOrderId.get(item.order_id).push(enrichedItem);
}
const enrichedOrders = orders.map((order) => ({
...order,
items: itemsByOrderId.get(order.id) || [],
}));
res.json(enrichedOrders);
});
Зміни по черзі:
- Композитний індекс на
orders (user_id, created_at DESC)перетворює умовуWHEREразом ізORDER BYна сканування за допомогою індексу замість послідовного сканування та сортування 500 000 рядків.
order_items (order_id) означає, що пошук елементів більше не вимагає обробки 1,5 мільйона рядків.$1, замінюють вставку рядків, що закриває можливості для SQL-ін’єкцій. Зауважте, що з node-postgres за кожну виконання все одно планується неназваний параметризований запит; якщо ви хочете, щоб Postgres повторно використовував цей план, скористайтеся названим підготовленим запитом.Одна практична порада: видалення поля etag змінює структуру відповіді. Переконайтеся, що жоден клієнт від нього не залежить, перш ніж впроваджувати цю зміну.
Переписування звіту за допомогою GROUP BY
Оптимізований звіт виконує два цілеспрямовані запити на агрегацію: один групує дані за user_id та status для підрахунку кількостей, інший — за user_id для обчислення коефіцієнта скасувань. JavaScript лише перетворює вже агреговані рядки у формат відповіді. Як і раніше, обробник — це JavaScript, який містить SQL-запити.
// GET /reports/order-status-summary (OPTIMIZED / "after")
app.get('/reports/order-status-summary-optimized', async (req, res) => {
const statusResult = await pool.query(`
SELECT user_id, status, count(*)::int AS count
FROM orders
GROUP BY user_id, status
`);
const rateResult = await pool.query(`
SELECT
user_id,
round(
count(*) FILTER (WHERE status = 'cancelled')::numeric
/ NULLIF(count(*), 0),
4
) AS cancellation_rate
FROM orders
GROUP BY user_id
`);
const totalsByUser = {};
for (const row of statusResult.rows) {
totalsByUser[row.user_id] = totalsByUser[row.user_id] || {};
totalsByUser[row.user_id][row.status] = row.count;
}
const cancellationRateByUser = {};
for (const row of rateResult.rows) {
cancellationRateByUser[row.user_id] = Number(row.cancellation_rate) || 0;
}
res.json({
usersProcessed: Object.keys(totalsByUser).length,
totalsByUser,
cancellationRateByUser,
});
});
Що змінилося та чому це важливо:
- Корелюваний підзапит тепер є частиною оператора
GROUP BY user_id. Postgres обчислює коефіцієнт один раз на користувача під час одного проходження агрегації: 5 000 обчислень замість приблизно 500 000. - Кількість рядків у результаті зменшилася з 500 000 до приблизно 5 000–20 000. Postgres повертає один рядок для кожної пари
(user_id, status)для підрахунку кількостей та один рядок на користувача для коефіцієнта скасувань, без дубльованих обчислених стовпців.
orders, замість того щоб для кожного рядка виконувати запит, вкладений у інший.Час виконання скоротився з приблизно 26 до 29 секунд до близько 0,1–0,19 секунди — це від 150 до 260 разів швидше. Також було перевірено, що новий ендпоїнт повертає такі самі значення totalsByUser та cancellationRateByUser, як і оригінальний, для всіх 5 000 користувачів. Ця перевірка еквівалентності варта наслідування: щоразу, коли ви переписуєте запит для прискорення, порівнюйте результати з повільною версією перед її заміною.
Обидва способи обробки все ще зчитують усю таблицю orders при кожному запиті. Це прийнятно на такому рівні масштабування, але якщо таблиця продовжуватиме зростати, один запит із використанням конструкцій FILTER, матеріалізованого перегляду чи таблиці підсумків, оновлюваної періодично, може покращити ефективність звіту.
Ключові висновки
- Створюйте індекси для стовпців, за допомогою яких ви фільтруєте та сортуєте дані, та відповідно до цього формуйте складний індекс: спочатку стовпці для порівняння, потім — стовпець сортування.
- Будь-який виклик
await db.query()усередині циклу слід вважати ознакою проблеми; замініть його на запити типуJOINчиANY($1)у блоці обробки. - Завжди використовуйте параметризовані запити для значень, які надходять з запиту.
- Стежте за корелюваними підзапитами, які перераховують дані для кожного рядка заново, тоді як їх можна об’єднати лише один раз на елемент.
- Дозвольте базі даних виконувати агрегацію та повертайте лише ті рядки, які дійсно потрібні у відповіді.