Головна / Статті / Усування дублікатів запитів ORM під час відтворення Next.js за допомогою React cache()

Усування дублікатів запитів ORM під час відтворення Next.js за допомогою React cache()

Дізнайтеся, чому компоненти сервера, розміщені разом, можуть запитувати один і той самий запис кілька разів за запит, як це перевірити, та як функція React cache() вирішує цю проблему без необхідності проходження через всі пропи.

2929 слів

Маршрут Next.js може здаватися швидким у браузері, тоді як його база даних тихо відповідає на те саме запитання три-чотири рази за кожен перегляд сторінки. Причиною рідко є повільний запит; це звичайне пошукове операція, яка повторюється через межі рендерингу, що у вашому коді здаються незалежними: generateMetadata(), сама сторінка, навігаційна смуга, вкладений серверний компонент. У цій статті пояснюється, як виникає це дублювання, як переконатися, що воно справді відбувається, та як усунути його за допомогою функції cache() у React, залишаючи доступ до даних біля компонентів, які його потребують. Також чітко розмежовується ця мемоізація за кожним запитом та постійне кешування, яке вирішує зовсім іншу проблему.

Як один перегляд сторінки перетворюється на чотири пошуку

Візьмемо динамічний маршрут продукту:

/products/[slug]

Кілька частин цього маршруту потребують одного й того самого продукту. Функція generateMetadata() потребує назви та опису для заголовка документа. Сторінка потребує повної інформації про продукт. Для навігаційної смуги потрібна категорія. Вкладений серверний компонент може відображати ціну чи статус запасів. Кожен з цих користувачів може розумно завантажувати те, що йому потрібно, самостійно. Функція метаданих робить це наступним чином:

export async function generateMetadata({
  params,
}: PageProps<'/products/[slug]'>) {
  const { slug } = await params
  const product = await getProduct(slug)
return {
    title: product.name,
  }
}

Компонент сторінки робить те саме:

export default async function ProductPage({
  params,
}: PageProps<'/products/[slug]'>) {
  const { slug } = await params
  const product = await getProduct(slug)
return <ProductDetails product={product} />
}

А ще глибше в ієрархії інший серверний компонент незалежно викликає:

const product = await getProduct(slug)

З точки зору проектування компонентів це абсолютно правильно. Кожен елемент інтерфейсу запитує дані там, де їх використовує, і обов’язки залишаються чіткими. Проблема виникає лише тоді, коли дивитися з іншого боку: на журнали запитів до бази даних чи показники API вищого рівня. Один надходження запиту може призвести до кількох ідентичних пошуків продуктів. Один відповідь надсилається до браузера, проте його створення може коштувати базі даних чотирьох обходів.

Що Next.js вже дедуплікує, а що — ні

Існує важлива різниця, яку потрібно правильно зрозуміти перед будь-якими змінами. Next.js автоматично мемоїзує ідентичні вбудовані запити fetch, які виконуються під час рендерингу дерева компонентів React, і в його документації зазначено, що це стосується функції generateMetadata, лейаутів, сторінок та серверних компонентів. Якщо getProduct() побудований на основі fetch, ці дублюючі запити вже можуть об’єднатися в один.

Коли дані надходять з іншого джерела, ніж fetch (ORM, драйвер бази даних, SDK від стороннього виробника), автоматична мемоїзація не відбувається. У такому разі Next.js рекомендує використовувати функцію React cache() для об’єднання повторних операцій у межах одного запиту.

Основна ідея варта того, щоб її сформулювати просто: витрати на продуктивність часто полягають не у одному дорогому запиті, а у дешевих операціях, які повторюються через межі компонентів, що фреймворк дозволяє легко об’єднувати окремо. Рішення полягає не у тому, щоб перемістити кожен запит до одного величезного батьківського компонента, а у наданні цим повторюваним операціям доступу до даних єдиного спільного ідентифікатора.

Колокація робить повторювану роботу непомітною

За допомогою серверних компонентів природним підходом є розміщення місця доступу до даних поруч із тим, хто ними користується. Компонент, який відображає деталі продукту, може завантажувати сам продукт, і „хлібні крихти“ не повинні отримувати великий об’єкт продукту, який проходить через непов’язані компоненти, лише тому, що його спочатку завантажив якийсь предок.

Без цієї свободи звичайним способом уникнення дублювання роботи є переміщення всього завантаження на початок маршруту та передача результатів далі через кожен проміжний шар. Це ефективно, але це пов’язує компоненти, які насправді не мають жодного зв’язку. Альтернативою є розміщення кожного серверного компонента так, щоб він залежав від однієї функції для обробки даних, яку можна використовувати багаторазово:

const product = await getProduct(slug)

Це дозволяє розміщувати кожну вимогу поруч із тим, хто її використовує. Відкритим питанням залишається те, чи справді ці виклики використовують одну й ту саму основну операцію.

Якщо вони зрештою відправляють ідентичні запити типу fetch, React зберігає їх у пам’яті всередині дерева компонентів. У документації Next.js саме це наводиться як обґрунтування для завантаження даних у компоненті, який їх використовує, а не на початку маршруту з передачею параметрів далі. Прямий виклик ORM, наприклад:

db.product.findUnique({
  where: { slug },
})

Він не проявляє жодної з цих особливостей лише через те, що два компоненти передають йому однаковий ідентифікатор. Для React це просто довільна асинхронна функція, яка виконуватиметься щоразу, коли її викликають, поки ви не надасте їй ідентичність у форматі memo.

Межі компонентів визначають, хто відповідає за певну частину інтерфейсу. Вони нічого не говорять про те, хто відповідає за обробку повторюваних даних. Розміщення в одному місці не є помилкою; припущення про розміщення в одному місці означає припущення про видалення дублікатів.

Вимірюйте перед тим, як використовувати мемоїзацію

Мемоїзація — це реакція на фактичне дублювання, а не на підозру. Ось приклад виклику цієї функції у чотирьох різних файлах:

await getProduct(slug)

Це не є доказом того, що до бази даних здійснювалися чотири запити. У Next.js це розрізнення особливо важливе, оскільки фреймворк може вже усувати дублікати ідентичних викликів fetch. Останні версії Next.js також надають функцію логування розробки для діяльності fetch на серверній стороні, що допомагає побачити, що саме запитується (дивіться актуальну документацію, щоб дізнатися, як це увімкнути у вашій версії).

Якщо getProduct() знаходиться у ORM, драйвері бази даних чи SDK, слід інструментувати саме цей шар. Для швидкого локального дослідження навіть примітивний звіт про час виконання достатній, щоб виявити певний закономірність:

export async function getProduct(slug: string) {
  console.time(`product:${slug}`)
const product = await db.product.findUnique({
    where: { slug },
  })
  console.timeEnd(`product:${slug}`)
  return product
}

Якщо ви бачите, що ця мітка друкується чотири рази на завантаження сторінки, це є доказом. У продакшені потрібні більш виразні сигнали: журнали запитів до бази даних, розподілений трекінг, дані APM, лічильники запитів вищого рівня та ідентифікатори запитів, які дозволяють пов’язати кожен запит із переглядом сторінки, що його спричинив.

Ключове — розрізняти два твердження, які звучать схоже:

Function called four times

на відміну від:

Underlying data source hit four times

Вони не є еквівалентними. Якщо операція вже виконується лише один раз, її обгортання в інший шар не є оптимізацією; це лише ускладнює розуміння структури даних. Оптимізуйте ту роботу, яка повторюється насправді, а не повторні виклики функцій, які ви випадково помітили у коді.

Чому функція, побудована на ORM, уникає видалення дублікатів

Припустимо, що механізм завантаження продуктів є максимально простим:

export async function getProduct(slug: string) {
  return db.product.findUnique({
    where: { slug },
  })
}

Тепер уявіть, що цю функцію викликають з функції метаданих, сторінки, навігаційного меню та компонента ціноутворення під час одного запиту. Функція залишається такою ж, аргументи та запит також однакові. Без механізму мемоїзації кожен виклик все одно виконує власний запит до бази даних.

Для ORM або прямого доступу до бази даних функція cache() у React забезпечує мемоізацію для кожного запиту, тоді як нативна функція fetch отримує цю можливість безкоштовно в рамках структури React. Next.js детально описує саме таку схему для прямих запитів до бази даних, включаючи випадки, коли одну й ту саму інформацію потребують як функція generateMetadata, так і сторінка.

Це розуміння також допомагає уникнути поширеного міфу. Якби функція getProduct() була повністю побудована на ідентичних викликах fetch, то обгортання її функцією cache() виключно для усунення цих дублікатів мало б мало ефекту, адже Next.js вже зберігає їх у пам’яті. Тож правило полягає не в тому, щоб обгортати кожен серверний лоадер функцією cache(), а у перевірці того, чи вже спосіб завантаження даних зберігається у пам’яті, та додаванні спільної ідентичності лише там, де її немає. Цю версію значно складніше застосовувати сліпо.

Рішення: одна функція з даними у пам’яті

Сама зміна в коді є незначною. Ось лоадер до змін:

export async function getProduct(slug: string) {
  return db.product.findUnique({
    where: { slug },
  })
}

А ось він після обгортання функцією cache():

import { cache } from 'react'
import 'server-only'
export const getProduct = cache(async (slug: string) => {
  return db.product.findUnique({
    where: { slug },
  })
})

Варто звернути увагу на два моменти. Імпорт з позначкою server-only призводить до невдачі процесу будування, якщо цей модуль потрапляє до коду клієнта, що є розумним захистом для будь-чого, що взаємодіє з вашою базою даних. Крім того, функція cache() обгортає її один раз на рівні модуля, тож кожен імпортер отримує однакову функцію з кешуванням. Кожен користувач продовжує викликати її точно так само, як і раніше:

const product = await getProduct(slug)

React зберігає результат для кожного аргументу у своєму кеші на серверній стороні. Будь-який подальший виклик під час того ж запиту, через цей самий обгортач та з ідентичним аргументом, отримує вже збережений результат, який насправді є тією самою обіцянкою, тож паралельні виклики чекають на один запит. React видаляє ці результати з кешу між запитами до сервера.

Що не потрібно було змінювати

Цінною частиною цього виправлення є все те, що залишилося незмінним. Заголовок продукту все ще вказує на свої власні вимоги до даних. „Пшеничні крихти“ не отримують жодних нових атрибутів. Генерація метаданих та відображення сторінки продовжують отримувати інформацію про продукт окремо, і жоден компонент користувацького інтерфейсу не примушується до відносин власності, яких у нього природньо немає. Оптимізація відбувається повністю на рівні даних. Те, що було централізовано, — це не місце споживання даних, а ідентичність операції, яка їх генерує.

Проблеми з cache()

Кілька деталей реалізації визначають, чи справді функція мемоїзації працює ефективно:

  • Розділіть одну функцію з мемоізацією. Огортання одного лоадера функцією cache() у двох різних місцях створює дві незалежні функції з мемоізацією, кожна з власним сховищем даних — про таку поведінку React прямо йдеться в документації. Визначте цей обгортач один раз у вашому модулі доступу до даних та імпортуйте його всюди.
  • Віддавайте перевагу примітивним аргументам. React порівнює аргументи за ідентичністю, тому рядок у форматі slug надійно потрапляє до кешу, тоді як об’єкт, створений заново, наприклад { slug }, при кожному виклику завжди пропускається.
  • Пам’ятайте про область видимості. Функція cache() призначена для рендерингу на сервері; поза запитом, наприклад у клієнтському компоненті, вона не забезпечує такої дедуплікації.

Чому просто не завантажити все на сторінці?

Очевидною альтернативою є завантаження продукту один раз у верхній частині та передача його далі:

export default async function ProductPage({
  params,
}: PageProps<'/products/[slug]'>) {
  const { slug } = await params
  const product = await getProduct(slug)
return (
    <>
      <Breadcrumbs product={product} />
      <ProductHeader product={product} />
      <ProductDetails product={product} />
    </>
  )
}

Коли сторінка справді є власником всього об’єкта продукту, це є абсолютно правильним підходом до проектування. Проблеми починаються тоді, коли для уникнення дублювання роботи на бекенді переносять усі залежності від даних туди. З часом кількість пропсів зростає, проміжні компоненти починають передавати дані, яких вони ніколи не використовують, а кожен новий дочірній компонент, якому потрібне певне поле, змушує вносити зміни у всю ієрархію. Межі компонентів в кінцевому підсумку відображають механізми оптимізації, а не справжню власність.

Необхідно змінити підход до мемоїзації запитів, щоб знайти баланс. Чинні рекомендації Next.js передбачають, що ідентичні виклики fetch можуть залишатися у тих компонентах, які їх потребують, замість того, щоб вимагати завантаження на верхньому рівні та глибокого пошуку пропсів, а для прямого доступу до бази даних cache() забезпечує спільну серверну функцію з аналогічною семантикою усунення дублікатів. Таким чином можна одночасно отримати обидві переваги:

data close to consumer
        +
deduplicated underlying work

Видалення повторної роботи не повинно змушувати непов’язані компоненти ділитися власністю на один об’єкт даних. Використання підходу „hoisting“ залишається гарним вибором, коли батьківський компонент природним чином володіє даними; проте це не має бути обов’язковим, оскільки шар даних не має ідентичності для повторної обробки.

Мемоізація запитів — це не постійне кешування

Термінологія Next.js може спричинити плутанину, тому варто бути точними. Функція React cache() у цьому патерні не перетворює пошук продукту одного відвідувача на збережену відповідь для майбутніх відвідувачів. React очищує свої мемоізовані результати сервера для кожного запиту. У межах одного запиту повторні виклики використовують цей результат знову:

getProduct("keyboard")
getProduct("keyboard")
getProduct("keyboard")
//reuse the memoized result

Наступний запит починається з порожнього кешу та знову виконує пошук:

New request
getProduct("keyboard")
//perform the lookup again

Це мемоізація запитів, і нічого більше. Повторне використання результатів у різних запитах — це окреме архітектурне рішення. У поточній версії Next.js компоненти Cache Components надають директиву use cache для зберігання даних поза межами одного запиту, причому cacheLife() контролює термін існування запису, а cacheTag() дозволяє скасовувати його за певними мітками. посібник з use cache та перевірки даних за мітками детально розглядає цей аспект.

Корисною уявною моделлю є те, що ці два механізми відповідають на різні запитання:

  • Мемоізація запитів: чи слід виконувати однакову операцію чотири рази під час формування однієї відповіді?
  • Постійне кешування: чи може пізніший запит використати результат, який був обчислений раніше?

Лише другий підхід впроваджує елемент свіжості та скасування операцій. Якщо розглядати їх як два окремі рішення, ситуація з кешуванням у Next.js стає значно менш заплутаною.

Звідки насправді походить економія

Сам запит до продукту може бути абсолютно нормальним. Припустімо, телеметрія фіксує чотири ідентичні операції з базою даних за кожен запит, а після змін залишається лише одна. Це означає економію трьох запитів на один перегляд сторінки, що здається дрібницею. Але якщо помножити це на обсяг трафіку: маршрут, який обслуговує тисячі переглядів, уникає втричі більшої кількості запитів, а активний маршрут протягом дня уникає ще більшої кількості. Незначне дублювання на сильно відвідуваному маршруті може призвести до тисяч непотрібних запитів до бази даних чи зовнішніх сервісів, причому жодна окрема операція не виглядає тривожною.

Саме тому конкретні цифри економії можна наводити лише у твердженнях, якщо вони базуються на ваших власних вимірюваннях. Якщо показники вашого виробництва вказують на певну кількість уникнутих операцій, наведіть саме цю цифру; без телеметрії фраза «можна заощадити тисячі» чесно описує ефект множення, не вимагаючи вигадування конкретного кейсу.

Це також змінює підхід до роботи над продуктивністю. Звичайне запитання звучить так:

Which query takes 800 ms?

Часто більш корисним є запитання:

Why are we paying for this normal query
four times within one request?

Одиночне використання може бути дешевим, але колективно — марнотратним. Робота над продуктивністю полягає не лише у прискоренні кожної операції; іноді йдеться про видалення операцій, які ніколи не мали існувати, і це особливо важливо, коли невелика неефективність знаходиться на критичному шляху виконання.

Вибір правильних меж повторного використання

Зрозуміти принцип запиту на мемоїзацію відносно просто, адже React ніколи не передає мемоїзований результат у наступний запит. Для постійного кешування потрібне більш детальне обговорення правильності роботи. За допомогою компонентів кешування кешовані дані можуть мати чітко визначені терміни життя та мітки для повторної перевірки саме через те, що їх повторне використання між запитами ставить питання про те, як довго значення залишається дійсним та які події мають зробити його застарілим.

Тож краще запитання не є:

Can we cache this?

а ось яке:

Across which boundary is reuse correct?

Орієнтовний спосіб розуміння можливих меж:

  • У межах одного запиту: безпечно для майже будь-якого читання, включаючи дані окремих користувачів, оскільки ніщо не існує довше за відповідь. Саме тут працює функція cache().
  • Між запитами, для публічних даних: підходить для контенту, який однаковий для всіх відвідувачів, за умови визначення терміну життя та способу анулювання дійсності.
  • У різних запитах для даних, прив’язаних до конкретного користувача: ключ кешу має містити ідентифікатор користувача, інакше одному користувачеві може бути надано результат іншого користувача.
  • У різних запитах для даних, залежних від авторизації: те, що визначає доступ, має бути частиною ідентифікатора для повторного використання, інакше перевірки дозволів будуть проходити без ефекту.
  • Ці два останні пункти стосуються не лише Next.js; вони актуальні для будь-якого кешу. Як тільки результат змінюється залежно від того, хто запитує чи що він бачить, ключ, який визначає можливість повторного використання, має кодувати саме цю різницю. Висока швидкість отримання даних не має значення, якщо відповідь може потрапити до запиту, який не мав на неї права. Найкращий кеш — це той, чиї правила повторного використання відповідають правилам коректності даних.

    Запити створювали проблему з межами

    Погляньте назад на виклик, який усе це почав:

    await getProduct(slug)
    

    У функції generateMetadata(), на сторінці чи в вкладеному серверному компоненті не було нічого неправильного. Кожен користувач справді потребував продукту. Марнотратство виникло лише тому, що кожен з цих обґрунтованих викликів окремо перетинав ту саму межу бекенду. Для виправлення не потрібно було прискорювати жодні запити; достатньо було помітити, що одна й та сама відповідь купується кілька разів під час кожного оновлення сторінки. У коді весь рішення може складатися лише з одного обгортка, визначеного один раз у модулі даних та використовуваного кожним користувачем:

    cache(async (...) => ...)
    

    Основні висновки

    • Ідентичні виклики нативної функції fetch вже мемоізуються в дереві React за допомогою Next.js. Виклики ORM, драйверів та SDK не мемоізуються, тому cache() надає їм унікальний ідентифікатор для кожного запиту.
    • Спочатку вимірюйте. Функція, яка викликається чотири рази, — це не те саме, що джерело даних, яке обробляється чотири рази.
  • Розділіть одну функцію, яка зберігається в кеші; окремі обгортки cache() не діляться результатами.
  • Не потрібно жертвувати хорошими межами компонентів. Усувайте дублікації всередині запиту там, де це корисно, а постійне кешування має бути своїм окремим рішенням із власними правилами свіжості та безпеки.
  • Ширший урок: багато значущих покращень продуктивності в Next.js досягаються не стільки через місце завантаження даних, скільки через вирішення про те, як довго один доступ до даних має вважатися окремою операцією.