ISR 规模化:在 Vercel 上运行 25,000+ 页面的增量静态再生成
大规模运行 ISR:在 Vercel 上部署 25,000+ 页面的增量静态再生
去年我们在 Vercel 上部署了一个 Next.js 网站,包含超过 25,000 个静态生成的页面。产品页面、博客文章、位置登陆页面、动态分类过滤器——应有尽有。增量静态再生 (ISR) 的承诺确实很诱人:获得静态网站的速度和服务器渲染内容的新鲜度。说实话?它基本上兑现了承诺。但在 25,000+ 页面的规模下,ISR 的表现与 50 页营销网站上的表现截然不同。边界情况成为主要情况。成本开始上升。文档中看似理论性的缓存失效问题变得非常真实。
这是我希望在开始之前就存在的文章。这里的一切都来自生产经验——真实的指标、真实的账单惊喜,以及我们做出的真实架构决策(有时还为此后悔)。

目录
- ISR 实际工作原理
- 为什么 25,000 页面改变了一切
- 构建策略:预渲染什么与推迟什么
- 实际可用的再生模式
- Vercel 特定的陷阱和限制
- 生产规模的真实成本
- 在生产环境中监控和调试 ISR
- 架构决策:ISR vs. 替代方案
- 我们部署的性能基准
- 常见问题
ISR 实际工作原理
在深入探讨规模问题之前,让我们确保大家对 ISR 的功能有相同的理解。当你在 Next.js 页面中设置 revalidate: 60 时,实际的流程是这样的:
部署后的首次请求:如果页面在构建时已预渲染,Vercel 从边界缓存提供该页面。如果没有(你返回了
fallback: 'blocking'或在 App Router 中使用了dynamicParams: true),它会进行服务器端渲染,缓存结果,然后提供给用户。在再生窗口内的后续请求:从缓存提供。很快。没有计算。
再生窗口过期后的首次请求:陈旧的页面立即被提供(这是"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 网站最重要的架构决策。这是我们使用的框架:
| 页面分类 | 我们的数量 | 策略 | 原因 |
|---|---|---|---|
| 高流量页面(前 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 选项卡中,查找:
- 函数日志中的 缓存状态:
HIT、MISS、STALE - 无服务器函数指标中的 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 开发功能,根据你的内容模型获取更具体的建议。