
我一直觉得做内容型站点的人都应该认真看一眼 Next.js 的增量静态再生ISR。如果你只听说过 SSG 和 SSR却不知道怎么在一个项目里同时拿到两者的好处这篇就是写给你的。ISR 解决的是我从 2019 年开始做内容站时特别头疼的问题静态页面速度快但内容更新太被动服务端渲染实时性强但每个请求都跑数据源流量稍微大一点就喘。增量静态再生把这两件事捏在一起——大部分时间页面是静态的到了时间节点自动后台重新生成用户永远不感知。这篇不仅讲 ISR 怎么用我还会把我自己踩过的坑一起交代清楚包括 revalidate 参数的真实行为、fallback 三种模式怎么选、生产环境里为什么会出现页面不更新的假象、以及什么时候你压根不该用 ISR。看完你应该能判断自己的项目适不适合 ISR并且能直接照着代码落地。1. ISR 出现之前SSG 和 SSR 的两难选择1.1 SSG 的静态红利与更新代价静态站点生成做了这么多年核心优势一句话就能说清构建时把数据全塞进 HTML部署到 CDN 上用户访问走边缘节点没有数据库查询、没有服务端计算、没有动态渲染开销。这套架构对流量非常友好一个页面哪怕一天被刷十万次成本也几乎不变。但 SSG 有个天然的死穴数据变了页面不变。假设我的商品详情页在构建时生成了「价格 ¥299」写死在 HTML 里后台上调价格到 ¥349除非重新触发一次构建否则用户永远看到 ¥299。小型站点手动跑一次构建还好内容织一多、页面成百上千每次改一篇文章就全量重新构建构建时间线性膨胀更新窗口越长越难受。1.2 SSR 的实时性代价SSR 换了条路每次请求都实时执行服务端渲染访问瞬间去数据库拿最新数据拼出 HTML 返回。数据永远是最新的用户体验也不会差因为没有客户端白屏。问题出在资源占用和响应时间上高并发时每个请求都穿透到数据库和模板渲染层CPU、内存、数据库连接全在燃烧。就算加了缓存层也得权衡缓存什么、缓存多久、怎么失效。我在一个中等流量的电商项目里被 SSR 的数据库压力搞到半夜起来扩容那次之后我对「每个请求都实时」这个方案产生了警惕。用户的访问模式是稀疏随机的绝大部分请求访问的数据可能几个小时都没变过却白白消耗了后端资源。1.3 增量静态再生是怎么补上这两块的Next.js 的增量静态再生本质上是一种混合模式构建时预渲染一批页面同时约定一个「过期时间」用户请求时先拿到已生成的静态页哪怕这个页面在后台已经被标记为过期用户也不等返回旧版本的同时系统在后台重新生成新版本下个请求开始就是新内容了。这套思路其实不是 Next.js 原创HTTP 层面的 stale-while-revalidate 缓存策略早就存在。但 Next.js 把它变成了一个正经的框架能力开发者不需要自己搭缓存系统、不需要写后台任务队列、不需要手动管理 CDN 缓存刷新一个 revalidate 参数就把整件事串起来了。增量这个关键词在 Next.js 里的意思不是「生成新页面」这个动作增量而是更新策略增量——每次只重新生成需要变的页面不动其他页面构建成本不再随总页面数线性膨胀时间和流量都只花在真正变化了的内容上。2. ISR 的核心机制一次请求如何从旧页面平滑过渡到新页面2.1 stale-while-revalidate 模型在 Next.js 里的映射要理解 ISR脑子里必须先想清楚 stale-while-revalidate 这个缓存模型。它把缓存资源的使用周期拆成两段新鲜期之内请求一律走缓存零成本返回新鲜期一过缓存进入过期状态但这会儿不直接回源而是先把准时的旧缓存丢给当前请求保证响应速度不塌方然后异步触发一次后台回源把新数据补位。Next.js 的 ISR 把这个模型搬到了页面级别页面构建完成的那一刻开始计时revalidate 的秒数就是新鲜期。比如我设revalidate 60那么页面上次生成后的 60 秒内所有请求都是直接拿现成 HTML第 61 秒之后第一个请求来了用户依然拿到旧 HTML用户完全不察觉后台同时跑一次页面重新生成这场重新生成结束后再往后都是新页面天下了。这个概念一定要记牢——不是「过期后页面立刻变新」而是「过期后下次访问触发更新」。你要是理解成「每 60 秒页面自动刷新一次」后续排查问题会绕很多弯路。2.2 revalidate 参数的真面目生效条件与触发时机设置 revalidate 时容易产生一种错觉我设了 60那系统肯定每 60 秒准时更新。不是的系统的逻辑简单说就是请求进来看一眼距离上次生成时间是否超过了 revalidate 秒数没超过直接返回缓存超过就返回旧页面同时触发后台重新生成。如果这 60 秒内一条请求都没有那 60 秒这个数字就只是个数字没有任何后台定时器在跑自然也不会生成新页面。实际运作里的一个重要分支是页面重新生成失败怎么办。假如后台重新运行 getStaticProps 时你的数据库崩了或者上游 API 超时了ISR 不会给你返回一张错误页面顶掉旧内容它会保留当前这份旧页面然后把「下次生成重试」的概率继续留着等下一个请求来了再试一次。这是 ISR 很人性化的一个设计——旧页面永远是你的兜底后台失败不伤害线上可用性只会延迟内容更新。2.3 首次访问与动态路由fallback 的角色前面说的都是「构建时已经生成的页面」但 ISR 还有一层增量生成能力专门处理动态路由的页面。试想一个电商系统商品型号是在运营后台不断录入的构建时不可能预先知道未来会上架哪些商品。这是 fallback 参数登场的场景——fallback: true首次访问某个尚不存在的商品路径时Next.js 立即给用户返回一个带骨架的同页面的 HTML同时在后台执行 getStaticProps 生成真实页面生成完成后存起来之后这个商品的请求就直接落到真实页面上了。fallback: blocking的做法不同用户的第一个请求会一直在服务端等待直到页面生成完毕再返回而不是先给骨架。这三种模式的本质差异我后面在实战部分展开你只需要先建立一个大框架ISR 构建时预生成 过期后异步重建 首次访问按需生成。3. 代码实战从 Page Router 到 App Router 的 ISR 实现3.1 Page Router 下的 getStaticProps revalidatePage Router 时代的写法非常直白写一个普通的 getStaticProps加一行 revalidate 就够了// pages/products/[id].js export async function getStaticProps({ params }) { const product await fetchProductById(params.id); return { props: { product }, revalidate: 300, }; } export async function getStaticPaths() { const products await fetchAllProducts(); const paths products.map((p) ({ params: { id: p.id } })); return { paths, fallback: blocking, }; }这段代码的含义是构建时先把所有已知商品生成一遍响应 header 缓存时间相关状态由框架管理每个页面在上次生成后的 300 秒内走缓存超过后触发后台重新生成如果用户访问了一个构建时没生成的商品路径fallback: blocking 会撑住连接等页面首次生成完再返回。3.2 fallback 的三种模式该怎么选这是不少人容易搞拧的地方我把三种模式和我的选型经验放一起说。模式首次访问行为适用场景风险与代价fallback: false没构建过的路径直接 404路径全集在构建时确定比如固定文档站新增内容必须触发重新构建fallback: true先返回骨架页后台生成通过router.isFallback区分状态内容量大、新增频繁的列表页或详情页首次访问有轻微体验折损需要处理骨架状态fallback: blocking服务端等生成完成后一次性返回完整 HTML对 SEO 敏感的页面不希望出现临时骨架首次请求耗时增加生成时间略长时体验受影响就电商场景来说我通常用fallback: blocking原因是 SEO 团队不接受返回骨架页给搜索引擎搜索引擎拿到的如果是空骨架收录质量会受影响。fallback: true更适合后台管理系统内部页面用户登录进来等个几百毫秒无所谓骨架还能提示加载状态。fallback: false适合那种文章集合固定、不频繁增删的场景比如产品帮助中心路径在文档发布流水线里是可控的。3.3 App Router 下的 revalidate 写法Next.js 13.4 之后 App Router 成为推荐路线ISR 的配置从函数参数变成了更分散的导出声明方式但核心模型完全没变。页面级写法// app/products/[id]/page.js export const revalidate 300; async function getProduct(id) { const res await fetch(https://api.example.com/products/${id}); return res.json(); } export default async function ProductPage({ params }) { const product await getProduct(params.id); return ProductView product{product} /; }数据级写法只对某个 fetch 请求设置过期时间export default async function ProductPage({ params }) { const res await fetch( https://api.example.com/products/${params.id}, { next: { revalidate: 300 } } ); const product await res.json(); return ProductView product{product} /; }这俩的区别在于页面级导出revalidate管的是整页重新生成数据级配置只管这条 fetch 的数据失效时间页面里其他数据还能单独控制。大多数项目用页面级就够了但如果某个页面里既有商品信息又有评论信息价格 5 分钟更新一次、评论 1 分钟更新一次数据级配置会更灵活。App Router 里 ISR 的重新生成机制与 Page Router 一致都遵循 stale-while-revalidate只是路由和数据获取的代码组织方式换了而已。从老项目迁移过去时你只需要把原来 getStaticProps 的返回值拆成「页面组件直接异步取数 revalidate 导出」逻辑本身是不变的。4. 增量再生的关键细节持久化、数据源与 CDN 协同4.1 生成后的页面到底存哪了磁盘持久化与多实例注意点ISR 页面构建完成后不是放在内存里完事的Next.js 会在服务器上持久化生成结果。自托管部署时这些页面存在于.next/server相关目录下跑在 Vercel 这类平台时生成结果会落到平台的持久化存储里。这个细节对多实例部署特别重要如果你把 Next.js 自托管在多个 Node 实例后面每个实例的 ISR 页面是独立缓存的同一个路径在实例 A 上可能已经生成了新版本实例 B 上还是旧的。集群环境下我建议把 ISR 的触发频次和自然流量错开或者考虑用 Sticky Session 让同一个页面的请求尽量落到同一个实例再或者直接换成容器重启频繁度可控的部署方式。很多人以为 ISR 是自己写的全局缓存其实它更多依赖部署平台的持久化机制这个认知误差在踩坑时很致命。4.2 为什么说 ISR 天然是 CDN 友好的ISR 页面在服务端生成的产物是纯静态 HTML响应头里会带上与 stale-while-revalidate 相关的缓存头。CDN 拿到这份响应后就能直接缓存并在过期后按照同样的策略回源而不是穿透到数据库。这意味着你的 CDN 边缘节点实际上继承了一套「用户无感的页面更新机制」回源频率被压到每次 data change 后最多一两次成本很低用户体验却是实时的。我之前在博客里分享过一个对比数据同一个博客站纯 SSR 模式下 CDN 回源率大概是每小时几千次切到 ISR 后按小时维度回源率降到几十次。这个差距在源站成本上的体现非常直观。4.3 按需重新验证把更新时间线从轮询变成事件驱动纯时间驱动的 revalidate 更新策略有一个弱点你可能希望在特定事件发生时立刻更新页面而不是等待下一个窗口期。比如运营在 CMS 里把一篇文章从草稿改成了已发布你迫不及待想让线上页面立刻更新靠 revalidate 60 秒也能更新但总觉得不够专业。Next.js 提供了按需重新验证用 API Route 主动触发某个页面的重新生成。写一个简单的接口// pages/api/revalidate.js export default async function handler(req, res) { if (req.query.secret ! process.env.REVALIDATE_SECRET) { return res.status(401).json({ error: Invalid token }); } try { await res.revalidate(/products/123); return res.json({ ok: true }); } catch (err) { return res.status(500).json({ error: Revalidation failed }); } }CMS 那边在文章发布的时候顺手请求一次这个接口路径对应的页面立刻进入后台重新生成流程完成时新内容就在线了。这个思路把 ISR 从「固定时间轮询」升级成了「事件驱动」内容更新链路更干净。5. 验证与实测一个商品页的 ISR 全流程观察5.1 搭一个最小的 ISR 工程理论讲再多不如亲手验证一遍 ISR 的网络行为。我建议你花十分钟搭个最小的测试环境新建一个 Next.js 项目用 Page Router 写一个带 getStaticProps 的页面页面里渲染一个服务端生成时间戳revalidate 设成 10 秒。// pages/time.js export default function TimePage({ now }) { return divServer time: {now}/div; } export async function getStaticProps() { return { props: { now: new Date().toISOString() }, revalidate: 10, }; }构建并启动生产模式这个页面就能用来观察 ISR 的行为细节。5.2 观察响应头里的 Cache-Control 变化部署完这个测试页以后用 curl 去盯响应头curl -I http://localhost:3000/time你会看到响应头里有类似这样的字段Cache-Control: public, max-age0, s-maxage10, stale-while-revalidates-maxage10就是 CDN 层的缓存新鲜期对应 revalidate 的 10 秒stale-while-revalidate则表示允许在过期状态下继续服务旧内容并异步更新。只要看到这个响应头就说明 ISR 生效了它也解释了为什么这套机制能跟 CDN 顺畅配合。5.3 实战观察时间戳刷新十次的演进过程现在做一组连续刷新实验把页面内容变化和响应头放一起看。我第一次访问http://localhost:3000/time页面显示一个时间 A接下来 10 秒内疯狂按刷新时间一直是 A因为新鲜期没过大约第 11 秒再刷新你可能依然看到时间 A因为过期后的第一个请求是直接返回旧页面的但这个请求已经触发后台重新生成紧接着再刷新一次看到时间 B新页面开始持续生效。这组实验还原了 ISR 最核心的语义用户不会在自己的某一次请求上看到「页面正在更新」的状态旧页面一直服务到新页面落定为止。而且你可以注意到重新生成的触发是「过期后第一个请求」不是「第 10 秒整的那个请求」——没有请求就没有生成。6. 避坑清单ISR 实战中我踩过的五个坑6.1 坑一开发模式和生产模式的行为完全不同开发模式下Next.js 为了 debug 方便每次请求都可能直接重新执行 getStaticPropsrevalidate 的限制并不严格。我第一次跑 ISR 时在 dev 环境观察时间戳发现每次刷新都变化以为 ISR 没生效差点把这套方案否了。后来用生产模式构建跑了一遍才发现行为正常。记住一条铁律ISR 的缓存与过期行为只在 production build 中真实存在本地调试需要跑npm run build npm start。6.2 坑二构建时数据库不可达导致整个构建失败getStaticProps 在构建阶段要被真实执行一遍也就是构建机器必须能访问你的数据库和外部 API。我曾碰过一次生产环境构建数据库连接串配错了环境变量构建直接因数据库拒绝连接而失败。这个问题在纯 SSR 项目里不一定会致命因为很多错误可以在运行期兜住但 ISR 的构建行为非常实在——拿到路径列表就跑去取数取不到就报错。规避方案也很明确构建环境的数据源访问能力、超时设置、错误边界必须和运行时环境一起列入部署演练清单。页面的 getStaticProps 里最好做一层容错上游不可用时返回某种降级数据确保构建能顺利完成。6.3 坑三缓存页面里藏了不该被复用的用户数据ISR 页面是全站共享的同一个 URL 的用户拿到的都是同一份 HTML。如果你在 getStaticProps 里根据某种用户态比如请求头里的 cookie、登录信息去拼装页面内容做出来的页面就出大问题了——用户 A 的数据可能被缓存在公共页面上用户 B 刷新时看到的是 A 的私人信息。ISR 只适合存那些对所有用户都一致的内容任何个性化内容千万别塞进 getStaticProps 的输出。这个坑其实和 SSG 一致但 ISR 的异步重新生成给人造成一种「页面是动态的」错觉更容易把个性化逻辑顺手写进去。我建议每次在 getStaticProps 里取数据前先问一句这个数据是否与当前用户无关如果答案是「有关」请把它挪到客户端渲染或用 SSR 处理。6.4 坑四revalidate 值设太小反而把后端拖垮了revalidate 越小后台重新生成的触发越频繁。假设你有 10 万个页面revalidate 设为 1 秒高流量下每秒可能有大量页面同时过期、同时触发重建数据源一秒被打几百次效果比 SSR 更糟。因为 SSR 至少还有请求级并发控制ISR 的重建是后台批量发起的。我建议 revalidate 先从小时级或分钟级起步再根据实际内容和流量观察逐步调低不要凭感觉往小了设。6.5 坑五动态参数路径的 fallback 误配置有人为了让所有动态页面都能实时生成把所有路径都从 getStaticPaths 里省掉只留一个fallback: true让每个页面的首次访问都走按需生成。这样一来首次访问的响应时间会显著下降SEO 爬虫还可能在骨架状态下抓取内容而且高流量情形下大量首次请求同时触发后台生成数据源瞬间过载。更合理的做法是热门页面提前在 getStaticPaths 里列进去构建时完成生成长尾页面靠 fallback 按需兜底。这样既控制了构建时间又保证长尾内容可用两全其美。7. 什么时候该用 ISR一张决策表7.1 ISR vs SSG vs SSR 的对比维度SSGISRSSR构建阶段行为全量预生成预生成 增量重建不预生成数据实时性构建时固化过期后异步更新请求时实时首次访问速度极快极快取决于数据源延迟高并发后端压力极低低高内容变化响应需手动重建时间或事件驱动即时个性化内容支持差差好这张表格一眼能看出 ISR 的田野内容型、公开访问、更新频率不太夸张的场景。一旦涉及登录态、用户专属数据ISR 不仅不合适还容易踩出安全问题。7.2 内容更新频率与延迟接受度我给自己总结过一个判断框架按内容更新频率和可接受延迟来对号入座内容几个月不变比如公司官网主页直接 SSG连 revalidate 都不用设省心。内容每天固定时间更新比如日报类栏目SSG 按需 revalidate上午一条通知触发更新即可。内容每隔几分钟到几十分钟变化一次比如商品价格、库存ISR 是首选revalidate 按分钟设再加按需更新接口配合 CMS 变更事件。内容秒级变化比如抢购倒计时、实时榜单别用 ISR直接用 SSR 或客户端动态获取避免在旧数据上叠加复杂的失效逻辑。7.3 几条经验层面的建议ISR 的价值在于把「静态化红利」和「内容新鲜度」很好地缝合了起来但你要始终记住它是给「公开数据 低频变更 可容忍小段延迟」设计的方案。新项目如果是从零开始我建议直接在 App Router 下用fetch数据级 revalidate 和页面级导出搭配引路老项目从 Page Router 迁移时先在访问量最低、内容最稳的页面试水观察一段时间后台构建日志的反应再逐步扩大应用面。还有一点要提醒如果你的页面在 ISR 下出现「看起来没有更新」的情况先别急着怀疑框架按这个顺序排查——确认你跑的是生产模式、确认响应头里 Cache-Control 确实带上了 revalidate 数值、确认距离上次生成时间是否真的超过了 revalidate 秒数、确认 CDN 层没有缓存过长的 s-maxage。这四个环节里绝大多数问题都出在 CDN 配置上Next.js 侧的机制反而是最让人省心的那部分。