发布日期

第 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。

学习目标

读完本讲,你能:

  1. 描述 IncrementalCache 的双层结构:内存 LRU + FileSystemCache 持久化。
  2. 看懂 revalidateTag 是如何在内存 tagsManifest 里登记"被失效的 tag + 时间戳",让下次 cache get 自动判失效。
  3. 区分 4 种 revalidate 触发方式:time-based(revalidate=60)/ on-demand(revalidateTag)/ on-demand(revalidatePath)/ 显式 res.revalidate()
  4. 实现一个 redis cache handler,让多 K8s pod 共享 ISR cache 和 tag invalidation。
  5. 排查生产 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
TTFB200-500ms10-30ms10-30ms
数据新鲜度实时build 时刻取决于 revalidate 间隔
build 时间不影响数据多时极慢短(只预渲染热门)
服务端负载极低极低 + 周期性低峰

ISR 是 "99% 静态体验 + 1% 后台更新" 的折衷,适合电商商品、博客文章、新闻这类"频繁读、低频写"场景。

二、IncrementalCache 双层架构

175:packages/next/src/server/lib/incremental-cache/file-system-cache.ts
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 的流程:

  1. ResponseCache.get(cacheKey) → MISS → 进 2
  2. IncrementalCache.get(cacheKey) →
    • memoryCache HIT → 返回
    • memoryCache MISS → 查 FileSystemCache
      • .next/server/app/<path>.html + .rsc + .meta
      • HIT → 反序列化,加入 memoryCache,返回
      • MISS → 返回 null
  3. 进 render,得到 HTML + RSC payload + cache headers
  4. IncrementalCache.set(cacheKey, value)
    • 写 memoryCache(淘汰最旧)
    • 写 FileSystemCache(更新 .html / .rsc / .meta)
  5. ResponseCache.set(cacheKey, value)
  6. 返回响应

三、CacheHandler 接口

82:packages/next/src/server/lib/incremental-cache/index.ts
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 | null
  • set(cacheKey, data, ctx):写 cache
  • revalidateTag(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') 看起来是个魔法函数,实际上它做的极简:

30:packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts
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 部分入 keyCookie 是否入 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 → 用 locks map 防重,只触发一次 revalidate
packages/next/src/server/lib/incremental-cache/index.ts
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(如 CloudFront CreateInvalidation
  • 或 CDN cache 短一点(如 60s),让 Next.js 一致

十一、配套 fixture:观察 ISR 生命周期

fixtures/lecture-30/ 提供:

  • /posts/[id] 启用 revalidate = 30 的 ISR page
  • /api/posts/[id]/update Route 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。

十二、本讲小结

  1. IncrementalCache 双层:内存 LRU + FileSystemCache,进程内单例。
  2. tagsManifest 是失效戳的 MaprevalidateTag 只写戳,读时懒判定。
  3. 4 种触发:time-based / tag / path / 显式 revalidate。
  4. 多实例部署必用 external cache handler:redis 或类似中央存储。
  5. 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。