Сокращайте нагрузку на Prisma и PostgreSQL перед тем, как покупать более мощную базу данных
Пятнадцать практических методов — от EXPLAIN ANALYZE и комбинированных индексов до решений проблемы N+1, счетчиков, объединения ресурсов и операций очистки — для сокращения объема операций в базе данных Prisma.
Источники: I Reduced Prisma Database Operations by 60% Without Upgrading My Database
В приложениях на Prisma и PostgreSQL база данных часто становится первым реальным узким местом. Запрос, который выполняется за 5 мс при работе с несколькими сотнями строк, может занять секунды, когда в таблице оказывается миллион записей, и первым побуждением является покупка более мощной версии или дороже плана. Возьмем, к примеру, приложение типа маркетплейса, где объем работы с базой данных достигает 5 000–6 000 операций в день — этого достаточно, чтобы бесплатный тариф, основанный на объеме использования, исчерпался еще до конца месяца. Недельная работа по анализу производительности и оптимизации запросов в таком случае может сократить количество операций более чем на 60 процентов, ускорить отклики API и снизить счета, при этом не требуя замены оборудования.
Ниже приведены методы, расположенные в порядке от диагностики до проектирования схемы и ее обслуживания, которые обычно помогают добиться такого результата.
Измеряйте перед тем, как что-либо менять
Не предполагайте, что запрос медленный; измерьте его. Для большинства случаев подходят два инструмента:
EXPLAIN ANALYZEотображает план выполнения PostgreSQL вместе с количеством просканированных строк, использованием индексов и фактическим временем выполнения.- Логирование запросов Prisma показывает точный SQL-код, генерируемый клиентом.
Выполнение EXPLAIN ANALYZE для фильтрации по продавцу выглядит так. Обратите внимание, что ANALYZE действительно выполняет запрос, поэтому запросы, изменяющие данные, следует помещать в транзакцию, которую можно откатить.
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE seller_id = 123;
Даже казалось бы корректный вызов Prisma может привести к последовательному сканированию всей таблицы; только план выполнения помогает это определить. pg_stat_statements (который ранжирует запросы по общему времени выполнения), инструменты анализа запросов Prisma и панели управления, такие как те, что предоставляет Vercel, помогают определить, что нужно исправить в первую очередь. Для структурированного подхода см. как найти настоящий узкий место в медленном Node.js-эндпоинте.
Индексируйте те запросы, которые вы действительно выполняете
Индексы обычно являются фактором с наибольшим влиянием на производительность. Без них запрос подобного типа может заставить PostgreSQL проверять каждую строку в таблице orders:
SELECT *
FROM orders
WHERE seller_id = 123;
Простой индекс типа B-tree на отфильтрованной колонке позволяет планировщику сразу перейти к соответствующим строкам:
CREATE INDEX idx_orders_seller
ON orders(seller_id);
Когда приложение регулярно фильтрует данные по нескольким колонкам одновременно, композитный индекс позволяет работать непосредственно с этой комбинацией:
CREATE INDEX idx_orders_seller_status
ON orders(seller_id, status);
Благодаря ему условия, касающиеся как продавца, так и статуса, приводят к более эффективному поиску в индексе:
WHERE seller_id = 123
AND status = 'PAID'
Порядок столбцов имеет значение в составном индексе. (seller_id, status) также помогает запросам, фильтрующим по seller_id, но не запросам, фильтрующим только по status. Общее правило заключается в том, чтобы индексировать только те столбцы, по которым действительно производится фильтрация, сортировка или объединение, а не все; каждый индекс занимает дисковое пространство и замедляет любые записи в таблицу.
Устранение запросов N+1
Паттерн N+1 — это классическая проблема ORM. Вы загружаете список из 100 заказов:
const orders = await prisma.order.findMany();
а затем в цикле по одному запрашиваете пользователя каждого заказа:
for (const order of orders) {
await prisma.user.findUnique({
where: {
id: order.userId
}
});
}
Это 101 запрос для одной страницы данных. Если попросить Prisma загрузить связанные данные вместе с заказами, цикл упрощается:
const orders = await prisma.order.findMany({
include: {
user: true
}
});
Один вызов заменяет цикл, что снижает задержку и нагрузку во много раз. В зависимости от версии и конфигурации Prisma выполняет операцию include либо как объединение данных, либо как дополнительный пакетный запрос; в любом случае исчезают повторные запросы к каждой строке.
Выбирайте только необходимые колонки
Сокращение объема передаваемых данных — один из самых простых способов улучшения производительности. Простой метод findMany возвращает все колонки из каждой строки:
await prisma.user.findMany();
Оператор select ограничивает результат тем, что действительно используется вызывающим кодом:
await prisma.user.findMany({
select: {
id: true,
name: true
}
});
При наличии в таблице JSON-данных, форматирования Markdown, изображений или длинного текста это может значительно сократить время отклика, к тому же такой подход защищает конфиденциальные колонки от попадания в ответы API.
Заменяйте повторные агрегации хранимыми счетчиками
Панели аналитики часто пересчитывают агрегатные показатели при каждой загрузке страницы:
COUNT(*)
SUM(amount)
AVG(...)
Это нормально при 100 строках, но крайне проблематично при миллионах. Например, вычисление общего объема продаж при каждом запросе подразумевает суммирование всей таблицы:
SELECT SUM(amount)
FROM orders;
Альтернатива — обновлять суммарные показатели в момент записи, храня такие значения, как общая сумма, непосредственно в записи пользователя:
User
-------------
totalSales
totalOrders
totalRevenue
Обновляйте эти поля каждый раз после завершения покупки, тогда операции чтения станут поиском за O(1) вместо сканирования миллионов строк; в приведенном выше случае это было одним из самых значительных сокращений нагрузки. Обновляйте счетчики атомарно (например, с помощью функции increment в Prisma в той же транзакции, что и вставка заказа), и время от времени сверяйте их, поскольку денормализованные суммы могут отклоняться.
Пагинация всех списков
Таблицы не остаются маленькими, поэтому никогда не возвращайте бесконечный список в таком виде:
await prisma.product.findMany();
Используйте take и skip для получения ограниченной страницы; в этом примере возвращается вторая из 20 страниц:
await prisma.product.findMany({
take: 20,
skip: 20
});
Для больших наборов данных или функции бесконечного прокручивания предпочтительнее использование пагинации с курсором: смещение позволяет PostgreSQL пропустить все пропущенные строки, в то время как курсор на индексированной колонке возобновляет обработку именно там, где заканчивалась предыдущая страница.
Кэширование редко меняющихся данных
Самый быстрый запрос — это тот, который вообще не выполняется. Данные, которые меняются редко, отлично подходят для кэширования: страницы товаров, публичные витрины, настройки приложения и списки категорий. Распространенными решениями являются Upstash Redis, кэш Next.js и внутренний кэш LRU. Вынос часто используемых запросов из PostgreSQL снижает нагрузку; ценой этого становится необходимость обновления кэша, поэтому необходимо определить, насколько устаревшими могут быть данные, и обновлять их при изменении строк.
Пакетные записи вместо циклов
Каждый ожидаемый вызов представляет собой сетевую поездку туда-обратно, и их количество быстро растет. Классическим примером является создание записей по одной в цикле:
for (const item of items) {
await prisma.item.create({
data: item
});
}
createMany вставляет всё массив одним запросом:
await prisma.item.createMany({
data: items
});
Одна поездка туда-обратно лучше, чем сотни. Обратите внимание, что createMany не создаёт вложенные связи и возвращает количество записей, а не сами записи; если вам нужны данные, ознакомьтесь с документацией к вашей версии Prisma.
Группируйте связанные операции записи в транзакции
Создание заказа часто подразумевает другие операции записи, такие как корректировка запасов, обновление балансов и генерация квитанции. Эти изменения связаны между собой, и $transaction гарантирует, что они либо все выполнатся успешно, либо все будут откатены:
await prisma.$transaction([
prisma.order.create(...),
prisma.user.update(...),
prisma.balance.update(...)
]);
Настоящая польза заключается в согласованности, а не в скорости: без обновления баланса невозможно гарантировать порядок выполнения операций, если что-то сорвется на полпути. Сохраняйте транзакции короткими, поскольку они занимают блокировки и поддерживают соединение.
Повторное использование одного Prisma Client
Создание нового объекта PrismaClient для каждого запроса дорогостояще и может исчерпать количество соединений; к тому же при разработке функция горячей замены может создавать новый клиент при каждой изменении файла. Использование синглтона позволяет избежать обоих проблем. Импортируйте клиент:
import { PrismaClient } from "@prisma/client";
затем сохраните экземпляр в переменной globalThis вне среды производства, чтобы перезагружаемые модули использовали существующий клиент вместо того, чтобы создавать новый:
const globalForPrisma =
globalThis as unknown as {
prisma: PrismaClient | undefined;
};export const prisma =
globalForPrisma.prisma ??
new PrismaClient();if (process.env.NODE_ENV !== "production")
globalForPrisma.prisma = prisma;
Преобразование с помощью as unknown as придает переменной globalThis свойство типа prisma; в среде производства модуль загружается только один раз, поэтому глобальная кэш-система не требуется.
Использование пула соединений, особенно в серверных средах
Платформы без сервера могут запускать тысячи одновременных экземпляров функций, каждая из которых нуждается в собственном подключении к базе данных. Без пуллинга у PostgreSQL может закончиться количество подключений задолго до того, как возникнут проблемы с процессором. Prisma Accelerate и PgBouncer работают между приложением и базой данных, позволяя множеству запросов делиться гораздо меньшим количеством реальных подключений к PostgreSQL.
Создайте индексы для полей JSONB, по которым вы выполняете фильтрацию
Тип JSONB в PostgreSQL очень мощный, но фильтрация по вложенным полям без индекса обычно приводит к полному сканированию таблицы. Если вы регулярно выполняете запросы внутри документов JSON, добавьте GIN-индекс к этой колонке (или индекс на выражение для конкретного ключа, по которому часто фильтруете), тем самым превращая медленный поиск в документах в операции с использованием индексов.
Для поиска уникальных записей предпочитайте функцию findUnique
При выполнении запросов по первичному ключу или другому уникальному полю используйте этот метод:
findUnique()
Это сообщает Prisma, что поиск направлен на одну строку, что позволяет Prisma объединять несколько вызовов findUnique из одного цикла в один запрос, что удобно в резолверах GraphQL. Например:
await prisma.user.findUnique({
where: {
id
}
});
Используйте его вместо следующего варианта, когда поле является уникальным:
findFirst()
findFirst остается правильным выбором для фильтров, не требующих уникальности, когда вам действительно нужна «первая соответствующая строка».
Архивируйте старые строки вместо постоянного косвенного удаления
Косвенное удаление удобно: достаточно просто отметить строку:
isDeleted = true
Однако со временем каждый запрос должен фильтровать удалённые строки, что приводит к увеличению размера таблиц и индексов. В системах с высокой нагрузкой необходимо периодически перемещать удалённые записи в архивные таблицы: меньшие таблицы означают меньшие индексы и более быстрые запросы. Частичный индекс над неудалёнными строками представляет собой промежуточное решение.
Поддержание работоспособности автовакуума
PostgreSQL использует MVCC (контроль конкурентности с несколькими версиями), поэтому обновленные и удалённые строки не удаляются сразу; они остаются в виде «мертвых» туплей. Автовакуум — это фоновый процесс, который их удаляет. Хорошо настроенный автовакуум:
- освобождает пространство, занимаемое «мертвыми» туплями;
- обновляет статистику планировщика;
- поддерживает эффективность индексов;
- обеспечивает точность планов запросов.
Игнорирование автовакуума приводит к увеличению размера базы данных и к плохим планам выполнения запросов, особенно в таблицах с частыми обновлениями, таких как приведённые выше счётчики.
Основные выводы
Большинство проблем с производительностью баз данных решаются не за счёт увеличения размера серверов, а благодаря более эффективным способам доступа к данным. В приведённом выше случае провайдер хостинга и размер базы данных оставались прежними. Улучшения были достигнуты благодаря:
- анализу дорогостоящих запросов вместо угадывания;
В совокупности они позволили сократить количество операций более чем на 60 процентов, ускорить работу API и обеспечить возможность обработки гораздо большего объема трафика. Прежде чем масштабировать систему, выясните, как на самом деле используется ваша база данных; существующие запросы часто скрывают большую часть возможностей по улучшению производительности.