Главная / Статьи

Сокращайте нагрузку на Prisma и PostgreSQL перед тем, как покупать более мощную базу данных

Пятнадцать практических методов — от EXPLAIN ANALYZE и комбинированных индексов до решений проблемы N+1, счетчиков, объединения ресурсов и операций очистки — для сокращения объема операций в базе данных Prisma.

Издатель: REACTAPP.TOP8 мин чтения1747 слов

Источники: 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 (контроль конкурентности с несколькими версиями), поэтому обновленные и удалённые строки не удаляются сразу; они остаются в виде «мертвых» туплей. Автовакуум — это фоновый процесс, который их удаляет. Хорошо настроенный автовакуум:

  • освобождает пространство, занимаемое «мертвыми» туплями;
  • обновляет статистику планировщика;
  • поддерживает эффективность индексов;
  • обеспечивает точность планов запросов.

Игнорирование автовакуума приводит к увеличению размера базы данных и к плохим планам выполнения запросов, особенно в таблицах с частыми обновлениями, таких как приведённые выше счётчики.

Основные выводы

Большинство проблем с производительностью баз данных решаются не за счёт увеличения размера серверов, а благодаря более эффективным способам доступа к данным. В приведённом выше случае провайдер хостинга и размер базы данных оставались прежними. Улучшения были достигнуты благодаря:

  • анализу дорогостоящих запросов вместо угадывания;
  • добавление индексов, соответствующих реальным фильтрам;
  • удаление циклов N+1 и ненужных столбцов;
  • кэширование повторяющихся запросов;
  • замена дорогостоящих агрегаций на поддерживаемые счетчики;
  • установка ограничений и пагинации для каждого списка.
  • В совокупности они позволили сократить количество операций более чем на 60 процентов, ускорить работу API и обеспечить возможность обработки гораздо большего объема трафика. Прежде чем масштабировать систему, выясните, как на самом деле используется ваша база данных; существующие запросы часто скрывают большую часть возможностей по улучшению производительности.