Устранение проблемы N+1 запросов и связанных подзапросов в API Node.js с Postgres
Разбор двух медленных экспресс-эндпоинтов на 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. Это создаёт уязвимость к инъекциям, поскольку данные поступают непосредственно из 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, как и оригинальный, для всех 5000 пользователей. Эта проверка эквивалентности стоит скопировать: каждый раз, когда вы переписываете запрос для ускорения, сравнивайте результаты с медленной версией перед заменой.
Оба способа агрегации по-прежнему считывают всю таблицу orders при каждом запросе. При текущих масштабах это нормально, но если таблица будет продолжать расти, один запрос с использованием операторов FILTER, материализованного представления или периодически обновляемой сводной таблицы может улучшить производительность отчета.
Основные выводы
- Создавайте индексы для столбцов, по которым производится фильтрация и сортировка, и соответствуйте порядку композитных индексов порядку запроса: сначала столбцы для проверки равенства, затем столбец сортировки.
- Считайте любой вызов
await db.query()внутри цикла тревожным сигналом; замените его на пакетные запросы с использованиемJOINилиANY($1). - Всегда используйте параметризованные запросы для значений, поступающих из запроса.
- Обращайте внимание на коррелированные подзапросы, которые пересчитывают данные для каждой строки там, где их можно было бы сгруппировать один раз на каждую запись.
- Позвольте базе данных выполнять агрегацию и возвращайте только те строки, которые действительно необходимы в ответе.