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() 就必然产生两次网络请求。
这里有两个重要限定:
- 请求记忆只针对同一次服务端渲染生命周期。
- 请求记忆不是跨请求持久化缓存。
假设用户 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 等能力影响。生产代码不应依赖“默认到底缓存还是不缓存”,而应根据数据语义显式写出 cache 或 next.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 条目抽象为:
其中:
- :缓存键;
- :数据结果;
- :时间信息,例如创建时间和重新验证时间;
- :附加索引,例如标签集合。
一个简化的缓存键可以表示为:
这里的 表示框架内部的键生成过程。实际实现细节不应被当作公开稳定算法,但这个模型说明了一个事实:
请求语义不同,就不能只因为 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 版本演进;使用时应核对当前版本文档。上例的关键不是记住函数形式,而是保证:
- 缓存键包含所有影响结果的参数;
- 标签与同一数据域对应;
- 数据库调用发生在缓存函数内部;
- 失效时能定位到正确的租户范围。
五、Revalidation:缓存如何重新变成可信数据
5.1 定义
Revalidation,重新验证或重新生效,是指缓存条目在一定条件下再次向数据源确认,并用新结果更新缓存。
它不是简单的“删除缓存”。两者的区别是:
删除缓存:
下次请求必须重新获取数据
重新验证:
允许框架根据策略获取新数据,并更新缓存
Next.js 中常见的两类重新验证是:
- 基于时间的重新验证;
- 按需重新验证。
六、基于时间的重新验证
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"
}
每一步的因果关系是:
- 商品读取请求将结果写入带有
products标签的 Data Cache; - 管理端调用重新验证接口;
revalidateTag("products", "max")找到所有带该标签的缓存条目;- 这些条目被标记为需要重新验证;
- 后续读取请求再次向 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、请求头或数据库查询没有限制租户,缓存标签也不能修复越权。
十、revalidateTag、revalidatePath 与更新语义
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 都拥有相同语义。使用前要确认:
- 当前 Next.js 版本;
- 调用位置是 Server Action 还是 Route Handler;
- 需要 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.revalidate或force-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 内暂时不一致。
这也是为什么“本地测试标签失效成功”不能证明生产环境一定全局生效。验证时应覆盖:
- 多个实例或多个运行容器;
- 缓存命中和未命中;
- 标签失效前后的响应;
- 部署后缓存是否保留;
- 上游失败时旧数据如何处理。
十六、一个可操作的选择模型
可以用数据的两个属性决定缓存方式:
- :数据变化频率;
- :数据是否与请求用户或权限相关。
情况一:低频变化、公共数据
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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:Next.js 路由与布局:Segment、并行路由、拦截和错误边界
- 下一篇:Next.js Server Actions:序列化、认证、校验、重放和渐进增强
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论