把「Next 的缓存」理解成 fetch 上那个 cache 开关,会在生产里立刻失效。出 bug 几乎都是同一句话的变体:你以为没缓存,其实还有一层在;你以为刷掉了,其实刷的是另一层。Next 15 把默认从「尽量缓存」改成「fetch 默认不进 Data Cache」,并没有把四层模型拆掉。Request Memoization、Data Cache、Full Route Cache、Client Router Cache 还在,只是默认值和入口变了。Next 16 的 Cache Components('use cache' + cacheLife + cacheTag)是把隐式 fetch 缓存收成显式指令,层还是那些层。
这篇文章只讲生命周期:每一层的 key 是什么、活多久、被谁失效、和动态函数怎么打架。读完你应该能独立回答三个问题:这次请求打到了哪一层、突变之后该 revalidateTag 还是 updateTag、为什么 next dev 里是新的、next start 里还是旧的。
1. 缓存不是开关,是四段不同寿命的状态机
把四层画在一张表上,排障才有坐标:
- Request Memoization。活在这一次服务端渲染。同一棵树里两次
fetch(url)或两次cache()(id),只打源一次。请求结束就扔。它不是跨用户的缓存,是去重。 - Data Cache。跨请求、跨用户(除非你把 cookie 编进 key)。存 fetch 结果或
unstable_cache/'use cache'的函数结果。失效靠时间、tag、path。 - Full Route Cache。整段路由的 HTML + RSC Flight payload。静态路由构建期生成;ISR 在过期后于后台再生成。动态路由根本不进这一层。
- Client Router Cache。浏览器里的段缓存。prefetch 和回退导航走它。Next 15 起动态页默认不再缓存 30 秒(
staleTimes.dynamic = 0),layout 仍可能留下。
四层的 key 不同。Memoization 的 key 是调用参数;Data Cache 的 key 是 fetch URL+选项或你给 unstable_cache 的 key 数组;Full Route Cache 的 key 是路由路径加被允许的 searchParams;Router Cache 的 key 是段。改错 key 的症状是:你以为按商品失效,结果整站目录还是旧的,或反过来一次 tag 把无关页全刷了。
不变量:没有「关缓存」这一个动作。 cache: 'no-store' 只退出 Data Cache;layout 的 Full Route Cache、客户端点过的段、同一次请求里的 memoize,都还在。export const dynamic = 'force-dynamic' 退出 Full Route Cache 和这段的静态生成,不退出你在别的函数里写的 'use cache'。
2. Request Memoization:一次渲染里的 Map
React 在服务端渲染时给 fetch 和 cache() 准备了一张请求内的 Map。layout 调 getUser(id),page 再调一次,数据库只打一次。这就是「为什么我没做 Data Cache,日志里也只有一条 SQL」。
import { cache } from "react";
import { db } from "@/lib/db";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
cache() 的 key 是参数的浅比较。对象参数每次 new 一个字面量,去重失败。传 id: string,不要传整个 searchParams 对象。fetch 的 memoize 看 URL、method 和一部分 headers;两个调用点写了不同的 Authorization,就变成两次。
它不跨请求。用户 A 和用户 B 不会共享这张 Map。把它当全局 LRU 是错的。unstable_cache / 'use cache' 才跨请求。
开发态 React 严格模式会把某些客户端逻辑跑两遍,但服务端组件的 memoize 仍按一次请求算。不要用「我看到两条日志」直接断定 memoize 坏了——先看是不是两条请求、是不是构建时预渲染加运行时又跑、是不是 Route Handler 和 RSC 各打一次(它们不是同一棵渲染树)。
cookies() / headers() 读了之后,这次渲染被标成动态,但 memoize 仍在。动态不等于不去重。去重回答的是「这一次计算要不要重复付」,动态回答的是「这次结果能不能留给下一次」。两件事叠在同一个 await 上,所以日志看起来像一回事。
把 getUser 写成普通 async 函数、在三个 Server Component 里各调一次,没有 cache() 也没有相同的 fetch,就会打三次库。这不是 Next 的锅,是你没有请求内去重。ORM 自己的 dataloader 可以补;和 cache() 二选一,不要套两层还用不同的 key 规则。
3. Data Cache:跨请求的那一层,以及默认为什么改了
Next 14:fetch 默认 force-cache,GET 进 Data Cache。大量「CMS 改了前台没变」来自这里。Next 15:fetch 默认不进 Data Cache,等价于你以前手写的 no-store。升级之后「怎么变慢了 / 怎么源站 QPS 涨了」,不是性能回归,是默认值把账单还给了你。团队把「Next 会帮我们缓存」写进规范,却没写进代码,升级就是一次静默的架构变更。
要进 Data Cache,必须显式:
const res = await fetch(url, { cache: "force-cache" });
const res2 = await fetch(url, {
next: { revalidate: 3600, tags: ["product", `product-${id}`] },
});
revalidate: 3600 是 stale-while-revalidate:过期后先把旧的给出去,后台再拉。失败则继续用旧的。不要把它理解成「准时 3600 秒切新」。准时是缓存的幻觉。第一个过期请求的用户,拿到的仍可能是旧 HTML,他只是顺便当了刷新扳机。
非 fetch 的源(Prisma、fs、RPC)没有 fetch 那套选项。Next 15 用 unstable_cache;打开 Cache Components 之后用 'use cache':
import { unstable_cache } from "next/cache";
export const getProduct = unstable_cache(
async (id: string) => db.product.findUnique({ where: { id } }),
["product"],
{ revalidate: 3600, tags: ["product"] },
);
["product"] 是 key 前缀,真正的 key 还要加上函数参数。参数必须可序列化。传一个 Prisma client 或 class 实例,key 对不上或直接抛。漏掉 locale、币种、feature flag,就会出现「英文站看到中文缓存」。多出来的输入(整个 headers 对象)会让缓存永远 miss。
'use cache' 把缓存从 fetch 选项提升到函数/组件作用域:
import { cacheLife, cacheTag } from "next/cache";
export async function getProduct(id: string) {
"use cache";
cacheLife("hours");
cacheTag("product", `product-${id}`);
return db.product.findUnique({ where: { id } });
}
cacheLife 的 profile 不是一个数字,是三段:stale(客户端可以先用多久)、revalidate(服务端何时在后台刷新)、expire(过了就不再当命中)。把 ISR 的「一个 revalidate 秒数」理解成这三段,才能解释「为什么用户还看到旧的,但日志里已经在刷新」。
Data Cache 的存储位置是平台的事:本地在 .next/cache,Vercel 一类平台有跨实例的 Data Cache / Runtime Cache。多实例之间是否共享,决定 revalidateTag 能不能打到所有人。自己用多台 Node 跑 next start 而没有共享缓存,tag 失效只发生在接到 Action 的那台——这是架构问题,不是 API 用错。
4. Full Route Cache:缓存的是渲染结果,不是那一次 fetch
即使 Data Cache 命中,动态函数仍能让整页不进 Full Route Cache。反过来:所有 fetch 都 no-store,这一页也进不了 Full Route Cache。这一层缓存的是已经渲染完的 RSC payload + HTML。构建时静态页写进制品;运行时 ISR 把新的结果写回。CDN 能端的是这一层的 HTML,不是你函数里的 Map。
一段路由能不能静态,看它在渲染时碰了什么:
cookies()、headers()、draftMode()、connection():绑定到这次请求,静态外壳没了。- page 的
searchParams:这一页按搜索参数分叉,默认偏动态。 - 未缓存的
fetch/ 未缓存的 DB:框架认为结果不可复用。 Math.random()/Date.now():构建期写死一个值,或把段标动态,取决于是否被'use cache'包住。
export const revalidate = 60;
export const dynamic = "auto";
段配置是粗粒度锤子。revalidate = 60 给这棵段一个默认 ISR 窗口;dynamic = 'force-dynamic' 直接放弃 Full Route Cache。生产里先让叶子数据带 tag,再对真正永远动态的壳(用户栏)用 connection() 或 Suspense 挖洞,而不是整页 force-dynamic。
Partial Prerendering / Cache Components 的静态壳 + 动态洞,就是把 Full Route Cache 的粒度从「整页」降到「洞以外的那一圈」。洞必须有 Suspense 边界,否则洞的动态性会泡到整页——和流式渲染是同一条边界。壳里出现一次未缓存的 cookies(),洞白挖。
5. Client Router Cache:回退为什么还显示旧页
客户端点过 /product/1,RSC payload 会留在内存。Next 14 动态段默认 30 秒内回退复用;Next 15 把 staleTimes.dynamic 默认改成 0,动态页回退会重新向服务器要。静态段、layout 仍可能复用。这不是「浏览器 HTTP 缓存」,是 Next 路由器自己的段表。禁用 CDN 也关不掉它。
所以「我已经 revalidateTag 了,点浏览器后退还是旧的」要分层问:
- 服务端 Data Cache / Full Route Cache 是否真的失效(看
next start的响应头和源站日志)。 - 客户端是不是根本没发新请求(Router Cache 命中)。
router.refresh()强制当前段再拉。Server Action 成功返回后,Next 会把本次响应里带的刷新打到客户端缓存;你在 Action 里忘了 revalidate,客户端不会自己猜。 - prefetch:
<Link>默认对静态段预取。预取的是当时的 payload。tag 失效后,下一次预取才是新的。不要指望「失效的瞬间所有已经打开的 tab 都变」。
"use client";
import { useRouter } from "next/navigation";
export function RefreshButton() {
const router = useRouter();
return (
<button type="button" onClick={() => router.refresh()}>
刷新当前段
</button>
);
}
router.refresh() 不卸 layout 的客户端状态。这是 App Router 的契约:layout 保活。刷新的是 RSC 子树,不是整页 location.reload()。输入框里没提交的字还在,详情数字应该变——若数字也不变,是服务端层没失效,不是 refresh 没调用。
6. 动态函数、PPR 洞、和「误伤整页」
cookies() 在 layout 里一读,这个 layout 以下默认都不能静态。认证壳要读 cookie,于是整棵营销站都变成每个请求 SSR——这是最常见的误伤。修法是把读 cookie 的那一块推进 Suspense 洞,或推进一个客户端叶子去调 Route Handler,让静态壳留下来。
Next 15 的 connection() 显式说「我要等真实请求」。比在代码里随手 headers() 更可读。构建时预渲染碰到 connection() 会挂起,等运行时。
import { connection } from "next/server";
import { cookies } from "next/headers";
export async function UserChip() {
await connection();
const jar = await cookies();
const token = jar.get("session")?.value;
if (!token) return null;
return <span>{await whoami(token)}</span>;
}
page 里用洞把它隔开:
import { Suspense } from "react";
export default function Page() {
return (
<>
<StaticHero />
<Suspense fallback={<span>…</span>}>
<UserChip />
</Suspense>
</>
);
}
没有 Suspense,动态子树的等待会堵住整页,Full Route Cache 也保不住壳。draftMode() 是另一条显式动态:编辑预览必须绕过 Data Cache 和 Full Route Cache 看草稿。忘记关 draft,等于你一个人在「永远 no-store」的会话里调试缓存,结论全部作废。
searchParams 不要在静态营销页的 page 组件上解构「以防以后有 UTM」。有 UTM 就整页动态。UTM 交给客户端或一个隔离的洞。
7. 失效:时间、tag、path,以及第二参数
三条失效:
- 时间。
revalidate: 60/cacheLife('minutes')。适合能忍过期的目录、价格不敏感的内容。不适合库存、权限、支付状态。 - tag。 数据带着
product-42和product-list。突变product-42时刷自己和列表,不要刷product这个全站桶。 - path。
revalidatePath('/blog/foo')刷这一段的 Full Route Cache;revalidatePath('/blog', 'layout')刷这段 layout 以下。path 不知道 Data Cache 里那些没渲染进这页的 key,所以「只改了 DB、没渲染过的详情页」仍可能在 Data Cache 里活着——tag 才能打到它们。
"use server";
import { revalidatePath, revalidateTag, updateTag } from "next/cache";
export async function publishProduct(id: string) {
await db.product.update({ where: { id }, data: { published: true } });
revalidateTag(`product-${id}`, "max");
revalidateTag("product-list", "max");
revalidatePath(`/product/${id}`);
}
Next 16 起 revalidateTag(tag, profile) 的第二参数是 stale-while-revalidate 的窗口:立刻给旧的,后台刷新。单参数形式已弃用。updateTag(tag) 是读己之写:Action 所在的这次后续读要看到新值,适合用户刚改完自己的显示名。选错的症状很具体:revalidateTag 之后当前表单还显示旧名,是你需要 updateTag;所有人立刻 404 风暴打源,是你把热 tag 做成了同步击穿。
不要 revalidatePath('/') 当万能钥匙。根 path 加上 'layout' 等于整站 Full Route Cache 作废,源站和构建缓存一起抖。CMS webhook 里按 slug 刷 tag,不要按站点根刷。
8. 取舍:静态、ISR、按 tag、完全动态、Cache Components
| 模式 | 买到什么 | 代价 |
|---|---|---|
| 纯静态 | CDN、零源站 | 内容变了要重建或等窗口 |
ISR / cacheLife | 多数请求仍快,偶尔后台刷新 | 过期边界上的人看到旧的 |
| tag 点杀 | 突变后精确刷新 | tag 设计本身是 API,设计错就等于没缓存或整站刷 |
force-dynamic / 默认 no-store | 正确性最简单 | TTFB、源站、无法 CDN 整页 |
| Cache Components 挖洞 | 壳静态、洞动态 | 必须会画 Suspense 边界,洞里不能偷读 cookies 到壳 |
默认选:公开内容带 tag + 合理 cacheLife;用户私有数据不要进 Data Cache(或 key 里显式含 user id,且绝不放 Full Route Cache);突变走 Action + updateTag / revalidateTag。不要为了「看起来像 SSR」把可静态的营销页打成动态。
'use cache' 和 fetch cache: 'force-cache' 叠在一起会双重缓存,失效要打两层。能包函数就包函数,不要 fetch 一层、unstable_cache 再包一层还用不同 tag。函数必须是纯的:同样输入同样输出,没有偷偷读 cookie。框架若检测不到,就会出现「缓存了不该缓存的请求」。纯,不是风格,是正确性。
9. 排障:按层问,不要先加 no-store
- dev 和新的、start 旧的。
next dev的缓存行为不是生产。结论只认next build && next start或平台预览。 - Action 成功页面没变。 先看 Action 有没有
revalidateTag/updateTag;再看 tag 字符串是否和读路径完全一致(多一个空格都是新 tag);再看是不是 Router Cache:同一 SPA 会话里router.refresh()。 - 改了 A 商品 B 商品也变。 tag 太粗。列表用
product-list,实体用product-${id},互不替代。 - 源站被打穿。 失效后所有实例同时 miss。热 key 用 SWR(
revalidateTag带 profile),不要用立刻过期的 expire。 - 个性化内容串用户。 把 cookie 读在已缓存的函数里,或 Full Route Cache 了带用户名的 HTML。私有数据退出 Full Route Cache,Data Cache 的 key 必须含用户维度,更好是不缓存私有。
Date.now()写进缓存函数。 构建期或首次命中的时间被冻住。时间放进未缓存的洞,或不要放进输出。- 升级 14 → 15 源站 QPS 暴涨。 fetch 默认不再进 Data Cache。公开接口补
next: { revalidate, tags }或'use cache'。 - 多实例 tag 不生效。 没有共享 Data Cache。需要平台缓存或自己的失效总线,不能假设进程内存是全局的。
- 构建时和运行时各一份。
generateStaticParams写进制品的页,运行时 tag 失效后要能重新生成;dynamicParams = false的未知 id 直接 404,不会在首次访问时现填缓存。
日志:给每次源站访问打 request id。命中缓存时这条日志不该出现。出现了还觉得「缓存开了」,是 key 或层写错。响应头 x-nextjs-cache / 平台自己的 cache status 只覆盖其中一层,不要用一个 HIT 断定四层都 HIT。
10. 和 RSC、Server Action 怎么咬合
RSC 树在服务端走完,缓存的决策发生在 await 点。没有 Suspense 的整页 await getProduct() 会让这一页的缓存粒度变成「整个 page 函数」。拆成带 tag 的 getProduct 和带 connection() 的 UserChip,壳和洞才分开。
Server Action 是突变的提交点。它跑完后框架根据你调用的 revalidate 去刷 Data Cache 和 Full Route Cache,并把刷新指令带回客户端。Action 里再 fetch 默认仍是这次请求的语义;不要在 Action 里靠 Data Cache 读「刚写的值」——写后读用 updateTag 或直接用 DB 返回值。Action 里 cookies() 很正常,它本来就是一次 POST,不会因此把营销首页变成动态,除非你在首页的渲染路径上也读了同一份 cookie。
generateStaticParams 决定哪些详情页进构建期 Full Route Cache。没返回的 id 走动态。目录很大时不要在构建期生成全部,用 ISR / tag 让第一次访问写入 Data Cache + 路由缓存。构建时间是产品参数,和缓存命中率同一张账。
11. key 设计:比调用缓存 API 更值钱
tag 是你的失效 API,不是日志标签。建议:
- 实体:
${type}-${id} - 集合:
${type}-list或${type}-list-${filterHash} - 关系:改评论时刷
post-${postId},不要只刷comment-${id}而留下文章页的旧计数
读路径和写路径必须共用同一份常量,不要各写各的字符串字面量。TypeScript 用联合类型把 tag 收窄:
export type CacheTag = `product-${string}` | "product-list" | "catalog";
export function productTag(id: string): CacheTag {
return `product-${id}`;
}
key 数组必须包含所有会改变结果的输入。不要把用户 id 放进公开列表的 key。不要把时间戳放进 key 还指望命中。unstable_cache 的 key 不自动含闭包里读到的全局;闭包读了 process.env.PRICE_GROUP,必须手动放进 key,否则切环境变量后旧条目还在。
缓存入口函数不要做权限判决。先鉴权,再按 id 读已缓存的公开投影。把「当前用户能不能看」编进缓存函数,要么串权,要么 key 膨胀到每人一份——那已经不是 Data Cache 该管的粒度。
12. 小结
缓存是分层状态机。Memoization 去重一次渲染;Data Cache 跨请求存数据;Full Route Cache 存渲染结果;Router Cache 存客户端段。Next 15 改的是 fetch 默认值,Next 16 改的是入口('use cache'),不是这四层。失效要对着层和 key:时间换吞吐,tag 换精确,path 换整段 HTML,updateTag 换当前用户的读己之写。调任何开关之前先问在哪一层。下一篇把突变收到 React 19 的 Action 模型里:pending 不是你的 useState,是过渡的生命周期。