Головна / Статті

Зменшіть навантаження на Prisma та PostgreSQL, перш ніж купувати більшу базу даних

П’ятнадцять практичних технік — від EXPLAIN ANALYZE та комбінованих індексів до рішень проблеми N+1, лічильників, пулінгу та операції vacuum — для скорочення кількості операцій у базі даних 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; у середовищі виробництва модуль завантажується лише один раз, тому глобальна кеш-система не потрібна.

Використовуйте пул з’єднань, особливо у серверless-середовищах

Платформи без сервера можуть запускати тисячі одночасних екземплярів функцій, кожен з яких потребує власного підключення до бази даних. Без механізму пулінгу PostgreSQL може вичерпати кількість підключень задовго до того, як з’являться проблеми з CPU. 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 та забезпечити можливість обробки значно більшого обсягу трафіку. Перед масштабуванням дізнайтеся, як насправді використовується ваша база даних; існуючі запити часто приховують більшу частину потенціалу продуктивності, який вам потрібен.