- 发布日期
第 30 讲:ISR、on-demand revalidation 与 Tag 失效
ISR 增量静态再生成、on-demand revalidation 与 revalidateTag 实现机制
第 13 讲讲过 Caching 全景(4 层缓存),第 20 讲讲过 fallback 模式触发首次 prerender,第 28 讲讲过 prerender 三件套
.html/.rsc/.meta的生成。本讲把这些零件拼起来:生产中一条 ISR 数据从 "build 时预渲染" → "60s 后陈旧" → "revalidate 触发重建" → "下次请求新内容"的完整生命周期,以及多实例部署如何用 redis 共享 cache。
学习目标
读完本讲,你能:
- 描述 IncrementalCache 的双层结构:内存 LRU + FileSystemCache 持久化。
- 看懂
revalidateTag是如何在内存tagsManifest里登记"被失效的 tag + 时间戳",让下次 cache get 自动判失效。 - 区分 4 种 revalidate 触发方式:time-based(
revalidate=60)/ on-demand(revalidateTag)/ on-demand(revalidatePath)/ 显式res.revalidate()。 - 实现一个 redis cache handler,让多 K8s pod 共享 ISR cache 和 tag invalidation。
- 排查生产 ISR 4 类常见问题:cache 不刷新、tag 失效不传播、stale-while-revalidate 卡死、内存 LRU 爆掉。
本讲对应代码:
packages/next/src/server/lib/incremental-cache/index.ts(IncrementalCache)packages/next/src/server/lib/incremental-cache/file-system-cache.ts(默认 handler)packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts(tag 失效登记)packages/next/src/server/lib/incremental-cache/memory-cache.external.ts(LRU)packages/next/src/server/response-cache/(Response cache 层)packages/next/src/server/web/spec-extension/revalidate.ts(用户 API)
一、ISR 是什么、为什么需要
ISR = Incremental Static Regeneration。简单说就是 "静态 page 在 build 后还能更新":
- build 时预渲染 100 个商品页 → 出 100 个 .html / .rsc
- 上线后用户访问,CDN/server cache 命中,毫秒级响应
- 60s 后,page 标记 stale;下个请求触发后台 revalidate
- revalidate 完成后,cache 更新,再下个请求命中新内容
收益 vs 传统 SSR/SSG:
| SSR(每次渲染) | SSG(build 时) | ISR | |
|---|---|---|---|
| TTFB | 200-500ms | 10-30ms | 10-30ms |
| 数据新鲜度 | 实时 | build 时刻 | 取决于 revalidate 间隔 |
| build 时间 | 不影响 | 数据多时极慢 | 短(只预渲染热门) |
| 服务端负载 | 高 | 极低 | 极低 + 周期性低峰 |
ISR 是 "99% 静态体验 + 1% 后台更新" 的折衷,适合电商商品、博客文章、新闻这类"频繁读、低频写"场景。
二、IncrementalCache 双层架构
constructor(ctx: FileSystemCacheContext) {
this.fs = ctx.fs
this.flushToDisk = ctx.flushToDisk
this.serverDistDir = ctx.serverDistDir
this.revalidatedTags = ctx.revalidatedTags
if (ctx.maxMemoryCacheSize) {
if (!FileSystemCache.memoryCache) {
FileSystemCache.memoryCache = getMemoryCache(ctx.maxMemoryCacheSize)
}
}
}
每个 next-server 进程内部维护两层 cache:
┌─────────────────────────────────────┐
│ ResponseCache (per-request dedup) │ ← 防止同一 path 并发请求重复 render
├─────────────────────────────────────┤
│ IncrementalCache │
│ ├─ memoryCache (LRU) │ ← 进程内 in-memory,热数据
│ └─ FileSystemCache │ ← .next/cache/fetch-cache/ +
│ │ .next/server/app/<path>.html
└─────────────────────────────────────┘
请求一条 page 的流程:
- ResponseCache.get(cacheKey) → MISS → 进 2
- IncrementalCache.get(cacheKey) →
- memoryCache HIT → 返回
- memoryCache MISS → 查 FileSystemCache
- 读
.next/server/app/<path>.html+.rsc+.meta - HIT → 反序列化,加入 memoryCache,返回
- MISS → 返回 null
- 读
- 进 render,得到 HTML + RSC payload + cache headers
- IncrementalCache.set(cacheKey, value)
- 写 memoryCache(淘汰最旧)
- 写 FileSystemCache(更新 .html / .rsc / .meta)
- ResponseCache.set(cacheKey, value)
- 返回响应
三、CacheHandler 接口
export class CacheHandler {
constructor(_ctx: CacheHandlerContext) {}
public async get(
_cacheKey: string,
_ctx: GetIncrementalFetchCacheContext | GetIncrementalResponseCacheContext
): Promise<CacheHandlerValue | null> {
return {} as any
}
public async set(
_cacheKey: string,
_data: IncrementalCacheValue | null,
_ctx: SetIncrementalFetchCacheContext | SetIncrementalResponseCacheContext
): Promise<void> {}
public async revalidateTag(
_tags: string | string[],
_durations?: { expire?: number }
): Promise<void> {}
public resetRequestCache(): void {}
}
四个方法:
get(cacheKey, ctx):读 cache,返回CacheHandlerValue | nullset(cacheKey, data, ctx):写 cacherevalidateTag(tags):标记 tag 失效resetRequestCache():清掉 per-request scope 的 cache(避免跨请求泄露)
Next.js 提供默认实现 FileSystemCache;用户可以自定义 handler 接入 redis/s3:
// next.config.js
module.exports = {
cacheHandler: require.resolve("./my-redis-cache-handler.js"),
cacheMaxMemorySize: 0, // 关掉内存 cache(如果 redis 已经够快)
};
四、tag-based invalidation 的内部实现
revalidateTag('products') 看起来是个魔法函数,实际上它做的极简:
export const tagsManifest = new Map<string, TagManifestEntry>()
export const areTagsExpired = (tags: string[], timestamp: Timestamp) => {
for (const tag of tags) {
const entry = tagsManifest.get(tag)
const expiredAt = entry?.expired
if (typeof expiredAt === 'number') {
// 如果 cache entry 的 timestamp < tag 的 expired 时间,则失效
if (timestamp <= expiredAt) {
return true
}
}
}
return false
}
tagsManifest 是 进程级 Map:tag → 失效时间戳。
调用 revalidateTag('foo'):
tagsManifest.set("foo", { expired: Date.now() });
下次任何带 tags: ['foo'] 的 cache entry 被读取时,areTagsExpired 比较 entry 的写入时间和 'foo' 的失效时间:
- entry timestamp ≤ 失效时间 → 失效,重新 fetch/render
- entry timestamp > 失效时间 → 仍然有效(说明 entry 是在失效之后才写入的)
这套设计的精妙之处:不需要遍历所有 cache entry 找到带这个 tag 的删除,只需要更新一个戳,懒判定。
单进程的限制
tagsManifest 是进程内 Map,意味着:
- 多个 K8s pod 互不相通
- pod A 调
revalidateTag('foo'),只有 A 知道 - pod B/C 的 cache 仍然返回旧值
解决:用 redis-based cache handler,把 tagsManifest 同步到 redis。下面会写。
五、4 种 revalidate 触发方式
1. time-based(revalidate = N)
// app/products/[slug]/page.tsx
export const revalidate = 60; // 单位:秒
// 或在 fetch 上
const data = await fetch(url, { next: { revalidate: 60 } });
机制:
- cache entry 写入时记录
expireAt = now() + 60 - 后续读取看
now() > expireAt→ stale - 触发 background revalidate(不阻塞当前请求)
- 当前请求仍返回旧内容(stale-while-revalidate 模式)
- 后台 revalidate 完成后,下一个请求拿到新内容
2. on-demand tag 失效
// 在 Server Action 或 Route Handler 里
import { revalidateTag } from 'next/cache'
export async function POST(req) {
await db.products.update(...)
revalidateTag('products')
return Response.json({ ok: true })
}
机制:
- 调用
revalidateTag('products')→ 写tagsManifest - 所有带
tags: ['products']的 cache entry 立刻判失效(不等到 revalidate 间隔) - 下次任何请求触发的 fetch / cache.get 都会 MISS,重新生成
fetch 加 tag:
const data = await fetch(url, {
next: { tags: ["products", `product-${id}`] },
});
'use cache' 函数也可以加 tag:
async function getProducts() {
"use cache";
cacheTag("products");
return db.products.findMany();
}
3. on-demand path 失效
import { revalidatePath } from "next/cache";
revalidatePath("/products/iphone"); // 特定 path
revalidatePath("/products/[slug]", "page"); // 所有匹配的 dynamic page
revalidatePath("/", "layout"); // 整个 layout 下所有 page
内部实现:把"路径"也当成一种隐式 tag,写入 tagsManifest。
4. 显式 res.revalidate(pages router)
Pages Router 的 API route 里:
export default async function handler(req, res) {
await res.revalidate("/products/iphone");
res.json({ ok: true });
}
App Router 已弃用此 API,统一用 revalidatePath。
六、Cache key 设计
每条 cache entry 的 key 包括:
{pathname}#{searchParams hash}#{revalidate flags}#{locale}#...
由 getDerivedTags(meta.path, params) 计算。
关键点:
- searchParams 默认进 key:意味着
?utm=1和无utm是两条 cache entry- 用
experimental.staticPageGenerationTimeout配 ignored search params
- 用
- request headers 部分入 key:
Cookie是否入 key 取决于具体 page 配置 - locale:i18n 项目每个 locale 独立 entry
七、实战:redis cache handler
// redis-cache-handler.js
const Redis = require("ioredis");
module.exports = class RedisCacheHandler {
constructor(ctx) {
this.client = new Redis(process.env.REDIS_URL, {
lazyConnect: true,
});
this.revalidatedTags = ctx.revalidatedTags ?? [];
this.connecting = this.client.connect().catch(console.error);
}
async get(key) {
await this.connecting;
const raw = await this.client.get(`next:${key}`);
if (!raw) return null;
const entry = JSON.parse(raw);
// 检查 tag 失效
const tagsExpiredKey = entry.tags?.length
? await this.client.mget(entry.tags.map((t) => `next:tag:${t}`))
: [];
const latestTagExpiredAt = Math.max(
0,
...tagsExpiredKey.map(Number).filter(Boolean),
);
if (latestTagExpiredAt >= entry.lastModified) {
return null; // 失效
}
return entry;
}
async set(key, data, ctx) {
await this.connecting;
const entry = {
value: data,
lastModified: Date.now(),
tags: ctx.tags ?? [],
};
// 用 EX 设过期,避免无限增长
const ttl = ctx.cacheControl?.revalidate ?? 86400;
await this.client.set(
`next:${key}`,
JSON.stringify(entry),
"EX",
Math.max(60, ttl * 2),
);
}
async revalidateTag(tags) {
const tagList = Array.isArray(tags) ? tags : [tags];
await this.connecting;
// 用 set + timestamp 表示"该 tag 在某时刻失效"
const now = Date.now();
const pipeline = this.client.pipeline();
for (const tag of tagList) {
pipeline.set(`next:tag:${tag}`, now, "EX", 86400);
}
// 同时通过 redis pub/sub 通知其它实例
pipeline.publish(
"next:revalidate",
JSON.stringify({ tags: tagList, at: now }),
);
await pipeline.exec();
}
resetRequestCache() {
// 可选:清掉 in-request cache
}
};
next.config.js:
module.exports = {
cacheHandler: require.resolve("./redis-cache-handler.js"),
cacheMaxMemorySize: 0,
};
效果:5 个 K8s pod 共享 redis,pod A 调 revalidateTag('products'),pod B/C 立刻看到失效。
优化:内存 LRU + redis 二级 cache
纯 redis 每次都走网络,~1ms。可以前面再加内存 LRU:
const LRU = require('lru-cache')
const memCache = new LRU({ max: 1000, ttl: 5000 }) // 5s in-memory TTL
async get(key) {
const cached = memCache.get(key)
if (cached) return cached
const fromRedis = await this.getFromRedis(key)
if (fromRedis) memCache.set(key, fromRedis)
return fromRedis
}
5s 内的同 key 请求走内存,5s 后才查 redis。trade-off:tag 失效有最多 5s 延迟,但 QPS 上限提升 50-100x。
八、stale-while-revalidate 机制
这是 ISR 的核心 UX 保证:
请求 1 (t=0): cache MISS → render → 写 cache (expire=t+60)
请求 2 (t=30): cache HIT, fresh → 直接返回
请求 3 (t=70): cache HIT, stale → 返回旧内容 + 后台触发 revalidate
请求 4 (t=71): cache HIT, fresh (后台 revalidate 已完成) → 返回新内容
注意请求 3 不等 revalidate 完成就返回,所以用户从来不会等。
实现细节:
- ResponseCache 检测到 stale → 启动 background promise
- background promise 调 render,结束后 set cache
- 多个并发请求看到 stale → 用
locksmap 防重,只触发一次 revalidate
private readonly locks = new Map<string, Promise<void>>()
九、生产排障实战清单
| 现象 | 排查 |
|---|---|
调了 revalidateTag 但页面没刷新 | 多 pod 部署没共享 cache handler;用 redis-based handler |
| 页面永远是 build 时数据 | 没设 revalidate 也没 tag;或 dynamic = 'force-static' 误用 |
revalidate 频繁触发,DB 压力大 | 多个 page 共用一个 fetch;用 tag-based 而非时间触发 |
| dev 模式能 revalidate,prod 不行 | prod 用 file-system cache,dev 是 no-cache;确保 .next/cache/fetch-cache/ 可写 |
| 内存爆掉 | cacheMaxMemorySize 默认 50MB;高 QPS 大 page 项目要调小或用 redis 替换 |
| 多 pod cache 不一致 | 标志性问题,必须 external cache handler |
| 部分 page 永不 stale | 检查 page 是否被识别为静态(build log 里是 ○ 还是 ●) |
revalidateTag 调用慢 | redis handler 用 publish 而非 keys * 扫描;批量操作用 pipeline |
| ISR 第一个请求超时 | 首次 prerender 慢;用 experimental.dynamicIO.timeout 或预热 |
revalidatePath('/foo') 不生效 | path 写错(动态路由要写 /foo/[slug] 不是 /foo/iphone) |
十、CDN 与 ISR 的配合
ISR 在 Vercel 上是开箱即用的(Vercel 接管了 cache 层)。自托管时要注意:
浏览器 → CDN (CloudFront/Nginx) → Next.js
cache cache
两层 cache 都要正确:
- CDN 层:
Cache-Control: s-maxage=60, stale-while-revalidate=86400 - Next.js IncrementalCache:内部管理
如果 CDN cache 强力(s-maxage=600),但 Next.js revalidate=60,revalidateTag 只能让 Next.js 立刻失效,CDN 仍旧持有旧 page 10 分钟。
解决:
revalidatePath后用 CDN API 主动 purge(如 CloudFrontCreateInvalidation)- 或 CDN cache 短一点(如 60s),让 Next.js 一致
十一、配套 fixture:观察 ISR 生命周期
fixtures/lecture-30/ 提供:
/posts/[id]启用revalidate = 30的 ISR page/api/posts/[id]/updateRoute Handler 模拟更新数据 +revalidateTag/api/posts/[id]/peek查看当前 cache 状态- 一个简化的 in-memory + file-system custom cache handler 打 debug log
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-30
pnpm install
pnpm build
pnpm start &
# 第一次请求,cache MISS,slow
time curl http://localhost:3030/posts/1
# 第二次请求,cache HIT,fast
time curl http://localhost:3030/posts/1
# 触发 update + revalidateTag
curl -X POST http://localhost:3030/api/posts/1/update -d 'text=hello'
# 再次请求,应该看到新数据
curl http://localhost:3030/posts/1
pnpm start 输出会显示自定义 handler 的 debug log。
十二、本讲小结
- IncrementalCache 双层:内存 LRU + FileSystemCache,进程内单例。
tagsManifest是失效戳的 Map:revalidateTag只写戳,读时懒判定。- 4 种触发:time-based / tag / path / 显式 revalidate。
- 多实例部署必用 external cache handler:redis 或类似中央存储。
- CDN 与 Next.js 两层 cache 要协调:max-age 配置 + 主动 purge。
下讲预告
第 31 讲《国际化、rewrites、basePath 与 Proxy》。深入 routes-manifest 里的 rewrites/redirects/headers 是如何在 router-server 的 resolveRoutes 里被消费的;i18n routing 的实现;basePath / assetPrefix 在 CDN 配置中的作用;以及 Vercel 风格的 url normalization。