Главная / Статьи / Устранение дублирования запросов 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

Устранение дублирующейся работы не должно принуждать нерелевантные компоненты делить между собой права владения одним объектом данных. Использование подхода хоистинга остается хорошим решением, когда родительский компонент естественным образом владеет данными; однако это не должно быть обязательным, поскольку у слоя данных отсутствует идентификатор для дублирующейся работы.

Мемоизация запросов — это не постоянное кэширование

Терминология 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 достигаются не столько от способа загрузки данных, сколько от решения о том, как долго один запрос к данным должен считаться единой операцией.