大规模运行 ISR:在 Vercel 上部署 25,000+ 页面的增量静态再生

去年我们在 Vercel 上部署了一个 Next.js 网站,包含超过 25,000 个静态生成的页面。产品页面、博客文章、位置登陆页面、动态分类过滤器——应有尽有。增量静态再生 (ISR) 的承诺确实很诱人:获得静态网站的速度和服务器渲染内容的新鲜度。说实话?它基本上兑现了承诺。但在 25,000+ 页面的规模下,ISR 的表现与 50 页营销网站上的表现截然不同。边界情况成为主要情况。成本开始上升。文档中看似理论性的缓存失效问题变得非常真实。

这是我希望在开始之前就存在的文章。这里的一切都来自生产经验——真实的指标、真实的账单惊喜,以及我们做出的真实架构决策(有时还为此后悔)。

ISR at Scale: Running 25,000+ Pages with Incremental Static Regeneration on Vercel

目录

ISR 实际工作原理

在深入探讨规模问题之前,让我们确保大家对 ISR 的功能有相同的理解。当你在 Next.js 页面中设置 revalidate: 60 时,实际的流程是这样的:

  1. 部署后的首次请求:如果页面在构建时已预渲染,Vercel 从边界缓存提供该页面。如果没有(你返回了 fallback: 'blocking' 或在 App Router 中使用了 dynamicParams: true),它会进行服务器端渲染,缓存结果,然后提供给用户。

  2. 在再生窗口内的后续请求:从缓存提供。很快。没有计算。

  3. 再生窗口过期后的首次请求:陈旧的页面立即被提供(这是"stale-while-revalidate"部分),并在后台触发再生。下一个访问者获得新鲜页面。

从概念上讲,这很简单。但在 25,000 页面的规模下,后台再生步骤成为一条消防水带。

// App Router (Next.js 14/15)
export const revalidate = 60; // 秒

export async function generateStaticParams() {
  // 在 25k 页面中,你可能不想在这里返回所有页面
  const topPages = await getTop500Pages();
  return topPages.map((page) => ({ slug: page.slug }));
}

export default async function ProductPage({ params }: { params: { slug: string } }) {
  const product = await getProduct(params.slug);
  return <ProductTemplate product={product} />;
}

Stale-While-Revalidate 的权衡

让人困惑的是:ISR 总是向触发再生的请求提供陈旧内容。这是一个特性,不是 bug——这意味着没有访问者需要等待渲染。但这也意味着你的内容总是至少落后一个请求。对于一个 25,000 页面的网站,其中某些页面每周只被访问一次,"落后一个请求"可能意味着有人在再生窗口过期后看到几天前的内容,因为没有人访问来触发再生。

为什么 25,000 页面改变了一切

在小规模时,ISR 基本上是魔法。在大规模时,三件事改变了:

构建时间成为瓶颈

如果你试图在构建时预渲染所有 25,000 页面,你会看到构建时间让你质疑人生的选择。每个页面需要获取数据、将 React 渲染为 HTML 并生成静态资产。即使每个页面需要 200ms(如果你正在访问 CMS API,这已经是乐观的了),那也是 5,000 秒——超过 83 分钟。Vercel 的 Pro 计划的构建超时时间为 45 分钟。企业版本可获得更多时间,但你仍在消耗计算额度。

缓存失效成为真正的问题

有 25,000 页面时,你无法在内容更改时"重新构建所有内容"。你需要精准的失效。Vercel 的 revalidatePath()revalidateTag() API 有帮助,但在规模下它们有自己的怪癖,我们会讲到。

后台再生负载峰值

想象 5,000 页面都有 revalidate: 60 并且同时获得流量。这是每分钟发生 5,000 次无服务器函数调用。你的 CMS API 最好能够处理这种情况。

ISR at Scale: Running 25,000+ Pages with Incremental Static Regeneration on Vercel - architecture

构建策略:预渲染什么与推迟什么

这是大规模 ISR 网站最重要的架构决策。这是我们使用的框架:

页面分类 我们的数量 策略 原因
高流量页面(前 500 名) 500 在构建时预渲染 这些在部署后立即被访问。没有冷启动惩罚。
中等流量页面 4,500 fallback: 'blocking' 推迟 第一个访问者等待 ~300ms,然后它被缓存。可接受。
长尾页面 20,000 fallback: 'blocking' 推迟 大多数在部署后数小时/数天内都不会被访问。不值得预渲染。

关键见解:不要预渲染部署后第一小时内没人会访问的页面。 你在浪费构建分钟和金钱。

// generateStaticParams - 仅返回你的高流量页面
export async function generateStaticParams() {
  // 我们使用分析数据来确定热门页面
  const topPages = await fetch('https://api.example.com/pages/top?limit=500', {
    headers: { Authorization: `Bearer ${process.env.CMS_TOKEN}` },
  }).then(r => r.json());

  return topPages.map((page: { slug: string }) => ({
    slug: page.slug,
  }));
}

通过这种方法,我们的构建从超时变为大约 8 分钟完成。这是巨大的差异。我们在 Next.js 开发工作的背景下撰写了类似的优化策略——这些原则广泛适用。

`dynamicParams` 设置很重要

在 App Router 中,设置 dynamicParams = true(默认)意味着不由 generateStaticParams 返回的页面将按需呈现并缓存。将其设置为 false 会对任何未预渲染的页面返回 404。对于 25,000 页面的网站,你几乎肯定想要 true

export const dynamicParams = true; // 允许对不在 generateStaticParams 中的页面进行按需渲染

实际可用的再生模式

基于时间的再生

最简单的方法。将 revalidate 设置为秒数。但是什么数字?

以下是我们经过数个月调整后得出的结论:

内容类型 再生周期 为什么
产品价格 60 秒 价格变化频繁,客户注意到过时价格
产品描述 3600 秒(1 小时) 很少更改,不时效敏感
博客文章 86400 秒(24 小时) 发布后基本不变
分类/列表页面 300 秒(5 分钟) 新产品出现,但轻微延迟是可以的
位置页面 86400 秒(24 小时) 地址信息几乎不变

我们早期犯的错误:将所有内容设置为 60 秒。这对我们的 CMS(我们的情况下是 Contentful)API 造成了沉重的再生请求轰击,在流量峰值期间我们触及了速率限制。

按需再生

这是最适合大多数内容更新的方法。与轮询的基于时间的再生不同,你在内容实际更改时触发再生:

// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidation-secret');
  
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
  }

  const body = await request.json();
  
  // 基于标签的再生 -- 这是方法
  if (body.tag) {
    revalidateTag(body.tag);
    return NextResponse.json({ revalidated: true, tag: body.tag });
  }

  // 基于路径的再生作为回退
  if (body.path) {
    revalidatePath(body.path);
    return NextResponse.json({ revalidated: true, path: body.path });
  }

  return NextResponse.json({ error: 'No tag or path provided' }, { status: 400 });
}

然后在你的 CMS 中设置一个 webhook,在发布内容时点击这个端点。我们将此与更长的基于时间的再生(如 24 小时)配对作为安全网。

大规模的基于标签的再生

这是 Next.js 14+ 真正为大型网站闪耀的地方。你可以标记获取请求并按标签失效:

async function getProduct(slug: string) {
  const res = await fetch(`https://api.cms.com/products/${slug}`, {
    next: { 
      tags: [`product-${slug}`, 'products', 'all-content'],
      revalidate: 86400 // 24 小时安全网
    },
  });
  return res.json();
}

现在当单个产品更新时,你调用 revalidateTag('product-blue-widget'),只有那个页面重新生成。当你进行批量价格更新时,调用 revalidateTag('products'),所有产品页面在下次访问时重新生成。

陷阱:在有 25,000 个产品页面的网站上调用 revalidateTag('products') 不会立即重新生成它们所有。它将它们全部标记为陈旧。它们在下次访问时重新生成。这很重要——这意味着某些页面可能在数天内都不会实际更新,如果它们的流量较低。

Vercel 特定的陷阱和限制

我们从 2024 年初开始在 Vercel 上运行此服务。以下是文档强调不足的事项:

ISR 缓存存储

Vercel 在其边界网络缓存中存储 ISR 页面。截至 2025 年,Vercel Data Cache 有一些你应该知道的限制:

  • Pro 计划:包含的 ISR 缓存很宽裕,但在非常高的容量下有缓存读写成本
  • 企业版:自定义限制,但你为此付费
  • 缓存条目不永远存在:即使 revalidate: false,Vercel 也可以驱逐未被最近访问过的缓存条目。我们在 Pro 计划上看到页面在大约 30 天无流量后从缓存中消失。

无服务器函数持续时间

后台再生作为无服务器函数运行。在 Vercel Pro 上,默认超时为 60 秒(你可以配置最多 300 秒)。如果你的页面重新生成时间超过该时间——比如说,因为你的 CMS 很慢或你正在进行繁重的图像处理——再生会无声地失败,陈旧的页面继续被提供。

我们在从三个不同 API 获取数据的页面上遇到了这个问题。修复是添加一个缓存层(Redis 通过 Upstash)在 Next.js 应用和最慢的 API 之间。

并发再生限制

Vercel 没有公布硬数字,但当超过 ~1,000 次 ISR 再生同时被触发时(例如,在广泛使用的标签上调用 revalidateTag 之后),我们观察到了限制。再生排队并在几分钟内处理,而不是全部立即处理。为此做好计划。

冷启动

很久未被访问过的页面(并已从边界缓存中驱逐)在下次访问时会经历冷启动。在我们的基准中:

  • 温暖缓存命中:15-40ms TTFB
  • 陈旧再生(从缓存提供):15-40ms TTFB(相同,因为提供陈旧)
  • 冷再生(无缓存,阻止):400-1200ms TTFB 取决于 API 响应时间

生产规模的真实成本

让我们谈谈钱。这是人们感到惊讶的地方。

我们的 25,000 页面网站在 Vercel Pro($20/月基础)上使用 ISR:

成本组件 月度 注释
Vercel Pro 订阅 $20 基础计划
无服务器函数执行 $180-$340 随流量变化。ISR 再生计为函数调用。
边界带宽 $90-$150 25k 页面带图像会加起来
Vercel Data Cache $40-$80 ISR 的缓存读写
Vercel 总计 $330-$590/月 取决于流量月份
Contentful (CMS) $489/月 他们的团队计划。来自 ISR 再生的 API 调用很快让我们超过免费层级。
Upstash Redis(缓存) $30/月 添加以减少 CMS API 调用
总计 $849-$1,109/月 对于一个服务 ~2M 页面浏览量/月的网站

这很贵吗?与传统服务器设置相比,它很有竞争力。与 CDN 上的静态网站相比,它很昂贵。ISR 再生函数调用是最大的可变成本——每次页面再生,都有一个无服务器函数运行 1-5 秒。

我们与探索 基于 Astro 的方法的客户合作过,针对 ISR 成本开始超过其益处的内容丰富的网站。对于内容很少更改的网站,使用 Astro 进行完整静态构建的托管成本可能要便宜得多。

在生产环境中监控和调试 ISR

ISR 故障默认是无声的。陈旧的页面继续被提供,你可能不知道你的再生已经失败了好几天。这是我们的监控设置:

自定义再生日志记录

// lib/with-regeneration-logging.ts
export async function fetchWithLogging(
  url: string,
  options: RequestInit & { next?: { tags?: string[]; revalidate?: number } }
) {
  const start = Date.now();
  try {
    const res = await fetch(url, options);
    const duration = Date.now() - start;
    
    // 记录到你的监控服务
    if (duration > 5000) {
      console.warn(`[ISR] Slow fetch: ${url} took ${duration}ms`);
      // 发送到 Datadog/Sentry/等
    }
    
    return res;
  } catch (error) {
    console.error(`[ISR] Fetch failed: ${url}`, error);
    // 这很关键 -- 如果获取失败,再生失败
    throw error;
  }
}

Vercel 的内置工具

Vercel 的仪表板显示 ISR 缓存命中率和再生计数。在 Analytics 选项卡中,查找:

  • 函数日志中的 缓存状态HITMISSSTALE
  • 无服务器函数指标中的 ISR 再生持续时间
  • 你的 ISR 路由上的 错误率

`x-vercel-cache` 头

Vercel 的每个响应都包含这个头:

  • HIT -- 从边界缓存提供,新鲜
  • STALE -- 从边界缓存提供,在后台触发再生
  • MISS -- 不在缓存中,按需呈现

我们设置了一个简单的监控程序,每小时检查 100 个随机页面,如果超过 10% 返回 MISS,则发出警报——这表明存在缓存驱逐问题。

架构决策:ISR vs. 替代方案

在运行 ISR 达到这种规模超过一年后,这是我对何时使用它以及何时不使用它的诚实看法:

何时使用 ISR:

  • 你有 5,000-100,000 个以不同频率更改的页面
  • 内容新鲜度以分钟为单位(不是秒)是可接受的
  • 你已经承诺使用 Next.js
  • 你的团队理解缓存失效(在这个规模上不是可选的知识)

何时考虑替代方案:

  • 你需要实时内容(改用 SSR 或客户端获取)
  • 你的网站很少更改(完整静态构建更简单且更便宜)
  • 你有 500,000+ 页面(ISR 在非常高的页面数下开始紧张——考虑分布式构建方法)
  • 成本是首要考虑(自托管 Next.js 和你自己的 CDN 可以便宜 60-70%)

对于具有复杂内容架构的客户,我们经常推荐 无头 CMS 设置,它使你能够根据内容类型在 ISR、SSR 和完整静态之间灵活切换。

我们实际使用的混合方法

我们不在我们 25k 页面网站的所有内容上使用 ISR。这是分解:

  • ISR:产品页面、分类页面、位置页面(22,000 页面)
  • SSR:搜索结果、用户仪表板、购物车
  • 静态:关于、联系、法律页面(在构建时生成,无再生)
  • 客户端:实时库存计数、用户特定定价

与我们初始的"ISR 一切"策略相比,这种混合方法将我们的无服务器函数成本降低了约 40%。

我们部署的性能基准

这些是来自我们生产部署的真实数字,在 Q1 2025 期间测量:

指标 ISR 缓存命中 ISR 缓存未命中(阻止) 完整 SSR(无缓存)
TTFB (p50) 22ms 480ms 620ms
TTFB (p95) 58ms 1,100ms 1,450ms
TTFB (p99) 120ms 2,800ms 3,200ms
LCP (p50) 1.1s 1.8s 2.2s
CLS 0.02 0.02 0.05
核心网站生命周期通过率 96% 78% 64%

缓存命中和未命中之间的差异是巨大的。这就是为什么你的预渲染策略很重要——你希望你的高流量页面总是温暖的。

一个有趣的发现:当我们将低变化内容的 revalidate: 60 改为 revalidate: 3600 时,我们的核心网站生命周期得分提高了 12%。再生次数减少意味着缓存命中更一致,这意味着性能更一致。

常见问题

在性能下降之前,ISR 可以在 Vercel 上处理多少页面? 我们已经成功运行了 25,000 页面,而且我没有听说过 100,000+ 页面的部署工作良好。瓶颈不是缓存中的页面数——它是同时再生的速率。如果你有 50,000 个页面都有 revalidate: 60,你会遇到问题。根据内容更改频率和流量分散再生期,你会没问题。

ISR 在 Vercel 上比 SSR 成本更高吗? 一般来说,ISR 对于相同流量而言明显比 SSR 便宜。使用 ISR,大多数请求从边界缓存提供(基本上免费计算)。使用 SSR,每个请求都运行无服务器函数。对于我们的 2M 页面浏览量/月网站,ISR 的函数调用(来自再生)大约是完整 SSR 的 15%。

当 ISR 再生失败时会发生什么? 陈旧的版本继续被提供。这既是特性又是风险。你的用户看不到错误,但他们可能看到过期的内容。我们遇到过 CMS API 中断意味着页面提供 6 小时前内容的情况,直到有人注意到。设置监控。

我可以在 Next.js App Router 中使用 ISR 吗? 是的,在 App Router 中实际上更干净。你在页面或布局级别使用 export const revalidate = 60,并在获取调用中使用 next: { revalidate, tags }generateStaticParams 函数替换 getStaticPaths。我们在本文中描述的一切都在 Pages Router 和 App Router 中都有效,尽管对于 2025 年的新项目,我们建议使用 App Router 语法。

如何使用动态查询参数处理 ISR? ISR 仅基于 URL 路径缓存,不基于查询参数。如果你需要 ?color=red?color=blue 的不同缓存版本,你需要使用实际的路径段(/product/widget/red 而不是 /product/widget?color=red)或在客户端处理变化。这在我们的过滤实现上让我们措手不及。

按需再生在规模上是否可靠? 基本上是的。我们看到调用 revalidateTag() 和缓存在所有边界位置实际被失效之间偶尔会延迟 10-30 秒。对于 99% 的用例,这没问题。如果你需要即时全局失效,你可能需要添加缓存破坏查询参数或对那些特定页面使用 SSR。

我应该为大型 ISR 网站自托管 Next.js 而不是使用 Vercel 吗? 这取决于你的团队。自托管(例如在 AWS 上)让你更好地控制缓存行为,在规模上可以便宜 50-70%。但你负责设置 CDN 缓存失效、管理构建管道和处理边界分布。我们看到团队花费数个月复制 Vercel 开箱即用提供的内容。如果你想探索选项,请与我们联系——我们两者都做过。

25,000+ 页面 ISR 网站的最佳 CMS 是什么? 我们在这个规模上使用了 Contentful、Sanity 和 Hygraph。Contentful 处理基于 webhook 的再生很好,但速率限制可能是一个问题(计划缓存)。Sanity 的 GROQ 订阅非常适合实时意识内容更改。Hygraph 的 webhook 系统很可靠。关键要求是可靠的 webhook 交付和可以处理再生风暴爆发流量的 API。查看我们的 无头 CMS 开发功能,根据你的内容模型获取更具体的建议。