React 基础体系 · 第 66/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

Next.js 数据与缓存:请求记忆、Data Cache、Revalidation 和标签

本文讨论 Next.js App Router 中服务端数据获取的四个相关概念:

  • 请求记忆(Request Memoization):同一次服务端渲染过程中,如何避免重复执行相同的请求。
  • Data Cache:跨请求、跨渲染复用数据的持久化缓存。
  • Revalidation:缓存何时失效、何时重新验证和更新。
  • 标签(Cache Tags):如何按业务数据而不是按 URL 精确失效缓存。

这些概念经常被统称为“Next.js 缓存”,但它们处于不同生命周期,解决的问题也不同。先把服务端组件、客户端组件和缓存边界分开,后面的行为才容易推导。


一、先确定讨论范围:App Router 与服务端数据

本文示例使用 Next.js App Router,也就是 app/ 目录下的路由。React Server Components 是默认的组件模型:

// app/products/page.tsx
export default async function ProductsPage() {
  const response = await fetch("https://api.example.com/products");
  const products = await response.json();

  return <pre>{JSON.stringify(products, null, 2)}</pre>;
}

这个页面组件没有 "use client",因此它是服务端组件。fetch 在服务端执行,结果用于生成服务端组件输出。

如果文件顶部添加 "use client"

"use client";

export default function ProductsPage() {
  // 这里不能直接依赖服务端数据库连接、服务器环境变量或服务端专属缓存。
  return <div>Client Component</div>;
}

该组件及其子树进入客户端边界。客户端组件可以:

  • 使用状态和事件处理器;
  • 在浏览器中调用 API;
  • 使用 React Query、SWR 等客户端数据库。

但它不会自动获得服务端组件中的 Data Cache。浏览器请求 /api/products 和服务端组件直接请求后端,是两条不同的数据路径。

一个常见的结构是:

浏览器
  │
  │ 请求页面
  ▼
Next.js 服务端组件
  │
  ├─ 请求记忆:本次渲染期间去重
  ├─ Data Cache:跨请求复用数据
  └─ 后端 API / 数据库

如果服务端组件把数据作为 props 传给客户端组件:

// app/products/page.tsx
import ProductList from "./ProductList";

export default async function ProductsPage() {
  const response = await fetch("https://api.example.com/products", {
    next: {
      revalidate: 60,
      tags: ["products"],
    },
  });

  if (!response.ok) {
    throw new Error(`加载商品失败:${response.status}`);
  }

  const products = await response.json();

  return <ProductList products={products} />;
}
// app/products/ProductList.tsx
"use client";

type Product = {
  id: string;
  name: string;
};

export default function ProductList({
  products,
}: {
  products: Product[];
}) {
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

缓存发生在服务端的 fetch 上,客户端组件拿到的只是已经序列化并传下来的数据。客户端组件不能通过修改自己的状态来使服务端 Data Cache 失效。


二、请求记忆:一次渲染中的重复请求去重

2.1 定义

请求记忆是指:在一次服务端渲染过程中,多个组件发出等价请求时,框架复用同一个进行中的请求或其结果,而不是重复访问后端。

例如,页面和侧边栏都调用相同的数据函数:

async function getCurrentUser() {
  const response = await fetch("https://api.example.com/me", {
    headers: {
      Authorization: "Bearer server-token",
    },
  });

  if (!response.ok) {
    throw new Error("无法获取当前用户");
  }

  return response.json() as Promise<{
    id: string;
    name: string;
  }>;
}
// app/dashboard/page.tsx
import UserSummary from "./UserSummary";

export default async function DashboardPage() {
  const user = await getCurrentUser();

  return (
    <>
      <h1>控制台</h1>
      <UserSummary />
      <p>欢迎,{user.name}</p>
    </>
  );
}
// app/dashboard/UserSummary.tsx
import { getCurrentUser } from "@/lib/user";

export default async function UserSummary() {
  const user = await getCurrentUser();

  return <aside>当前用户:{user.name}</aside>;
}

在满足请求等价条件时,服务端渲染期间不会因为两个组件分别调用 getCurrentUser() 就必然产生两次网络请求。

这里有两个重要限定:

  1. 请求记忆只针对同一次服务端渲染生命周期。
  2. 请求记忆不是跨请求持久化缓存。

假设用户 A 和用户 B 分别访问 /dashboard

用户 A 的渲染:请求 /me 一次,两个组件共享
用户 B 的渲染:可以再次请求 /me,也可以命中 Data Cache,取决于数据缓存配置

因此,请求记忆不能替代 Data Cache。

2.2 等价请求的判断

对于基于 fetch 的请求,Next.js/React 会根据请求 URL 和请求配置判断是否是同一个请求。下面两次调用通常不应被设计成“等价请求”:

fetch("https://api.example.com/products");

fetch("https://api.example.com/products", {
  headers: {
    Authorization: "Bearer token",
  },
});

它们的请求头不同,后端看到的请求语义也可能不同。即使某个实现能够进行更复杂的归一化,也不应依赖未明确保证的对象顺序、默认值或自定义封装行为。

更稳妥的做法是让数据函数集中定义请求选项:

export function getProducts() {
  return fetch("https://api.example.com/products", {
    next: {
      revalidate: 60,
      tags: ["products"],
    },
    headers: {
      Authorization: `Bearer ${process.env.API_TOKEN}`,
    },
  }).then(async (response) => {
    if (!response.ok) {
      throw new Error(`获取商品失败:${response.status}`);
    }

    return response.json() as Promise<
      Array<{ id: string; name: string }>
    >;
  });
}

2.3 非 fetch 函数的请求记忆

如果访问的是数据库、文件系统或 SDK,不能假定 Next.js 会自动对任意函数做请求记忆:

async function getProductsFromDatabase() {
  return db.product.findMany();
}

可以使用 React 的 cache 对同一次服务端渲染中的函数调用进行记忆:

import { cache } from "react";

export const getProductsFromDatabase = cache(async () => {
  return db.product.findMany();
});

这解决的是“同一渲染过程中的重复调用”,不是跨请求缓存。它也不负责定时失效、标签失效或持久化。

还要注意参数:

import { cache } from "react";

export const getProduct = cache(async (id: string) => {
  return db.product.findUnique({
    where: { id },
  });
});

getProduct("p1")getProduct("p2") 是两个不同的记忆条目。参数必须能准确表达数据依赖,否则可能产生错误复用。


三、Data Cache:跨请求保存数据结果

3.1 Data Cache 的定义

Data Cache 是 Next.js 在服务端保存数据获取结果的缓存。与请求记忆相比,它具有更长生命周期:

请求记忆:
一次渲染开始 ───────── 一次渲染结束
                只在这里去重

Data Cache:
请求 1 ─ 请求 2 ─ 部署后或失效前 ─ 请求 3
       可以持续复用数据

最常见的入口是带缓存选项的 fetch

const response = await fetch("https://api.example.com/products", {
  cache: "force-cache",
});

或者使用时间型重新验证:

const response = await fetch("https://api.example.com/products", {
  next: {
    revalidate: 60,
  },
});

其中:

  • cache: "force-cache" 表示允许从 Data Cache 读取并缓存结果;
  • next.revalidate: 60 表示数据最长以 60 秒为一个重新验证周期;
  • cache: "no-store" 表示每次请求都从数据源获取,不使用持久化 Data Cache。

现代 Next.js 的默认缓存行为受到版本、路由配置和 Cache Components 等能力影响。生产代码不应依赖“默认到底缓存还是不缓存”,而应根据数据语义显式写出 cachenext.revalidate

3.2 一个缓存命中的完整过程

设请求如下:

fetch("https://api.example.com/products", {
  next: {
    revalidate: 60,
    tags: ["products"],
  },
});

t0 是第一次请求的时间,Data Cache 中没有该条目。

第一次请求:缓存未命中

1. 计算请求缓存键 K
2. Data Cache 查询 K:没有结果
3. 访问 API
4. API 返回数据 D
5. 保存 K → D,并记录:
   - 重新验证时间:t0 + 60 秒
   - 标签:products
6. 将 D 返回给服务端组件

60 秒内的后续请求:缓存命中

1. 计算同一个缓存键 K
2. Data Cache 查询 K:找到 D
3. 直接返回 D
4. 不访问 API

超过 60 秒:进入重新验证流程

Next.js 的重新验证行为可能受具体版本和部署运行时影响,但基本语义是:

1. 当前缓存数据 D 已过期
2. Next.js 触发重新验证
3. 后端返回新数据 D'
4. Data Cache 更新为 K → D'
5. 后续请求读取 D'

在支持 stale-while-revalidate 语义的配置中,过期数据可能先被返回,同时后台刷新;也可能由当前请求等待新数据。工程代码不能假设“过期请求一定阻塞”或“一定立即返回旧值”,应以所使用 Next.js 版本和部署平台的文档为准。

3.3 no-store 与动态数据

用户专属、强实时或不可缓存的数据通常使用:

const response = await fetch("https://api.example.com/me", {
  cache: "no-store",
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

它的含义是:

  • 这次请求仍然可以在一次渲染中被请求记忆;
  • 但结果不进入跨请求的 Data Cache;
  • 下一次页面请求仍需要访问数据源。

请求记忆和 no-store 并不矛盾:

同一次渲染:
  组件 A ─┐
           ├─ 共享一个进行中的 no-store 请求
  组件 B ─┘

下一次渲染:
  重新请求数据源

3.4 HTTP 方法与响应状态

fetch 返回 404 或 500 时,Promise 默认不会自动 reject:

const response = await fetch(url);

if (!response.ok) {
  throw new Error(`请求失败:${response.status}`);
}

如果不检查 response.ok,错误页面可能把后端错误 JSON 当成正常数据渲染,甚至把错误结果放入应用层自己的对象中。

对于缓存数据,应该明确决定哪些响应可以缓存。通常:

  • 成功的 2xx 响应才进入业务数据缓存;
  • 认证失败、权限失败和临时错误不应被当成正常业务数据;
  • 不同用户的数据不能使用一个没有用户维度的共享缓存键。

例如,下面的设计存在严重风险:

fetch("https://api.example.com/profile", {
  next: {
    revalidate: 300,
  },
});

如果后端根据 Cookie 返回不同用户的 profile,而缓存键没有正确区分用户,就可能把用户 A 的数据复用给用户 B。对于用户相关请求,应使用 no-store,或确保缓存键和数据隔离策略正确。


四、缓存键:为什么“同一个 URL”不一定是同一份数据

可以把一个 Data Cache 条目抽象为:

E=(K,D,T,X)E = (K, D, T, X)

其中:

  • KK:缓存键;
  • DD:数据结果;
  • TT:时间信息,例如创建时间和重新验证时间;
  • XX:附加索引,例如标签集合。

一个简化的缓存键可以表示为:

K=H(URL,method,headers,body,cache options)K = H(\text{URL}, \text{method}, \text{headers}, \text{body}, \text{cache options})

这里的 HH 表示框架内部的键生成过程。实际实现细节不应被当作公开稳定算法,但这个模型说明了一个事实:

请求语义不同,就不能只因为 URL 相同而假设结果可以共享。

例如:

fetch("/api/orders", {
  headers: {
    Authorization: "Bearer user-a",
  },
});

fetch("/api/orders", {
  headers: {
    Authorization: "Bearer user-b",
  },
});

这两个请求的数据权限不同。若把它们作为一个公共缓存条目处理,会产生数据泄漏。

另一个常见问题是把时间、租户或权限放在闭包中,却没有让缓存系统感知它们:

const tenantId = getTenantFromRequest();

const getProducts = unstable_cache(
  async () => db.product.findMany({ where: { tenantId } }),
  ["products"],
  { revalidate: 60 }
);

这里的 tenantId 没有出现在 key 中。即使代码能够运行,也不能安全地假定不同租户会得到不同缓存项。应把租户标识纳入缓存键:

import { unstable_cache } from "next/cache";

export function getProducts(tenantId: string) {
  return unstable_cache(
    () =>
      db.product.findMany({
        where: { tenantId },
      }),
    ["products", tenantId],
    {
      revalidate: 60,
      tags: [`products:${tenantId}`],
    }
  )();
}

unstable_cache 是为非 fetch 数据源提供 Data Cache 的传统 API。它的名字带有 unstable,说明 API 形态可能随 Next.js 版本演进;使用时应核对当前版本文档。上例的关键不是记住函数形式,而是保证:

  1. 缓存键包含所有影响结果的参数;
  2. 标签与同一数据域对应;
  3. 数据库调用发生在缓存函数内部;
  4. 失效时能定位到正确的租户范围。

五、Revalidation:缓存如何重新变成可信数据

5.1 定义

Revalidation,重新验证或重新生效,是指缓存条目在一定条件下再次向数据源确认,并用新结果更新缓存。

它不是简单的“删除缓存”。两者的区别是:

删除缓存:
  下次请求必须重新获取数据

重新验证:
  允许框架根据策略获取新数据,并更新缓存

Next.js 中常见的两类重新验证是:

  1. 基于时间的重新验证
  2. 按需重新验证

六、基于时间的重新验证

6.1 next.revalidate

export async function getProducts() {
  const response = await fetch("https://api.example.com/products", {
    next: {
      revalidate: 60,
      tags: ["products"],
    },
  });

  if (!response.ok) {
    throw new Error("商品服务返回错误");
  }

  return response.json() as Promise<
    Array<{ id: string; name: string }>
  >;
}

60 的单位是秒。它表达的是缓存新鲜度策略,而不是严格的定时任务:

错误理解:
  每 60 秒后台一定自动请求一次 API

更准确的理解:
  数据至少可以缓存一个 60 秒周期;
  过期后,在后续访问中触发重新验证。

如果一个页面中有多个数据请求:

const products = await fetch("/products", {
  next: { revalidate: 60 },
});

const categories = await fetch("/categories", {
  next: { revalidate: 3600 },
});

页面的完整输出还可能受路由级缓存、静态生成和其他动态 API 影响。不能简单地说“页面固定每 60 秒刷新”,因为:

  • Data Cache 和 Full Route Cache 是不同层;
  • 页面可能使用 cookies()headers() 等动态请求信息;
  • 某个请求可能显式设置为 no-store
  • 流式渲染和不同路由段可能有不同生命周期。

因此应分别检查数据缓存和路由输出缓存。

6.2 过期、错误和旧数据

重新验证期间可能发生后端故障:

缓存 D 已过期
        │
        ├─ API 成功:保存 D',返回新数据
        │
        └─ API 失败:根据运行时策略保留旧缓存或让请求失败

在增量静态生成等场景中,Next.js 通常倾向于在重新生成失败时继续提供上一次成功结果,并在后续请求中再次尝试;但这不应被理解为所有 Data Cache、所有部署平台、所有错误类型都拥有完全相同的回退行为。

应用层仍然应记录:

  • 数据源状态码;
  • 重新验证失败的异常;
  • 缓存命中率和缓存年龄;
  • 用户看到的是新数据还是旧数据。

“页面还能显示”不等于“数据已经恢复”。


七、按需重新验证:为什么需要标签

时间策略无法表达所有业务需求。例如:

  • 商品后台保存后,商品列表应立即更新;
  • CMS 发布文章后,文章详情和首页推荐都应失效;
  • 某个租户修改配置后,只应清理该租户的数据;
  • 库存变化不应等待 60 秒。

这时可以给缓存条目附加标签:

const response = await fetch("https://api.example.com/products", {
  next: {
    revalidate: 3600,
    tags: ["products"],
  },
});

该缓存条目除了拥有自己的缓存键,还加入了标签索引:

K1 → 商品列表数据,tags = ["products"]
K2 → 商品详情 p1,tags = ["products", "product:p1"]
K3 → 商品详情 p2,tags = ["products", "product:p2"]

标签不是缓存键,也不是 URL。它是“哪些缓存条目属于同一业务数据域”的索引。


八、标签失效的端到端示例

8.1 读取数据

// lib/products.ts
type Product = {
  id: string;
  name: string;
  price: number;
};

export async function getProducts(): Promise<Product[]> {
  const response = await fetch("https://api.example.com/products", {
    next: {
      revalidate: 3600,
      tags: ["products"],
    },
  });

  if (!response.ok) {
    throw new Error(`读取商品失败:${response.status}`);
  }

  return response.json();
}
// app/products/page.tsx
import { getProducts } from "@/lib/products";

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <main>
      <h1>商品</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name}:{product.price}
          </li>
        ))}
      </ul>
    </main>
  );
}

第一次访问会获取 API 并写入 Data Cache。之后 3600 秒内,可以直接返回缓存结果;如果管理操作触发 products 标签失效,下一次访问会重新验证。

8.2 在 Route Handler 中失效标签

// app/api/admin/products/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  const authorization = request.headers.get("authorization");

  if (authorization !== `Bearer ${process.env.REVALIDATE_SECRET}`) {
    return NextResponse.json(
      { error: "Unauthorized" },
      { status: 401 }
    );
  }

  revalidateTag("products", "max");

  return NextResponse.json({
    revalidated: true,
    tag: "products",
  });
}

测试命令:

curl -X POST \
  -H "Authorization: Bearer your-secret" \
  http://localhost:3000/api/admin/products/revalidate

预期响应:

{
  "revalidated": true,
  "tag": "products"
}

每一步的因果关系是:

  1. 商品读取请求将结果写入带有 products 标签的 Data Cache;
  2. 管理端调用重新验证接口;
  3. revalidateTag("products", "max") 找到所有带该标签的缓存条目;
  4. 这些条目被标记为需要重新验证;
  5. 后续读取请求再次向 API 获取或重新验证数据。

当前推荐形式中的第二个参数 "max" 表达较新的 stale-while-revalidate 语义。早期 Next.js 版本常见的是:

revalidateTag("products");

旧形式的失效语义和兼容性与当前推荐形式不同,且在较新版本中可能被标记为过时。项目应按照实际 Next.js 版本的 API 签名编写,不能把不同版本的示例混用。

8.3 标签失效必须在服务端执行

以下代码不能放在普通客户端组件中:

"use client";

import { revalidateTag } from "next/cache"; // 错误边界

export function RefreshButton() {
  // 客户端不能直接执行服务端缓存失效
  return <button>刷新</button>;
}

原因不是 TypeScript 语法,而是职责边界:

浏览器:
  没有 Next.js 服务器 Data Cache 的直接控制权

服务端:
  才能修改 Data Cache 或触发标签失效

客户端按钮应调用一个受保护的 Route Handler,或者调用执行在服务端的 Server Action。无论采用哪种方式,都必须验证操作者权限,不能暴露任意标签失效接口。


九、标签设计:范围、粒度和一致性

标签的设计应反映数据依赖关系。

9.1 列表和详情

// 商品列表
next: {
  tags: ["products"],
}

// 商品详情
next: {
  tags: ["products", `product:${id}`],
}

更新商品 p1 时:

revalidateTag("product:p1", "max");

只失效详情:

product:p1       失效
product:p2       保留
products         保留

如果商品更新也会改变列表排序、价格或库存,则必须同时失效:

revalidateTag("product:p1", "max");
revalidateTag("products", "max");

标签不会自动理解业务关系。给详情加了 product:p1,并不代表系统会自动知道列表也依赖 p1

9.2 租户范围

多租户应用不应只使用公共标签:

tags: ["products"]

更安全的设计是:

tags: [`tenant:${tenantId}:products`]

失效时只清理一个租户:

revalidateTag(`tenant:${tenantId}:products`, "max");

公共标签适合真正对所有用户相同的数据,例如公开的国家列表;租户数据、用户数据和权限数据则应包含作用域。

9.3 标签不是权限控制

标签只决定缓存条目何时失效,不决定谁可以读取条目。下面的授权逻辑仍然必须存在:

const tenantId = await requireTenant();

const response = await fetch(
  `https://api.example.com/tenants/${tenantId}/products`,
  {
    next: {
      tags: [`tenant:${tenantId}:products`],
      revalidate: 60,
    },
  }
);

即使标签设计正确,如果后端 URL、请求头或数据库查询没有限制租户,缓存标签也不能修复越权。


十、revalidateTagrevalidatePath 与更新语义

10.1 revalidateTag

revalidateTag 面向数据集合:

revalidateTag("products", "max");

它的优点是一个标签可以关联多个页面、多个请求和多个路由:

首页推荐 ─────┐
商品列表 ─────┼─ tag: products
搜索结果 ─────┘

失效标签后,所有相关数据条目都会受到影响。

10.2 revalidatePath

revalidatePath 面向路由路径:

import { revalidatePath } from "next/cache";

revalidatePath("/products");

它表达的是“这个路径的缓存输出需要重新验证”。它适合在明确知道某个页面受影响时使用,但不一定能表达跨页面的数据依赖。

例如,修改商品可能影响:

  • /products
  • /products/p1
  • /
  • /search?q=keyboard

此时只调用:

revalidatePath("/products");

可能无法完整覆盖所有依赖。使用标签可以围绕数据本身组织失效:

revalidateTag("products", "max");
revalidateTag("product:p1", "max");

实践中可以组合使用:

revalidateTag(`product:${id}`, "max");
revalidateTag("products", "max");
revalidatePath(`/products/${id}`);

但不要为了“保险”无差别清理大量路径。失效范围越大,后续重新获取数据的压力越大。

10.3 updateTag 的区别

较新的 Next.js 版本还提供了面向 Server Action 的更新语义,例如 updateTag。它与 revalidateTag 的差别通常在于:

  • revalidateTag 更适合标记数据需要重新验证;
  • updateTag 面向写入后的立即失效和 read-your-writes 场景;
  • 可调用位置和参数形式具有版本约束。

因此,不能把“标签失效”笼统理解为所有 API 都拥有相同语义。使用前要确认:

  1. 当前 Next.js 版本;
  2. 调用位置是 Server Action 还是 Route Handler;
  3. 需要 stale-while-revalidate,还是写入后当前用户必须立即看到新值。

十一、完整写入流程:保存商品并让读取缓存失效

一个简化的 Server Action 示例:

// app/products/actions.ts
"use server";

import { revalidateTag } from "next/cache";

export async function updateProduct(
  id: string,
  input: { name: string; price: number }
) {
  const user = await getCurrentUser();

  if (!user || !user.isAdmin) {
    throw new Error("没有权限修改商品");
  }

  if (!input.name.trim() || input.price < 0) {
    throw new Error("商品参数无效");
  }

  await db.product.update({
    where: { id },
    data: {
      name: input.name.trim(),
      price: input.price,
    },
  });

  revalidateTag(`product:${id}`, "max");
  revalidateTag("products", "max");

  return { ok: true };
}

这个流程的顺序很重要:

1. 校验身份
2. 校验输入
3. 写入数据库
4. 数据库写入成功后再失效缓存
5. 向调用方返回成功

如果先失效缓存,再写数据库:

失效缓存
  ↓
数据库写入失败
  ↓
后续请求重新读取旧数据

这虽然不会必然造成永久错误,但会产生不必要的缓存抖动。更严重的是,如果更新操作部分成功、部分失败,必须根据事务边界确定哪些标签可以失效。

如果数据库写入成功但 revalidateTag 失败,数据库是新状态,缓存可能暂时是旧状态。生产环境应记录失效失败,并提供可重试的失效机制;不要把缓存失效当作数据库事务的一部分来假设原子性。


十二、Data Cache 与 Full Route Cache 不是一回事

Next.js 还可能缓存渲染后的路由输出,这通常称为 Full Route Cache。可以把两者分开表示:

请求页面 /products
        │
        ▼
读取 products 数据
        │
        ├─ 命中 Data Cache:得到 D
        │
        ▼
服务端组件渲染
        │
        ▼
可能生成并缓存页面输出

这里存在两个不同问题:

Data Cache

回答:

后端 API 或数据库结果是否可以复用?

控制方式包括:

fetch(url, {
  next: {
    revalidate: 60,
    tags: ["products"],
  },
});

Full Route Cache

回答:

这个路由生成出来的 RSC/HTML 输出是否可以复用?

它受到路由是否静态、是否使用动态请求信息、数据请求配置等因素影响。

因此:

  • 清理 Data Cache 不一定等同于清理所有客户端已经保存的页面数据;
  • 清理页面路径不一定能替代所有数据标签;
  • 浏览器中的 Router Cache 又是另一层客户端缓存。

在用户已经打开页面时,即使服务端 Data Cache 已失效,浏览器当前页面也不会凭空重新渲染。客户端需要:

  • 导航到新页面;
  • 刷新路由;
  • 重新请求 API;
  • 或由客户端数据层主动更新。

十三、常见错误与失败表现

错误一:把请求记忆当成持久化缓存

const a = await fetch(url, { cache: "no-store" });
const b = await fetch(url, { cache: "no-store" });

在一次渲染中,等价请求可能被去重;但下一次页面请求仍然会访问数据源。若误以为它们会跨请求缓存,生产环境会出现请求量远高于预期。

诊断方法:

  • 在后端 API 打印请求时间和请求 ID;
  • 区分“同一次渲染多个组件重复调用”和“多个页面请求重复访问”;
  • 检查是否配置了 next.revalidateforce-cache

错误二:在客户端修改服务端缓存

客户端点击刷新按钮不会直接执行服务端的 revalidateTag。如果按钮只改变了本地状态,服务端 Data Cache 仍然可能返回旧数据。

正确路径是:

客户端按钮
  ↓ fetch / Server Action
服务端权限校验
  ↓
数据库写入
  ↓
revalidateTag
  ↓
客户端刷新或更新本地数据

错误三:缓存了带用户身份的数据

export async function getCart() {
  return fetch("https://api.example.com/cart", {
    next: { revalidate: 300 },
  }).then((response) => response.json());
}

如果 /cart 根据 Cookie 或 Authorization 返回不同用户的购物车,这种公共缓存配置可能错误。购物车通常应使用:

fetch("https://api.example.com/cart", {
  cache: "no-store",
  headers: {
    Cookie: request.headers.get("cookie") ?? "",
  },
});

或者使用严格隔离用户身份的缓存方案。

错误四:标签写了,但从未触发失效

next: {
  revalidate: 86400,
  tags: ["products"],
}

这只建立了标签关联,不会自动在数据库发生变化时感知变化。标签失效必须由:

  • 管理后台写入流程;
  • CMS Webhook;
  • 定时任务;
  • 受保护的管理接口;

显式触发。

错误五:以为 revalidate: 60 是强实时保证

revalidate 是缓存策略,不是实时订阅,也不是精确到秒的定时刷新。它不能保证:

数据库更新后最多 60 秒内所有用户都同时看到新数据

如果业务要求写入后立即一致,应在写入流程中执行按需失效,并设计客户端刷新或 Server Action 返回后的 UI 更新。

错误六:只检查 HTTP 请求是否完成,不检查业务状态

const data = await fetch(url).then((r) => r.json());
return data.items;

当服务器返回 500 JSON 时,代码仍可能继续执行。应先检查状态:

const response = await fetch(url);

if (!response.ok) {
  throw new Error(`上游服务错误:${response.status}`);
}

const data = await response.json();

缓存策略不能替代错误处理。


十四、非 fetch 数据源的缓存选择

14.1 只需要请求级去重

import { cache } from "react";

export const getSettings = cache(async () => {
  return db.setting.findMany();
});

适用于:

  • 同一页面树中多个组件需要相同结果;
  • 数据必须每个请求重新读取;
  • 不需要跨请求缓存。

14.2 需要跨请求缓存和标签

import { unstable_cache } from "next/cache";

export function getSettings(tenantId: string) {
  return unstable_cache(
    () =>
      db.setting.findMany({
        where: { tenantId },
      }),
    ["settings", tenantId],
    {
      revalidate: 300,
      tags: [`tenant:${tenantId}:settings`],
    }
  )();
}

适用于:

  • 数据库查询成本较高;
  • 数据允许短时间复用;
  • 需要按标签失效。

14.3 直接使用外部缓存系统

Redis、Memcached 或数据库物化视图可以作为应用级缓存,但这不等同于 Next.js Data Cache:

Next.js Data Cache:
  由 Next.js fetch / cache API 管理

Redis:
  由应用自行定义 key、TTL、锁、序列化和失效逻辑

两者可以叠加,但会产生多层缓存:

页面
  ↓
Next.js Data Cache
  ↓
Redis
  ↓
数据库

多层缓存的收益是减少后端压力,代价是更复杂的失效和故障诊断。必须能回答“哪个层存了旧值”和“哪个层已经失效”。


十五、生产环境中的部署边界

Data Cache 是否跨实例共享,取决于运行方式和平台实现。

单实例部署通常可以复用该实例上的缓存;多实例部署时可能出现:

用户请求实例 A:实例 A 的 Data Cache 已更新
用户请求实例 B:实例 B 仍持有旧缓存

如果部署平台提供共享 Data Cache,失效可以在实例之间传播;如果没有,就需要:

  • 使用平台提供的缓存后端;
  • 配置共享缓存处理器;
  • 通过消息系统广播失效事件;
  • 或接受各实例在 TTL 内暂时不一致。

这也是为什么“本地测试标签失效成功”不能证明生产环境一定全局生效。验证时应覆盖:

  1. 多个实例或多个运行容器;
  2. 缓存命中和未命中;
  3. 标签失效前后的响应;
  4. 部署后缓存是否保留;
  5. 上游失败时旧数据如何处理。

十六、一个可操作的选择模型

可以用数据的两个属性决定缓存方式:

  • FF:数据变化频率;
  • CC:数据是否与请求用户或权限相关。

情况一:低频变化、公共数据

F 低,C 公共

适合:

next: {
  revalidate: 3600,
  tags: ["countries"],
}

情况二:变化频繁、公共数据

F 高,C 公共

适合较短 TTL,或者写入成功后主动失效:

next: {
  revalidate: 30,
  tags: ["inventory"],
}

情况三:用户专属数据

C 用户相关

默认优先考虑:

cache: "no-store"

除非已经明确设计了用户隔离、授权和缓存键。

情况四:非 fetch 数据源

数据库 / SDK / 文件系统

根据需求选择:

一次渲染去重:React cache
跨请求缓存:unstable_cache 或当前版本对应的缓存 API
严格实时:直接读取,不使用持久化 Data Cache

十七、最终的因果链

一个带标签和时间策略的数据请求,可以抽象为下面的状态机:

flowchart TD
    A[服务端组件发起请求] --> B{本次渲染中是否已有等价请求}
    B -- 是 --> C[复用进行中的请求或结果]
    B -- 否 --> D{Data Cache 是否存在有效条目}
    C --> E[得到数据]
    D -- 是 --> E[返回缓存数据]
    D -- 否 --> F[访问 API 或数据库]
    F --> G{数据源请求成功}
    G -- 否 --> H[抛出错误或按运行时策略使用旧数据]
    G -- 是 --> I[写入 Data Cache 与标签]
    I --> E
    E --> J[渲染服务端组件]
    K[定时过期或 revalidateTag] --> L[标记缓存条目需重新验证]
    L --> D

关键点在于:

  • 请求记忆发生在一次渲染内部;
  • Data Cache 发生在跨请求的数据层;
  • revalidate 决定时间上的新鲜度;
  • 标签决定业务上的失效范围;
  • 页面输出缓存和浏览器 Router Cache 仍是其他层;
  • 客户端组件不能直接控制服务端 Data Cache;
  • 用户相关数据必须首先满足隔离和授权,再讨论缓存收益。

当这些边界明确后,Next.js 缓存就不再是“页面偶尔显示旧数据”的黑盒,而可以被拆成可观察、可失效、可验证的数据生命周期。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。