首页 / 文章 / 在 Next.js 渲染过程中使用 React cache() 去重 ORM 查询

在 Next.js 渲染过程中使用 React cache() 去重 ORM 查询

了解为何共置的服务器组件会在每次请求中多次查询同一条记录,如何确认这一点,以及 React 的 cache() 函数如何在不进行属性层层传递的情况下解决该问题。

2929 词

在浏览器中,Next.js 路由似乎响应很快,但实际上每次页面加载时,数据库都会默默地重复处理相同的问题三到四次。造成这一现象的往往并非缓慢的查询,而是那些在代码中看似独立的渲染环节中被反复调用的普通查找操作:generateMetadata()、页面内容、导航面包屑以及嵌套的 Server Component。本文将解释这种重复现象是如何产生的,如何证明它确实存在,以及如何利用 React 的 cache() 功能消除这一问题,同时让数据访问更贴近需要它的组件。此外,文章还会明确区分这种按请求进行的记忆化处理与持久缓存,因为二者解决的是完全不同的问题。

为何一次页面浏览会引发四次查找

以一个动态产品路由为例:

/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会自动对在渲染React组件树时发出的相同原生fetch请求进行记忆化处理,其文档指出这一机制适用于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 来说,它只是一个普通的异步函数,每次被调用时都会执行,除非为其提供缓存后的唯一标识。

组件边界决定了哪个组件负责处理某段 UI,但并未说明谁来处理重复的数据操作。组件放置在一起并非错误,但若认为放置在一起就意味着数据不会重复,则是错误的。

先评估再缓存

缓存是针对实际发现的重复情况而采取的措施,而非基于猜测。例如在四个不同的文件中都发现了如下调用:

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跟踪数据、上游请求计数器以及请求ID,这些都能帮助你将每个查询与触发它的页面视图关联起来。

关键在于区分两个听起来相似的表述:

Function called four times

与……相反:

Underlying data source hit four times

它们并不等价。如果某个操作本来就只会执行一次,再为其添加另一层封装并非优化手段,反而会让数据层的逻辑更难以理解。应针对实际观察到的重复性工作进行优化,而非对代码中偶然注意到的重复函数调用进行处理。

为何基于ORM的函数能避免去重

假设产品加载器的实现已经非常简单:

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

现在想象在单个请求中,它会被元数据函数、页面、面包屑导航以及定价组件同时调用。这些调用的函数相同、参数相同,查询条件也相同。如果没有记忆化机制,每次调用仍会向数据库发起独立的查询。

对于 ORM 或直接访问数据库的场景,React 的 cache() 能实现按请求记忆化的功能,而 React 内部的 fetch 则可以免费获得这种记忆化效果。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会将每个参数的结果存储在服务器端的缓存中。在该请求之后的任何调用,只要通过相同的封装函数且参数相同,就能获取到已存储的结果,实际上也就是同一个Promise,因此多个并发调用只会等待一次查询。在两次服务器请求之间,React会丢弃这些已缓存的结果。

无需更改的部分

此次修复的宝贵之处在于所有未发生变化的部分。产品标题仍会声明其自身的数据需求,面包屑导航也没有新增任何属性。元数据生成与页面渲染仍会独立请求产品数据,且没有任何 UI 组件被强制纳入其本不应承担的所有权关系中。优化完全发生在数据层面。你所集中处理的并非数据的消费位置,而是生成数据的操作标识。

cache() 的潜在问题

是否有实际的缓存效果取决于几个实现细节:

  • 共享一个缓存函数。如果在两个不同的地方用cache()包裹同一个加载器,就会生成两个独立的缓存函数,每个函数都有各自的存储空间,这是React明确规定的行为。应在数据访问模块中定义一次该包装器,然后在所有地方导入使用。
  • 优先使用原始参数。React是通过标识符来比较参数的,因此字符串形式的标识符能够可靠地被缓存,而像每次调用时新创建的{ 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} />
    </>
  )
}

当页面真正拥有整个产品对象时,这是一种非常合理的设计。问题在于,人们为了避免重复的后端处理而将所有数据依赖都集中到一处。随着时间推移,props数量不断增加,中间组件开始传递它们根本不会使用的数据,而每一个需要某个字段的新子组件都会迫使上层组件也进行相应修改。最终,组件的边界反映的竟是优化手段而非真正的所有权关系。

请求缓存机制可以解决这种权衡问题。当前Next.js的指导原则是,相同的fetch调用可以保留在需要它们的组件中,无需进行顶层加载或prop层层传递;对于直接访问数据库的情况,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 (...) => ...)
    

    关键要点

    • Next.js已经对React框架中的相同原生fetch调用进行了缓存。而ORM、驱动程序和SDK的调用则没有,因此使用cache()可以为它们赋予每次请求独立的缓存标识。
    • 先进行测量。函数被调用四次并不等同于数据源被访问了四次。
  • 仅共享一个已缓存的函数;独立的cache()封装不会共享结果。
  • 无需牺牲良好的组件边界。在合适的情况下对请求内的数据去重,而持久化缓存则应作为独立的选择,拥有自身的有效性及安全规则。
  • 更广泛的启示是:Next.js性能提升的许多有效方法并非来自数据加载的位置,而是来自于如何决定某次数据访问应被视为一次独立操作的时间长度。