把「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. 缓存不是开关,是四段不同寿命的状态机

把四层画在一张表上,排障才有坐标:

  1. Request Memoization。活在这一次服务端渲染。同一棵树里两次 fetch(url) 或两次 cache()(id),只打源一次。请求结束就扔。它不是跨用户的缓存,是去重。
  2. Data Cache。跨请求、跨用户(除非你把 cookie 编进 key)。存 fetch 结果或 unstable_cache / 'use cache' 的函数结果。失效靠时间、tag、path。
  3. Full Route Cache。整段路由的 HTML + RSC Flight payload。静态路由构建期生成;ISR 在过期后于后台再生成。动态路由根本不进这一层。
  4. 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

  1. dev 和新的、start 旧的。 next dev 的缓存行为不是生产。结论只认 next build && next start 或平台预览。
  2. Action 成功页面没变。 先看 Action 有没有 revalidateTag / updateTag;再看 tag 字符串是否和读路径完全一致(多一个空格都是新 tag);再看是不是 Router Cache:同一 SPA 会话里 router.refresh()。
  3. 改了 A 商品 B 商品也变。 tag 太粗。列表用 product-list,实体用 product-${id},互不替代。
  4. 源站被打穿。 失效后所有实例同时 miss。热 key 用 SWR(revalidateTag 带 profile),不要用立刻过期的 expire。
  5. 个性化内容串用户。 把 cookie 读在已缓存的函数里,或 Full Route Cache 了带用户名的 HTML。私有数据退出 Full Route Cache,Data Cache 的 key 必须含用户维度,更好是不缓存私有。
  6. Date.now() 写进缓存函数。 构建期或首次命中的时间被冻住。时间放进未缓存的洞,或不要放进输出。
  7. 升级 14 → 15 源站 QPS 暴涨。 fetch 默认不再进 Data Cache。公开接口补 next: { revalidate, tags } 或 'use cache'。
  8. 多实例 tag 不生效。 没有共享 Data Cache。需要平台缓存或自己的失效总线,不能假设进程内存是全局的。
  9. 构建时和运行时各一份。 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,是过渡的生命周期。